commit a47da5bb74f07d4ca70a7c128e8f54f320cc46f2 Author: chazmm Date: Sun Sep 6 03:44:14 2026 +0100 Import supplied skater project and standalone Windows setup diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..96310228 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/bin/ +/logs/ +/assets/private/ diff --git a/BUILD.bat b/BUILD.bat new file mode 100644 index 00000000..a2280f87 --- /dev/null +++ b/BUILD.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Build.ps1" +if errorlevel 1 pause diff --git a/Build.ps1 b/Build.ps1 new file mode 100644 index 00000000..4bd1c926 --- /dev/null +++ b/Build.ps1 @@ -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 } diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..680934d4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5937 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "simd_cesu8", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomicow" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301801c08259e328a1c7da556608c0c22687708831b22024dbd3a57ea741e6de" +dependencies = [ + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bevy" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e" +dependencies = [ + "bevy_dylib", + "bevy_internal", +] + +[[package]] +name = "bevy_a11y" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e887b25c84f384ffe3278a17cf0e4b405eaa3c8fbc3db24d05d560a11780676d" +dependencies = [ + "accesskit", + "bevy_app", + "bevy_derive", + "bevy_ecs", + "bevy_reflect", +] + +[[package]] +name = "bevy_android" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c58de772ac1148884112e8a456c4f127a94b95a0e42ab5b160b7a11895a241" +dependencies = [ + "android-activity", +] + +[[package]] +name = "bevy_animation" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5bf5b285f0d3fab983b4505e62e195e06930a29007ffc95bdabde834e163a2" +dependencies = [ + "bevy_animation_macros", + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_utils", + "blake3", + "derive_more", + "downcast-rs 2.0.2", + "either", + "petgraph", + "ron", + "serde", + "smallvec", + "thiserror 2.0.20", + "thread_local", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_animation_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf35516d0e7ac9ec25df533be1bf8cbaa20596a8e65f36838a3f7803a267d6d" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_anti_alias" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_utils", + "tracing", +] + +[[package]] +name = "bevy_app" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def9f41aa5bf9b9dec8beda307a332798609cffb9d44f71005e0cfb45164f2f6" +dependencies = [ + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "cfg-if", + "console_error_panic_hook", + "ctrlc", + "downcast-rs 2.0.2", + "log", + "thiserror 2.0.20", + "variadics_please", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "bevy_asset" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f86fed15972b9fb1a3f7b092cf0390e67131caaedab15a2707c043e3a3c886" +dependencies = [ + "async-broadcast", + "async-channel", + "async-fs", + "async-io", + "async-lock", + "atomicow", + "bevy_android", + "bevy_app", + "bevy_asset_macros", + "bevy_diagnostic", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.13.1", + "blake3", + "crossbeam-channel", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "either", + "futures-io", + "futures-lite", + "futures-util", + "js-sys", + "ron", + "serde", + "stackfuture", + "thiserror 2.0.20", + "tracing", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "bevy_asset_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cb8d948365b06561b43b7d709282e62a6abb756baac5d8e295206d5e156168" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_audio" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d68da32468ce7f4bb2863b71326acfaaa88e9aef8da8306257cd487d40cede4" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_math", + "bevy_reflect", + "bevy_transform", + "coreaudio-sys", + "cpal", + "rodio", + "tracing", +] + +[[package]] +name = "bevy_camera" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ed9eed054e14341852236d06a7244597b1ace39ff9ae023fbd188ffde88619" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "downcast-rs 2.0.2", + "serde", + "smallvec", + "thiserror 2.0.20", + "wgpu-types", +] + +[[package]] +name = "bevy_color" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb41e8310a85811d14a4e75cfc2d6c07ac70661d6a4883509fc960f622970a8" +dependencies = [ + "bevy_math", + "bevy_reflect", + "bytemuck", + "derive_more", + "encase", + "serde", + "thiserror 2.0.20", + "wgpu-types", +] + +[[package]] +name = "bevy_core_pipeline" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d0810e85c2436e50c67448d48a83bf0bb1b5849899619ae2c7ea817221e9172" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.1", + "nonmax", + "radsort", + "smallvec", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "318ee0532c3da93749859d18f89a889c638fbc56aabac4d866583df7b951d103" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_dev_tools" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_state", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_window", + "tracing", +] + +[[package]] +name = "bevy_diagnostic" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8543a0f7afd56d3499ba80ffab6ef0bad12f93c2d2ca9aa7b1f1b8816c3980" +dependencies = [ + "atomic-waker", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_tasks", + "bevy_time", + "const-fnv1a-hash", + "log", + "serde", + "sysinfo", +] + +[[package]] +name = "bevy_dylib" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f142f72bbbc1a61338bf6da5dc7cf6dcf223683b9d53bcd635ebff4bfeb5308f" +dependencies = [ + "bevy_internal", +] + +[[package]] +name = "bevy_ecs" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d" +dependencies = [ + "arrayvec", + "bevy_ecs_macros", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.13.1", + "bumpalo", + "concurrent-queue", + "derive_more", + "fixedbitset", + "indexmap", + "log", + "nonmax", + "serde", + "slotmap", + "smallvec", + "thiserror 2.0.20", + "variadics_please", +] + +[[package]] +name = "bevy_ecs_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908baf585e2ea16bd53ef0da57b69580478af0059d2dbdb4369991ac9794b618" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_encase_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fee46eeddcbc00a805ae00ffa973f224671fc5cf0fe1a796963804faeade90" +dependencies = [ + "bevy_macro_utils", + "encase_derive_impl", +] + +[[package]] +name = "bevy_feathers" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_text", + "bevy_ui", + "bevy_ui_render", + "bevy_ui_widgets", + "bevy_window", + "smol_str", +] + +[[package]] +name = "bevy_gilrs" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "611827ab0ce43b88c0a695e6603901b5f34687efecaf526c861456c9d8e6fedb" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_platform", + "bevy_time", + "gilrs", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_gizmos" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aaff0dd5f405c83d290c5cd591835f1ae8009894947ab19dadcb323062bd7e7" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_gizmos_macros", + "bevy_light", + "bevy_math", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_utils", +] + +[[package]] +name = "bevy_gizmos_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6960ea308d7e94adcac5c712553ff86614bba6b663511f3f3812f6bec028b51e" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_gizmos_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a8d18c089102de4c5e9326023ad96ba618a6961029f8102a33640b966883237" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_ecs", + "bevy_gizmos", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_render", + "bevy_shader", + "bevy_sprite_render", + "bevy_transform", + "bevy_utils", + "bytemuck", + "tracing", +] + +[[package]] +name = "bevy_gltf" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f37fb52655d0439656ca0a1db027d46926e463c81d893d4b1639668e5d7f1c1" +dependencies = [ + "async-lock", + "base64", + "bevy_animation", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_tasks", + "bevy_transform", + "fixedbitset", + "gltf", + "itertools 0.14.0", + "percent-encoding", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_image" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a71daf9b2afdd032c2b1122d1d501f99126218cb3e9983b3604ec381daa35f22" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "bitflags 2.13.1", + "bytemuck", + "futures-lite", + "guillotiere", + "half", + "image", + "ktx2", + "rectangle-pack", + "ruzstd", + "serde", + "thiserror 2.0.20", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_input" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc8ffbd02df34dfc52faf420a5263985973765e228043adf542fd0d790a6b21" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "derive_more", + "log", + "smol_str", + "thiserror 2.0.20", +] + +[[package]] +name = "bevy_input_focus" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d48a5bceccb9157549a39ab3de4017f5368b65db6471605e9a3f1c19d91bbc" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_window", + "log", + "thiserror 2.0.20", +] + +[[package]] +name = "bevy_internal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754" +dependencies = [ + "bevy_a11y", + "bevy_android", + "bevy_animation", + "bevy_anti_alias", + "bevy_app", + "bevy_asset", + "bevy_audio", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_dev_tools", + "bevy_diagnostic", + "bevy_ecs", + "bevy_feathers", + "bevy_gilrs", + "bevy_gizmos", + "bevy_gizmos_render", + "bevy_gltf", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_picking", + "bevy_platform", + "bevy_post_process", + "bevy_ptr", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_state", + "bevy_tasks", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_utils", + "bevy_window", + "bevy_winit", +] + +[[package]] +name = "bevy_light" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "tracing", +] + +[[package]] +name = "bevy_log" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2aac1187f83a1ab2eae887564f7fb14b4abb3fbe8b2267a6426663463923120" +dependencies = [ + "android_log-sys", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_utils", + "tracing", + "tracing-log", + "tracing-oslog", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b147843b81a7ec548876ff97fa7bfdc646ef2567cb465566259237b39664438" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "bevy_math" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84" +dependencies = [ + "approx", + "arrayvec", + "bevy_reflect", + "derive_more", + "glam", + "itertools 0.14.0", + "libm", + "rand", + "rand_distr", + "serde", + "thiserror 2.0.20", + "variadics_please", +] + +[[package]] +name = "bevy_mesh" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288f590c8173d4cca3cae5f2ba579accd5ed1a35dd3fab338f427eb39d55f05e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mikktspace", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bitflags 2.13.1", + "bytemuck", + "derive_more", + "hexasphere", + "thiserror 2.0.20", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_mikktspace" +version = "0.17.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59" + +[[package]] +name = "bevy_pbr" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bitflags 2.13.1", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "offset-allocator", + "smallvec", + "static_assertions", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_picking" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_window", + "crossbeam-channel", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_platform" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6b36504169b644acd26a5469fd8d371aa6f1d73ee5c01b1b1181ae1cefbf9b" +dependencies = [ + "critical-section", + "foldhash 0.2.0", + "futures-channel", + "hashbrown 0.16.1", + "js-sys", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "bevy_post_process" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f77a4e894aea992e3d6938f1d5898a1cdbb87dba6eebfb95cb4038d0a2600e9" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.1", + "nonmax", + "radsort", + "smallvec", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "bevy_ptr" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a9329e8dc4e01ced480eeec4902e6d7cb56e56ec37f6fbc4323e5c937290a7" + +[[package]] +name = "bevy_reflect" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1dfeb67a9fe4f59003a84f5f99ba6302141c70e926601cbc6abfd4a1eea9ca9" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "erased-serde", + "foldhash 0.2.0", + "glam", + "indexmap", + "inventory", + "petgraph", + "serde", + "smallvec", + "smol_str", + "thiserror 2.0.20", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "475f68c93e9cd5f17e9167635c8533a4f388f12d38245a202359e4c2721d87ba" +dependencies = [ + "bevy_macro_utils", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "bevy_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243523e33fe5dfcebc4240b1eb2fc16e855c5d4c0ea6a8393910740956770f44" +dependencies = [ + "async-channel", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_encase_derive", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render_macros", + "bevy_shader", + "bevy_tasks", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.13.1", + "bytemuck", + "derive_more", + "downcast-rs 2.0.2", + "encase", + "fixedbitset", + "glam", + "image", + "indexmap", + "js-sys", + "naga", + "nonmax", + "offset-allocator", + "send_wrapper", + "smallvec", + "thiserror 2.0.20", + "tracing", + "variadics_please", + "wasm-bindgen", + "web-sys", + "wgpu", +] + +[[package]] +name = "bevy_render_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b6325e9c495a71270446784611e8d7f446f927eac8506c4c099fd10cb4c3ed" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_scene" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cc1047d85ec8048261b63ef675c12f1e6b5782dc0b422fbcee0c140d026bd4" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "derive_more", + "ron", + "serde", + "thiserror 2.0.20", + "uuid", +] + +[[package]] +name = "bevy_shader" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eea95f0273c32be13d6a0b799a93bc256ad7830759ede595c404d5234302da2" +dependencies = [ + "bevy_asset", + "bevy_platform", + "bevy_reflect", + "naga", + "naga_oil", + "serde", + "thiserror 2.0.20", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_sprite" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ec5bc0cbdee551b610a46f41d30374bbe42b8951ffc676253c6243ab2b9395" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_picking", + "bevy_reflect", + "bevy_text", + "bevy_transform", + "bevy_window", + "radsort", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_sprite_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b82cb08905e7ddcea2694a95f757ae7f1fd01e6a7304076bad595d2158e4bfe0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bitflags 2.13.1", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "tracing", +] + +[[package]] +name = "bevy_state" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae0682968e97d29c1eccc8c6bb6283f2678d362779bc03f1bb990967059473b" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_state_macros", + "bevy_utils", + "log", + "variadics_please", +] + +[[package]] +name = "bevy_state_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73d32f90f9cfcef5a44401db7ce206770daaa1707b0fb95eb7a96a6933f54f1b" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_tasks" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384eb04d80aa38664d69988fd30cbbe03e937ecb65c66aa6abe60ce0bca826aa" +dependencies = [ + "async-channel", + "async-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "concurrent-queue", + "crossbeam-queue", + "derive_more", + "futures-lite", + "heapless", + "pin-project", +] + +[[package]] +name = "bevy_text" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc5233291dfc22e584de2535f2e37ae9766d37cb5a01652de2133ba202dcb9b" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "cosmic-text", + "serde", + "smallvec", + "sys-locale", + "thiserror 2.0.20", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_time" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5ef9af4e523195e561074cf60fbfad0f4cb8d1db504855fee3c4ce8896c7244" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "crossbeam-channel", + "log", + "serde", +] + +[[package]] +name = "bevy_transform" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3bb3de7842fef699344beb03f22bdbff16599d788fe0f47fbb3b1e6fa320eb" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_log", + "bevy_math", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "derive_more", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "bevy_ui" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1691a411014085e0d35f8bb8208e5f973edd7ace061a4b1c41c83de21579dc70" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "smallvec", + "taffy", + "thiserror 2.0.20", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_ui_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2c35402d8a052f512e3fec1f36b26e83eee713fcca57f965c244ee795e1fcb0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_text", + "bevy_transform", + "bevy_ui", + "bevy_utils", + "bytemuck", + "derive_more", + "tracing", +] + +[[package]] +name = "bevy_ui_widgets" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_camera", + "bevy_ecs", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_ui", +] + +[[package]] +name = "bevy_utils" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2111910cd7a4b1e6ce07eaaeb6f68a2c0ea0ca609ed0d0d506e3eb161101435b" +dependencies = [ + "bevy_platform", + "disqualified", + "thread_local", +] + +[[package]] +name = "bevy_window" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df06e6993a0896bae2fe7644ae6def29a1a92b45dfb1bcebbd92af782be3638" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "log", + "raw-window-handle", + "serde", +] + +[[package]] +name = "bevy_winit" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2de1c13d32ab8528435b58eca7ab874a1068184c6d6f266ee11433ae99d4069" +dependencies = [ + "accesskit", + "accesskit_winit", + "approx", + "bevy_a11y", + "bevy_android", + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_window", + "bytemuck", + "cfg-if", + "js-sys", + "tracing", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winit", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "bytemuck", + "serde_core", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", + "portable-atomic", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + +[[package]] +name = "const_panic" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3" +dependencies = [ + "typewit", +] + +[[package]] +name = "const_soft_float" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ca1caa64ef4ed453e68bb3db612e51cf1b2f5b871337f0fcab1c8f87cc3dff" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "constgebra" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aaf9b65849a68662ac6c0810c8893a765c960b907dd7cfab9c4a50bf764fbc" +dependencies = [ + "const_soft_float", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cosmic-text" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cadaea21e24c49c0c82116f2b465ae6a49d63c90e428b0f8d9ae1f638ac91f" +dependencies = [ + "bitflags 2.13.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.39.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "disqualified" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encase" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1acbc46b960480c4959213714511e22f9a2e0d361520693bcf5057b5924f53e" +dependencies = [ + "const_panic", + "encase_derive", + "thiserror 2.0.20", +] + +[[package]] +name = "encase_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e30d38c47336a0afbdfde297b442944041cf6dccbb1a79eceea16abfb27a92a" +dependencies = [ + "encase_derive_impl", +] + +[[package]] +name = "encase_derive_impl" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495d24abe406f090f2ba8fc362ecaa8a001e192522aa75eae7b144163c3235ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gilrs" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" +dependencies = [ + "bytemuck", + "encase", + "libm", + "rand", + "serde_core", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "byteorder", + "gltf-json", + "lazy_static", + "serde_json", +] + +[[package]] +name = "gltf-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" +dependencies = [ + "inflections", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gltf-json" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" +dependencies = [ + "gltf-derive", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0caaee032384c10dd597af4579c67dee16650d862a9ccbe1233ff1a379abc07" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.36.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "portable-atomic", + "stable_deref_trait", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hexasphere" +version = "16.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a164ceff4500f2a72b1d21beaa8aa8ad83aec2b641844c659b190cb3ea2e0b" +dependencies = [ + "constgebra", + "glam", + "tinyvec", +] + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "ktx2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7f53bdf698e7aa7ec916411bbdc8078135da11b66db5182675b2227f6c0d07" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "pp-rs", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "naga_oil" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310c347db1b30e69581f3b84dc9a5c311ed583f67851b39b77953cb7a066c97f" +dependencies = [ + "codespan-reporting", + "data-encoding", + "indexmap", + "naga", + "regex", + "rustc-hash 1.1.0", + "thiserror 2.0.20", + "tracing", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "offset-allocator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e234d535da3521eb95106f40f0b73483d80bfb3aacf27c40d7e2b72f1a3e00a2" +dependencies = [ + "log", + "nonmax", +] + +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", + "serde_derive", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radsort" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "rectangle-pack" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d463f2884048e7153449a55166f91028d5b0ea53c79377099ce4e8cf0cf9bb" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rodio" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +dependencies = [ + "cpal", + "lewton", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skate-core" +version = "0.1.0" + +[[package]] +name = "skate-data" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "skate-core", +] + +[[package]] +name = "skate-game" +version = "0.1.0" +dependencies = [ + "bevy", + "skate-core", + "skate-data", +] + +[[package]] +name = "skrifa" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9eb0b904a04d09bd68c65d946617b8ff733009999050f3b851c32fb3cfb60e" +dependencies = [ + "bytemuck", + "read-fonts 0.36.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stackfuture" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115beb9c69db2393ff10b75a1b8587a51716e5551d015001e55320ed279d32f9" +dependencies = [ + "const_panic", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-oslog" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950" +dependencies = [ + "cc", + "cfg-if", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.6.0+11769913", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "serde", + "thiserror 2.0.20", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..b60f3173 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/Launch.ps1 b/Launch.ps1 new file mode 100644 index 00000000..b2fdda15 --- /dev/null +++ b/Launch.ps1 @@ -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 } diff --git a/PLAY.bat b/PLAY.bat new file mode 100644 index 00000000..371a973d --- /dev/null +++ b/PLAY.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Launch.ps1" +if errorlevel 1 pause diff --git a/README.md b/README.md new file mode 100644 index 00000000..56e9ec3f --- /dev/null +++ b/README.md @@ -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. diff --git a/SETUP-PROVENANCE.json b/SETUP-PROVENANCE.json new file mode 100644 index 00000000..c4457fad --- /dev/null +++ b/SETUP-PROVENANCE.json @@ -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" + ] +} diff --git a/crates/skate-core/Cargo.toml b/crates/skate-core/Cargo.toml new file mode 100644 index 00000000..3a98493d --- /dev/null +++ b/crates/skate-core/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "skate-core" +version.workspace = true +edition.workspace = true +publish.workspace = true diff --git a/crates/skate-core/src/air/body_flip.rs b/crates/skate-core/src/air/body_flip.rs new file mode 100644 index 00000000..7ece34de --- /dev/null +++ b/crates/skate-core/src/air/body_flip.rs @@ -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, + /// Hash 3D95C06DC6E3A002. + pub maximum_speed: Option, + /// Hash 89763E47712871C3. + pub spin_scale: Option, + 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], + ] +} diff --git a/crates/skate-core/src/air/body_spin.rs b/crates/skate-core/src/air/body_spin.rs new file mode 100644 index 00000000..c86ecc54 --- /dev/null +++ b/crates/skate-core/src/air/body_spin.rs @@ -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) +} diff --git a/crates/skate-core/src/air/ground_jump/math.rs b/crates/skate-core/src/air/ground_jump/math.rs new file mode 100644 index 00000000..76f8d8da --- /dev/null +++ b/crates/skate-core/src/air/ground_jump/math.rs @@ -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, + ] +} diff --git a/crates/skate-core/src/air/ground_jump/mod.rs b/crates/skate-core/src/air/ground_jump/mod.rs new file mode 100644 index 00000000..55b12e66 --- /dev/null +++ b/crates/skate-core/src/air/ground_jump/mod.rs @@ -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, + } +} diff --git a/crates/skate-core/src/air/ground_jump/types.rs b/crates/skate-core/src/air/ground_jump/types.rs new file mode 100644 index 00000000..9332f049 --- /dev/null +++ b/crates/skate-core/src/air/ground_jump/types.rs @@ -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 +} diff --git a/crates/skate-core/src/air/ground_normal.rs b/crates/skate-core/src/air/ground_normal.rs new file mode 100644 index 00000000..6f6bbf80 --- /dev/null +++ b/crates/skate-core/src/air/ground_normal.rs @@ -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(¤t.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) +} diff --git a/crates/skate-core/src/air/known/body.rs b/crates/skate-core/src/air/known/body.rs new file mode 100644 index 00000000..e57739a4 --- /dev/null +++ b/crates/skate-core/src/air/known/body.rs @@ -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) +} diff --git a/crates/skate-core/src/air/known/data.rs b/crates/skate-core/src/air/known/data.rs new file mode 100644 index 00000000..494f6bbe --- /dev/null +++ b/crates/skate-core/src/air/known/data.rs @@ -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, +} diff --git a/crates/skate-core/src/air/known/footplant.rs b/crates/skate-core/src/air/known/footplant.rs new file mode 100644 index 00000000..cae109f5 --- /dev/null +++ b/crates/skate-core/src/air/known/footplant.rs @@ -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); +} diff --git a/crates/skate-core/src/air/known/lifecycle.rs b/crates/skate-core/src/air/known/lifecycle.rs new file mode 100644 index 00000000..a23eb238 --- /dev/null +++ b/crates/skate-core/src/air/known/lifecycle.rs @@ -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); +} diff --git a/crates/skate-core/src/air/known/mod.rs b/crates/skate-core/src/air/known/mod.rs new file mode 100644 index 00000000..daf992d8 --- /dev/null +++ b/crates/skate-core/src/air/known/mod.rs @@ -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; diff --git a/crates/skate-core/src/air/known/output.rs b/crates/skate-core/src/air/known/output.rs new file mode 100644 index 00000000..0526243c --- /dev/null +++ b/crates/skate-core/src/air/known/output.rs @@ -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); + } +} diff --git a/crates/skate-core/src/air/known/post_physics.rs b/crates/skate-core/src/air/known/post_physics.rs new file mode 100644 index 00000000..82fec84d --- /dev/null +++ b/crates/skate-core/src/air/known/post_physics.rs @@ -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); + } +} diff --git a/crates/skate-core/src/air/known/runtime.rs b/crates/skate-core/src/air/known/runtime.rs new file mode 100644 index 00000000..bc03c97e --- /dev/null +++ b/crates/skate-core/src/air/known/runtime.rs @@ -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; +} diff --git a/crates/skate-core/src/air/known/tests/mock.rs b/crates/skate-core/src/air/known/tests/mock.rs new file mode 100644 index 00000000..4b6ae624 --- /dev/null +++ b/crates/skate-core/src/air/known/tests/mock.rs @@ -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, + 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) { + 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], + } + } +} diff --git a/crates/skate-core/src/air/known/tests/mod.rs b/crates/skate-core/src/air/known/tests/mod.rs new file mode 100644 index 00000000..9ba3b5ff --- /dev/null +++ b/crates/skate-core/src/air/known/tests/mod.rs @@ -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); +} diff --git a/crates/skate-core/src/air/known/trajectory.rs b/crates/skate-core/src/air/known/trajectory.rs new file mode 100644 index 00000000..eb5f7d60 --- /dev/null +++ b/crates/skate-core/src/air/known/trajectory.rs @@ -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 } +} diff --git a/crates/skate-core/src/air/known/update.rs b/crates/skate-core/src/air/known/update.rs new file mode 100644 index 00000000..c87d3895 --- /dev/null +++ b/crates/skate-core/src/air/known/update.rs @@ -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); +} diff --git a/crates/skate-core/src/air/mod.rs b/crates/skate-core/src/air/mod.rs new file mode 100644 index 00000000..04b6ea3a --- /dev/null +++ b/crates/skate-core/src/air/mod.rs @@ -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; diff --git a/crates/skate-core/src/air/reckoning/data.rs b/crates/skate-core/src/air/reckoning/data.rs new file mode 100644 index 00000000..1caefecd --- /dev/null +++ b/crates/skate-core/src/air/reckoning/data.rs @@ -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, +} diff --git a/crates/skate-core/src/air/reckoning/math.rs b/crates/skate-core/src/air/reckoning/math.rs new file mode 100644 index 00000000..5fbf107b --- /dev/null +++ b/crates/skate-core/src/air/reckoning/math.rs @@ -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) + }) +} diff --git a/crates/skate-core/src/air/reckoning/mod.rs b/crates/skate-core/src/air/reckoning/mod.rs new file mode 100644 index 00000000..e5d138fc --- /dev/null +++ b/crates/skate-core/src/air/reckoning/mod.rs @@ -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; diff --git a/crates/skate-core/src/air/reckoning/tests.rs b/crates/skate-core/src/air/reckoning/tests.rs new file mode 100644 index 00000000..a1de5d24 --- /dev/null +++ b/crates/skate-core/src/air/reckoning/tests.rs @@ -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); +} diff --git a/crates/skate-core/src/air/state/board.rs b/crates/skate-core/src/air/state/board.rs new file mode 100644 index 00000000..14ec9791 --- /dev/null +++ b/crates/skate-core/src/air/state/board.rs @@ -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); + } +} diff --git a/crates/skate-core/src/air/state/data.rs b/crates/skate-core/src/air/state/data.rs new file mode 100644 index 00000000..0dce290c --- /dev/null +++ b/crates/skate-core/src/air/state/data.rs @@ -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, +} diff --git a/crates/skate-core/src/air/state/jump_velocity.rs b/crates/skate-core/src/air/state/jump_velocity.rs new file mode 100644 index 00000000..455ab39c --- /dev/null +++ b/crates/skate-core/src/air/state/jump_velocity.rs @@ -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 +} diff --git a/crates/skate-core/src/air/state/lifecycle.rs b/crates/skate-core/src/air/state/lifecycle.rs new file mode 100644 index 00000000..1c925099 --- /dev/null +++ b/crates/skate-core/src/air/state/lifecycle.rs @@ -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) + } +} diff --git a/crates/skate-core/src/air/state/math.rs b/crates/skate-core/src/air/state/math.rs new file mode 100644 index 00000000..794618cb --- /dev/null +++ b/crates/skate-core/src/air/state/math.rs @@ -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) +} diff --git a/crates/skate-core/src/air/state/mod.rs b/crates/skate-core/src/air/state/mod.rs new file mode 100644 index 00000000..bae10dd1 --- /dev/null +++ b/crates/skate-core/src/air/state/mod.rs @@ -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; diff --git a/crates/skate-core/src/air/state/post_physics.rs b/crates/skate-core/src/air/state/post_physics.rs new file mode 100644 index 00000000..0c94ce5e --- /dev/null +++ b/crates/skate-core/src/air/state/post_physics.rs @@ -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); +} diff --git a/crates/skate-core/src/air/state/runtime.rs b/crates/skate-core/src/air/state/runtime.rs new file mode 100644 index 00000000..2d8613d5 --- /dev/null +++ b/crates/skate-core/src/air/state/runtime.rs @@ -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; + 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); +} diff --git a/crates/skate-core/src/air/state/tests/mod.rs b/crates/skate-core/src/air/state/tests/mod.rs new file mode 100644 index 00000000..6fe43b1e --- /dev/null +++ b/crates/skate-core/src/air/state/tests/mod.rs @@ -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, + 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, + 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, + 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, + forced_length: Option, + 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); +} diff --git a/crates/skate-core/src/air/state/update.rs b/crates/skate-core/src/air/state/update.rs new file mode 100644 index 00000000..a4740c11 --- /dev/null +++ b/crates/skate-core/src/air/state/update.rs @@ -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( + 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) +} diff --git a/crates/skate-core/src/air/trajectory/launch.rs b/crates/skate-core/src/air/trajectory/launch.rs new file mode 100644 index 00000000..18853938 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/launch.rs @@ -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 { + 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, + pub velocities: Vec, + 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); +} diff --git a/crates/skate-core/src/air/trajectory/math.rs b/crates/skate-core/src/air/trajectory/math.rs new file mode 100644 index 00000000..7dae5af0 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/math.rs @@ -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]))) +} diff --git a/crates/skate-core/src/air/trajectory/mod.rs b/crates/skate-core/src/air/trajectory/mod.rs new file mode 100644 index 00000000..df8428b3 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/mod.rs @@ -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}; diff --git a/crates/skate-core/src/air/trajectory/prediction.rs b/crates/skate-core/src/air/trajectory/prediction.rs new file mode 100644 index 00000000..690573d7 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/prediction.rs @@ -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) + } +} diff --git a/crates/skate-core/src/air/trajectory/query.rs b/crates/skate-core/src/air/trajectory/query.rs new file mode 100644 index 00000000..89377896 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/query.rs @@ -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( + request: QueryRequest, + mut line: impl FnMut(Vector, Vector, f32) -> Result, E>, + mut nearby: impl FnMut(Vector, f32) -> Result, E>, +) -> Result { + 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) +} diff --git a/crates/skate-core/src/air/trajectory/scoring.rs b/crates/skate-core/src/air/trajectory/scoring.rs new file mode 100644 index 00000000..db97cd01 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/scoring.rs @@ -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, String>, +) -> Result { + 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, String>, +) -> Result { + 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) +} diff --git a/crates/skate-core/src/air/trajectory/selector.rs b/crates/skate-core/src/air/trajectory/selector.rs new file mode 100644 index 00000000..796e6e16 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/selector.rs @@ -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, + batch: Option, + candidates: Vec, + selection: Option, + selected_index: Option, + grind_locked_to_middle: bool, //9653 + grind_normal: Option, //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, //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 { + self.suggested_normal + } + pub fn selection(&self) -> Option<&Selection> { + self.selection.as_ref() + } + pub fn selected_index(&self) -> Option { + self.selected_index + } + pub fn grind_locked_to_middle(&self) -> bool { + self.grind_locked_to_middle + } + pub fn grind_normal(&self) -> Option { + 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 { + self.batch.as_ref().map(|b| b.com_displacement) + } + pub fn local_com_position(&self) -> Option { + self.batch.as_ref().map(|b| b.local_com_position) + } + pub fn local_board_position(&self) -> Option { + self.batch.as_ref().map(|b| b.local_board_position) + } + pub fn board_position(&self) -> Option { + self.batch.as_ref().map(|b| b.board_position) + } + pub fn query_origin(&self) -> Option { + 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 { + 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, String>, + ) -> Result { + 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 + } +} diff --git a/crates/skate-core/src/air/trajectory/types.rs b/crates/skate-core/src/air/trajectory/types.rs new file mode 100644 index 00000000..12501757 --- /dev/null +++ b/crates/skate-core/src/air/trajectory/types.rs @@ -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 +} diff --git a/crates/skate-core/src/animation/air_leg_extension.rs b/crates/skate-core/src/animation/air_leg_extension.rs new file mode 100644 index 00000000..122e6e4e --- /dev/null +++ b/crates/skate-core/src/animation/air_leg_extension.rs @@ -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, + 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, + 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 } +} diff --git a/crates/skate-core/src/animation/body_tilt.rs b/crates/skate-core/src/animation/body_tilt.rs new file mode 100644 index 00000000..b6cd397a --- /dev/null +++ b/crates/skate-core/src/animation/body_tilt.rs @@ -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 { + 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 } +} diff --git a/crates/skate-core/src/animation/channel_playback.rs b/crates/skate-core/src/animation/channel_playback.rs new file mode 100644 index 00000000..a1732a56 --- /dev/null +++ b/crates/skate-core/src/animation/channel_playback.rs @@ -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=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{remaining0.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=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_elapsed0.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,channel:&[AnimationAttribute])->Vec { + 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{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()); + } +} diff --git a/crates/skate-core/src/animation/clip_clock.rs b/crates/skate-core/src/animation/clip_clock.rs new file mode 100644 index 00000000..790523c2 --- /dev/null +++ b/crates/skate-core/src/animation/clip_clock.rs @@ -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 + } + } +} diff --git a/crates/skate-core/src/animation/commands/batch.rs b/crates/skate-core/src/animation/commands/batch.rs new file mode 100644 index 00000000..eaa4cdda --- /dev/null +++ b/crates/skate-core/src/animation/commands/batch.rs @@ -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, + dependencies: Vec, +} +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, + 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], + ) -> Result { + 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, +} +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 { + 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 + } +} diff --git a/crates/skate-core/src/animation/commands/buffers.rs b/crates/skate-core/src/animation/commands/buffers.rs new file mode 100644 index 00000000..c86ab0d7 --- /dev/null +++ b/crates/skate-core/src/animation/commands/buffers.rs @@ -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 { + slot: usize, + owner: usize, + marker: PhantomData, +} +impl Buffer { + pub fn at(self, first_bone: usize) -> BoneSlice { + BoneSlice { + buffer: self, + first_bone, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BoneSlice { + pub buffer: Buffer, + 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 { + owner: usize, + allocations: Vec>>, +} +impl Default for BoneBuffers { + fn default() -> Self { + Self { + owner: new_owner(), + allocations: Vec::new(), + } + } +} +impl BoneBuffers { + /// Allocate exactly the requested extent without inventing a bind pose. + pub fn allocate(&mut self, count: usize) -> Buffer { + self.insert(vec![None; count]) + } + pub fn initialized(&mut self, values: impl IntoIterator) -> Buffer { + self.insert(values.into_iter().map(Some).collect()) + } + fn insert(&mut self, allocation: Vec>) -> Buffer { + let slot = self.allocations.len(); + self.allocations.push(allocation); + Buffer { + slot, + owner: self.owner, + marker: PhantomData, + } + } + fn range( + &self, + source: BoneSlice, + count: usize, + ) -> Result, 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, count: usize) -> Result, 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, + destination: BoneSlice, + 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, + pub matrices: BoneBuffers, +} diff --git a/crates/skate-core/src/animation/commands/mod.rs b/crates/skate-core/src/animation/commands/mod.rs new file mode 100644 index 00000000..10341511 --- /dev/null +++ b/crates/skate-core/src/animation/commands/mod.rs @@ -0,0 +1,4 @@ +//! Typed animation-command storage and recovered workers. +pub mod batch; +pub mod buffers; +pub mod set_data; diff --git a/crates/skate-core/src/animation/commands/set_data.rs b/crates/skate-core/src/animation/commands/set_data.rs new file mode 100644 index 00000000..20d496e6 --- /dev/null +++ b/crates/skate-core/src/animation/commands/set_data.rs @@ -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 { + /// Native opcode 12: byte-sized partial SET count. + Set { + source: BoneSlice, + bone_count: u8, + }, + /// Every other opcode skips the SET copy, but still permits extraction. + Preserve, +} +#[derive(Clone, Copy, Debug)] +pub struct CopyCommand { + pub update: InternalUpdate, + pub internal: BoneSlice, + pub external: Option>, +} +#[derive(Clone, Copy, Debug)] +pub enum SetDataCommand { + Sqt(CopyCommand), + Matrix(CopyCommand), +} +/// 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( + buffers: &mut BoneBuffers, + skeleton_bones: u16, + command: &CopyCommand, +) -> 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(()) +} diff --git a/crates/skate-core/src/animation/crouching.rs b/crates/skate-core/src/animation/crouching.rs new file mode 100644 index 00000000..5478bd81 --- /dev/null +++ b/crates/skate-core/src/animation/crouching.rs @@ -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,pub auto_pump_magnitude:Option,pub crouch:Option,pub hard_turn_crouch:Option,pub manual:Option, + /// 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 !(absorptiona.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_potentiala.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()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))} diff --git a/crates/skate-core/src/animation/foot_ik/blend.rs b/crates/skate-core/src/animation/foot_ik/blend.rs new file mode 100644 index 00000000..194d9a40 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/blend.rs @@ -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 +} diff --git a/crates/skate-core/src/animation/foot_ik/contact.rs b/crates/skate-core/src/animation/foot_ik/contact.rs new file mode 100644 index 00000000..8323bcd5 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/contact.rs @@ -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; +} diff --git a/crates/skate-core/src/animation/foot_ik/drive.rs b/crates/skate-core/src/animation/foot_ik/drive.rs new file mode 100644 index 00000000..37715cf9 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/drive.rs @@ -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; 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; 24], + part_frames: &[Transform; 24], + ) -> Result { + 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 { + 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 { + 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 { + 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]; + } +} diff --git a/crates/skate-core/src/animation/foot_ik/external.rs b/crates/skate-core/src/animation/foot_ik/external.rs new file mode 100644 index 00000000..61ad5b7b --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/external.rs @@ -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); + } +} diff --git a/crates/skate-core/src/animation/foot_ik/math.rs b/crates/skate-core/src/animation/foot_ik/math.rs new file mode 100644 index 00000000..feb1c5a8 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/math.rs @@ -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 +} diff --git a/crates/skate-core/src/animation/foot_ik/mod.rs b/crates/skate-core/src/animation/foot_ik/mod.rs new file mode 100644 index 00000000..ca465219 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/mod.rs @@ -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; diff --git a/crates/skate-core/src/animation/foot_ik/physical_solve.rs b/crates/skate-core/src/animation/foot_ik/physical_solve.rs new file mode 100644 index 00000000..59ef7de8 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/physical_solve.rs @@ -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]) +} diff --git a/crates/skate-core/src/animation/foot_ik/post_contact.rs b/crates/skate-core/src/animation/foot_ik/post_contact.rs new file mode 100644 index 00000000..af122be0 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/post_contact.rs @@ -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}" + ); + } + } +} diff --git a/crates/skate-core/src/animation/foot_ik/post_physics.rs b/crates/skate-core/src/animation/foot_ik/post_physics.rs new file mode 100644 index 00000000..a41b1588 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/post_physics.rs @@ -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]) +} diff --git a/crates/skate-core/src/animation/foot_ik/settings.rs b/crates/skate-core/src/animation/foot_ik/settings.rs new file mode 100644 index 00000000..edf42a3e --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/settings.rs @@ -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, +} diff --git a/crates/skate-core/src/animation/foot_ik/state.rs b/crates/skate-core/src/animation/foot_ik/state.rs new file mode 100644 index 00000000..df94193a --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/state.rs @@ -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, + ) + } +} diff --git a/crates/skate-core/src/animation/foot_ik/status.rs b/crates/skate-core/src/animation/foot_ik/status.rs new file mode 100644 index 00000000..9ba2fdde --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/status.rs @@ -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; +} diff --git a/crates/skate-core/src/animation/foot_ik/tests.rs b/crates/skate-core/src/animation/foot_ik/tests.rs new file mode 100644 index 00000000..25904854 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/tests.rs @@ -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); +} diff --git a/crates/skate-core/src/animation/foot_ik/transforms.rs b/crates/skate-core/src/animation/foot_ik/transforms.rs new file mode 100644 index 00000000..d6abdbd2 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/transforms.rs @@ -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, +} + +#[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 +} diff --git a/crates/skate-core/src/animation/foot_ik/two_bone.rs b/crates/skate-core/src/animation/foot_ik/two_bone.rs new file mode 100644 index 00000000..ea96da58 --- /dev/null +++ b/crates/skate-core/src/animation/foot_ik/two_bone.rs @@ -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)) +} diff --git a/crates/skate-core/src/animation/ground_acceleration.rs b/crates/skate-core/src/animation/ground_acceleration.rs new file mode 100644 index 00000000..907b86be --- /dev/null +++ b/crates/skate-core/src/animation/ground_acceleration.rs @@ -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)); + } +} diff --git a/crates/skate-core/src/animation/intent_filter.rs b/crates/skate-core/src/animation/intent_filter.rs new file mode 100644 index 00000000..2e45cbf4 --- /dev/null +++ b/crates/skate-core/src/animation/intent_filter.rs @@ -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, + pub blend_rising: f32, + pub blend_falling: f32, + pub blend_out: Option, + pub clamp_velocity: Option, + pub clamp_acceleration: Option, +} + +#[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, + 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; diff --git a/crates/skate-core/src/animation/intent_filter/tests.rs b/crates/skate-core/src/animation/intent_filter/tests.rs new file mode 100644 index 00000000..4ab9e097 --- /dev/null +++ b/crates/skate-core/src/animation/intent_filter/tests.rs @@ -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); +} diff --git a/crates/skate-core/src/animation/kickturn.rs b/crates/skate-core/src/animation/kickturn.rs new file mode 100644 index 00000000..0283735f --- /dev/null +++ b/crates/skate-core/src/animation/kickturn.rs @@ -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; diff --git a/crates/skate-core/src/animation/landing_quality.rs b/crates/skate-core/src/animation/landing_quality.rs new file mode 100644 index 00000000..5a62f350 --- /dev/null +++ b/crates/skate-core/src/animation/landing_quality.rs @@ -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 } +} diff --git a/crates/skate-core/src/animation/mod.rs b/crates/skate-core/src/animation/mod.rs new file mode 100644 index 00000000..00960638 --- /dev/null +++ b/crates/skate-core/src/animation/mod.rs @@ -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; diff --git a/crates/skate-core/src/animation/output/actor_packet.rs b/crates/skate-core/src/animation/output/actor_packet.rs new file mode 100644 index 00000000..4028dbd9 --- /dev/null +++ b/crates/skate-core/src/animation/output/actor_packet.rs @@ -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; + /// Record+164. This read is skipped by the fixed-settings branch. + fn wheel_hardness(&mut self, player_index: u32) -> Result; + /// Record+172. Read after either branch has written both output scalars. + fn requested_physics_mode(&mut self, player_index: u32) -> Result; +} + +/// 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; + ///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, Self::Error>; + /// Read global byte830B7C30 verbatim, without bool normalization. + fn ignore_respawn_reset_button(&mut self) -> Result; + /// 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; + /// 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; +} + +/// 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( + 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); +} diff --git a/crates/skate-core/src/animation/output/attributes.rs b/crates/skate-core/src/animation/output/attributes.rs new file mode 100644 index 00000000..7cc2e568 --- /dev/null +++ b/crates/skate-core/src/animation/output/attributes.rs @@ -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; 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, + 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); + } + } +} diff --git a/crates/skate-core/src/animation/output/hierarchy.rs b/crates/skate-core/src/animation/output/hierarchy.rs new file mode 100644 index 00000000..aedbaa68 --- /dev/null +++ b/crates/skate-core/src/animation/output/hierarchy.rs @@ -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 +} diff --git a/crates/skate-core/src/animation/output/intents.rs b/crates/skate-core/src/animation/output/intents.rs new file mode 100644 index 00000000..184f318b --- /dev/null +++ b/crates/skate-core/src/animation/output/intents.rs @@ -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; 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/crates/skate-core/src/animation/output/mod.rs b/crates/skate-core/src/animation/output/mod.rs new file mode 100644 index 00000000..ed1c7371 --- /dev/null +++ b/crates/skate-core/src/animation/output/mod.rs @@ -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 }, +} diff --git a/crates/skate-core/src/animation/output/motion_graph_packet.rs b/crates/skate-core/src/animation/output/motion_graph_packet.rs new file mode 100644 index 00000000..1862ebf8 --- /dev/null +++ b/crates/skate-core/src/animation/output/motion_graph_packet.rs @@ -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]; +} diff --git a/crates/skate-core/src/animation/output/packet_reset.rs b/crates/skate-core/src/animation/output/packet_reset.rs new file mode 100644 index 00000000..2275e96d --- /dev/null +++ b/crates/skate-core/src/animation/output/packet_reset.rs @@ -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(()) +} diff --git a/crates/skate-core/src/animation/output/physics_packet.rs b/crates/skate-core/src/animation/output/physics_packet.rs new file mode 100644 index 00000000..0fb4d9aa --- /dev/null +++ b/crates/skate-core/src/animation/output/physics_packet.rs @@ -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, +} + +pub struct ActorPoseBuffers { + /// Actor-owned hierarchy-global matrices, full SkaterAnim+13460. + pub hierarchy: BoneSlice, + /// Actor-owned local matrices, full SkaterAnim+13468. + pub local: BoneSlice, +} + +/// 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, + pub local: Vec, + 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 { + 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 { + let count=packet.bone_count as usize; + if hierarchy.len() 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 +} diff --git a/crates/skate-core/src/animation/output/setup_physics.rs b/crates/skate-core/src/animation/output/setup_physics.rs new file mode 100644 index 00000000..12c30fb0 --- /dev/null +++ b/crates/skate-core/src/animation/output/setup_physics.rs @@ -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 { + 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, PublicationError> { + 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 }) +} diff --git a/crates/skate-core/src/animation/output/sqt.rs b/crates/skate-core/src/animation/output/sqt.rs new file mode 100644 index 00000000..bad5835f --- /dev/null +++ b/crates/skate-core/src/animation/output/sqt.rs @@ -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 { + 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, + ], + ] +} diff --git a/crates/skate-core/src/animation/output/tests/intents.rs b/crates/skate-core/src/animation/output/tests/intents.rs new file mode 100644 index 00000000..af8b9789 --- /dev/null +++ b/crates/skate-core/src/animation/output/tests/intents.rs @@ -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() + ); +} diff --git a/crates/skate-core/src/animation/phase_blend.rs b/crates/skate-core/src/animation/phase_blend.rs new file mode 100644 index 00000000..3152e99c --- /dev/null +++ b/crates/skate-core/src/animation/phase_blend.rs @@ -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, + order: Vec, + values: Vec, + 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) -> Result { + 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 { + 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,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 { + 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) -> Result { + 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) + } +} diff --git a/crates/skate-core/src/animation/physical_feedback.rs b/crates/skate-core/src/animation/physical_feedback.rs new file mode 100644 index 00000000..ad90fff6 --- /dev/null +++ b/crates/skate-core/src/animation/physical_feedback.rs @@ -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 + } +} diff --git a/crates/skate-core/src/animation/playback.rs b/crates/skate-core/src/animation/playback.rs new file mode 100644 index 00000000..00d00cd6 --- /dev/null +++ b/crates/skate-core/src/animation/playback.rs @@ -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, + pub mirror_animation: Option, + pub no_board_animation: Option, + pub playback_speed: f32, + pub apply_posture: bool, + pub transition: TransitionSettings, + pub parameters: Vec, +} + +#[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, + pub is_mirrored: Option, + pub board_available: Option, + pub pro_skater: AttributeName, + pub transition_override: Option, +} + +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; +} + +#[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); + 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(()) +} diff --git a/crates/skate-core/src/animation/playback_attributes.rs b/crates/skate-core/src/animation/playback_attributes.rs new file mode 100644 index 00000000..aefce9ac --- /dev/null +++ b/crates/skate-core/src/animation/playback_attributes.rs @@ -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, right: &[AnimationAttribute], weight: f32) -> Result, 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) +} diff --git a/crates/skate-core/src/animation/playback_clip.rs b/crates/skate-core/src/animation/playback_clip.rs new file mode 100644 index 00000000..f9de4c9f --- /dev/null +++ b/crates/skate-core/src/animation/playback_clip.rs @@ -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, +} + +#[derive(Clone, Debug)] +pub struct PlaybackClip { + pub clock: ClipClock, + pub attributes: Vec, +} +impl PlaybackClip { + pub fn new(frames: f32, fps: f32, base_speed: f32, flags: u32, attributes: Vec) -> 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, 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, 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 { + 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 { + 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)) +} diff --git a/crates/skate-core/src/animation/playback_parameters.rs b/crates/skate-core/src/animation/playback_parameters.rs new file mode 100644 index 00000000..fff8f2ae --- /dev/null +++ b/crates/skate-core/src/animation/playback_parameters.rs @@ -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, + pub default_value: Option, + 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; + fn filtered_intent(&self, name: &str) -> Option; + fn last_attribute(&mut self, name: AttributeName) -> Result, String>; +} + +pub trait AttributeSink { + fn set_attribute(&mut self, attribute: SettableAttribute); +} + +#[derive(Clone, Debug, Default)] +pub struct SettableAttributes { + entries: Vec, +} +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] +} diff --git a/crates/skate-core/src/animation/playback_transition.rs b/crates/skate-core/src/animation/playback_transition.rs new file mode 100644 index 00000000..d57ecd96 --- /dev/null +++ b/crates/skate-core/src/animation/playback_transition.rs @@ -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, + pub to:Box, + 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 { + 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,String> {self.attributes_child().attributes(mask)} + pub fn query_attribute(&self,name:AttributeName,mask:u32,output:&mut AnimationAttribute)->Result {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.seconds1.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)->Result { + 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) + } +} diff --git a/crates/skate-core/src/animation/playback_tree.rs b/crates/skate-core/src/animation/playback_tree.rs new file mode 100644 index 00000000..44f4e1a3 --- /dev/null +++ b/crates/skate-core/src/animation/playback_tree.rs @@ -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, + posture: Option, + board_backwards: bool, + mirror_modes: Vec, + attribute_mirror: std::sync::Arc, + }, +} +#[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 { + 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, 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, 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 { + 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, + ) -> Result { + 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; diff --git a/crates/skate-core/src/animation/playback_tree/selection_space.rs b/crates/skate-core/src/animation/playback_tree/selection_space.rs new file mode 100644 index 00000000..af36818c --- /dev/null +++ b/crates/skate-core/src/animation/playback_tree/selection_space.rs @@ -0,0 +1,159 @@ +//! Type11's authored metric and first-update selection: 82D26CC0/82D26FF8. +//! Native construction clears transition/reselect bits. No exposed operation +//! on this tree enables them; ordinary playback therefore latches one child. +use super::super::{output::attributes::AttributeName, playback_parameters::SettableAttribute}; +use super::PlaybackTree; + +#[derive(Clone, Debug)] +pub struct Parameter { + pub name: AttributeName, + pub mode: u32, + pub weight: f32, + pub minimum: f32, + pub maximum: f32, +} +#[derive(Clone, Debug)] +pub struct Candidate { + pub name: String, + pub values: Vec, + pub tree: PlaybackTree, +} +#[derive(Clone, Debug)] +pub struct SelectionSpace { + pub parameters: Vec, + pub candidates: Vec, + pub selected: Option, + /// Host GetAnimTree callback has supplied the selected child's wrappers. + pub child_constructed: bool, + values: Vec>, + speed: f32, + requested_time: f32, +} +impl SelectionSpace { + pub fn new(parameters: Vec, candidates: Vec) -> Result { + if parameters.len() > 10 + || candidates.is_empty() + || candidates + .iter() + .any(|c| c.values.len() != parameters.len()) + { + return Err("Invalid native selection space dimensions".into()); + } + let values = vec![None; parameters.len()]; + Ok(Self { + parameters, + candidates, + selected: None, + child_constructed: false, + values, + speed: 1.0, + requested_time: 0.0, + }) + } + pub fn current(&self) -> Option<&PlaybackTree> { + self.selected.map(|i| &self.candidates[i].tree) + } + pub fn current_mut(&mut self) -> Option<&mut PlaybackTree> { + self.selected.map(|i| &mut self.candidates[i].tree) + } + pub fn set_time(&mut self, time: f32) { + self.requested_time = time; + if let Some(child) = self.current_mut() { + child.set_time(time); + } + } + pub fn set_speed(&mut self, speed: f32) { + self.speed = speed; + if let Some(child) = self.current_mut() { + child.set_speed(speed); + } + } + pub fn select(&mut self, attributes: &[SettableAttribute]) -> Result<(), String> { + for attribute in attributes { + for (parameter, value) in self.parameters.iter().zip(&mut self.values) { + if parameter.name == attribute.name { + *value = Some(attribute.value); + } + } + } + if self.selected.is_none() { + let values = self + .values + .iter() + .enumerate() + .map(|(i, value)| { + value.ok_or_else(|| { + format!("SelectionSpace parameter {} has no source producer", i) + }) + }) + .collect::, _>>()?; + let mut best = f32::MAX; + let mut selected = None; + for (i, candidate) in self.candidates.iter().enumerate() { + let distance = distance(&self.parameters, &values, &candidate.values); + if distance < best { + best = distance; + selected = Some(i); + } + } + let chosen = selected + .ok_or_else(|| "SelectionSpace has no finite native minimum".to_string())?; + //82D26EE4..F88 resolves the first child record with the chosen name. + self.selected = self + .candidates + .iter() + .position(|c| c.name == self.candidates[chosen].name); + } + Ok(()) + } + pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result { + self.select(attributes)?; + let speed = self.speed; + if let Some(child) = self.current_mut() { + child.set_attributes(attributes)?; + child.set_speed(speed); + } + Ok(true) + } +} + +/// Original 82D26FF8 uses two accumulators and FMA for pairs, then a separate +/// multiply/multiply/add for an odd tail. Do not reorder into a generic sum. +pub fn distance(parameters: &[Parameter], values: &[f32], candidate: &[f32]) -> f32 { + let mut even = 0.0f32; + let mut odd = 0.0f32; + let pairs = parameters.len() / 2 * 2; + for i in (0..pairs).step_by(2) { + let d = delta(¶meters[i], values[i], candidate[i]); + even = (parameters[i].weight * d).mul_add(d, even); + let d = delta(¶meters[i + 1], values[i + 1], candidate[i + 1]); + odd = (parameters[i + 1].weight * d).mul_add(d, odd); + } + let sum = odd + even; + if pairs < parameters.len() { + let d = delta(¶meters[pairs], values[pairs], candidate[pairs]); + sum + (parameters[pairs].weight * d) * d + } else { + sum + 0.0 + } +} +fn delta(parameter: &Parameter, mut value: f32, mut candidate: f32) -> f32 { + let range = parameter.maximum - parameter.minimum; + if range > f32::from_bits(0x3780_0000) { + candidate /= range; + value /= range; + } + match parameter.mode { + 1 if value > candidate => (1.0 - value) + candidate, + 2 => { + let difference = (value - candidate).abs(); + let wrapped = 1.0 - difference; + if difference - wrapped >= 0.0 { + wrapped + } else { + difference + } + } + _ => candidate - value, + } +} diff --git a/crates/skate-core/src/animation/pose_add.rs b/crates/skate-core/src/animation/pose_add.rs new file mode 100644 index 00000000..2282311c --- /dev/null +++ b/crates/skate-core/src/animation/pose_add.rs @@ -0,0 +1,18 @@ +//! ACS immediate AddSQT828CCC08, used by SkaterAnim::AddBindPose82B98118. +use super::{output::Sqt, pose_trajectory::{multiply, rotate}}; + +/// Native operand B supplies the reference frame. The designated motion +/// operand supplies the channel weight, independently of operand ordering. +pub fn add(a: Sqt, b: Sqt, motion_is_a: bool) -> Sqt { + let rotated = rotate(b.rotation, [a.translation[0], a.translation[1], a.translation[2]]); + Sqt { + scale: core::array::from_fn(|i| b.scale[i] * a.scale[i]), + rotation: multiply(b.rotation, a.rotation), + translation: [ + rotated[0] + b.translation[0], + rotated[1] + b.translation[1], + rotated[2] + b.translation[2], + if motion_is_a { a.translation[3] } else { b.translation[3] }, + ], + } +} diff --git a/crates/skate-core/src/animation/pose_blend.rs b/crates/skate-core/src/animation/pose_blend.rs new file mode 100644 index 00000000..bf178f29 --- /dev/null +++ b/crates/skate-core/src/animation/pose_blend.rs @@ -0,0 +1,62 @@ +//! Standard SQT blend in TU3 ACS828CB418..4A4 and worker828D6BA8..6C2C. +//! The caller owns tree order and coefficients; interpolation does not flatten +//! nested blends or use a renderer's quaternion blend implementation. +use super::output::{PoseBufferError, Sqt}; +use crate::physics::native_arithmetic; + +pub fn blend( + first: &[Sqt], + second: &[Sqt], + weight: f32, + output: &mut [Sqt], +) -> Result<(), PoseBufferError> { + if first.len() != second.len() { + return Err(PoseBufferError::ShortInput); + } + if output.len() < first.len() { + return Err(PoseBufferError::ShortOutput); + } + for ((&a, &b), destination) in first.iter().zip(second).zip(output) { + *destination = blend_sample(a, b, weight); + } + Ok(()) +} + +pub fn blend_sample(first: Sqt, second: Sqt, weight: f32) -> Sqt { + let positive = native_arithmetic::dot4(first.rotation, second.rotation) > 0.0; + let rotation = core::array::from_fn(|lane| { + if positive { + (second.rotation[lane] - first.rotation[lane]).mul_add(weight, first.rotation[lane]) + } else { + (-(second.rotation[lane] + first.rotation[lane])).mul_add(weight, first.rotation[lane]) + } + }); + let squared = native_arithmetic::dot4(rotation, rotation); + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + Sqt { + scale: interpolate(first.scale, second.scale, weight), + rotation: rotation.map(|lane| lane * inverse), + translation: interpolate(first.translation, second.translation, weight), + } +} + +/// ACSChannelBlend828CC91C..CA64 selects the first subtree's translation.W +/// when requested, otherwise the second's, and clamps the weighted coefficient. +pub fn channel_blend_sample(first: Sqt, second: Sqt, weight: f32, use_first_weights: bool) -> Sqt { + let channel = if use_first_weights { first.translation[3] } else { second.translation[3] }; + let coefficient = weight * channel; + let coefficient = if coefficient > 1.0 { 1.0 } else { coefficient }; + let coefficient = if 0.0 > coefficient { 0.0 } else { coefficient }; + blend_sample(first, second, coefficient) +} + +fn interpolate(first: [f32; 4], second: [f32; 4], weight: f32) -> [f32; 4] { + core::array::from_fn(|lane| (second[lane] - first[lane]).mul_add(weight, first[lane])) +} + +#[cfg(test)] +#[path = "tests/pose_blend.rs"] +mod tests; diff --git a/crates/skate-core/src/animation/pose_mirror.rs b/crates/skate-core/src/animation/pose_mirror.rs new file mode 100644 index 00000000..9b506c5c --- /dev/null +++ b/crates/skate-core/src/animation/pose_mirror.rs @@ -0,0 +1,37 @@ +//! ACS immediate Mirror828CDAF8. Pairing comes from the stock hierarchy. +use super::{output::Sqt, pose_trajectory::multiply}; + +pub fn mirror(pose: &mut [Sqt], parents: &[i32], partners: &[i32], trajectory_mode: u32) -> Result<(), String> { + if pose.len() != parents.len() || pose.len() != partners.len() { + return Err("Mirror pose and hierarchy dimensions differ".into()); + } + for (i, &partner) in partners.iter().enumerate() { + if partner < -1 || partner >= pose.len() as i32 { + return Err(format!("Invalid mirror partner for bone{i}")); + } + // -1 excludes a bone; the smaller index processes both sides of a pair. + if partner < i as i32 { continue; } + if trajectory_mode == 1 && (i == 0 || parents[i] == 0) { + if i != 0 { + // Literal quaternion8232F740, before the alternate reflection. + pose[i].rotation = multiply(pose[i].rotation, [0.0, 1.0, 0.0, 0.0]); + } + pose[i].rotation[1] *= -1.0; + pose[i].rotation[2] *= -1.0; + pose[i].translation[0] *= -1.0; + } else { + reflect(&mut pose[i]); + } + if partner > i as i32 { + pose.swap(i, partner as usize); + reflect(&mut pose[i]); + } + } + Ok(()) +} + +fn reflect(pose: &mut Sqt) { + pose.rotation[0] *= -1.0; + pose.rotation[1] *= -1.0; + pose.translation[2] *= -1.0; +} diff --git a/crates/skate-core/src/animation/pose_sample.rs b/crates/skate-core/src/animation/pose_sample.rs new file mode 100644 index 00000000..607f2d9b --- /dev/null +++ b/crates/skate-core/src/animation/pose_sample.rs @@ -0,0 +1,76 @@ +//! TU3 timed frame selection82D20788 and decoded VBR interpolation82D1DE08. +//! The data adapter supplies complete decoded keys; no clip substitution occurs. +use super::{output::Sqt, pose_blend::blend_sample}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameSelection { + pub first: usize, + pub second: usize, + pub coefficient: f32, +} + +/// Clip evaluation supplies bounded nonnegative seconds. Frame blending uses +/// zero truncation offset; disabled blending uses the FetchSys setting at+8. +pub fn select_frames( + time: f32, + fps: f32, + frames: usize, + blend_frames: bool, + truncation_offset: f32, +) -> Result { + if frames == 0 || !time.is_finite() || time < 0.0 || !fps.is_finite() || fps <= 0.0 { + return Err("Invalid stock animation sampling input"); + } + let last = (frames - 1) as f32; + let position = time * fps; + let position = if position - last >= 0.0 { + last + } else { + position + }; + let offset = if blend_frames { 0.0 } else { truncation_offset }; + let first = (position + offset) as usize; + if first >= frames { + return Err("Stock frame selection exceeds decoded samples"); + } + let second = if blend_frames { + (first + 1).min(frames - 1) + } else { + first + }; + //82D20C08 truncates to16bits,82D1E090 expands using literal822F88B8. + let fraction = ((position - first as f32) * 65535.0) as u16; + Ok(FrameSelection { + first, + second, + coefficient: f32::from(fraction) * f32::from_bits(0x3780_0080), + }) +} + +pub fn sample_key(first: Sqt, second: Sqt, selection: FrameSelection) -> Sqt { + // Same-frame extraction does not normalize its decoded quaternion. + if selection.first == selection.second { + first + } else { + blend_sample(first, second, selection.coefficient) + } +} + +/// TU3 VBR's two-frame extraction selects the second compressed block at an +/// 8-frame boundary, and uses its second frame for both decoded keys. +/// Original raw82D1E0C0..E0 selects the block;82E89BD0..BF4 replaces the first +/// intra-block index. Keep the ORIGINAL selection for interpolation: two-frame +/// extraction still normalizes the quaternion, even when both samples coincide. +pub fn sample_key_vbr(first: Sqt, second: Sqt, selection: FrameSelection) -> Sqt { + let crosses_block = selection.first / 8 != selection.second / 8; + let copies_second = crosses_block && (selection.first % 8 == 7 || selection.second % 8 == 0); + sample_key( + if copies_second { second } else { first }, + second, + selection, + ) +} + +#[cfg(test)] +#[path = "tests/pose_sample_vbr.rs"] +mod vbr_tests; diff --git a/crates/skate-core/src/animation/pose_trajectory.rs b/crates/skate-core/src/animation/pose_trajectory.rs new file mode 100644 index 00000000..9584dd24 --- /dev/null +++ b/crates/skate-core/src/animation/pose_trajectory.rs @@ -0,0 +1,61 @@ +//! TU3 FetchSys trajectory delta82D178D8. This modifies only the trajectory SQT. +use super::output::Sqt; +use crate::physics::native_arithmetic::dot3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LoopTransform { + pub rotation: [f32; 4], + pub translation: [f32; 3], +} + +/// BatchFetch82D20A40 selects the authored transform when the loop argument is +/// nonzero, otherwise identity. It does not exponentiate by the loop count. +pub fn delta(current: Sqt, previous: Sqt, loop_transform: Option) -> Sqt { + let loop_transform = loop_transform.unwrap_or(LoopTransform { + rotation: [0.0, 0.0, 0.0, 1.0], + translation: [0.0; 3], + }); + let inverse_previous = [ + -previous.rotation[0], -previous.rotation[1], -previous.rotation[2], + previous.rotation[3], + ]; + let loop_displacement = rotate(current.rotation, loop_transform.translation); + let displacement = core::array::from_fn(|i| { + (loop_displacement[i] + current.translation[i]) - previous.translation[i] + }); + let translation = rotate(inverse_previous, displacement); + let rotation = multiply(multiply(loop_transform.rotation, current.rotation), inverse_previous); + Sqt { + scale: current.scale, + rotation, + // The extractor restores the channel weight separately for channel clips. + translation: [translation[0], translation[1], translation[2], current.translation[3]], + } +} + +/// Fused cross arithmetic from82D17908..17A24, without quaternion normalization. +fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 3] { + [ + (-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]), + ] +} + +pub(super) fn rotate(q: [f32; 4], v: [f32; 3]) -> [f32; 3] { + let vector = [v[0], v[1], v[2], 0.0]; + let first = cross(q, vector); + let intermediate = core::array::from_fn(|i| if i < 3 { q[3].mul_add(v[i], first[i]) } else { 0.0 }); + let second = cross(q, intermediate); + core::array::from_fn(|i| 2.0f32.mul_add(second[i], v[i])) +} + +pub(super) fn multiply(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + let vector = cross(a, b); + [ + a[0].mul_add(b[3], b[0].mul_add(a[3], vector[0])), + a[1].mul_add(b[3], b[1].mul_add(a[3], vector[1])), + a[2].mul_add(b[3], b[2].mul_add(a[3], vector[2])), + a[3] * b[3] - dot3(a, b), + ] +} diff --git a/crates/skate-core/src/animation/posture.rs b/crates/skate-core/src/animation/posture.rs new file mode 100644 index 00000000..0c144ff6 --- /dev/null +++ b/crates/skate-core/src/animation/posture.rs @@ -0,0 +1,86 @@ +//! TU3 posture profile selection and the pending GetAnimTree construction latch. +//! 82590B50 maps profile values; 82678990 sets the latch; 82B984C8 consumes it. +//! Channel construction bypasses this service entirely (82D1CC20/82D1D0F0). + +/// Registration order at 82858810, independent of asset directory ordering. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PosturePose { + Stiff, + Slouch, + Buff, +} + +impl PosturePose { + pub const fn from_profile(value: u32) -> Option { + match value { + 1 => Some(Self::Stiff), + 2 => Some(Self::Slouch), + 3 => Some(Self::Buff), + _ => None, + } + } + + pub const fn name(self) -> &'static str { + match self { + Self::Stiff => "POSTURE_STIFF_POSE", + Self::Slouch => "POSTURE_SLOUCH_POSE", + Self::Buff => "POSTURE_BUFF_POSE", + } + } +} + +/// Actual profile selection and a request for the next eligible main tree. +/// Native reset 824FA9A0 supplies profile 0; constructor 82B973C8 clears pending. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PendingPosture { + profile: u32, + pending: bool, +} + +impl PendingPosture { + /// Profile refresh changes selection without inventing a playback request. + pub fn set_profile(&mut self, profile: u32) { + self.profile = profile; + } + + pub fn profile(&self) -> u32 { + self.profile + } + + /// Every Play Begin overwrites this latch, including an authored false. + pub fn set_requested(&mut self, requested: bool) { + self.pending = requested; + } + + pub fn is_pending(&self) -> bool { + self.pending + } + + pub fn selected_pose(&self) -> Option { + if self.pending { + PosturePose::from_profile(self.profile) + } else { + None + } + } + + /// Wrap a successfully built main motion tree before its bind-pose wrapper. + /// The caller must first establish a valid registered static-pose bank. + /// Missing motion/bank must skip this call and leave the latch untouched. + /// Clear only after posture construction succeeds, before outer bind work. + pub fn apply( + &mut self, + motion: T, + wrap: impl FnOnce(T, PosturePose) -> Result, + ) -> Result { + let Some(pose) = self.selected_pose() else { + return Ok(motion); + }; + let wrapped = wrap(motion, pose)?; + self.pending = false; + Ok(wrapped) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/animation/posture/tests.rs b/crates/skate-core/src/animation/posture/tests.rs new file mode 100644 index 00000000..2e3beba2 --- /dev/null +++ b/crates/skate-core/src/animation/posture/tests.rs @@ -0,0 +1,70 @@ +use super::{PendingPosture, PosturePose}; + +#[test] +fn native_default_stays_disabled_even_after_request() { + let mut state = PendingPosture::default(); + assert_eq!(state.profile(), 0); + assert!(!state.is_pending()); + state.set_requested(true); + let tree = state.apply::<_, ()>(7, |_, _| panic!("disabled profile wrapped")); + assert_eq!(tree, Ok(7)); + assert!(state.is_pending()); +} + +#[test] +fn profile_values_follow_registration_not_alphabetical_order() { + for (value, name) in [ + (1, "POSTURE_STIFF_POSE"), + (2, "POSTURE_SLOUCH_POSE"), + (3, "POSTURE_BUFF_POSE"), + ] { + assert_eq!(PosturePose::from_profile(value).unwrap().name(), name); + } + for value in [0, 4, u32::MAX] { + assert_eq!(PosturePose::from_profile(value), None); + } +} + +#[test] +fn failed_construction_keeps_request_for_fallback() { + let mut state = PendingPosture::default(); + state.set_profile(2); + state.set_requested(true); + assert_eq!(state.apply((), |_, _| Err("missing pose")), Err("missing pose")); + assert!(state.is_pending()); + let tree = state.apply::<_, ()>(Vec::new(), |mut tree, pose| { + tree.push(pose); + Ok(tree) + }).unwrap(); + assert_eq!(tree, [PosturePose::Slouch]); + assert!(!state.is_pending()); +} + +#[test] +fn synchronous_begins_each_wrap_without_changing_previous_tree() { + let mut state = PendingPosture::default(); + state.set_profile(1); + let mut trees = Vec::new(); + for requested in [true, true, false] { + state.set_requested(requested); + trees.push(state.apply::<_, ()>(None, |_, pose| Ok(Some(pose))).unwrap()); + } + assert_eq!(trees, [Some(PosturePose::Stiff), Some(PosturePose::Stiff), None]); + assert!(!state.is_pending()); +} + +#[test] +fn false_overwrites_unconsumed_request_and_refresh_does_not_request() { + let mut state = PendingPosture::default(); + state.set_requested(true); + state.set_requested(false); + state.set_profile(3); + assert_eq!(state.selected_pose(), None); + state.set_requested(true); + assert_eq!(state.selected_pose(), Some(PosturePose::Buff)); + state.set_profile(99); + assert_eq!(state.selected_pose(), None); + assert!(state.is_pending()); + state.set_profile(1); + assert_eq!(state.selected_pose(), Some(PosturePose::Stiff)); +} diff --git a/crates/skate-core/src/animation/posture/tree_tests.rs b/crates/skate-core/src/animation/posture/tree_tests.rs new file mode 100644 index 00000000..77ad9076 --- /dev/null +++ b/crates/skate-core/src/animation/posture/tree_tests.rs @@ -0,0 +1,69 @@ +use crate::animation::{ + playback_attributes::AttributeMirror, + playback_clip::PlaybackClip, + playback_tree::{Evaluation, PlaybackTree, PoseCommand}, + posture::PosturePose, +}; +use std::sync::Arc; + +#[test] +fn posture_commands_stay_inside_bind_and_mirror_layers() { + for posture in [ + None, + Some(PosturePose::Stiff), + Some(PosturePose::Slouch), + Some(PosturePose::Buff), + ] { + let mut tree = PlaybackTree::BindPose { + motion: Box::new(PlaybackTree::Clip { + name: "motion".into(), + clip: PlaybackClip::new(31.0, 30.0, 1.0, 0, Vec::new()), + }), + posture, + board_backwards: true, + mirror_modes: vec![2, 1], + attribute_mirror: Arc::new(AttributeMirror(Vec::new())), + }; + tree.set_time(0.25); + assert_eq!(tree.time(), 0.25); + assert_eq!(tree.length(), 1.0); + let parameters = Evaluation { + cull_threshold: 0.0, + update_history: false, + }; + let mut commands = Vec::new(); + assert!(!tree.evaluate(parameters, false, &mut commands).unwrap()); + assert!(commands.is_empty()); + //Cloning preserves the inner posture selection through tree ownership changes. + assert!( + tree.clone() + .evaluate(parameters, true, &mut commands) + .unwrap() + ); + assert!(matches!(commands.remove(0), PoseCommand::Clip { .. })); + let mut expected = Vec::new(); + if let Some(pose) = posture { + expected.push(PoseCommand::Pose { + name: pose.name().into(), + }); + expected.push(PoseCommand::Add { motion_is_a: true }); + } + expected.extend([ + PoseCommand::Pose { + name: "RIG_TPOSE".into(), + }, + PoseCommand::Add { motion_is_a: true }, + PoseCommand::Pose { + name: "BOARD_BACKWARDS".into(), + }, + PoseCommand::Add { motion_is_a: false }, + PoseCommand::Pose { + name: "BOARD_BACKWARDS_IK".into(), + }, + PoseCommand::Add { motion_is_a: true }, + PoseCommand::Mirror { trajectory_mode: 2 }, + PoseCommand::Mirror { trajectory_mode: 1 }, + ]); + assert_eq!(commands, expected); + } +} diff --git a/crates/skate-core/src/animation/pumping_channel.rs b/crates/skate-core/src/animation/pumping_channel.rs new file mode 100644 index 00000000..7f89c36f --- /dev/null +++ b/crates/skate-core/src/animation/pumping_channel.rs @@ -0,0 +1,30 @@ +//! Pumping behavior82BAC1B0 and instance seeds82BAC528. +use crate::point_graph::PointGraph; +#[derive(Clone,Debug)] +pub struct Settings { + pub amplify:PointGraph<8>,pub input_blend:f32,pub maximum_physics_pump:f32, + pub new_pump_threshold:f32,pub blend_in:f32,pub blend_out:f32, +} +#[derive(Clone,Copy,Debug)] +pub struct State {value:f32,filtered:f32,channel:Option,peak:f32} +impl Default for State {fn default()->Self {Self {value:0.0,filtered:0.0,channel:None,peak:0.0}}} +pub struct Update {pub start:Option,pub influence:Option<(usize,f32)>} +impl State { + pub fn update(&mut self,physics_pump:f32,occupied:[bool;5],s:&Settings)->Update { + let previous=self.value; + let input=physics_pump/s.maximum_physics_pump; + let lower=if -input>=0.0 {0.0}else{input}; + let input=if 1.0-lower>=0.0 {lower}else{1.0}; + //82BAC2F8 is fmadds; preserve the single final rounding. + self.filtered=input.mul_add(s.input_blend,(1.0-s.input_blend)*self.filtered); + self.value=s.amplify.evaluate(self.filtered); + let start=if !(previous>s.new_pump_threshold)&&self.value>s.new_pump_threshold { + occupied.iter().position(|&exists|!exists) + } else {None}; + if let Some(index)=start {self.channel=Some(index);self.peak=self.value;} + let influence=if self.value>=previous&&self.value>=self.peak { + self.channel.map(|index|{self.peak=self.value;(index,self.value)}) + } else {None}; + Update {start,influence} + } +} diff --git a/crates/skate-core/src/animation/riding_fakie.rs b/crates/skate-core/src/animation/riding_fakie.rs new file mode 100644 index 00000000..b83927f0 --- /dev/null +++ b/crates/skate-core/src/animation/riding_fakie.rs @@ -0,0 +1,66 @@ +//! UpdateRidingFakie82BB2330. The two persistent clocks belong to the graph +//! instance, so re-entering another leaf does not restart them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Settings { + pub high_speed: f32, + pub low_speed: f32, + pub slowly_backwards_seconds: f32, + pub after_teleport_seconds: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub category: u32, + pub grind_state: u32, + pub doing_trick: bool, + /// PhysOutSkeleton+0, as produced by Skeleton::GetData. + pub board_axis: [f32; 4], + pub deck_velocity: [f32; 4], + /// PhysOut bundle+36, used outside filtered Ground category. + pub external_velocity: [f32; 4], + pub ground_projected_speed: f32, +} +#[derive(Clone, Copy, Debug, Default)] +pub struct State { + slowly_backwards: f32, + after_teleport: f32, +} +impl State { + /// None preserves the existing fakie flag during grounded/air trick execution. + pub fn update(&mut self, physical: Physical, dt: f32, settings: Settings) -> Option { + let eligible = if physical.category == 5 { + self.after_teleport = 0.0; + false + } else { + let eligible = self.after_teleport > settings.after_teleport_seconds; + self.after_teleport += dt; + eligible + }; + if !eligible { + self.slowly_backwards = 0.0; + return Some(false); + } + let allowed = (matches!(physical.category, 1 | 2) + || physical.category == 6 && physical.grind_state == 503) + && !physical.doing_trick; + if !allowed { + self.slowly_backwards = 0.0; + return (!matches!(physical.category, 1 | 2)).then_some(false); + } + let velocity = if physical.category == 1 { + physical.deck_velocity + } else { + physical.external_velocity + }; + let projection = crate::physics::native_arithmetic::dot3(velocity, physical.board_axis); + if physical.ground_projected_speed > settings.high_speed && projection < -0.5 { + self.slowly_backwards = 0.0; + Some(true) + } else if physical.ground_projected_speed > settings.low_speed && projection < -0.5 { + self.slowly_backwards = dt + self.slowly_backwards; + Some(self.slowly_backwards > settings.slowly_backwards_seconds) + } else { + self.slowly_backwards = 0.0; + Some(false) + } + } +} diff --git a/crates/skate-core/src/animation/skeleton_input/attribute_finalization.rs b/crates/skate-core/src/animation/skeleton_input/attribute_finalization.rs new file mode 100644 index 00000000..9eeeb1e7 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/attribute_finalization.rs @@ -0,0 +1,105 @@ +//! Special scalar handlers and unconditional suffix of82BDA0D0. +use super::{extended_attributes::ExtendedAttributes, scalar_attributes::ScalarAttributeInputs}; +use crate::input::controller::ActionMap; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct JumpAttributeState { + /// Skeleton16488/16492, cached by PrepareJump. + pub prepared_controls: [f32; 2], + /// Skeleton16496/16511; this cache survives only the native suffix gate. + pub height_override: f32, + pub height_override_active: bool, +} + +impl JumpAttributeState { + /// Skeleton constructor82BD7A54..82BD7A98, once per skeleton. + pub fn new() -> Self { + Self {prepared_controls:[0.0;2],height_override:0.0,height_override_active:false} + } +} + +#[derive(Clone, Copy, Debug)] +pub struct FinalizationInput { + /// Selected global292 layout1872/1876/1880. + pub select_jump_extremes: bool, + pub low_jump_threshold: f32, + pub high_jump_threshold: f32, + /// Selected state variant layout76. + pub allow_height_override: bool, + /// Selected global208 layout452. + pub use_prepared_controls: bool, + /// Actual AnimOutPhysIn10496 and10932. + pub external_impulse_active: bool, + pub animation_flags: u32, +} + +/// Function locals are reset on every ProcessAnimAttributes invocation. +pub struct Accumulator { + revert: bool, + revert_direction: f32, + minimum_jump: f32, + trick_height: f32, + has_trick_height: bool, +} +impl Accumulator { + pub fn new() -> Self { + Self {revert:false,revert_direction:1.0,minimum_jump:0.0,trick_height:0.0,has_trick_height:false} + } + pub fn dispatch(&mut self, name:&str, scalar:impl Fn()->Result, + fields:&mut ScalarAttributeInputs, cached:&mut JumpAttributeState, + map:&mut Option<&mut dyn ActionMap>) -> Result { + match name { + "Revert" => self.revert=true, + "RevertDir" => {self.revert_direction=scalar()?;fields.flags2472|=0x200000;} + "MinJump" => self.minimum_jump=scalar()?, + "TrickHeight" => {self.trick_height=scalar()?;self.has_trick_height=true;} + "PrepareJump" => { + fields.flags2468|=0x200000; + if let Some(map)=map { + //82590358/825903C8 call stock cInputMap64 and65. + cached.prepared_controls=[clamp_control(map.value(64)),clamp_control(map.value(65))]; + } + } + "JumpHeightOverride" => {cached.height_override=scalar()?;cached.height_override_active=true;} + _=>return Ok(false), + } + Ok(true) + } + pub fn finish(self, fields:&mut ScalarAttributeInputs, extra:&mut ExtendedAttributes, + cached:&mut JumpAttributeState, input:FinalizationInput, map:Option<&mut dyn ActionMap>) { + if self.revert {extra.revert_direction=self.revert_direction;} + if fields.flags2468&0x400000!=0 { + extra.jump_strength=if input.select_jump_extremes && self.trick_height<=input.low_jump_threshold { + self.minimum_jump + } else if input.select_jump_extremes && self.trick_height>=input.high_jump_threshold { + 1.0 + } else {self.trick_height}; + } + if self.has_trick_height && input.allow_height_override { + if cached.height_override_active { + let difference=cached.height_override-extra.jump_strength; + if difference>=0.0 {extra.jump_strength=cached.height_override;} + } + } else {cached.height_override=0.0;cached.height_override_active=false;} + if extra.flags2480&0x1000!=0 {extra.jump_strength=self.trick_height;} + if input.use_prepared_controls {extra.jump_controls=cached.prepared_controls;} + else if let Some(map)=map { + //The suffix deliberately selects68 through8255E010, not65 used + //by PrepareJump. Keep the recovered source's distinct routing. + extra.jump_controls=[clamp_control(map.value(64)),clamp_control(map.value(68))]; + } + if input.external_impulse_active {fields.flags2468|=0x40000;} + //82BDC650 reads a BYTE at10932, the high byte of this big-endian + //word. The later publications read the complete word separately. + extra.flags2480=(extra.flags2480&!8)|((input.animation_flags>>24)&8); + fields.flags2484=(fields.flags2484&!2)|((input.animation_flags>>22)&2); + fields.flags2484=(fields.flags2484&0x7fffffff)|((input.animation_flags<<6)&0x80000000); + fields.flags2484=(fields.flags2484&!0x01000000)|((input.animation_flags>>4)&0x01000000); + if fields.flags2476&0x600!=0 {fields.flags2476&=!0x400000;} + } +} + +fn clamp_control(value:f32)->f32 { + let lower=if -1.0-value>=0.0 {-1.0} else {value}; + if 1.0-lower>=0.0 {lower} else {1.0} +} diff --git a/crates/skate-core/src/animation/skeleton_input/catalog.rs b/crates/skate-core/src/animation/skeleton_input/catalog.rs new file mode 100644 index 00000000..b5f219a7 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/catalog.rs @@ -0,0 +1,191 @@ +//! All151 scalar-name comparison sites in82BDA0D0, in native search order. +//! Raw disassembly corrects the old130-entry catalog: direct-name checks were +//! omitted, Spin was misidentified, and left/right grab calls were conflated. +use super::name::encode; +use crate::animation::output::attributes::AttributeName; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScalarAttribute { + pub name: &'static str, + pub encoded_name: AttributeName, + /// Traceability only, never an executable/data address in the host. + pub comparison_site: u32, +} +const fn entry(name: &'static str, comparison_site: u32) -> ScalarAttribute { + ScalarAttribute { + name, + encoded_name: encode(name.as_bytes()), + comparison_site, + } +} +pub const SCALAR_ATTRIBUTES: &[ScalarAttribute] = &[ + entry("SlideBoard", 0x82bdacf8), + entry("SlideNose", 0x82bdad10), + entry("SlideTail", 0x82bdad28), + entry("Grind5050", 0x82bdad40), + entry("Grind5_O", 0x82bdad58), + entry("GrindNose", 0x82bdad70), + entry("GrabWorld", 0x82bdad88), + entry("BoardAdjustLeft", 0x82bdadb0), + entry("BoardAdjustRight", 0x82bdadd4), + entry("BoardAdjustUp", 0x82bdadf8), + entry("BoardAdjustDown", 0x82bdae1c), + entry("GrindFacingForwards", 0x82bdae40), + entry("GrindFacingBackwards", 0x82bdae6c), + entry("NoInput", 0x82bdae98), + entry("ExitGrind", 0x82bdaed0), + entry("NewAutoPump", 0x82bdaef8), + entry("EnteringCoffin", 0x82bdaf24), + entry("InCoffin", 0x82bdaf58), + entry("LeavingCoffin", 0x82bdaf8c), + entry("PlayingLanding", 0x82bdafb4), + entry("Balance", 0x82bdafdc), + entry("Spin", 0x82bdb004), + entry("KickTurn", 0x82bdb02c), + entry("Anticipating", 0x82bdb058), + entry("BodySpin", 0x82bdb084), + entry("ManualBrake", 0x82bdb0ac), + entry("Brake", 0x82bdb0d4), + entry("Turn", 0x82bdb0f8), + entry("IsStumbling", 0x82bdb120), + entry("IsWipeoutPushOffImpulse", 0x82bdb148), + entry("IsWipeoutPushOff", 0x82bdb170), + entry("IsMuteRFoot", 0x82bdb198), + entry("IsMuteLFoot", 0x82bdb1c0), + entry("IsForceDownRFoot", 0x82bdb1e8), + entry("IsForceDownLFoot", 0x82bdb210), + entry("TurnScale", 0x82bdb238), + entry("MagScale", 0x82bdb25c), + entry("AnimEndCOMX", 0x82bdb280), + entry("AnimEndCOMY", 0x82bdb2c0), + entry("AnimEndCOMZ", 0x82bdb300), + entry("AnimTransX", 0x82bdb340), + entry("AnimTransY", 0x82bdb380), + entry("AnimTransZ", 0x82bdb3c0), + entry("AnimTime", 0x82bdb400), + entry("AnimPhysBlendSec", 0x82bdb424), + entry("IsAnimInterpToEdge", 0x82bdb448), + entry("CadenceEndPercent", 0x82bdb470), + entry("OB_Traj", 0x82bdb494), + entry("OB_Air", 0x82bdb4bc), + entry("Carving", 0x82bdb4e4), + entry("RawTurn", 0x82bdb50c), + entry("HardTurn", 0x82bdb534), + entry("Slide", 0x82bdb55c), + entry("FrontFlip", 0x82bdb584), + entry("BackFlip", 0x82bdb5ac), + entry("Grabbing", 0x82bdb5d4), + entry("WantsLeftAirGrab", 0x82bdb5fc), + entry("WantsRightAirGrab", 0x82bdb614), + entry("FlipTrick", 0x82bdb62c), + entry("WipeOutRecover", 0x82bdb654), + entry("ControlledWipeout", 0x82bdb67c), + entry("Revert", 0x82bdb6a4), + entry("RevertDir", 0x82bdb6c8), + entry("Wipeout", 0x82bdb6f4), + entry("MinJump", 0x82bdb71c), + entry("TrickHeight", 0x82bdb73c), + entry("PrepareJump", 0x82bdb764), + entry("jump", 0x82bdb7e8), + entry("DangerZone", 0x82bdb810), + entry("LateTrick", 0x82bdb838), + entry("NoDangerZone", 0x82bdb860), + entry("ChristAir", 0x82bdb888), + entry("PhysGrindTranslation", 0x82bdb8e0), + entry("PhysGrindStabilityNudge", 0x82bdb904), + entry("PhysGrindUpDown", 0x82bdb928), + entry("PhysGrindGrabMinHeight", 0x82bdb94c), + entry("PhysBodySpin", 0x82bdb970), + entry("world_grab_y", 0x82bdb994), + entry("world_grab_z", 0x82bdb9b8), + entry("PushContact", 0x82bdb9dc), + entry("NewHandPlant", 0x82bdba04), + entry("AnimCommittedHandPlant", 0x82bdba28), + entry("DisableFeetTargetIK", 0x82bdba4c), + entry("OB_Jump", 0x82bdba70), + entry("OB_StandingJump", 0x82bdba98), + entry("OB_Sprint", 0x82bdbac0), + entry("OB_Turn", 0x82bdbae8), + entry("OB_Mag", 0x82bdbb10), + entry("OB_BipedWorldX", 0x82bdbb34), + entry("OB_BipedWorldZ", 0x82bdbb58), + entry("OB_LookAtX", 0x82bdbb7c), + entry("OB_LookAtY", 0x82bdbba0), + entry("OB_ObjectMvZ", 0x82bdbbc4), + entry("OB_ObjectMvX", 0x82bdbbe8), + entry("OB_ObjectMvRot", 0x82bdbc0c), + entry("PlayerControlledPump", 0x82bdbc30), + entry("OneFootAir", 0x82bdbc58), + entry("WipeoutControlX", 0x82bdbc7c), + entry("WipeoutControlY", 0x82bdbca0), + entry("OB_Mounting", 0x82bdbcc4), + entry("OB_Unmounting", 0x82bdbce8), + entry("OB_Dismount", 0x82bdbd0c), + entry("OB_HoldBoard", 0x82bdbd30), + entry("OB_ReleaseBoard", 0x82bdbd58), + entry("OB_ThrowBoard", 0x82bdbd84), + entry("OB_RetrieveBoard", 0x82bdbdac), + entry("OB_RetrievingBoard", 0x82bdbdd4), + entry("OB_DroppingBoard", 0x82bdbdfc), + entry("BipedBoardOnGround", 0x82bdbe24), + entry("IsGroundMount", 0x82bdbe4c), + entry("FootJump", 0x82bdbe74), + entry("AnimSkateboard", 0x82bdbe98), + entry("HippyJumping", 0x82bdbebc), + entry("PushTricking", 0x82bdbee0), + entry("KickoutDismount", 0x82bdbf04), + entry("RunoutDismount", 0x82bdbf28), + entry("FixLeftFoot", 0x82bdbf4c), + entry("FixRightFoot", 0x82bdbf70), + entry("DoLandOnBoard", 0x82bdbf94), + entry("TransitioningOnOffBoard", 0x82bdbfb8), + entry("JumpAboutToTakeOff", 0x82bdbfdc), + entry("JumpInProgress", 0x82bdc004), + entry("OB_JumpX", 0x82bdc02c), + entry("OB_JumpZ", 0x82bdc054), + entry("SkaterOffBoard", 0x82bdc078), + entry("JumpHeightOverride", 0x82bdc0a0), + entry("IsDark", 0x82bdc0d0), + entry("GrindTrick", 0x82bdc0f8), + entry("Shoving", 0x82bdc120), + entry("ShoveDirection", 0x82bdc148), + entry("BipedStartAngle", 0x82bdc170), + entry("BipedSpinAngle", 0x82bdc1a0), + entry("BipedAnimTime", 0x82bdc1c4), + entry("InManualZone", 0x82bdc1e8), + entry("OB_Toggle", 0x82bdc210), + entry("TrickFromManual", 0x82bdc238), + entry("WipeoutGestureStickX", 0x82bdc260), + entry("WipeoutGestureStickY", 0x82bdc284), + entry("StrongWipeoutArms", 0x82bdc2a8), + entry("StrongWipeoutLegs", 0x82bdc2d0), + entry("UnderflipBoardContact", 0x82bdc2f8), + entry("AudibleFootStepStrength", 0x82bdc324), + entry("IsTakeDownByBoard", 0x82bdc34c), + entry("DropInLock", 0x82bdc378), + entry("DropInRelease", 0x82bdc3a4), + entry("OB_DropIn", 0x82bdc3d0), + entry("BodyAdjustZ", 0x82bdc408), + entry("BodyAdjustX", 0x82bdc42c), + entry("OneFootIntent", 0x82bdc450), + entry("PushSpeed", 0x82bdc478), + entry("FootPlanting", 0x82bdc4a0), +]; + +/// First exact five-word match,824714D0. Unknown names fall through the native +/// chain without a handler; known but unimplemented handlers must not do so. +pub fn lookup(name: AttributeName) -> Option<&'static ScalarAttribute> { + SCALAR_ATTRIBUTES + .iter() + .find(|entry| entry.encoded_name == name) +} + +/// kind3/status&12 event name comparisons preceding the scalar chain. The +/// fifth comparison is of the event payload, not the attribute name. +pub const EVENT_COMPARISONS: &[ScalarAttribute] = &[ + entry("push_contact", 0x82bdab08), + entry("brake_contact", 0x82bdac40), + entry("right_hand_grab", 0x82bdac98), + entry("left_hand_grab", 0x82bdacb0), + entry("righthand", 0x82bdacc8), +]; diff --git a/crates/skate-core/src/animation/skeleton_input/contact_events.rs b/crates/skate-core/src/animation/skeleton_input/contact_events.rs new file mode 100644 index 00000000..da6d1eff --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/contact_events.rs @@ -0,0 +1,67 @@ +//! Complete kind3/status&12 dispatch82BDAAA0..82BDACE4. +//! Bone identity is resolved even for an unknown event name. Push speed comes +//! from the evaluated trajectory displacement, not a button-driven force. +use super::{name::encode, scalar_attributes::ScalarAttributeInputs}; +use crate::{animation::output::{NativeMatrix, attributes::{AnimationAttribute, AttributeName}}, + physics::native_arithmetic::{dot3, reciprocal_square_root_estimate}}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContactEventState { + /// ProcessedPhysIn2560, including native GetBone's -1 result. + pub bone: i32, + /// ProcessedPhysIn2608, consumed by the native push-force calculation. + pub push_speed: f32, +} + +pub struct ContactEventPose<'a> { + pub bone_names: &'a [AttributeName], + pub hierarchy: &'a [NativeMatrix], + /// Skeleton11716, assigned by SkeletonData initialization. + pub trajectory_bone: usize, + /// Skeleton4748: animation index of physical part19 (RIGHTTOEBASE). + pub right_toe_bone: i32, + /// Skeleton11704: current animation/physics input timestep. + pub timestep: f32, +} + +pub fn dispatch(attribute: &AnimationAttribute, pose: &ContactEventPose<'_>, + fields: &mut ScalarAttributeInputs, state: &mut ContactEventState) -> Result<(), String> { + if attribute.kind != 3 || attribute.status & 12 == 0 { return Ok(()); } + let mut words = [0; 6]; + for (destination, source) in words.iter_mut().zip(attribute.payload.0) { + *destination = source.ok_or("Active animation bone event has incomplete payload")?; + } + let name = AttributeName(words[..5].try_into().unwrap()); + let strength = f32::from_bits(words[5]); + state.bone = pose.bone_names.iter().position(|&bone| bone == name).map_or(-1, |i| i as i32); + if attribute.name == encode(b"push_contact") { + fields.flags2468 |= 1 << 27; + replace(&mut fields.flags2468, 26, state.bone == pose.right_toe_bone); + replace(&mut fields.flags2468, 25, strength == 1.0); + replace(&mut fields.flags2468, 24, strength == -1.0); + let translation = pose.hierarchy.get(pose.trajectory_bone) + .ok_or("Push contact requires the actual animation trajectory bone")?[3]; + let squared = dot3(translation, translation); + let mut inverse = reciprocal_square_root_estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + // Native clears a zero-length result before multiplying by scalar + // fdivs(1,dt). Estimate arithmetic remains hardware-unverified. + let length = if squared == 0.0 { 0.0 } else { squared * inverse }; + let speed = length * (1.0 / pose.timestep); + state.push_speed = if strength == 1.0 { speed } else { 0.0 }; + } else if attribute.name == encode(b"brake_contact") { + fields.flags2468 |= 1 << 28; + replace(&mut fields.flags2468, 30, strength == 1.0); + replace(&mut fields.flags2468, 23, strength == -1.0); + } else if attribute.name == encode(b"right_hand_grab") || attribute.name == encode(b"left_hand_grab") { + // The payload bone name, not the event's left/right label, selects it. + fields.flags2472 |= if name == encode(b"righthand") { 0x100 } else { 0x80 }; + } + Ok(()) +} + +fn replace(flags: &mut u32, bit: u32, set: bool) { + *flags = (*flags & !(1 << bit)) | (u32::from(set) << bit); +} diff --git a/crates/skate-core/src/animation/skeleton_input/extended_attributes.rs b/crates/skate-core/src/animation/skeleton_input/extended_attributes.rs new file mode 100644 index 00000000..a2306cb1 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/extended_attributes.rs @@ -0,0 +1,150 @@ +//! Remaining scalar fields and flags82BDB6A4..82BDC4B4. Values retain their +//! preceding ProcessData/reset state until the corresponding attribute writes. +use super::scalar_attributes::{AnimationControlOutput, ScalarAttributeInputs}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ExtendedAttributes { + pub flags2480: u32, + /// Processed2796..2820. + pub grind_translation: f32, + pub grind_stability_nudge: f32, + pub grind_up_down: f32, + pub grind_grab_min_height: f32, + pub physical_body_spin: f32, + pub world_grab_y: f32, + pub world_grab_z: f32, + /// Processed2680..2692,2704..2708,2784..2792. + pub offboard_turn: f32, + pub offboard_magnitude: f32, + pub biped_world_x: f32, + pub biped_world_z: f32, + pub look_x: f32, + pub look_y: f32, + pub object_move_z: f32, + pub object_move_x: f32, + pub object_move_rotation: f32, + /// Processed2824/2828,2840/2844,2916/2920,2928/2932. + pub wipeout_control: [f32; 2], + pub offboard_jump: [f32; 2], + pub wipeout_gesture: [f32; 2], + pub body_adjust: [f32; 2], + /// Processed2936/2940/2944; AnimOut24. + pub biped_start_angle: f32, + pub biped_spin_angle: f32, + pub biped_animation_time: f32, + pub footstep_strength: f32, + /// Processed2624/2628/2632/2744. + pub jump_strength: f32, + pub jump_controls: [f32; 2], + pub revert_direction: f32, +} + +impl ExtendedAttributes { + /// Reset82BF9EF0 zeros every represented ProcessedPhysIn field here. + /// AnimOut24 belongs to another owner and survives this reset. + pub fn reset(footstep_strength:f32) -> Self { + Self { + flags2480:0,grind_translation:0.0,grind_stability_nudge:0.0, + grind_up_down:0.0,grind_grab_min_height:0.0,physical_body_spin:0.0, + world_grab_y:0.0,world_grab_z:0.0,offboard_turn:0.0, + offboard_magnitude:0.0,biped_world_x:0.0,biped_world_z:0.0, + look_x:0.0,look_y:0.0,object_move_z:0.0,object_move_x:0.0, + object_move_rotation:0.0,wipeout_control:[0.0;2],offboard_jump:[0.0;2], + wipeout_gesture:[0.0;2],body_adjust:[0.0;2],biped_start_angle:0.0, + biped_spin_angle:0.0,biped_animation_time:0.0,footstep_strength, + jump_strength:0.0,jump_controls:[0.0;2],revert_direction:0.0, + } + } +} + +/// Special jump/revert handlers and finalization are in attribute_finalization. +/// Return false only for a name outside this contiguous scalar region. +pub fn dispatch(name: &str, scalar: impl Fn() -> Result, + input: &mut ScalarAttributeInputs, extra: &mut ExtendedAttributes, + output: &mut AnimationControlOutput) -> Result { + match name { + "Wipeout" => input.flags2468 |= 0x40000, + "jump" => input.flags2468 |= 0x400000, + "DangerZone" => input.flags2472 |= 0x8000, + "LateTrick" => input.flags2472 |= 0x20, + "NoDangerZone" => input.flags2472 |= 0x10, + "ChristAir" => input.flags2472 |= 0x40, + "PhysGrindTranslation" => extra.grind_translation = scalar()?, + "PhysGrindStabilityNudge" => extra.grind_stability_nudge = scalar()?, + "PhysGrindUpDown" => extra.grind_up_down = scalar()?, + "PhysGrindGrabMinHeight" => extra.grind_grab_min_height = scalar()?, + "PhysBodySpin" => extra.physical_body_spin = scalar()?, + "world_grab_y" => extra.world_grab_y = scalar()?, + "world_grab_z" => extra.world_grab_z = scalar()?, + "NewHandPlant" => extra.flags2480 |= 0x20000000, + "AnimCommittedHandPlant" => extra.flags2480 |= 0x10000000, + "DisableFeetTargetIK" => extra.flags2480 |= 0x08000000, + "OB_Jump" => input.flags2476 |= 0x80000, + "OB_StandingJump" => input.flags2476 |= 0x40000, + "OB_Sprint" => input.flags2484 |= 0x20000, + "OB_Turn" => extra.offboard_turn = -scalar()?, + "OB_Mag" => extra.offboard_magnitude = scalar()?, + "OB_BipedWorldX" => extra.biped_world_x = scalar()?, + "OB_BipedWorldZ" => extra.biped_world_z = scalar()?, + "OB_LookAtX" => extra.look_x = scalar()?, + "OB_LookAtY" => extra.look_y = scalar()?, + "OB_ObjectMvZ" => extra.object_move_z = scalar()?, + "OB_ObjectMvX" => extra.object_move_x = scalar()?, + "OB_ObjectMvRot" => extra.object_move_rotation = scalar()?, + "OneFootAir" => extra.flags2480 |= 0x04000000, + "WipeoutControlX" => extra.wipeout_control[0] = scalar()?, + "WipeoutControlY" => extra.wipeout_control[1] = scalar()?, + "OB_Mounting" => extra.flags2480 |= 0x80000, + "OB_Unmounting" => extra.flags2480 |= 0x40000, + "OB_Dismount" => extra.flags2480 |= 0x20000, + "OB_HoldBoard" => input.flags2476 |= 0x4000, + "OB_ReleaseBoard" => input.flags2476 = (input.flags2476 & !0x3000) | 0x2000, + "OB_ThrowBoard" => input.flags2476 |= 0x3000, + "OB_RetrieveBoard" => input.flags2476 |= 0x800, + "OB_RetrievingBoard" => input.flags2476 |= 0x400, + "OB_DroppingBoard" => input.flags2476 |= 0x200, + "BipedBoardOnGround" => input.flags2484 |= 1, + "IsGroundMount" => input.flags2488 |= 0x80000000, + "FootJump" => extra.flags2480 |= 0x2000, + "AnimSkateboard" => extra.flags2480 |= 0x4000, + "HippyJumping" => extra.flags2480 |= 0x1000, + "PushTricking" => extra.flags2480 |= 0x800, + "KickoutDismount" => extra.flags2480 |= 0x100, + "RunoutDismount" => extra.flags2480 |= 0x80, + "FixLeftFoot" => extra.flags2480 |= 0x40, + "FixRightFoot" => extra.flags2480 |= 0x20, + "DoLandOnBoard" => extra.flags2480 |= 0x10, + "TransitioningOnOffBoard" => extra.flags2480 |= 4, + "JumpAboutToTakeOff" => input.flags2484 |= 0x40000000, + "JumpInProgress" => input.flags2484 |= 0x20000000, + "OB_JumpX" => extra.offboard_jump[0] = -scalar()?, + "OB_JumpZ" => extra.offboard_jump[1] = scalar()?, + "SkaterOffBoard" => input.flags2484 |= 0x08000000, + "IsDark" => input.flags2484 |= 0x200000, + "GrindTrick" => input.flags2484 |= 0x100000, + "Shoving" => input.flags2484 |= 0x80000, + "ShoveDirection" => input.flags2484 |= 0x40000, + "BipedStartAngle" => { input.flags2484 |= 0x4000; extra.biped_start_angle = scalar()?; } + "BipedSpinAngle" => extra.biped_spin_angle = scalar()?, + "BipedAnimTime" => extra.biped_animation_time = scalar()?, + "InManualZone" => input.flags2484 |= 0x2000, + "OB_Toggle" => input.flags2484 |= 0x1000, + "TrickFromManual" => input.flags2484 |= 0x800, + "WipeoutGestureStickX" => extra.wipeout_gesture[0] = scalar()?, + "WipeoutGestureStickY" => extra.wipeout_gesture[1] = scalar()?, + "StrongWipeoutArms" => input.flags2484 |= 0x20, + "StrongWipeoutLegs" => input.flags2484 |= 0x10, + "UnderflipBoardContact" => output.flags |= 0x04000000, + "AudibleFootStepStrength" => extra.footstep_strength = scalar()?, + "IsTakeDownByBoard" => output.flags |= 0x02000000, + "DropInLock" => output.flags |= 0x01000000, + "DropInRelease" => output.flags |= 0x00800000, + "OB_DropIn" => { output.flags |= 0x00400000; input.flags2488 |= 0x10000000; } + "BodyAdjustZ" => extra.body_adjust[1] = scalar()?, + "BodyAdjustX" => extra.body_adjust[0] = scalar()?, + "OneFootIntent" => input.flags2488 |= 0x02000000, + "FootPlanting" => extra.flags2480 |= 0x00800000, + _ => return Ok(false), + } + Ok(true) +} diff --git a/crates/skate-core/src/animation/skeleton_input/mod.rs b/crates/skate-core/src/animation/skeleton_input/mod.rs new file mode 100644 index 00000000..b21cd3dd --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/mod.rs @@ -0,0 +1,12 @@ +//! Concrete Skeleton animation-attribute consumers. These do not replace the +//! remaining ProcessInput and ProcessData pose/IK stages. +pub mod catalog; +pub mod name; +pub mod scalar_attributes; +pub mod contact_events; +pub mod extended_attributes; +pub mod attribute_finalization; +pub mod process_attributes; + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/animation/skeleton_input/name.rs b/crates/skate-core/src/animation/skeleton_input/name.rs new file mode 100644 index 00000000..69fea661 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/name.rs @@ -0,0 +1,33 @@ +//! Native five-word text encoding823C3C78 and six-character chunk82382B50. +//! This is an encoded string, not a vault/hash-table key or pointer identity. +use crate::animation::output::attributes::AttributeName; + +/// Encode up to30 bytes, stopping at NUL. Native accepts signed bytes and does +/// not validate its alphabet. Preserve its case folding, punctuation behavior, +/// wrapping word arithmetic and truncation; do not substitute UTF-8 casefold. +pub const fn encode(text: &[u8]) -> AttributeName { + let mut words = [0u32; 5]; + let mut position = 0; + while position < 30 && position < text.len() && text[position] != 0 { + let mut weight = 79_235_168u32; // 38^5, exactly the native first weight. + let word = position / 6; + let mut count = 0; + while count < 6 && position < text.len() && text[position] != 0 { + let byte = text[position] as i8 as i32; + let digit = if byte >= 97 { + byte - 86 + } else if byte > 90 { + byte - 58 + } else if byte > 57 { + byte - 54 + } else { + byte - 47 + }; + words[word] = words[word].wrapping_add((digit as u32).wrapping_mul(weight)); + weight /= 38; + count += 1; + position += 1; + } + } + AttributeName(words) +} diff --git a/crates/skate-core/src/animation/skeleton_input/process_attributes.rs b/crates/skate-core/src/animation/skeleton_input/process_attributes.rs new file mode 100644 index 00000000..3b2c76b9 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/process_attributes.rs @@ -0,0 +1,35 @@ +//! Full ordered Skeleton::ProcessAnimAttributes dispatch82BDA0D0. The game +//! supplies actual stock settings, pose and input map, and retains owned state. +use super::{attribute_finalization::{Accumulator,FinalizationInput,JumpAttributeState}, + catalog,contact_events::{self,ContactEventPose,ContactEventState}, + extended_attributes::{self,ExtendedAttributes}, + scalar_attributes::{self,AnimationControlOutput,DispatchError,ScalarAttributeInputs}}; +use crate::{animation::output::attributes::AnimationAttribute,input::controller::ActionMap}; + +pub fn process(attributes:&[AnimationAttribute], pose:&ContactEventPose<'_>, + fields:&mut ScalarAttributeInputs, extra:&mut ExtendedAttributes, + contacts:&mut ContactEventState, cached_jump:&mut JumpAttributeState, + output:&mut AnimationControlOutput, finalization:FinalizationInput, + mut input_map:Option<&mut dyn ActionMap>) -> Result<(),String> { + let mut accumulator=Accumulator::new(); + for (index,attribute) in attributes.iter().enumerate() { + let result=(|| { + if attribute.kind==3 { + return contact_events::dispatch(attribute,pose,fields,contacts); + } + match scalar_attributes::dispatch_attribute(attribute,fields,output) { + Ok(())=>return Ok(()), + Err(DispatchError::KnownScalarUnavailable {..})=>{}, + Err(error)=>return Err(format!("Invalid scalar payload: {error:?}")), + } + let name=catalog::lookup(attribute.name).unwrap().name; + let scalar=||attribute.payload.0[0].map(f32::from_bits).ok_or_else(||format!("{name}: scalar payload absent")); + if accumulator.dispatch(name,scalar,fields,cached_jump,&mut input_map)? + || extended_attributes::dispatch(name,scalar,fields,extra,output)? {Ok(())} + else {Err(format!("Unimplemented known Skeleton attribute {name}"))} + })(); + result.map_err(|error|format!("Skeleton attribute{index}: {error}"))?; + } + accumulator.finish(fields,extra,cached_jump,finalization,input_map); + Ok(()) +} diff --git a/crates/skate-core/src/animation/skeleton_input/scalar_attributes.rs b/crates/skate-core/src/animation/skeleton_input/scalar_attributes.rs new file mode 100644 index 00000000..29cc5698 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/scalar_attributes.rs @@ -0,0 +1,201 @@ +//! Complete contiguous scalar handlers82BDACF0..82BDB694, plus the two +//! independently verified scalar PushContact/PushSpeed flag handlers. +//! Input order is the packet's MG-then-tree order; no timing/value threshold +//! filters are added. process_attributes::process supplies the complete ordered +//! dispatch, kind3 events, remaining scalar handlers and finalization. +use super::catalog::{self, ScalarAttribute}; +use crate::animation::output::{ + attributes::{AnimationAttribute, AttributeName}, + setup_physics::PhysicsPacketBinding, +}; + +/// These flag words are shared with other ProcessedPhysIn stages. They must +/// be seeded by the real preceding reset/consumer stages, not zeroed here. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScalarAttributeInputs { + pub flags2468: u32, + pub flags2472: u32, + pub flags2476: u32, + pub flags2484: u32, + pub flags2488: u32, + /// Native+2492: left1,right2,up3,down4. Unknown prior enum values survive. + pub board_adjust: u32, + /// Native2720,2672,2640,2728,2676 respectively. + pub balance: f32, + pub spin: f32, + pub body_spin: f32, + pub brake: f32, + pub turn: f32, + /// Native2908/2912. + pub turn_scale: f32, + pub magnitude_scale: f32, + /// Native2880/2864; scalar handlers replace XYZ independently, preserving W. + pub animation_end_com: [f32; 4], + pub animation_translation: [f32; 4], + /// Native2896/2904/2900. + pub animation_time: f32, + pub animation_physics_blend_seconds: f32, + pub cadence_end_percent: f32, + /// Native2712/2716/2732. + pub raw_turn: f32, + pub hard_turn: f32, + pub slide: f32, +} + +impl ScalarAttributeInputs { + /// Represented ProcessedPhysIn fields written by Reset82BF9EF0. + /// This is the reset stage, not an implicit reset in attribute dispatch. + pub fn reset(previous_flags2468:u32, previous_flags2488:u32) -> Self { + Self { + flags2468:(previous_flags2468&8)|0x2000,flags2472:0,flags2476:0, + flags2484:0,flags2488:previous_flags2488&0x1fffff,board_adjust:0, + balance:0.0,spin:0.0,body_spin:0.0,brake:0.0,turn:0.0, + turn_scale:1.0,magnitude_scale:1.0,animation_end_com:[0.0;4], + animation_translation:[0.0;4],animation_time:0.0, + animation_physics_blend_seconds:0.0,cadence_end_percent:-1.0, + raw_turn:0.0,hard_turn:0.0,slide:0.0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AnimationControlOutput { + /// AnimOut+0..16: selected Slide/Grind name, copied as five exact words. + pub grind_name: AttributeName, + pub flags: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DispatchError { + /// Active kind3 requires node lookup82530D80 even for unknown event names. + EventConsumerUnavailable { + name: AttributeName, + }, + KnownScalarUnavailable { + attribute: ScalarAttribute, + }, + UninitializedScalar { + attribute: ScalarAttribute, + }, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PacketDispatchError { + pub attribute_index: usize, + pub cause: DispatchError, +} + +/// Dispatch actual packet attributes into their physical consumer fields. +/// On failure earlier attribute effects remain; the failed handler performs no +/// writes here. Success means these supported handlers ran, not that native +/// attribute finalization or Skeleton::ProcessData has completed. +pub fn dispatch_supported_packet( + binding: &PhysicsPacketBinding<'_>, + input: &mut ScalarAttributeInputs, + output: &mut AnimationControlOutput, +) -> Result<(), PacketDispatchError> { + for (attribute_index, attribute) in binding.packet().attributes.entries().iter().enumerate() { + dispatch_attribute(attribute, input, output).map_err(|cause| PacketDispatchError { + attribute_index, + cause, + })?; + } + Ok(()) +} + +pub fn dispatch_attribute( + attribute: &AnimationAttribute, + input: &mut ScalarAttributeInputs, + output: &mut AnimationControlOutput, +) -> Result<(), DispatchError> { + if attribute.kind != 0 { + if attribute.kind == 3 && attribute.status & 12 != 0 { + return Err(DispatchError::EventConsumerUnavailable { + name: attribute.name, + }); + } + return Ok(()); + } + let Some(entry) = catalog::lookup(attribute.name) else { + return Ok(()); + }; + let scalar = || { + attribute.payload.0[0] + .map(f32::from_bits) + .ok_or(DispatchError::UninitializedScalar { attribute: *entry }) + }; + match entry.name { + "SlideBoard" | "SlideNose" | "SlideTail" | "Grind5050" | "Grind5_O" | "GrindNose" => { + output.grind_name = attribute.name + } + "GrabWorld" => input.flags2476 |= 1 << 22, + "BoardAdjustLeft" => input.board_adjust = 1, + "BoardAdjustRight" => input.board_adjust = 2, + "BoardAdjustUp" => input.board_adjust = 3, + "BoardAdjustDown" => input.board_adjust = 4, + "GrindFacingForwards" => output.flags |= 1 << 31, + "GrindFacingBackwards" => output.flags &= !(1 << 31), + "NoInput" => { + output.flags |= 1 << 30; + input.flags2472 |= 1 << 23; + } + "ExitGrind" => input.flags2476 |= 1 << 29, + "NewAutoPump" => output.flags |= 1 << 29, + //82BDBC30..48: scalar value is not tested. + "PlayerControlledPump" => input.flags2476 |= 2, + "EnteringCoffin" => { + input.flags2472 |= 2; + input.flags2476 |= 1 << 30; + } + "InCoffin" => { + input.flags2472 |= 1; + input.flags2476 |= 1 << 30; + } + "LeavingCoffin" => input.flags2476 |= 0xc000_0000, + "PlayingLanding" => input.flags2476 |= 1 << 28, + "Balance" => input.balance = -scalar()?, + "Spin" => input.spin = -scalar()?, + "KickTurn" => output.flags |= 1 << 28, + "Anticipating" => output.flags |= 1 << 27, + "BodySpin" => input.body_spin = -scalar()?, + "ManualBrake" => input.flags2468 |= 1 << 29, + "Brake" => input.brake = scalar()?, + "Turn" => input.turn = -scalar()?, + "IsStumbling" => input.flags2484 |= 1 << 10, + "IsWipeoutPushOffImpulse" => input.flags2484 |= 1 << 8, + "IsWipeoutPushOff" => input.flags2484 |= 1 << 9, + "IsMuteRFoot" => input.flags2484 |= 1 << 7, + "IsMuteLFoot" => input.flags2484 |= 1 << 6, + "IsForceDownRFoot" => input.flags2484 |= 1 << 3, + "IsForceDownLFoot" => input.flags2484 |= 1 << 2, + "TurnScale" => input.turn_scale = scalar()?, + "MagScale" => input.magnitude_scale = scalar()?, + "AnimEndCOMX" => input.animation_end_com[0] = scalar()?, + "AnimEndCOMY" => input.animation_end_com[1] = scalar()?, + "AnimEndCOMZ" => input.animation_end_com[2] = scalar()?, + "AnimTransX" => input.animation_translation[0] = scalar()?, + "AnimTransY" => input.animation_translation[1] = scalar()?, + "AnimTransZ" => input.animation_translation[2] = scalar()?, + "AnimTime" => input.animation_time = scalar()?, + "AnimPhysBlendSec" => input.animation_physics_blend_seconds = scalar()?, + "IsAnimInterpToEdge" => input.flags2488 |= 1 << 27, + "CadenceEndPercent" => input.cadence_end_percent = scalar()?, + "OB_Traj" => input.flags2476 |= 1 << 15, + "OB_Air" => input.flags2476 |= 1 << 7, + "Carving" => input.flags2468 |= 1 << 31, + "RawTurn" => input.raw_turn = -scalar()?, + "HardTurn" => input.hard_turn = -scalar()?, + "Slide" => input.slide = -scalar()?, + "FrontFlip" => input.flags2468 |= 1 << 7, + "BackFlip" => input.flags2468 |= 1 << 6, + "Grabbing" => input.flags2468 |= 1 << 5, + "WantsLeftAirGrab" | "WantsRightAirGrab" => input.flags2468 |= 1 << 4, + "FlipTrick" => input.flags2472 |= 1 << 22, + "WipeOutRecover" => input.flags2472 |= 1 << 20, + "ControlledWipeout" => input.flags2472 |= 1 << 19, + // Independent scalar markers. Neither reads strength or queues force. + "PushContact" => input.flags2488 |= 1 << 29, + "PushSpeed" => input.flags2488 |= 1 << 23, + _ => return Err(DispatchError::KnownScalarUnavailable { attribute: *entry }), + } + Ok(()) +} diff --git a/crates/skate-core/src/animation/skeleton_input/tests.rs b/crates/skate-core/src/animation/skeleton_input/tests.rs new file mode 100644 index 00000000..a08a6952 --- /dev/null +++ b/crates/skate-core/src/animation/skeleton_input/tests.rs @@ -0,0 +1,48 @@ +use super::{attribute_finalization::*,catalog::SCALAR_ATTRIBUTES,contact_events::*, + extended_attributes::ExtendedAttributes,name::encode,process_attributes::process, + scalar_attributes::{AnimationControlOutput,ScalarAttributeInputs}}; +use crate::{animation::output::{attributes::*,packet_reset::RESET_POSE},input::controller::ActionMap}; + +struct Map; +impl ActionMap for Map { + fn value(&mut self,key:u32)->f32 {match key {64=>0.25,65=>-0.75,68=>0.5,_=>panic!("Unexpected source action{key}")}} + fn state(&mut self,_:u32)->u8 {panic!("Attribute finalization reads analog values only")} +} + +#[test] +fn complete_attribute_packet_keeps_contact_timing_reset_and_finalization() { + let mut fields=ScalarAttributeInputs::reset(8,u32::MAX); + let mut extra=ExtendedAttributes::reset(0.0); + let mut contacts=ContactEventState {bone:0,push_speed:0.0}; + let mut cached=JumpAttributeState::new(); + let mut output=AnimationControlOutput {grind_name:encode(b""),flags:0}; + let names=[encode(b"Trajectory"),encode(b"RightToeBase")]; + let mut hierarchy=[RESET_POSE;2]; + hierarchy[0][3]=[0.0,0.0,0.1,0.0]; + // Deliberately unrelated foot position: native speed is trajectory/dt. + hierarchy[1][3]=[20.0,10.0,-40.0,0.0]; + let pose=ContactEventPose {bone_names:&names,hierarchy:&hierarchy,trajectory_bone:0,right_toe_bone:1,timestep:1.0/60.0}; + let mut map=Map; + let input=FinalizationInput {select_jump_extremes:true,low_jump_threshold:0.25,high_jump_threshold:0.75, + allow_height_override:true,use_prepared_controls:false,external_impulse_active:false,animation_flags:0x08000000}; + let mut event=MotionGraphAttribute {name:encode(b"push_contact"),value:0.0}.to_animation(); + event.kind=3;event.status=4; + event.payload=AttributePayload(std::array::from_fn(|i|Some(if i<5 {names[1].0[i]} else {1.0f32.to_bits()}))); + process(&[event],&pose,&mut fields,&mut extra,&mut contacts,&mut cached,&mut output,input,Some(&mut map)).unwrap(); + assert_eq!(contacts.bone,1); + assert!((contacts.push_speed-6.0).abs()<0.00001); + assert_eq!(fields.flags2468&0x0f000000,0x0e000000); + assert_eq!(extra.flags2480&8,8); //byte at start of a big-endian word + assert_eq!(extra.jump_controls,[0.25,0.5]); + event.status=2;contacts.push_speed=7.0; + process(&[event],&pose,&mut fields,&mut extra,&mut contacts,&mut cached,&mut output,input,Some(&mut map)).unwrap(); + assert_eq!(contacts.push_speed,7.0); //inactive event performs no writes + let attributes:Vec<_>=SCALAR_ATTRIBUTES.iter().map(|a|MotionGraphAttribute {name:a.encoded_name,value:0.5}.to_animation()).collect(); + process(&attributes,&pose,&mut fields,&mut extra,&mut contacts,&mut cached,&mut output,input,Some(&mut map)).unwrap(); + assert_eq!(cached.prepared_controls,[0.25,-0.75]); + assert_eq!(fields.flags2476&2,2); //PlayerControlledPump + assert_eq!(fields.flags2476&0x400000,0); //retrieving/dropping clears GrabWorld + let reset=ScalarAttributeInputs::reset(fields.flags2468,fields.flags2488); + assert_eq!(reset.flags2468,0x2008); + assert_eq!((reset.turn_scale,reset.magnitude_scale,reset.cadence_end_percent),(1.0,1.0,-1.0)); +} diff --git a/crates/skate-core/src/animation/tests/kickturn.rs b/crates/skate-core/src/animation/tests/kickturn.rs new file mode 100644 index 00000000..58ea39ae --- /dev/null +++ b/crates/skate-core/src/animation/tests/kickturn.rs @@ -0,0 +1,56 @@ +use super::*; + +fn graph(y: [f32; 8]) -> PointGraph<8> { + PointGraph { + x: [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0], + y, + } +} +fn settings() -> Settings { + Settings { + spin: graph([0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0]), + height: graph([2.0; 8]), + } +} + +#[test] +fn first_update_uses_unscaled_height_seed_without_advancing_time() { + let settings = settings(); + let mut state = State::new(); + state.begin(&settings); + state.capture_animation_length(1.0); + let p = Parameters { + max_height: 0.3, + spin_scale: 0.5, + }; + let first = state.update(0.25, -0.8, p, &settings); + assert_eq!( + first, + Output { + balance: -0.3, + spin: -0.0 + } + ); + assert!(!state.needs_animation_length()); + //Absent/zero input keeps the original negative intent direction. + assert_eq!(state.update(0.25, 0.0, p, &settings).spin, -0.125); + assert_eq!(state.update(0.25, -0.0, p, &settings).spin, -0.25); + state.begin(&settings); + state.capture_animation_length(1.0); + assert_eq!(state.update(0.25, 0.0, p, &settings).spin.to_bits(), 0); +} + +#[test] +fn phase_clamp_preserves_zero_length_and_end_behavior() { + let settings = settings(); + let p = Parameters { + max_height: 0.3, + spin_scale: 0.5, + }; + let mut state = State::new(); + state.begin(&settings); + state.capture_animation_length(0.0); + //0/0 follows the original fsel path to phase1, rather than phase0. + assert_eq!(state.update(0.25, 1.0, p, &settings).spin, 0.5); + assert_eq!(state.update(10.0, -1.0, p, &settings).spin, -0.5); +} diff --git a/crates/skate-core/src/animation/tests/pose_blend.rs b/crates/skate-core/src/animation/tests/pose_blend.rs new file mode 100644 index 00000000..ec820dfb --- /dev/null +++ b/crates/skate-core/src/animation/tests/pose_blend.rs @@ -0,0 +1,39 @@ +use super::*; + +fn pose(rotation: [f32; 4]) -> Sqt { + Sqt { + scale: [1.0; 4], + rotation, + translation: [0.0; 4], + } +} + +#[test] +fn blend_retains_native_antipodal_and_zero_dot_decisions() { + let identity = pose([0.0, 0.0, 0.0, 1.0]); + let antipode = pose([0.0, 0.0, 0.0, -1.0]); + let same = blend_sample(identity, antipode, 0.5); + assert!((same.rotation[3] - 1.0).abs() < 1e-6); + let half_turn = pose([1.0, 0.0, 0.0, 0.0]); + let quarter = blend_sample(identity, half_turn, 0.5); + assert!(quarter.rotation[0] < 0.0); //Native sign gate is >0, not >=0. + assert!((quarter.rotation[0].abs() - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-6); + assert!((quarter.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-6); +} + +#[test] +fn nested_blends_preserve_tree_order_and_per_blend_normalization() { + let a = pose([0.0, 0.0, 0.0, 1.0]); + let b = pose([0.6, 0.0, 0.0, 0.8]); + let mut c = pose([0.0, 0.6, 0.0, 0.8]); + c.translation = [4.0, 8.0, 12.0, 0.0]; + let left = blend_sample(blend_sample(a, b, 0.5), c, 0.25); + let right = blend_sample(a, blend_sample(b, c, 0.25), 0.5); + assert_eq!(left.translation, [1.0, 2.0, 3.0, 0.0]); + assert!( + left.rotation + .iter() + .zip(right.rotation) + .any(|(a, b)| (a - b).abs() > 0.01) + ); +} diff --git a/crates/skate-core/src/animation/tests/pose_sample_vbr.rs b/crates/skate-core/src/animation/tests/pose_sample_vbr.rs new file mode 100644 index 00000000..7fd2bcb8 --- /dev/null +++ b/crates/skate-core/src/animation/tests/pose_sample_vbr.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn boundary_uses_second_key_but_retains_two_frame_normalization() { + let first = Sqt { + scale: [1.0; 4], rotation: [0.0, 0.0, 2.0, 0.0], + translation: [7.0, 0.0, 0.0, 1.0], + }; + let second = Sqt { + rotation: [0.0, 0.0, 0.0, 2.0], translation: [8.0, 0.0, 0.0, 1.0], ..first + }; + let at_boundary = FrameSelection { first: 7, second: 8, coefficient: 0.25 }; + let sampled = sample_key_vbr(first, second, at_boundary); + assert_eq!(sampled.translation, second.translation); + assert_eq!(sampled.rotation, [0.0, 0.0, 0.0, 1.0]); + let exact = FrameSelection { first: 8, second: 8, coefficient: 0.0 }; + assert_eq!(sample_key_vbr(second, second, exact).rotation, second.rotation); + let within = FrameSelection { first: 6, second: 7, coefficient: 0.25 }; + assert_eq!(sample_key_vbr(first, second, within), sample_key(first, second, within)); +} diff --git a/crates/skate-core/src/animation/vbr/bits.rs b/crates/skate-core/src/animation/vbr/bits.rs new file mode 100644 index 00000000..daa813a0 --- /dev/null +++ b/crates/skate-core/src/animation/vbr/bits.rs @@ -0,0 +1,64 @@ +//! TU3 Vector_UnPackFrameBlock82E8A928, expressed as scalar bit readers. +//! The native reverse-byte permutation830CEAD0 (initializer82F96DF8) makes +//! both streams little endian. Width nibbles come from big-endian header words. + +struct Bits<'a> { + bytes: &'a [u8], + position: usize, +} +impl Bits<'_> { + fn read(&mut self, count: u32) -> Result { + let mut value = 0; + for bit in 0..count { + let byte = self + .bytes + .get(self.position / 8) + .ok_or("VBR coefficient bit stream truncated")?; + value |= (((byte >> (self.position % 8)) & 1) as u32) << bit; + self.position += 1; + } + Ok(value) + } +} + +pub(super) fn unpack(block: &[u8], widths: &[u32]) -> Result, String> { + if widths.is_empty() { + return Ok(Vec::new()); + } + let prefix = *block.first().ok_or("VBR block has no selector length")? as usize; + let selectors = block.get(1..).ok_or("VBR selector stream missing")?; + let magnitudes = block + .get(1 + prefix.div_ceil(8)..) + .ok_or("VBR selector length exceeds block")?; + let mut selected = Bits { + bytes: selectors, + position: 0, + }; + let mut packed = Bits { + bytes: magnitudes, + position: 0, + }; + let mut output = Vec::with_capacity(widths.len()); + for &descriptor in widths { + let mut coefficients = [0.0; 8]; + for (i, coefficient) in coefficients.iter_mut().enumerate() { + let width = (descriptor >> (4 * i)) & 15; + // A zero width consumes neither a selector nor packed data. + if width != 0 && selected.read(1)? != 0 { + let positive = packed.read(1)? != 0; + let magnitude = packed.read(width)? as f32; + // The original complements the sign bit, then shifts it to + // bit31 and ORs it into the converted unsigned magnitude. + // Preserve negative zero; this is not two's complement. + *coefficient = + f32::from_bits(magnitude.to_bits() | if positive { 0 } else { 0x8000_0000 }); + } + } + output.push(coefficients); + } + Ok(output) +} + +#[cfg(test)] +#[path = "tests/bits.rs"] +mod tests; diff --git a/crates/skate-core/src/animation/vbr/header.rs b/crates/skate-core/src/animation/vbr/header.rs new file mode 100644 index 00000000..09b62d6a --- /dev/null +++ b/crates/skate-core/src/animation/vbr/header.rs @@ -0,0 +1,175 @@ +//! Original TU3 InitPerAnim82E88FA8 and UnPackHeaderBits82E8A780. +use std::ops::Range; + +#[derive(Clone, Debug)] +pub(super) struct Channel { + pub dynamic: usize, + pub constant: usize, + pub components: usize, + pub min: f32, + pub range: f32, +} + +pub(super) struct Header { + pub frames: usize, + pub bones: usize, + pub dct_scale: f32, + pub channels: Vec, + pub constants: Vec, + pub constant_flags: Vec, + pub widths: Vec, + pub blocks: Vec>, +} + +impl Header { + pub fn read( + bytes: &[u8], + part: usize, + header_relative: usize, + data_relative: usize, + ) -> Result { + let header_word = u32_at(bytes, part)?; + let bones = ((header_word >> 6) & 63) as usize + 1; + let header = offset(part, header_relative)?; + let header_end = offset(header, (header_word >> 12) as usize)?; + let data = offset(part, data_relative)?; + let data_end = offset(data, u32_at(bytes, offset(part, 4)?)? as usize)?; + take( + bytes, + header, + header_end.checked_sub(header).ok_or("VBR header range")?, + )?; + take( + bytes, + data, + data_end.checked_sub(data).ok_or("VBR data range")?, + )?; + let frames = u16_at(bytes, header + 2)? as usize; + let count = take(bytes, header + 12, 2)?; + if count[0] != 3 || frames == 0 { + return Err("VBR SQT requires three channel types and a nonempty frame range".into()); + } + let dictionary = header + 16; + let dictionary_count = count[1] as usize; + let size_table = offset(dictionary, dictionary_count * 4)?; + let block_count = frames.div_ceil(8); + let types = offset(offset(size_table, block_count * 2)?, 15)? & !15; + if offset(types, 48)? > header_end { + return Err("VBR channel descriptions exceed compression header".into()); + } + let mut channels = Vec::with_capacity(3); + for (i, expected_components) in [3, 4, 3].into_iter().enumerate() { + let at = types + 16 * i; + let dynamic = u16_at(bytes, at)? as usize; + let constant = u16_at(bytes, at + 2)? as usize; + let components = take(bytes, at + 12, 1)?[0] as usize; + let min = f32::from_bits(u32_at(bytes, at + 4)?); + let max = f32::from_bits(u32_at(bytes, at + 8)?); + if components != expected_components || dynamic + constant != bones { + return Err(format!( + "VBR channel {i} does not match its SQT output descriptor" + )); + } + channels.push(Channel { + dynamic, + constant, + components, + min, + range: max - min, + }); + } + let mut cursor = data; + let mut constants = Vec::new(); + for channel in &channels { + for _ in 0..channel.constant * channel.components { + let index = take(bytes, cursor, 1)?[0] as usize; + cursor += 1; + if index >= dictionary_count { + return Err("VBR constant dictionary index out of bounds".into()); + } + let value = f32::from_bits(u32_at(bytes, dictionary + index * 4)?); + // Original scalar fmadds82E8A804..878, including constants. + constants.push(value.mul_add(channel.range, channel.min)); + } + } + let runs = u16_at(bytes, header)? as usize; + let mut constant_flags = Vec::with_capacity(3 * bones); + for run in 0..runs { + let length = take(bytes, cursor, 1)?[0] as usize; + cursor += 1; + if constant_flags.len() + length > 3 * bones { + return Err("VBR constant-channel run exceeds channel count".into()); + } + constant_flags.extend(std::iter::repeat_n(run % 2 == 1, length)); + } + if runs == 0 && constants.is_empty() { + constant_flags.resize(3 * bones, false); + } + if constant_flags.len() != 3 * bones { + return Err("VBR constant-channel runs do not cover all channels".into()); + } + for (i, channel) in channels.iter().enumerate() { + if constant_flags[i * bones..(i + 1) * bones] + .iter() + .filter(|v| **v) + .count() + != channel.constant + { + return Err(format!( + "VBR channel {i} constant count differs from its runs" + )); + } + } + let dynamic_count = channels + .iter() + .map(|c| c.dynamic * c.components) + .sum::(); + let mut widths = Vec::with_capacity(dynamic_count); + for _ in 0..dynamic_count { + widths.push(u32_at(bytes, cursor)?); + cursor += 4; + } + let block_start = cursor; + let mut blocks = Vec::with_capacity(block_count); + // Vector_ExtractPackedNVBR82D1E0E4..118 accumulates u16 block sizes. + let mut displacement = 0u16; + for block in 0..block_count { + let size = u16_at(bytes, size_table + 2 * block)?; + let start = offset(block_start, displacement as usize)?; + let end = offset(start, size as usize)?; + if end > data_end || end < start || size < 3 { + return Err(format!("VBR block {block} exceeds compressed data")); + } + blocks.push(start..end); + displacement = displacement.wrapping_add(size); + } + let low = f32::from_bits(u32_at(bytes, header + 4)?).abs(); + let high = f32::from_bits(u32_at(bytes, header + 8)?).abs(); + Ok(Self { + frames, + bones, + dct_scale: low.max(high), + channels, + constants, + constant_flags, + widths, + blocks, + }) + } +} + +pub(super) fn offset(base: usize, relative: usize) -> Result { + base.checked_add(relative) + .ok_or_else(|| "VBR offset overflow".into()) +} +pub(super) fn take(bytes: &[u8], at: usize, count: usize) -> Result<&[u8], String> { + bytes + .get(at..offset(at, count)?) + .ok_or_else(|| format!("VBR read {at:#x}+{count} out of bounds")) +} +pub(super) fn u16_at(bytes: &[u8], at: usize) -> Result { + Ok(u16::from_be_bytes(take(bytes, at, 2)?.try_into().unwrap())) +} +pub(super) fn u32_at(bytes: &[u8], at: usize) -> Result { + Ok(u32::from_be_bytes(take(bytes, at, 4)?.try_into().unwrap())) +} diff --git a/crates/skate-core/src/animation/vbr/mod.rs b/crates/skate-core/src/animation/vbr/mod.rs new file mode 100644 index 00000000..b43edaf8 --- /dev/null +++ b/crates/skate-core/src/animation/vbr/mod.rs @@ -0,0 +1,85 @@ +//! Direct stock VBR SQT decoding from original Skate 3 TU3. +//! Source:82E88FA8/82E8A780(header),82E8A928(bits),82E89770(transform), +//! 82E89A60/82E88BA0(output), and82D20298(SQT channel descriptors). +//! Host allocation/cache ownership is independent. PC floating-point arithmetic +//! is not claimed to emulate the Xenon's numerical instructions bit for bit. +mod bits; +mod header; +mod transform; + +pub struct VbrDecoder<'a> { + bytes: &'a [u8], + header: header::Header, + cached_block: Option<(usize, Vec<[f32; 8]>)>, +} +impl<'a> VbrDecoder<'a> { + pub fn new( + bytes: &'a [u8], + part_offset: usize, + compression_header_relative: usize, + compressed_data_relative: usize, + ) -> Result { + Ok(Self { + bytes, + header: header::Header::read( + bytes, + part_offset, + compression_header_relative, + compressed_data_relative, + )?, + cached_block: None, + }) + } + pub fn frame_count(&self) -> usize { + self.header.frames + } + pub fn channel_count(&self) -> usize { + self.header.bones + } + + ///Return each authored bone's scaleXYZ, quaternionXYZW, translationXYZ bits. + ///No quaternion normalization, retargeting, hierarchy changes or weight edits. + pub fn decode_frame(&mut self, frame: usize) -> Result, String> { + if frame >= self.header.frames { + return Err(format!("VBR frame {frame} out of range")); + } + let block_index = frame / 8; + let block = &self.bytes[self.header.blocks[block_index].clone()]; + if self.cached_block.as_ref().map(|v| v.0) != Some(block_index) { + self.cached_block = + Some((block_index, bits::unpack(&block[3..], &self.header.widths)?)); + } + let coefficients = &self.cached_block.as_ref().unwrap().1; + let mut output = vec![[0; 10]; self.header.bones]; + let mut constant = 0; + let mut dynamic = 0; + let mut component_offset = 0; + for (channel_index, channel) in self.header.channels.iter().enumerate() { + for (bone, sqt) in output.iter_mut().enumerate() { + let is_constant = + self.header.constant_flags[channel_index * self.header.bones + bone]; + for component in 0..channel.components { + let value = if is_constant { + let value = self.header.constants[constant]; + constant += 1; + value + } else { + let value = transform::decode( + frame % 8, + block[channel_index], + self.header.dct_scale, + coefficients[dynamic], + channel.min, + channel.range, + ); + dynamic += 1; + value + }; + sqt[component_offset + component] = value.to_bits(); + } + } + component_offset += channel.components; + } + Ok(output) + } +} diff --git a/crates/skate-core/src/animation/vbr/tests/bits.rs b/crates/skate-core/src/animation/vbr/tests/bits.rs new file mode 100644 index 00000000..989c92b6 --- /dev/null +++ b/crates/skate-core/src/animation/vbr/tests/bits.rs @@ -0,0 +1,24 @@ +use super::unpack; + +#[test] +fn native_sign_bit_and_zero_width_do_not_consume_extra_selectors() { + // Widths3,0,2: selector bits11, packed negative5=1010 and positive2=101. + let values = unpack(&[2, 3, 0x5a], &[0x0203]).unwrap(); + assert_eq!(&values[0][..3], &[-5.0, 0.0, 2.0]); + let negative_zero = unpack(&[1, 1, 0], &[1]).unwrap(); + assert_eq!(negative_zero[0][0].to_bits(), 0x8000_0000); +} + +#[test] +fn packed_words_continue_across_the_native_vector_boundary() { + // Nine width15 coefficients consume nine sign+magnitude16-bit words. + let mut bytes = vec![9, 0xff, 1]; + bytes.extend_from_slice(&[ + 0xff, 0xff, 2, 0, 5, 0, 6, 0, 9, 0, 10, 0, 13, 0, 14, 0, 17, 0, + ]); + let values = unpack(&bytes, &[0xffff_ffff, 15]).unwrap(); + assert_eq!(values[0], [32767.0, -1.0, 2.0, -3.0, 4.0, -5.0, 6.0, -7.0]); + assert_eq!(values[1][0], 8.0); + bytes.pop(); + assert!(unpack(&bytes, &[0xffff_ffff, 15]).is_err()); +} diff --git a/crates/skate-core/src/animation/vbr/transform.rs b/crates/skate-core/src/animation/vbr/transform.rs new file mode 100644 index 00000000..a9619a5a --- /dev/null +++ b/crates/skate-core/src/animation/vbr/transform.rs @@ -0,0 +1,106 @@ +//! TU3 Init82E891D8, IDCT table82E88C58, DecompressFrameBlock82E89770. +//! All coefficient words below were read from the original TU3 mapped image. +use std::sync::OnceLock; + +struct Tables { + quantization: [[f32; 8]; 256], + inverse_dct: [[f32; 8]; 8], +} +fn tables() -> &'static Tables { + static TABLES: OnceLock = OnceLock::new(); + TABLES.get_or_init(|| { + let base: [f32; 8] = std::array::from_fn(|i| { + logarithm_for_frequency(i as u32 + 2) * f32::from_bits(0x3800_0000) + }); + let mut multiplier = 1.0f32; + let quantization = std::array::from_fn(|_| { + let row = base.map(|v| v * multiplier); + multiplier += f32::from_bits(0x3e4c_cccd); // 82099280; accumulated fadds + row + }); + // 822F8B48..58 and individual .785398/.1.570796 literal loads. + let angles = [ + 0, + 0x3ec9_0fdb, + 0x3f49_0fdb, + 0x3f96_cbe4, + 0x3fc9_0fdb, + 0x3ffb_53d2, + 0x4016_cbe4, + 0x402f_ede0, + ]; + let inverse_dct = std::array::from_fn(|frame| { + let phase = frame as f32 + 0.5; + let mut row = + angles.map(|a| crate::trigonometry::cos(phase * f32::from_bits(a)) * 0.25); + row[0] *= 0.5; + row + }); + Tables { + quantization, + inverse_dct, + } + }) +} + +/// Original logarithm polynomial82E89598..5DC, evaluated only at integers2..9. +/// For this bounded positive domain, exponent extraction supplies exactly the +/// integer requested by the original floor(log2-estimate) instruction pair. +fn logarithm_for_frequency(integer: u32) -> f32 { + let input = integer as f32; + let exponent = ((input.to_bits() >> 23) & 255) as i32 - 127; + let mantissa = f32::from_bits((input.to_bits() & 0x007f_ffff) | 0x3f80_0000); + let a = mantissa - 1.0; + let a2 = a * a; + let a3 = a * a2; + let a4 = a2 * a2; + // Original vectors8230EA70 and8230EA80. + let c = [ + 0x3fb8_aa0e, + 0xbf38_9e52, + 0x3ef5_162d, + 0xbeb1_d204, + 0x3e77_adbd, + 0xbe0c_d4fb, + 0x3d55_41c6, + 0xbc18_8b0b, + ] + .map(f32::from_bits); + let low = a.mul_add(c[1], c[0]); + let high = a.mul_add(c[5], c[4]); + let low = a2.mul_add(c[2], low); + let high = a2.mul_add(c[6], high); + let low = a3.mul_add(c[3], low); + let high = a3.mul_add(c[7], high); + let polynomial = a4.mul_add(high, low); + a.mul_add(polynomial, exponent as f32) * f32::from_bits(0x3f31_7218) //82139A70 +} + +pub(super) fn decode( + frame: usize, + quantization_index: u8, + scale: f32, + coefficients: [f32; 8], + channel_min: f32, + channel_range: f32, +) -> f32 { + let tables = tables(); + let squared = scale * scale; + // Literal820612E0 is 2^-15. This equation was independently recovered + // from original vmulfp/vmaddcfp instructions82E89930..4C. + let bias = scale * f32::from_bits(0x3800_0000); + let weights: [f32; 8] = std::array::from_fn(|i| { + tables.quantization[quantization_index as usize][i].mul_add(squared, bias) + * tables.inverse_dct[frame][i] + }); + let first = crate::physics::native_arithmetic::dot4( + coefficients[..4].try_into().unwrap(), + weights[..4].try_into().unwrap(), + ); + let last = crate::physics::native_arithmetic::dot4( + coefficients[4..].try_into().unwrap(), + weights[4..].try_into().unwrap(), + ); + // BSS830CEAF0 initializer82F96D58 converts integer1 with scale1 -> .5. + ((first + last) + 0.5).mul_add(channel_range, channel_min) +} diff --git a/crates/skate-core/src/camera/anchors.rs b/crates/skate-core/src/camera/anchors.rs new file mode 100644 index 00000000..c5948929 --- /dev/null +++ b/crates/skate-core/src/camera/anchors.rs @@ -0,0 +1,100 @@ +//! Seven normal-camera anchors created by TU382DF2DB8 and updated82DF69C0. +//! Names come from that constructor; notably HipsAnchor reads reckoned COM. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchorInputs { + /// Subject388: physical output entry0+144. + pub board_position: [f32; 4], + /// Subject392/396: board output entry1+80/+64 respectively. + pub board_velocity: [f32; 4], + pub board_acceleration: [f32; 4], + /// Subject456/460/464: reckoning direction, COM and filtered COM. + pub board_offset_direction: [f32; 4], + pub center_of_mass: [f32; 4], + pub damped_center_of_mass: [f32; 4], + /// Subject384 matrix+16 and436. + pub skeleton_root_up: [f32; 4], + pub grind_point: [f32; 4], + /// Subject596/544; reset is state output byte62. + pub grinding: bool, + pub reset: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchorState { + pub position: [f32; 4], + pub velocity: [f32; 4], + pub acceleration: [f32; 4], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Anchors { + /// Skateboard, Hips, Air, Grind, World, DampedCOM, POI in native order. + pub entries: [AnchorState; 7], + grind_position: [f32; 4], +} + +impl Anchors { + pub fn new() -> Self { + Self { + entries: [AnchorState { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + }; 7], + grind_position: [0.0; 4], + } + } + + pub fn update(&mut self, input: AnchorInputs) { + // Publisher82DF7790 always passes this fixed timestep to every anchor, + // independently of CameraMan's render update timestep. + let dt = f32::from_bits(0x3c888889); + let inverse = super::vector_tracker::refined_reciprocal(dt); + let board = core::array::from_fn(|i| { + input.board_offset_direction[i] + .mul_add(f32::from_bits(0x3e99999a), input.board_position[i]) + }); + let air = core::array::from_fn(|i| { + input.center_of_mass[i] - input.skeleton_root_up[i] * f32::from_bits(0x3ea8f5c3) + }); + // GrindAnchor82DF2C78 retains and advects its point after leaving a rail. + if input.reset || input.grinding { + let point = if input.reset { + input.board_position + } else { + input.grind_point + }; + self.grind_position = + core::array::from_fn(|i| input.board_offset_direction[i].mul_add(0.5, point[i])); + } else { + self.grind_position = core::array::from_fn(|i| { + input.board_velocity[i].mul_add(dt, self.grind_position[i]) + }); + } + let positions = [ + board, + input.center_of_mass, + air, + self.grind_position, + [0.0; 4], + input.damped_center_of_mass, + [0.0; 4], + ]; + for (index, (state, position)) in self.entries.iter_mut().zip(positions).enumerate() { + // Common82DF2950: first publish position, then derive velocity; + // acceleration comes from a separate getter and is not differenced. + let previous = state.position; + state.position = position; + state.velocity = if input.reset { + [0.0; 4] + } else { + core::array::from_fn(|i| inverse * (position[i] - previous[i])) + }; + state.acceleration = if matches!(index, 0 | 2 | 3) { + input.board_acceleration + } else { + [0.0; 4] + }; + } + } +} diff --git a/crates/skate-core/src/camera/angle_tracker.rs b/crates/skate-core/src/camera/angle_tracker.rs new file mode 100644 index 00000000..fd8c3fe9 --- /dev/null +++ b/crates/skate-core/src/camera/angle_tracker.rs @@ -0,0 +1,88 @@ +//! TU3 angular-tracking candidates; exceptional wrap conversion is unavailable. +use super::{ScalarTracker, ScalarTrackerParameters}; + +/// Angular state has the same six-word layout as the scalar tracker. Angles +/// and angular rates use radians; effective settings are explicitly supplied. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AngleTracker(pub ScalarTracker); + +impl AngleTracker { + pub fn update(&mut self, dt: f32, target: f32, parameters: ScalarTrackerParameters) { + let state = &mut self.0; + let position = state.position; + state.target = target; + let delta = normalize_angle(target - position); + if parameters.overshoot_zeroes_velocity && state.velocity * delta < 0.0 { + state.velocity *= 0.0; + } + let velocity = state.velocity; + let inverse_dt = 1.0 / dt; + state.acceleration = inverse_dt.mul_add(delta, -velocity) * inverse_dt; + let magnitude = delta.abs(); + let fraction = if magnitude < parameters.delta_umbra { + 1.0 + } else if magnitude < parameters.delta_penumbra { + 1.0 - (magnitude - parameters.delta_umbra) + / (parameters.delta_penumbra - parameters.delta_umbra) + } else { + 0.0 + }; + if state.acceleration * delta > 0.0 { + state.acceleration_clamp = super::tracker::interpolate( + parameters.acceleration_clamp_min, + parameters.acceleration_clamp_max, + fraction, + ); + let magnitude = state.acceleration.abs(); + if magnitude > state.acceleration_clamp { + state.acceleration = (state.acceleration / magnitude) * state.acceleration_clamp; + } + } + state.velocity = state.acceleration.mul_add(dt, velocity); + let magnitude = state.velocity.abs(); + if magnitude > parameters.speed_clamp { + state.velocity = (state.velocity / magnitude) * parameters.speed_clamp; + } + // Native normalizes each intermediate separately. Combining these into + // a single wrap, or fusing the position addition, changes boundary cases. + let next = normalize_angle(normalize_angle(state.velocity * dt) + position); + state.smoothing = super::tracker::interpolate( + parameters.smoothing_min, + parameters.smoothing_max, + fraction, + ); + let displacement = normalize_angle(next - position); + let smoothed = normalize_angle(displacement * (1.0 - state.smoothing)); + state.position = normalize_angle(smoothed + position); + } +} + +/// Candidate 8258DB98 wrapping sequence. Its fast interval is [-pi, pi), and +/// its reduction uses truncation followed by one correction, not rem_euclid. +pub fn normalize_angle(angle: f32) -> f32 { + const PI: f32 = f32::from_bits(0x40490fdb); + const NEG_PI: f32 = f32::from_bits(0xc0490fdb); + const INV_TAU: f32 = f32::from_bits(0x3e22f983); + const TAU: f32 = f32::from_bits(0x40c90fdb); + if angle >= NEG_PI && angle < PI { + return angle; + } + let turns = angle * INV_TAU; + // Ordinary finite truncation follows the native fctiwz operation. Its + // exceptional/overflow results are unresolved; do not borrow host rules. + if !turns.is_finite() || turns < i32::MIN as f32 || turns as f64 > i32::MAX as f64 { + panic!( + "Skate 3 exceptional angle integer conversion is unavailable: independent implementation required" + ); + } + let count = turns as i32; + let count = count as f32; + let reduced = -((count as f64).mul_add(TAU as f64, -(angle as f64))) as f32; + if reduced >= PI { + reduced - TAU + } else if reduced < NEG_PI { + reduced + TAU + } else { + reduced + } +} diff --git a/crates/skate-core/src/camera/avoidance.rs b/crates/skate-core/src/camera/avoidance.rs new file mode 100644 index 00000000..f13793a7 --- /dev/null +++ b/crates/skate-core/src/camera/avoidance.rs @@ -0,0 +1,262 @@ +//! Complete TU3 normal-camera avoidance82E04C20. Three native PathEvaluators +//! retain separate asynchronous requests. World/subject adapters are external. + +use super::vector_tracker::{dot, length, refined_reciprocal}; +use super::{ + MovingObstacleProvider, PathEvaluator, Positioner, PredictionPath, TrajectoryCollisionRequest, + direction_to_angles, position_from_angles, +}; + +pub trait AvoidanceSubject { + fn transform_376(&mut self) -> [[f32; 4]; 4]; + fn vector_452(&mut self) -> [f32; 4]; + fn vector_444(&mut self) -> [f32; 4]; + fn context_532(&mut self) -> u32; + fn flag_608(&mut self) -> u8; + fn flag_548(&mut self) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AvoidancePath { + pub evaluator: PathEvaluator, + /// Native path byte56, cleared by each avoidance submission. + pub prediction_flag: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AvoidanceSettings { + /// Parameter offsets628,632,636,640,920. + pub horizon: f32, + pub heading_smoothing: f32, + pub elevation_smoothing: f32, + pub ease_out_time: f32, + pub radius_padding: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigAvoidance { + pub anchor: [f32; 4], + pub velocity: [f32; 4], + pub tracked_anchor: [f32; 4], + pub heading_target: f32, + pub heading_reference: f32, + pub elevation_target: f32, + pub avoidance_heading: f32, + pub avoidance_elevation: f32, + pub reset_time: f32, + pub disable_time: f32, + pub height_transition_start: f32, + pub height_transition_end: f32, + pub height_transition_duration: f32, + pub height_transition_time: f32, + pub reference_height: f32, + pub prediction_distance: f32, + pub heading_weight: f32, + pub elevation_weight: f32, + pub avoidance_mode: u32, + pub height_mode: u32, + pub flags_516: u8, + pub paths: [AvoidancePath; 3], +} + +impl RigAvoidance { + /// Complete82E04C20. The last breadcrumb is supplied from the native ring + /// selector (capacity+index-1)%capacity; no sampled/fitted trail is generated. + pub fn update( + &mut self, + dt: f32, + settings: AvoidanceSettings, + positioner: &Positioner, + last_breadcrumb: [f32; 4], + subject: &mut impl AvoidanceSubject, + requests: &mut [impl TrajectoryCollisionRequest; 3], + moving: &mut impl MovingObstacleProvider, + ) { + if self.avoidance_mode == 1 && self.subject_speed(subject) < 0.5 { + return; + } + if self.flags_516 & 2 != 0 || self.disable_time > 0.0 || self.avoidance_mode == 0 { + self.heading_weight = 0.0; + self.elevation_weight = 0.0; + return; + } + match self.avoidance_mode { + 1 => { + let mut anchor = self.tracked_anchor; + anchor[1] = self.reference_height; + let delta = core::array::from_fn(|i| last_breadcrumb[i] - anchor[i]); + let magnitude = length(delta); + if f32::from_bits(0x38d1b717) > magnitude { + return; + } + let inverse = refined_reciprocal(magnitude); + let angles = direction_to_angles(delta.map(|v| inverse * v)); + self.avoidance_heading = -angles[1]; + self.avoidance_elevation = angles[0]; + } + 2 => { + let angles = direction_to_angles(subject.vector_452()); + self.avoidance_heading = -angles[1]; + self.avoidance_elevation = angles[0]; + } + 3 => { + self.avoidance_heading = self.heading_reference; + self.avoidance_elevation = f32::from_bits(0x3fbde44e); + } + 4 => { + self.avoidance_heading = + super::rig_tracking::wrap_vmx(self.heading_target + f32::from_bits(0x40490fdb)); + let low = f32::from_bits(0x3e32b8c2); + self.avoidance_elevation = if low - self.elevation_target >= 0.0 { + low + } else { + self.elevation_target + }; + } + _ => {} + } + if self.avoidance_elevation.is_nan() { + return; + } + let prediction = position_from_angles( + self.anchor, + [0.0; 4], + self.avoidance_elevation, + self.avoidance_heading, + self.prediction_distance, + positioner.config.clamp_height, + positioner.radius + positioner.config.floor_height, + ); + let transitioning = self.height_transition_time < self.height_transition_duration; + let mut velocity = self.velocity; + if transitioning { + velocity[1] = (self.height_transition_end - self.height_transition_start) + / self.height_transition_duration; + } + let acceleration_flag = u8::from(self.height_mode == 1 && !transitioning); + self.paths[0].set( + PredictionPath { + // v127 preserves the current positioner position across the + // prediction helper; its returned point is used by path1. + position: positioner.position, + velocity, + radius: settings.radius_padding + positioner.radius, + horizon: settings.horizon, + }, + acceleration_flag, + ); + let context = subject.context_532(); + self.paths[0] + .evaluator + .update(context, &mut requests[0], moving); + let camera_time = self.paths[0].evaluator.last_valid_time; + let second_start = if self.height_mode == 1 { + prediction + } else { + [ + self.anchor[0], + positioner.position[1], + self.anchor[2], + self.anchor[0], + ] + }; + self.paths[1].set( + PredictionPath { + position: second_start, + velocity, + radius: f32::from_bits(0x3ca3d70a), + horizon: settings.horizon, + }, + acceleration_flag, + ); + let context = subject.context_532(); + self.paths[1] + .evaluator + .update(context, &mut requests[1], moving); + let third_start = subject.vector_444(); + self.paths[2].set( + PredictionPath { + position: third_start, + velocity, + radius: f32::from_bits(0x3ca3d70a), + horizon: settings.horizon, + }, + acceleration_flag, + ); + let context = subject.context_532(); + self.paths[2] + .evaluator + .update(context, &mut requests[2], moving); + let second_time = self.paths[1].evaluator.last_valid_time; + let third_time = self.paths[2].evaluator.last_valid_time; + let any_acceleration = self.paths[1].evaluator.result_acceleration_flag != 0 + || self.paths[2].evaluator.result_acceleration_flag != 0; + let other_time = if second_time - third_time >= 0.0 { + second_time + } else { + third_time + }; + if third_time >= second_time { + self.flags_516 |= 1; + } + let subject_flag = subject.flag_608(); + if subject_flag == 0 && positioner.flags & 0x20 != 0 { + self.heading_weight = dt * 0.5 + self.heading_weight; + self.elevation_weight = dt * 0.5 + self.elevation_weight; + } else if subject_flag == 0 + && self.height_mode != 1 + && (if any_acceleration { + other_time * 0.5 > camera_time + } else { + other_time > camera_time + }) + { + let target = clamp(1.0 - camera_time / settings.horizon); + let heading_target = if self.heading_weight - target >= 0.0 { + self.heading_weight + } else { + target + }; + let elevation_target = if self.elevation_weight - target >= 0.0 { + self.elevation_weight + } else { + target + }; + self.heading_weight = (1.0 - settings.heading_smoothing) + .mul_add(heading_target - self.heading_weight, self.heading_weight); + self.elevation_weight = (1.0 - settings.elevation_smoothing).mul_add( + elevation_target - self.elevation_weight, + self.elevation_weight, + ); + } else if (self.subject_speed(subject) > f32::from_bits(0x3e19999a) + && self.reset_time <= 0.0) + || subject.flag_548() != 0 + { + self.heading_weight = floor_zero(self.heading_weight - dt / settings.ease_out_time); + self.elevation_weight = floor_zero(self.elevation_weight - dt / settings.ease_out_time); + self.flags_516 &= !1; + } + self.heading_weight = clamp(self.heading_weight); + self.elevation_weight = clamp(self.elevation_weight); + } + + /// Complete82E03060; subject transform+32 is its forward vector. + fn subject_speed(&self, subject: &mut impl AvoidanceSubject) -> f32 { + dot(self.velocity, subject.transform_376()[2]) + } +} + +impl AvoidancePath { + fn set(&mut self, path: PredictionPath, acceleration_flag: u8) { + self.evaluator.path = path; + self.prediction_flag = 0; + self.evaluator.acceleration_flag = acceleration_flag; + } +} +fn floor_zero(v: f32) -> f32 { + if v >= 0.0 { v } else { 0.0 } +} +fn clamp(v: f32) -> f32 { + let v = if -v >= 0.0 { 0.0 } else { v }; + if 1.0 - v >= 0.0 { v } else { 1.0 } +} diff --git a/crates/skate-core/src/camera/compass.rs b/crates/skate-core/src/camera/compass.rs new file mode 100644 index 00000000..85a100c2 --- /dev/null +++ b/crates/skate-core/src/camera/compass.rs @@ -0,0 +1,168 @@ +//! Nine stock gameplay compass headings, TU382DF3770/82DF39B0. These are +//! subject directions consumed by authored shots, not replay camera controls. +use super::{ManagerSubject, direction_to_angles}; +use super::manager_state::clamp; +use super::vector_tracker::length; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompassSettings { + pub heading_response: f32, //parameter604 + pub time_before_lineup: f32, //880 + pub lineup_speed: f32, //884 + pub minimum_deadzone_speed: f32, //888 + pub maximum_deadzone_speed: f32, //892 + pub maximum_deadzone_size: f32, //896 + pub deadzone_smoothing: f32, //900 +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompassInputs { + pub ground_normal: [f32; 4], //0: subject452 + pub landing_normal: [f32; 4], //16: subject412 or448 + /// Current physical entry0+64, publisher cache80; not the lagged subject376. + pub transform: [[f32; 4]; 4], //32 + pub trajectory_direction: [f32; 4], //96: subject420 or normalized392 + pub launch_position: [f32; 4], //112 + pub landing_position: [f32; 4], //128 + pub grind_direction: [f32; 4], //144: subject424 + pub skeleton_direction: [f32; 4], //160: subject440=Skeleton output0 + pub camera_position: [f32; 4], //176 + pub look_target: [f32; 4], //192: subject468=output6+64 + pub look: [f32; 2], //208 + pub trajectory_fraction: f32, //224 + pub selected_compass: u32, //228 + pub no_trajectory: bool, //232bit80 + pub state_103: bool, //bit40: subject564 + pub wiping_out: bool, //bit20 + pub grinding: bool, //bit10 + pub air_flag_452: bool, //bit8 +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Compass { + pub headings: [f32; 9], //240..272 and their working copies280..312 + pub movement_heading: f32, //316 + pub orbit_delta: f32, //320 + pub velocity: [f32; 4], //336 + pub damped_velocity: [f32; 4], //352 + pub previous_position: [f32; 4], //368 + pub orbit_position: [f32; 4], //384 + pub previous_reset: bool, //400 + pub stopped_time: f32, //404 + pub deadzone_size: f32, //408 + pub deadzone_position: [f32; 2], //416 + pub deadzone_velocity: [f32; 2], //432 +} + +impl Compass { + pub fn new() -> Self { + Self { headings: [0.0; 9], movement_heading: 0.0, orbit_delta: 0.0, + velocity: [0.0; 4], damped_velocity: [0.0; 4], previous_position: [0.0; 4], + orbit_position: [0.0; 4], previous_reset: true, stopped_time: 0.0, + deadzone_size: 0.0, deadzone_position: [0.0; 2], deadzone_velocity: [0.0; 2] } + } + + ///82DF7B90 supplies0 on subject reset and exactly1/60 otherwise. + pub fn update(&mut self, dt: f32, input: CompassInputs, settings: CompassSettings) -> [f32; 9] { + if self.previous_reset { + self.previous_position = input.transform[3]; + self.stopped_time = 0.0; + self.deadzone_size = 0.0; + } + self.update_movement(dt, input, settings); + let mut launch = heading(input.ground_normal); + let mut landing = heading(input.landing_normal); + if (launch - landing).abs() > PI { + if launch >= landing { landing += TAU; } else { launch += TAU; } + } + self.headings[1] = wrap((landing - launch).mul_add(input.trajectory_fraction, launch)); + self.headings[0] = self.movement_heading; + let difference = horizontal(sub(input.landing_position, input.launch_position)); + if input.trajectory_fraction > 0.0 && length(difference) > 1.0 { + self.headings[4] = if dt == 0.0 { direction_to_angles(input.skeleton_direction)[1] } + else { -direction_to_angles(input.trajectory_direction)[1] }; + self.headings[0] = wrap(blend(self.headings[0], self.headings[4], input.trajectory_fraction)); + } + self.headings[3] = heading(input.transform[2]); + self.update_orbit(dt, input); + self.update_follow(input); + let direction = horizontal(sub(input.transform[3], input.camera_position)); + let forward = heading(input.transform[2]); + let camera_heading = heading(direction); + let delta = wrap_floor(camera_heading - forward); + let minimum = f32::from_bits(0x3f9c61aa); + self.headings[8] = if delta.abs() <= minimum { + if delta >= 0.0 { forward + minimum } else { forward - minimum } + } else { camera_heading }; + self.previous_reset = dt == 0.0; + self.headings + } + + fn update_follow(&mut self, input: CompassInputs) { + let relative = sub(input.transform[3], input.camera_position); + if input.selected_compass != 7 { + self.headings[7] = heading(horizontal(relative)); + } else if input.air_flag_452 { + self.headings[7] = heading(horizontal(sub(input.look_target, input.transform[3]))); + } else { + let velocity = horizontal(self.velocity); + let speed = length(velocity); + if speed > 0.7 { + let maximum = clamp((speed - 0.7) * f32::from_bits(0x3ede9bd3), 0.0, 1.0) * 0.6; + let target = heading(velocity); + let previous = heading(relative); + self.headings[7] = clamp(wrap_floor(target - previous) * 0.25, + -maximum, maximum) + previous; + } + } + } +} + +pub(super) const PI: f32 = f32::from_bits(0x40490fdb); +pub(super) const TAU: f32 = f32::from_bits(0x40c90fdb); +pub(super) fn horizontal(v: [f32; 4]) -> [f32; 4] { [v[0], 0.0, v[2], v[3]] } +pub(super) fn sub(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { core::array::from_fn(|i| a[i] - b[i]) } +pub(super) fn wrap(v: f32) -> f32 { super::rig_tracking::wrap_vmx(v) } +pub(super) fn blend(from: f32, to: f32, weight: f32) -> f32 { wrap(wrap(to - from).mul_add(weight, from)) } +pub(super) fn wrap_floor(v: f32) -> f32 { + let turns = v.mul_add(f32::from_bits(0x3e22f983), 0.5).floor(); + -turns.mul_add(TAU, -v) +} +///82DF3888 intentionally tests positive Y only and does not normalize XZ. +pub(super) fn heading(v: [f32; 4]) -> f32 { + if v[1] > f32::from_bits(0x3f7ff2e5) { 0.0 } + else { super::manager_incline::atan_ratio(-v[0], v[2]) } +} + +/// Physical fields not already present in ManagerSubject, used by82DF7B90. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompassPoseInputs { + pub current_physical_transform: [[f32; 4]; 4], + pub skeleton_direction: [f32; 4], + pub look_target: [f32; 4], + pub board_velocity: [f32; 4], + pub trajectory_direction: [f32; 4], + pub state_103: bool, +} +impl CompassPoseInputs { + pub fn bind(self, subject: &ManagerSubject, camera_position: [f32; 4], + selected_compass: u32) -> CompassInputs { + let trajectory = subject.rig.trajectory_valid != 0; + let trajectory_direction = if subject.rig.grinding != 0 { subject.direction_424 } + else if trajectory { self.trajectory_direction } + else { super::orientation_math::normalize(self.board_velocity) }; + CompassInputs { + ground_normal: subject.rig.last_valid_ground_up, + landing_normal: if trajectory { subject.landing_normal } else { subject.ground_normal }, + transform: self.current_physical_transform, trajectory_direction, + launch_position: if trajectory { subject.launch_position } else { [0.0; 4] }, + landing_position: if trajectory { subject.landing_position } else { [0.0; 4] }, + grind_direction: subject.direction_424, skeleton_direction: self.skeleton_direction, + camera_position, look_target: self.look_target, look: subject.look, + trajectory_fraction: if trajectory { clamp(subject.trajectory_time / subject.trajectory_duration, 0.0, 1.0) } else { 0.0 }, + selected_compass, no_trajectory: !trajectory, state_103: self.state_103, + wiping_out: subject.rig.wiping_out != 0, grinding: subject.rig.grinding != 0, + air_flag_452: subject.rig.air_flag_452 != 0, + } + } +} diff --git a/crates/skate-core/src/camera/compass_movement.rs b/crates/skate-core/src/camera/compass_movement.rs new file mode 100644 index 00000000..42606cfd --- /dev/null +++ b/crates/skate-core/src/camera/compass_movement.rs @@ -0,0 +1,63 @@ +//! Movement compass82DF3AC0 and its two-dimensional dead-zone82DF9AD0. +use super::{Compass, CompassInputs, CompassSettings, direction_to_angles}; +use super::compass::{blend, horizontal, sub}; +use super::manager_state::clamp; +use super::vector_tracker::{length, refined_reciprocal}; + +impl Compass { + pub(super) fn update_movement(&mut self, dt: f32, input: CompassInputs, s: CompassSettings) { + if !input.no_trajectory { return; } + let reset = dt == 0.0 || self.previous_reset; + if reset { + self.velocity = [0.0; 4]; + self.damped_velocity = [0.0; 4]; + } else { + let inverse = refined_reciprocal(dt); + self.velocity = sub(input.transform[3], self.previous_position).map(|v| v * inverse); + let speed = length(horizontal(self.velocity)); + let fraction = clamp((speed - s.maximum_deadzone_speed) + / (s.minimum_deadzone_speed - s.maximum_deadzone_speed), 0.0, 1.0); + self.deadzone_size = fraction.mul_add(s.maximum_deadzone_size, -self.deadzone_size) + .mul_add(1.0 - s.deadzone_smoothing, self.deadzone_size); + self.update_deadzone(dt, [input.transform[3][0], input.transform[3][2]]); + self.damped_velocity = [self.deadzone_velocity[0], 0.0, + self.deadzone_velocity[1], self.deadzone_velocity[0]]; + self.previous_position = input.transform[3]; + } + let speed = length(horizontal(self.velocity)); + if reset { + self.movement_heading = -direction_to_angles(input.skeleton_direction)[1]; + } else if input.grinding { + self.movement_heading = -direction_to_angles(input.grind_direction)[1]; + } else { + let mut target = self.movement_heading; + let mut weight = 1.0; + self.stopped_time = if input.wiping_out || speed > 0.5 { 0.0 } + else { self.stopped_time + dt }; + if speed > 0.5 { + weight = clamp(s.heading_response, 0.0, 1.0); + target = -direction_to_angles(super::orientation_math::normalize(self.damped_velocity))[1]; + } else if !input.state_103 && self.stopped_time >= s.time_before_lineup { + weight = s.lineup_speed; + target = -direction_to_angles(input.transform[2])[1]; + } + self.movement_heading = blend(self.movement_heading, target, weight); + } + } + + fn update_deadzone(&mut self, dt: f32, target: [f32; 2]) { + let old = self.deadzone_position; + let difference: [f32; 2] = core::array::from_fn(|i| target[i] - old[i]); + // The source explicitly sums two squared lanes, not vmsum3fp. + let square = difference[0] * difference[0] + difference[1] * difference[1]; + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); } + let distance = if square == 0.0 { 0.0 } else { square * inverse }; + if distance > self.deadzone_size { + self.deadzone_position = core::array::from_fn(|i| + (difference[i] * inverse).mul_add(distance - self.deadzone_size, old[i])); + } + self.deadzone_velocity = if dt == 0.0 { [0.0; 2] } + else { core::array::from_fn(|i| (self.deadzone_position[i] - old[i]) * (1.0 / dt)) }; + } +} diff --git a/crates/skate-core/src/camera/compass_orbit.rs b/crates/skate-core/src/camera/compass_orbit.rs new file mode 100644 index 00000000..c23a3c88 --- /dev/null +++ b/crates/skate-core/src/camera/compass_orbit.rs @@ -0,0 +1,66 @@ +//! Free-look compass82DF4100. Retains native orbit position, angular dead-zone, +//! thirty-degree limit relative to the last camera, and two-unit arm length. +use super::{Compass, CompassInputs}; +use super::compass::{heading, horizontal, sub, wrap}; +use super::vector_tracker::{dot, length, refined_reciprocal}; + +impl Compass { + pub(super) fn update_orbit(&mut self, dt: f32, input: CompassInputs) { + let position = input.transform[3]; + if input.selected_compass != 6 || dt == 0.0 { + self.orbit_position = input.camera_position; + self.headings[6] = heading(horizontal(sub(position, self.orbit_position))); + self.orbit_delta = 0.0; + return; + } + let stick = -input.look[0]; + let mut step = 0.0; + let mut retained: f32 = 0.95; + if stick.abs() > 0.3 { + retained = 0.9; + step = ((stick - if stick >= 0.0 { 0.3 } else { -0.3 }) * dt) + * f32::from_bits(0x408f9d9c); + } + self.orbit_delta = wrap((1.0 - retained).mul_add(step, self.orbit_delta * retained)); + let half = self.orbit_delta * 0.5; + let axis = [0.0, crate::trigonometry::sin(half), 0.0, crate::trigonometry::cos(half)]; + self.orbit_position = add(position, rotate(axis, sub(self.orbit_position, position))); + let from = horizontal(sub(self.orbit_position, position)); + let previous = horizontal(sub(input.camera_position, position)); + let from_direction = normalize_safe(from); + let previous_direction = normalize_safe(previous); + let normal = cross(from_direction, previous_direction); + if dot(from_direction, previous_direction) < f32::from_bits(0x3f5db22d) { + let magnitude = length(normal); + if magnitude >= 0.001 { + let inverse = refined_reciprocal(magnitude); + let half = -f32::from_bits(0x3f060a92) * 0.5; + let sine = crate::trigonometry::sin(half); + let axis = [normal[0] * inverse * sine, normal[1] * inverse * sine, + normal[2] * inverse * sine, crate::trigonometry::cos(half)]; + self.orbit_position = add(position, rotate(axis, previous)); + } else { self.orbit_position = input.camera_position; } + } + let relative = sub(position, self.orbit_position); + let distance = length(relative); + if distance > 0.001 { + self.orbit_position = sub(position, relative.map(|v| v * (2.0 / distance))); + } + self.headings[6] = heading(horizontal(sub(position, self.orbit_position))); + } +} +fn normalize_safe(v: [f32; 4]) -> [f32; 4] { + if length(v) > f32::from_bits(0x358637bd) { super::orientation_math::normalize(v) } + else { [0.0; 4] } +} +fn add(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { core::array::from_fn(|i| a[i] + b[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 rotate(q: [f32; 4], v: [f32; 4]) -> [f32; 4] { + let first = cross(q, v); + let intermediate = core::array::from_fn(|i| q[3].mul_add(v[i], first[i])); + let second = cross(q, intermediate); + core::array::from_fn(|i| 2.0_f32.mul_add(second[i], v[i])) +} diff --git a/crates/skate-core/src/camera/direction.rs b/crates/skate-core/src/camera/direction.rs new file mode 100644 index 00000000..f3503591 --- /dev/null +++ b/crates/skate-core/src/camera/direction.rs @@ -0,0 +1,60 @@ +//! Complete TU3 8258DC10 direction-to-angular-vector operation, shared by +//! normal rig framing and avoidance. Input Y is consumed directly, not normalized. + +use super::{ + normalize_angle, + vector_tracker::{length, refined_reciprocal}, +}; + +pub fn direction_to_angles(direction: [f32; 4]) -> [f32; 4] { + let horizontal = [direction[0], 0.0, direction[2], 0.0]; + let magnitude = length(horizontal); + if !(magnitude > f32::from_bits(0x38d1b717)) { + return [0.0; 4]; + } + let inverse = refined_reciprocal(magnitude); + let x = inverse * horizontal[0]; + let z = inverse * horizontal[2]; + let (component, base) = if x >= 0.0 { + if z >= 0.0 { + (x, 0.0) + } else { + (-z, f32::from_bits(0x3fc90fdb)) + } + } else if z <= 0.0 { + (-x, f32::from_bits(0x40490fdb)) + } else { + (z, f32::from_bits(0x4096cbe4)) + }; + let heading = normalize_angle(asin_lane(clamp(component)) + base); + [asin_lane(clamp(direction[1])), heading, 0.0, 0.0] +} + +fn clamp(value: f32) -> f32 { + let lower = if -1.0 > value { -1.0 } else { value }; + if 1.0 < lower { 1.0 } else { lower } +} + +// Native inline asin polynomial: 822F9820/30/40, radicand822FB840. +// Keep it before the acos subtraction used by the separate82453298 operation. +fn asin_lane(value: f32) -> f32 { + let a = value.abs(); + let cube = (value * value) * a; + let c = f32::from_bits; + let p0 = c(0x400b1889).mul_add(a, c(0xc0d1360e)); + let p1 = c(0x3e663246).mul_add(a, c(0xbf983f2f)); + let p2 = c(0xbed65553).mul_add(a, c(0x408980bd)); + let p3 = c(0xbd6dd42d).mul_add(a, c(0x3f1dd7b6)); + let p0 = p0.mul_add(a, c(0x40af6ad8)); + let p1 = p1.mul_add(a, c(0x3fb58485)); + let p2 = p2.mul_add(a, c(0xc08f6ad9)); + let p3 = p3.mul_add(a, c(0xbfaf4418)); + let left = p1.mul_add(cube, p0); + let right = p3.mul_add(cube, p2); + let radicand = c(0x3f800001) - a; + let mut r = crate::physics::reciprocal_sqrt::estimate(radicand); + let correction = (-(radicand * 0.5)).mul_add(r * r, 0.5); + r = r.mul_add(correction, r); + let residual = (-a).mul_add(value, value) * right; + residual.mul_add(r, value * left) +} diff --git a/crates/skate-core/src/camera/drop_predictor.rs b/crates/skate-core/src/camera/drop_predictor.rs new file mode 100644 index 00000000..a7b06f88 --- /dev/null +++ b/crates/skate-core/src/camera/drop_predictor.rs @@ -0,0 +1,258 @@ +//! Native terrain drop prediction82DF84F8/89B0/8BC0/8740. +//! Retains one update of query history: a new batch is submitted only after the +//! previous batch has been consumed. Host execution may be synchronous. +use super::vector_tracker::length; +use super::{FatLine, FatLineResult}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DropSettings { + /// camera_droppredictor/default fields bound8252EF84..8252EFA4. + pub minimum_test_distance: f32, + pub total_test_time: f32, + pub maximum_test_distance: f32, + pub maximum_drop_distance: f32, +} + +pub trait DropCollisionProvider { + fn query(&mut self, lines: [FatLine; 10], context: u32) -> Result<[FatLineResult; 10], String>; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Probe { + start: [f32; 4], + end: [f32; 4], + normal: [f32; 4], + depth: f32, + valid: bool, + hit: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DropPredictor { + pub horizontal_velocity: [f32; 4], + pub steepest_normal: [f32; 4], + pub has_normal: bool, + pub elevation: f32, + pub distance_offset: f32, + pub target_elevation: f32, + spacing: f32, + selected: usize, + probes: [Probe; 5], + pending: Option<([FatLine; 10], [FatLineResult; 10])>, +} + +impl DropPredictor { + pub fn new() -> Self { + Self { + horizontal_velocity: [0.0; 4], + steepest_normal: [0.0; 4], + has_normal: false, + elevation: 0.0, + distance_offset: 0.0, + target_elevation: 0.0, + spacing: 0.0, + selected: 0, + probes: [Probe { + start: [0.0; 4], + end: [0.0; 4], + normal: [0.0; 4], + depth: 0.0, + valid: false, + hit: false, + }; 5], + pending: None, + } + } + + /// Reset82DF8440 preserves the five probe records and cancels their pending + /// query. The following disabled/reset update clears probe valid/depth. + pub fn reset(&mut self) { + self.horizontal_velocity = [0.0; 4]; + self.steepest_normal = [0.0; 4]; + self.has_normal = false; + self.elevation = 0.0; + self.distance_offset = 0.0; + self.target_elevation = 0.0; + self.spacing = 0.0; + self.selected = 0; + self.pending = None; + } + + /// Complete82E00B60 velocity selection, consuming the previous rig velocity. + pub fn prediction_velocity( + reset: bool, + transform: [[f32; 4]; 4], + rig_velocity: [f32; 4], + settings: DropSettings, + ) -> [f32; 4] { + if reset || settings.minimum_test_distance > length(rig_velocity) { + transform[2].map(|v| v * settings.minimum_test_distance) + } else { + rig_velocity + } + } + + pub fn update( + &mut self, + position: [f32; 4], + velocity: [f32; 4], + enabled: bool, + offboard: bool, + context: u32, + settings: DropSettings, + query: &mut impl DropCollisionProvider, + ) -> Result<(), String> { + self.horizontal_velocity = [velocity[0], 0.0, velocity[2], 0.0]; + if enabled { + let span = length(self.horizontal_velocity) * settings.total_test_time; + let span = if -span >= 0.0 { 0.0 } else { span }; + let span = if settings.maximum_test_distance - span >= 0.0 { + span + } else { + settings.maximum_test_distance + }; + self.spacing = span / 5.0; + if let Some((lines, results)) = self.pending.take() { + self.consume(lines, results, settings.maximum_drop_distance); + } + let lines = self.lines(position, settings.maximum_drop_distance); + let results = query.query(lines, context)?; + self.pending = Some((lines, results)); + let mut greatest_depth = 0.0; + self.selected = 0; + for (index, probe) in self.probes.iter().enumerate() { + if !probe.valid { + break; + } + if probe.depth > greatest_depth { + self.selected = index; + greatest_depth = probe.depth; + } + } + } else { + self.selected = 0; + for probe in &mut self.probes { + probe.valid = false; + probe.depth = 0.0; + } + } + self.update_elevation(enabled, offboard); + Ok(()) + } + + /// Native getter82DF8980. + pub fn drop_depth(&self) -> f32 { + let probe = self.probes[self.selected]; + if probe.valid { probe.depth } else { 0.0 } + } + + fn lines(&self, position: [f32; 4], depth: f32) -> [FatLine; 10] { + let direction = super::orientation_math::normalize(self.horizontal_velocity); + core::array::from_fn(|index| { + let point = core::array::from_fn(|i| { + (direction[i] * self.spacing).mul_add((index / 2 + 1) as f32, position[i]) + }); + if index % 2 == 0 { + FatLine { + start: position, + end: point, + radius: f32::from_bits(0x3c23d70a), + } + } else { + FatLine { + start: point, + end: core::array::from_fn(|i| point[i] - [0.0, 1.0, 0.0, 0.0][i] * depth), + radius: f32::from_bits(0x3dcccccd), + } + } + }) + } + + fn consume(&mut self, lines: [FatLine; 10], results: [FatLineResult; 10], maximum_depth: f32) { + self.has_normal = false; + for index in 0..5 { + if results[2 * index].hit != 0 { + for probe in &mut self.probes[index..] { + probe.valid = false; + probe.depth = 0.0; + } + break; + } + let line = lines[2 * index + 1]; + let result = results[2 * index + 1]; + let probe = &mut self.probes[index]; + *probe = Probe { + start: line.start, + end: line.end, + normal: [0.0; 4], + depth: maximum_depth, + valid: true, + hit: result.hit != 0, + }; + if probe.hit { + probe.depth = length(core::array::from_fn(|i| result.position[i] - line.start[i])); + probe.end = result.position; + probe.normal = result.normal; + if !self.has_normal || self.steepest_normal[1].abs() > result.normal[1].abs() { + self.steepest_normal = result.normal; + self.has_normal = true; + } + } + } + } + + fn update_elevation(&mut self, enabled: bool, offboard: bool) { + let probe = self.probes[self.selected]; + let mut angle = 0.0; + if probe.valid { + let distance = (self.selected + 1) as f32 * self.spacing; + // Native angle wrapper applies one reciprocal refinement here; the + // called82473B98 polynomial has its own independent reductions. + let initial = crate::physics::native_arithmetic::reciprocal_estimate(distance); + let inverse = initial.mul_add((-initial).mul_add(distance, 1.0), initial); + angle = crate::input::angle::atan(probe.depth.mul_add(inverse, 0.0)); + let sign = probe.depth.to_bits() & 0x80000000; + if 0.0 > distance { + angle = f32::from_bits(0x40490fdb | sign) + angle; + } + if distance == 0.0 { + angle = f32::from_bits(0x3fc90fdb | sign); + } + } + let half_pi = f32::from_bits(0x3fc90fdb); + if angle - half_pi >= 0.0 { + angle = half_pi; + } + let limit = (if offboard { 40.0 } else { 70.0 }) * f32::from_bits(0x3c8efa35); + if angle - limit >= 0.0 { + angle = limit; + } + self.target_elevation = if enabled && angle >= f32::from_bits(0x3e860a92) { + angle + } else { + 0.0 + }; + let delta = (self.target_elevation - self.elevation).abs(); + let degrees = delta * f32::from_bits(0x42652ee1); + let step = (degrees - 7.0).mul_add(f32::from_bits(0x3d3a2e8c), f32::from_bits(0x3dcccccd)); + let step = if f32::from_bits(0x3dcccccd) - step >= 0.0 { + f32::from_bits(0x3dcccccd) + } else { + step + }; + let step = if f32::from_bits(0x3fcccccd) - step >= 0.0 { + step + } else { + f32::from_bits(0x3fcccccd) + }; + if degrees > step { + self.elevation = if self.elevation >= self.target_elevation { + -step.mul_add(f32::from_bits(0x3c8efa35), -self.elevation) + } else { + step.mul_add(f32::from_bits(0x3c8efa35), self.elevation) + }; + } else { + self.elevation = self.target_elevation; + } + } +} diff --git a/crates/skate-core/src/camera/frame_composer.rs b/crates/skate-core/src/camera/frame_composer.rs new file mode 100644 index 00000000..295373ea --- /dev/null +++ b/crates/skate-core/src/camera/frame_composer.rs @@ -0,0 +1,169 @@ +//! Complete single/transition framing82E01260/82E01740. Camera positioning, +//! reference producers and stock shot selection remain the caller's inputs. +use super::{ + Shot, + orientation_math::{basis_from_angles, look_basis}, + shot_orientation::slerp, +}; +use crate::math::Basis3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameSettings { + pub maximum_pitch_degrees: f32, + pub roll_response: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameSubject { + pub positions: [[f32; 4]; 10], + /// Subject getter456 (the board-offset direction). + pub board_offset_direction: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameComposer { + pub roll: f32, + pub reference: [f32; 4], + pub yaw: f32, + pub pitch: f32, +} + +impl FrameComposer { + pub fn new() -> Self { + Self { + roll: 0.0, + reference: [0.0; 4], + yaw: 0.0, + pitch: 0.0, + } + } + + /// The native transition evaluates `to` before `from`: the latter's + /// reference/yaw/pitch are therefore the retained composer fields. + pub fn compose( + &mut self, + dt: f32, + fraction: f32, + mirror: f32, + from: Shot, + to: Shot, + fallback_reference: [f32; 4], + camera_position: [f32; 4], + subject: FrameSubject, + settings: FrameSettings, + ) -> [f32; 4] { + let to_orientation = self.single( + to, + fallback_reference, + camera_position, + mirror, + subject, + settings, + ); + let from_orientation = self.single( + from, + fallback_reference, + camera_position, + mirror, + subject, + settings, + ); + let orientation = slerp(from_orientation, to_orientation, fraction); + self.roll = if dt > 0.0 { + ((to.framing[0] - self.roll) * settings.roll_response).mul_add(dt, self.roll) + } else { + to.framing[0] + }; + orientation + } + + pub fn single( + &mut self, + shot: Shot, + fallback_reference: [f32; 4], + camera_position: [f32; 4], + mirror: f32, + subject: FrameSubject, + settings: FrameSettings, + ) -> [f32; 4] { + self.reference = shot.reference_point( + &subject.positions, + subject.board_offset_direction, + fallback_reference, + ); + self.yaw = shot.framing[1] * mirror; + self.pitch = -shot.framing[2]; + let framing = basis_from_angles(self.pitch, self.yaw, 0.0); + let mut direction: [f32; 3] = + core::array::from_fn(|i| self.reference[i] - camera_position[i]); + // Native copies X into W for the comparison, so only XYZ can cause + // the nonzero path. The retained fourth position lane is not tested. + if !direction + .iter() + .any(|v| v.abs() > f32::from_bits(0x34000000)) + { + direction = [0.0, 0.0, 1.0]; + } else { + direction = super::orientation_math::normalize(direction); + let ceiling = crate::trigonometry::sin( + settings.maximum_pitch_degrees * f32::from_bits(0x3c8efa35), + ); + if direction[1] > ceiling { + direction[1] = ceiling; + direction = super::orientation_math::normalize(direction); + } + } + let look = look_basis(direction); + let result = Basis3 { + columns: framing.columns.map(|column| { + core::array::from_fn(|i| { + look.columns[2][i].mul_add( + column[2], + look.columns[1][i].mul_add(column[1], look.columns[0][i] * column[0]), + ) + }) + }), + }; + quaternion_from_basis(result) + } +} + +/// The signed diagonal candidates and strict component selection are the +/// inline matrix conversion82E01608..82E01730; signs are XOR negations. +fn quaternion_from_basis(basis: Basis3) -> [f32; 4] { + let [right, up, at] = basis.columns; + let [x, y, z] = [right[0], up[1], at[2]]; + let trace = (x + y) + z; + let (sum, companion, dominant) = if trace > 0.0 { + ( + (x + y) + (z + 1.0), + [up[2] - at[1], at[0] - right[2], right[1] - up[0], 0.5], + 3, + ) + } else if x > y && x > z { + ( + (x + (-y)) + ((-z) + 1.0), + [0.5, up[0] + right[1], at[0] + right[2], up[2] - at[1]], + 0, + ) + } else if y > z { + ( + ((-x) + y) + ((-z) + 1.0), + [up[0] + right[1], 0.5, at[1] + up[2], at[0] - right[2]], + 1, + ) + } else { + ( + ((-x) + (-y)) + (z + 1.0), + [at[0] + right[2], at[1] + up[2], 0.5, right[1] - up[0]], + 2, + ) + }; + let mut inverse = crate::physics::reciprocal_sqrt::estimate(sum); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-sum).mul_add(inverse * inverse, 1.0), inverse); + } + let root = sum * inverse; + let half_inverse = 0.5 * inverse; + core::array::from_fn(|i| companion[i] * if i == dominant { root } else { half_inverse }) +} diff --git a/crates/skate-core/src/camera/look_input.rs b/crates/skate-core/src/camera/look_input.rs new file mode 100644 index 00000000..39cb8240 --- /dev/null +++ b/crates/skate-core/src/camera/look_input.rs @@ -0,0 +1,32 @@ +//! Stock gameplay FreeCamManager82E084A0, used by a shot's look-stick option. +//! This is the normal camera's offset filter, unrelated to the replay editor. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LookSettings { + pub heading_offset_degrees: f32, + pub heading_speed: f32, + pub elevation_offset_degrees: f32, + pub elevation_speed: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LookInput { + pub elevation: f32, + pub heading: f32, +} + +impl LookInput { + pub fn new() -> Self { Self { elevation: 0.0, heading: 0.0 } } + + pub fn update(&mut self, input: [f32; 2], settings: LookSettings) { + let radians = f32::from_bits(0x3c8efa35); + let limit = f32::from_bits(0x3fc90fdb) - 5.0 * radians; + let clamp = |value: f32| { + let lower = if -limit - value >= 0.0 { -limit } else { value }; + if limit - lower >= 0.0 { lower } else { limit } + }; + let heading = clamp((input[0] * settings.heading_offset_degrees) * radians); + self.heading = (heading - self.heading).mul_add(settings.heading_speed * f32::from_bits(0x3c888889), self.heading); + let elevation = clamp((input[1] * settings.elevation_offset_degrees) * radians); + self.elevation = (elevation - self.elevation).mul_add(settings.elevation_speed * f32::from_bits(0x3c888889), self.elevation); + } +} diff --git a/crates/skate-core/src/camera/manager.rs b/crates/skate-core/src/camera/manager.rs new file mode 100644 index 00000000..4e7a4ef0 --- /dev/null +++ b/crates/skate-core/src/camera/manager.rs @@ -0,0 +1,172 @@ +//! Normal CameraMan82DFEAA8/82DFEC90/82DFEE80. The host publishes the real +//! physics/animation subject, evaluates the stock graph between prepare and +//! update, and supplies the three native kinds of collision query. +use super::*; +use super::manager_blends::BlendEnvironment; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManagerSettings { + pub rig: RigSettings, + pub orientation: OrientationSettings, + pub framing: FrameSettings, + pub drop: DropSettings, + pub look: LookSettings, + pub shake: ShakeSettings, + pub steering_threshold: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CameraMan { + pub state: ManagerState, + pub rig: Rig, + pub shots: ShotManager, + pub orientation: RigOrientation, + pub composer: FrameComposer, + pub drop: DropPredictor, + pub look: LookInput, + pub shake: [ShakeEffect; 2], + pub frame: CameraFrame, + // Rig transform0..32 is the previous subject orientation; the camera's + // tracked orientation lives separately in RigOrientation (native64..96). + subject_forward: [f32; 4], + heading_multiplier: f32, +} + +impl CameraMan { + pub fn new() -> Self { + Self { + state: ManagerState::new(), rig: Rig::new(1), shots: ShotManager::new(), + orientation: RigOrientation::new(), composer: FrameComposer::new(), + drop: DropPredictor::new(), look: LookInput::new(), + shake: [ShakeEffect::new(); 2], frame: CameraFrame::new(), + subject_forward: [0.0, 0.0, 1.0, 0.0], heading_multiplier: 1.0, + } + } + + /// Once per published subject, before the camera graph reads its conditions. + /// Landing response consumes the previous velocity/normal before incline + /// publication replaces them; swapping these operations changes impacts. + pub fn prepare(&mut self, subject: &ManagerSubject, settings: ManagerSettings) { + self.state.update_timing(subject, settings.steering_threshold); + if let Some((normal, velocity)) = self.state.update_air(subject, &mut self.rig) { + self.shake[0].landing_impulse(normal, velocity, settings.shake); + } + self.state.update_incline(subject); + } + + pub fn set_shot(&mut self, name: &str, force: bool, subject: &ManagerSubject, + database: &impl ShotDatabase) -> Result { + let mut environment = BlendEnvironment { + state: &self.state, subject, rig: &self.rig, + rig_forward: self.subject_forward, angular_velocity: [0.0; 4], + heading_multiplier: self.heading_multiplier, + previous_distance: self.shots.previous.distance, + }; + self.shots.set_shot(name, force, database, &mut environment, ShotPlacement { + anchor: self.rig.fields.anchor, + camera_position: self.rig.positioner.position, + clamp_height: self.rig.positioner.config.clamp_height, + floor_height: self.rig.positioner.config.floor_height, + }) + } + + pub fn update(&mut self, dt: f32, subject: &mut ManagerSubject, + settings: ManagerSettings, samples: [&ShakeSamples; 2], + requests: &mut [impl TrajectoryCollisionRequest; 3], + moving: &mut impl MovingObstacleProvider, + collision: &mut (impl PositionerCollisionProvider + DropCollisionProvider), + ) -> Result { + if self.shots.current().name.is_empty() { + return Err("The stock camera graph has not selected a shot".into()); + } + self.state.frames = self.state.frames.wrapping_add(1); + if self.state.frames > 30 { self.state.flags |= 1; } + self.state.update_mirrors(dt, subject, &self.rig, &self.shots); + let reset = self.state.reset_requested(); + if self.state.options & 0x40 != 0 { + if reset { self.drop.reset(); } + let velocity = DropPredictor::prediction_velocity(reset, subject.rig.transform, + self.rig.fields.velocity, settings.drop); + self.drop.update(subject.rig.transform[3], velocity, + self.shots.current().shot.use_drop_predictor != 0 && !reset, + subject.rig.off_board != 0, subject.rig.context, settings.drop, collision)?; + } + let look_enabled = if reset { self.shots.first_leaf().use_free_camera_stick } + else { self.shots.current().shot.use_free_camera_stick } != 0; + self.rig.fields.flags_517 = (self.rig.fields.flags_517 & !0x10) + | (u8::from(!look_enabled) << 4); + self.look.update(if look_enabled { subject.look } else { [0.0; 2] }, settings.look); + + if reset { + self.shots.make_instant(); + self.rig.fields.velocity = [0.0; 4]; + self.rig.fields.acceleration = [0.0; 4]; + self.state.slowmo_frames = 0; + self.shake = [ShakeEffect::new(); 2]; + self.heading_multiplier = self.state.heading_mirror.position; + self.orientation.framing_mirror = self.state.framing_mirror.position; + } else { + self.state.slowmo_frames = if subject.rig.broken_bone_slowmo != 0 { + self.state.slowmo_frames.wrapping_add(1) + } else { 0 }; + if self.state.slowmo_frames == 1 { + let impulse = settings.shake.impulse_magnitude * 20.0; + if impulse > self.shake[0].impulse { self.shake[0].impulse = impulse; } + } + } + self.shots.update(dt, &mut BlendEnvironment { + state: &self.state, subject, rig: &self.rig, + rig_forward: self.subject_forward, angular_velocity: [0.0; 4], + heading_multiplier: self.heading_multiplier, + previous_distance: self.shots.previous.distance, + }); + self.bind_anchor(subject, reset); + self.heading_multiplier = self.state.bind_shot(self.shots.interpolated, subject, + &mut self.rig, &mut self.orientation, &self.drop, self.look, + self.composer.roll, self.shots.is_transitioning()); + if reset { + self.rig.teleport(); + self.frame.shake_translation = [0.0; 4]; + } + self.rig.update(dt, settings.rig, &mut subject.rig, requests, moving, collision); + self.state.opacity = self.shots.interpolated.subject_opacity; + self.state.field_of_view = super::manager_frame::lens_field_of_view( + self.shots.interpolated.lens_length, self.state.aspect_ratio); + self.orientation.target = self.composer.compose(dt, self.shots.framing_fraction(), + self.orientation.framing_mirror, self.shots.previous, self.shots.current().shot, + self.rig.anchor.position, self.rig.positioner.position, FrameSubject { + positions: subject.rig.reference_positions, + board_offset_direction: subject.board_offset_direction, + }, settings.framing); + self.orientation.update(dt, settings.orientation, self.rig.fields.reset_time, + &mut self.rig.fields.flags_516, self.rig.fields.flags_517); + self.finish_frame(dt, subject, settings.shake, samples, reset); + self.shots.instant_changes = reset || self.state.instant_frames > 0; + if reset { + self.state.options &= !0x80; + self.state.flags |= 2; + } else if subject.is_ground_camera(self.rig.fields.height_mode) { + self.state.flags &= !2; + } + if self.state.instant_frames > 0 { self.state.instant_frames -= 1; } + Ok(self.frame) + } + + fn bind_anchor(&mut self, subject: &ManagerSubject, reset: bool) { + let anchor = if reset { self.shots.interpolated.anchor } + else { self.shots.current().shot.anchor }; + if !reset && anchor != self.state.selected_anchor { self.rig.fields.flags_517 |= 0x40; } + self.state.selected_anchor = anchor; + let selected = subject.anchors[anchor as usize]; + self.state.anchor = selected.position; + self.state.anchor_velocity = if reset { [0.0; 4] } else { selected.velocity }; + // Native setter subtracts velocities without division by the timestep. + self.rig.fields.acceleration = core::array::from_fn(|i| + self.state.anchor_velocity[i] - self.rig.fields.velocity[i]); + self.rig.fields.velocity = self.state.anchor_velocity; + self.rig.fields.anchor = self.state.anchor; + self.subject_forward = subject.rig.transform[2]; + self.rig.fields.flags_516 |= 0x10; + self.composer.reference = if reset { self.state.anchor } else { self.rig.anchor.position }; + } +} diff --git a/crates/skate-core/src/camera/manager_air.rs b/crates/skate-core/src/camera/manager_air.rs new file mode 100644 index 00000000..f05d64f4 --- /dev/null +++ b/crates/skate-core/src/camera/manager_air.rs @@ -0,0 +1,85 @@ +//! Camera trajectory transitions82DFE0E0 and slope relation82DFE718. +use super::{ManagerState,ManagerSubject,Rig,direction_to_angles,normalize_angle}; +use super::manager_state::clamp; +use super::vector_tracker::{dot,length}; + +impl ManagerState { + /// Returns the exact normal/velocity pair passed to ShakeEffect82E055D8 + /// when the native ground-camera transition triggers a landing impulse. + pub(super) fn update_air(&mut self,subject:&ManagerSubject,rig:&mut Rig) + ->Option<([f32;4],[f32;4])> { + let previous_delta=self.landing_height_delta; + self.landing_height_delta=subject.landing_position[1]-subject.launch_position[1]; + self.apex_height=subject.apex_position[1]-subject.launch_position[1]; + self.apex_time=subject.apex_time; + self.launch_position=subject.launch_position; + self.apex_height_ratio=if self.trajectory_camera_distance!=0.0 { + self.apex_height/self.trajectory_camera_distance + } else {0.0}; + self.landing_normal=subject.landing_normal; + self.launch_incline=crate::trigonometry::acos(clamp(subject.launch_normal[1],-1.0,1.0)); + self.flags &= !0x10; + let ground=subject.is_ground_camera(rig.fields.height_mode); + if ground || subject.rig.trajectory_valid==0 { + self.flags &= !4; + } else { + if self.flags&4!=0 { + if (self.landing_height_delta-previous_delta).abs()>f32::from_bits(0x3a83126f) { + rig.fields.flags_517 |=8; + } + } else { + let selected=subject.anchors[self.selected_anchor as usize].position; + self.trajectory_camera_distance=length(core::array::from_fn(|i| + rig.positioner.position[i]-selected[i])); + rig.fields.flags_517 &= !8; + } + self.flags |=4; + self.landing_incline=crate::trigonometry::acos(clamp(self.landing_normal[1],-1.0,1.0)); + self.signed_landing_incline=self.landing_incline; + if dot(subject.rig.transform[2],self.landing_normal)>0.0 { + self.signed_landing_incline = -self.landing_incline; + } + if self.launch_incline>f32::from_bits(0x3f9c61aa) + && self.landing_incline>f32::from_bits(0x3f060a92) { + self.flags|=0x10; + } + self.update_slope_relation(subject); + } + rig.fields.height_transition_weight=if !ground && subject.rig.trajectory_valid!=0 { + clamp(subject.trajectory_time/subject.trajectory_duration,0.0,1.0) + } else {0.0}; + let mut impulse=None; + if ground { + if self.flags&0x40==0 && rig.positioner.flags&0x80==0 { + if dot(self.previous_velocity,self.ground_normal)< -0.0 { + impulse=Some((self.ground_normal,self.previous_velocity)); + } + self.landing_height_delta=0.0; + } + } else if rig.fields.height_transition_time>=rig.fields.height_transition_duration + && subject.rig.trajectory_valid!=0 && self.landing_height_delta > -3.0 + && subject.flag_684==0 { + if self.flags&2!=0 || subject.rig.air_flag_452!=0 { + rig.fields.flags_516|=0x40; + } else if rig.fields.previous_height!=f32::MAX && rig.fields.reference_height!=f32::MAX { + rig.fields.height_transition_start=rig.fields.reference_height; + rig.fields.height_transition_end=self.landing_height_delta+rig.fields.reference_height; + rig.fields.height_transition_duration=subject.trajectory_duration-subject.trajectory_time; + rig.fields.height_transition_time=0.0; + } + } + self.flags=(self.flags&!0x40)|(u8::from(ground)<<6); + self.previous_velocity=rig.fields.velocity; + impulse + } + + fn update_slope_relation(&mut self,subject:&ManagerSubject) { + self.launch_landing_heading_delta=f32::from_bits(0x40490fdb); + if subject.launch_normal[1] { + pub state:&'a ManagerState, + pub subject:&'a ManagerSubject, + pub rig:&'a Rig, + pub rig_forward:[f32;4], + pub angular_velocity:[f32;4], + /// Rig424, including the compass2 override from the previous binding. + pub heading_multiplier:f32, + /// ShotManager992+60: previous shot distance. + pub previous_distance:f32, +} +impl ShotEnvironment for BlendEnvironment<'_> { + fn heading_mirror(&self)->f32 { self.heading_multiplier } + fn compass_north(&self,entry:u32)->f32 { self.subject.compass[entry as usize] } + fn special_camera_flag(&self)->bool { self.state.flags & 0x10 != 0 } + fn raw_blend_value(&mut self,kind:u32,authored:f32,dt:f32)->f32 { + let s=self.subject;let m=self.state; + match kind { + 0=>authored, + 1=>dot(self.rig.fields.velocity,s.rig.transform[2]).abs(), + 2=>dot(self.rig.fields.acceleration,self.rig_forward), + 3=>self.heading_multiplier*(-self.angular_velocity[1]), + 4=>m.apex_height, + 5=>self.air_progress(), + 6=>m.velocity_incline*DEGREES, + 7=>self.previous_distance, + 8=>self.apex_progress(), + 9=>m.absolute_velocity_incline*DEGREES, + 10=>m.landing_incline*DEGREES, + 11=>if m.mirrored() {-s.steering[1]} else {s.steering[1]}, + 12=>s.steering_for_blend(m.mirrored()), + 13=>if dt==0.0 {0.0} else {s.steering_for_turn(m.mirrored())}, + 14=>if dt==0.0 {0.0} else {f32::MAX}, + 15=>m.frontside_angle, + 16=>s.valid_trajectory_duration(), + 17=>s.look[0],18=>s.look[1],19=>s.look_heading(), + 20=>m.direction_incline*DEGREES, + 21=>s.value_512,22=>-s.value_516,23=>m.effect_weight, + _=>0.5, + } + } +} +impl BlendEnvironment<'_> { + fn air_progress(&self)->f32 { + let s=self.subject; + if s.is_ground_camera(self.rig.fields.height_mode) {return 1.0;} + if self.state.apex_height<=0.0 {return 0.0;} + let remaining=s.trajectory_duration-s.trajectory_time; + clamp(if s.trajectory_duration<=0.0 {1.0} else { + (s.trajectory_duration-remaining)/s.trajectory_duration + },0.0,1.0) + } + fn apex_progress(&self)->f32 { + let s=self.subject;let m=self.state; + if s.is_ground_camera(self.rig.fields.height_mode) { + return if m.flags&0x40!=0 {0.0} else {1.0}; + } + if m.apex_height<=0.0 || s.valid_trajectory_duration()twice { + clamp((s.trajectory_time-twice)/remaining+1.0,1.0,2.0) + } else {s.trajectory_time/twice} + } +} diff --git a/crates/skate-core/src/camera/manager_controls.rs b/crates/skate-core/src/camera/manager_controls.rs new file mode 100644 index 00000000..04b98ada --- /dev/null +++ b/crates/skate-core/src/camera/manager_controls.rs @@ -0,0 +1,41 @@ +//! Normal camera state timing82DFEAA8 and stance tracking82E00108. +use super::{ManagerState,ManagerSubject,Rig,ShotManager}; +use super::manager_state::{STEP,mirror_parameters}; + +impl ManagerState { + pub(super) fn update_timing(&mut self,subject:&ManagerSubject,steering_threshold:f32) { + self.time_without_trajectory=if subject.rig.trajectory_valid!=0 {0.0} + else {self.time_without_trajectory+STEP}; + if subject.reset!=0 || self.reset_requested() { + self.options|=0x80; + self.flags &= !4; + self.trajectory_camera_distance=0.0; + self.centred_time=0.0; + self.steering_time=0.0; + self.collision_elevation=0.0; + self.instant_frames=3; + self.anchor_velocity=[0.0;4]; + } + self.steering_time=if subject.steering[0].abs()<=steering_threshold {0.0} + else {self.steering_time+STEP}; + self.centred_time=if subject.steering_for_turn(self.mirrored()).abs()>=steering_threshold {0.0} + else {self.centred_time+STEP}; + self.flags=(self.flags&!8)|(u8::from(subject.stance_592!=subject.stance_560)<<3); + } + + pub(super) fn update_mirrors(&mut self,dt:f32,subject:&ManagerSubject,rig:&Rig,shots:&ShotManager) { + if !self.reset_requested() && !subject.is_ground_camera(rig.fields.height_mode) {return;} + let option=if self.reset_requested() {shots.first_leaf().mirror_for_stance} + else {shots.current().shot.mirror_for_stance}; + let target=if option!=0 && self.mirrored() {-1.0} else {1.0}; + if self.reset_requested() { + for tracker in [&mut self.heading_mirror,&mut self.framing_mirror] { + tracker.target=target;tracker.position=target; + tracker.velocity=0.0;tracker.acceleration=0.0; + } + } else { + self.heading_mirror.update(dt,target,mirror_parameters(100.0,500.0)); + self.framing_mirror.update(dt,target,mirror_parameters(15.0,50.0)); + } + } +} diff --git a/crates/skate-core/src/camera/manager_frame.rs b/crates/skate-core/src/camera/manager_frame.rs new file mode 100644 index 00000000..d4922d00 --- /dev/null +++ b/crates/skate-core/src/camera/manager_frame.rs @@ -0,0 +1,54 @@ +//! Final CameraMan frame arithmetic82DFF88C..82DFFF28, Euler82DFCD00. +use crate::math::Basis3; + +#[derive(Clone,Copy,Debug,PartialEq)] +pub struct CameraFrame { + pub basis:Basis3, + pub position:[f32;4], + pub previous_basis:Basis3, + pub previous_position:[f32;4], + pub linear_velocity:[f32;4], + pub angular_velocity:[f32;4], + pub shake_translation:[f32;4], + pub discontinuity:bool, + pub field_of_view_degrees:f32, + pub opacity:f32, + pub blur:f32, +} +impl CameraFrame { + pub(super) fn new()->Self { + let basis=Basis3 {columns:[[1.0,0.0,0.0],[0.0,1.0,0.0],[0.0,0.0,1.0]]}; + Self {basis,position:[0.0;4],previous_basis:basis,previous_position:[0.0;4], + linear_velocity:[0.0;4],angular_velocity:[0.0;4],shake_translation:[0.0;4], + discontinuity:false,field_of_view_degrees:0.0,opacity:1.0,blur:1.0} + } + pub(super) fn motion(&mut self,dt:f32,basis:Basis3,position:[f32;4]) { + let inverse=if dt!=0.0 {1.0/dt} else {1.0}; + self.linear_velocity=core::array::from_fn(|i|(position[i]-self.position[i])*inverse); + let old=angles(self.basis);let new=angles(basis); + self.angular_velocity=core::array::from_fn(|i| + if i==3 {0.0} else {super::normalize_angle(new[i]-old[i])*inverse}); + } +} + +pub(super) fn lens_field_of_view(lens:f32,aspect:f32)->f32 { + let angle=crate::input::angle::atan(35.0/(lens*2.0)); + let degrees=angle*f32::from_bits(0x42e52ee1); + square_root((degrees*degrees)/(aspect+1.0)) +} + +fn angles(basis:Basis3)->[f32;3] { + let [right,up,at]=basis.columns; + let horizontal=square_root(right[0].mul_add(right[0],right[1]*right[1])); + let yaw=super::manager_incline::atan_ratio(-right[2],horizontal); + if horizontal>f32::from_bits(0x3a83126f) { + [super::manager_incline::atan_ratio(up[2],at[2]),yaw, + super::manager_incline::atan_ratio(right[1],right[0])] + } else {[super::manager_incline::atan_ratio(-at[1],up[1]),yaw,0.0]} +} + +fn square_root(value:f32)->f32 { + let mut inverse=crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..2 {inverse=(inverse*0.5).mul_add((-value).mul_add(inverse*inverse,1.0),inverse);} + if value==0.0 {0.0} else {value*inverse} +} diff --git a/crates/skate-core/src/camera/manager_incline.rs b/crates/skate-core/src/camera/manager_incline.rs new file mode 100644 index 00000000..82eae761 --- /dev/null +++ b/crates/skate-core/src/camera/manager_incline.rs @@ -0,0 +1,63 @@ +//! Ground incline and frontside state, complete TU3 82E002A8. +use super::{ManagerState,ManagerSubject,direction_to_angles}; +use super::manager_state::{HALF_PI,UP}; +use super::vector_tracker::{dot,length}; + +impl ManagerState { + pub(super) fn update_incline(&mut self, subject:&ManagerSubject) { + self.ground_normal=subject.ground_normal; + let normal=subject.rig.last_valid_ground_up; + if normal[1]>f32::from_bits(0x3f7fff58) && subject.rig.grinding==0 { + self.velocity_incline=0.0; + self.absolute_velocity_incline=0.0; + self.frontside_angle=0.0; + self.ground_heading=0.0; + self.direction_incline=0.0; + self.flags &= !0x20; + return; + } + let direction=if length(self.anchor_velocity)>f32::from_bits(0x358637bd) { + super::orientation_math::normalize(self.anchor_velocity) + } else {subject.rig.transform[2]}; + self.velocity_incline=HALF_PI-angle_between(UP,direction); + self.absolute_velocity_incline=if self.velocity_incline<0.0 { + -self.velocity_incline + } else {self.velocity_incline}; + self.ground_heading=direction_to_angles(normal)[1]; + // Projection and subtraction are separate VMX operations, followed + // by the two-refinement normalization used inline at82E00554. + let projected=normal.map(|v|v*dot(UP,normal)); + let tangent=super::orientation_math::normalize(core::array::from_fn::<_,4,_>(|i|UP[i]-projected[i])); + let cross=[ + (-normal[2]).mul_add(tangent[1],normal[1]*tangent[2]), + (-normal[0]).mul_add(tangent[2],normal[2]*tangent[0]), + (-normal[1]).mul_add(tangent[0],normal[0]*tangent[1]),0.0, + ]; + let denominator=dot(direction,tangent); + let numerator=dot(direction,cross); + self.frontside_angle=atan_ratio(numerator,denominator); + self.flags=(self.flags&!0x20)|(u8::from(self.frontside_angle>0.0)<<5); + if self.mirrored() {self.frontside_angle *= -1.0;} + // vrlimi mask1 replaces W with X; this condition tests XYZ only. + if self.anchor_velocity[..3].iter().any(|v|v.abs()>f32::from_bits(0x34000000)) { + self.direction_incline=super::normalize_angle(HALF_PI-angle_between(subject.direction_424,UP)); + } + } +} + +pub(super) fn angle_between(a:[f32;4],b:[f32;4])->f32 { + let to_vector=|v:[f32;4]|crate::math::Vector3::new(v[0],v[1],v[2]); + crate::physics::board_ground::angle_between(to_vector(a),to_vector(b)) +} + +/// Inline atan wrapper82E005A4..82E00628: one reciprocal Newton step, +/// multiply-add with positive zero, then signed quadrant selection. +pub(super) fn atan_ratio(numerator:f32,denominator:f32)->f32 { + let initial=crate::physics::native_arithmetic::reciprocal_estimate(denominator); + let inverse=initial.mul_add((-initial).mul_add(denominator,1.0),initial); + let mut angle=crate::input::angle::atan(numerator.mul_add(inverse,0.0)); + let sign=numerator.to_bits()&0x80000000; + if 0.0>denominator {angle=f32::from_bits(0x40490fdb|sign)+angle;} + if denominator==0.0 {angle=f32::from_bits(0x3fc90fdb|sign);} + angle +} diff --git a/crates/skate-core/src/camera/manager_output.rs b/crates/skate-core/src/camera/manager_output.rs new file mode 100644 index 00000000..a7e41aeb --- /dev/null +++ b/crates/skate-core/src/camera/manager_output.rs @@ -0,0 +1,43 @@ +//! Final output/shake ordering from82DFFAA4..82DFFF28. +use super::{CameraMan, ManagerSubject, ShakeSamples, ShakeSettings}; +use super::vector_tracker::length; + +impl CameraMan { + pub(super) fn finish_frame(&mut self, dt: f32, subject: &ManagerSubject, + settings: ShakeSettings, samples: [&ShakeSamples; 2], reset: bool) { + let old_basis = self.frame.basis; + let old_position = self.frame.position; + let basis = self.orientation.basis; + let position = self.rig.positioner.position; + self.frame.motion(dt, basis, position); + let enabled = subject.rig.wiping_out == 0 || self.rig.positioner.flags & 0x80 != 0 + || subject.rig.broken_bone_slowmo != 0; + let velocity = self.rig.fields.velocity; + let speed = length([velocity[0], 0.0, velocity[2], 0.0]) + * if subject.flag_652 != 0 { f32::from_bits(0x3f6147ae) } else { 1.0 }; + let distance = length(core::array::from_fn(|i| self.rig.fields.anchor[i] - position[i])); + let first = self.shake[0].update(dt, speed, distance, basis, enabled, samples[0], settings); + let second = self.shake[1].update(dt, + if subject.shake_variant != 0 { speed * 50.0 } else { speed }, + distance, basis, enabled, samples[1], settings); + let selected = usize::from(subject.shake_variant != 0); + self.frame.basis = [first, second][selected]; + self.frame.position = position; + self.frame.shake_translation = self.shake[selected].translation; + //82DFFEC8..82DFFECC publishes ShakeEffect+16 into Rig+320. The + // positioner consumes this world-space offset on the following tick; + // it is not an extra translation added by the renderer to this frame. + self.rig.fields.offset = self.frame.shake_translation; + self.frame.discontinuity = self.rig.positioner.flags & 0x40 != 0 || reset; + if self.frame.discontinuity { + self.frame.previous_basis = self.frame.basis; + self.frame.previous_position = position; + } else { + self.frame.previous_basis = old_basis; + self.frame.previous_position = old_position; + } + self.frame.field_of_view_degrees = self.state.field_of_view; + self.frame.opacity = self.state.opacity; + self.frame.blur = self.state.blur; + } +} diff --git a/crates/skate-core/src/camera/manager_rig.rs b/crates/skate-core/src/camera/manager_rig.rs new file mode 100644 index 00000000..71958632 --- /dev/null +++ b/crates/skate-core/src/camera/manager_rig.rs @@ -0,0 +1,82 @@ +//! CameraMan shot-to-rig binding82DFF23C..82DFF864. +use super::{DropPredictor,LookInput,ManagerState,ManagerSubject,Rig,RigOrientation,Shot}; +use super::manager_state::clamp; +use super::vector_tracker::length; + +impl ManagerState { + pub(super) fn bind_shot(&mut self,shot:Shot,subject:&ManagerSubject,rig:&mut Rig, + orientation:&mut RigOrientation,drop:&DropPredictor,look:LookInput,previous_roll:f32, + transitioning:bool)->f32 { + let reset=self.reset_requested(); + rig.fields.avoidance_mode=u32::from(self.options&0x20!=0); + rig.fields.flags_516=(rig.fields.flags_516&!0x62) + |(u8::from(transitioning)<<5)|((shot.follow_subject_in_air<<6)&0x40) + |((shot.avoidance_override<<1)&2); + rig.fields.flags_517=(rig.fields.flags_517&0x7f)|(subject.stance_560<<7); + let [x,y,z,w]=shot.arm_orientation; + let basis=crate::physics::rigid_body::basis_from_quaternion( + crate::physics::rigid_body::RetailQuaternion {x,y,z,w}); + let at=basis.columns[2]; + let angles=super::direction_to_angles([at[0],at[1],at[2],0.0]); + let mut elevation=-angles[0];let mut heading=angles[1];let mut distance=shot.distance; + if subject.special_effect!=0 { + elevation += f32::from_bits(0x3eb2b8c2); + let time=(self.frames as f32)*f32::from_bits(0x3dd67750); + let distance_wave=crate::trigonometry::sin(time*f32::from_bits(0x3d321643)); + let heading_wave=crate::trigonometry::sin(time*f32::from_bits(0x3c23d70a)); + heading=heading_wave.mul_add(f32::from_bits(0x40278d36),heading); + distance=(distance_wave+distance)+1.0; + } + let mut drop_elevation=0.0; + if self.options&0x40!=0 { + let speed=length(self.anchor_velocity); + let (minimum,maximum)=if self.options&0x10!=0 {(0.7,0.8)} else {(0.9,1.0)}; + let scale=clamp(-(speed-4.0).mul_add(f32::from_bits(0x3cccccd0),-maximum),minimum,maximum); + drop_elevation=drop.elevation*scale; + distance=drop.distance_offset+distance; + } + let collision_elevation=if reset {0.0} else { + let maximum=if distance-2.5>=0.0 {2.5} else {distance}; + let fraction=clamp((maximum-rig.positioner.available_distance)/maximum,0.0,1.0); + let angle=(if subject.rig.off_board!=0 {60.0} else {50.0})*f32::from_bits(0x3c8efa35); + let target=angle.mul_add(fraction,-drop_elevation); + let mut target=if target>=0.0 {target} else {0.0}; + if subject.is_ground_camera(rig.fields.height_mode) && subject.ground_normal[1]< -0.2 { + target *= -1.0; + } + let outward=(target>0.0 && self.collision_elevationtarget); + let step=angle*(if outward {f32::from_bits(0x3d888889)} else {f32::from_bits(0x3c23d70a)}); + self.collision_elevation=if (target-self.collision_elevation).abs()>step { + if target>=self.collision_elevation {self.collision_elevation+step} + else {self.collision_elevation-step} + } else {target}; + clamp(self.collision_elevation,-f32::from_bits(0x3fb2b8c2),f32::from_bits(0x3fb2b8c2)) + }; + rig.fields.distance=distance; + let turns=heading.mul_add(f32::from_bits(0x3e22f983),0.5).floor(); + let reference=-turns.mul_add(f32::from_bits(0x40c90fdb),-heading); + rig.fields.heading_reference=reference; + rig.fields.heading_target=if reset {reference} else { + let from=rig.fields.avoidance_heading; + let delta=super::rig_tracking::wrap_vmx(reference-from); + super::rig_tracking::wrap_vmx(delta.mul_add(1.0-rig.fields.heading_weight,from)) + }; + rig.set_elevation(elevation,!reset); + rig.fields.elevation_offset=drop_elevation+look.elevation; + rig.fields.additional_elevation=collision_elevation; + rig.fields.flags_517=(rig.fields.flags_517&!0x20)|(u8::from(shot.compass_north!=5)<<5); + self.blur=if reset {0.0} else if transitioning {shot.transition_blur} else {shot.blur}; + let heading_mirror=if shot.compass_north==2 {1.0} else {self.heading_mirror.position}; + orientation.framing_mirror=if shot.compass_north==2 {1.0} else {self.framing_mirror.position}; + rig.fields.collision_mode=shot.collision_hint; + let h=1.0-shot.smoothing[0];let e=1.0-shot.smoothing[1]; + let pan=1.0-shot.smoothing[2];let tilt=1.0-shot.smoothing[3]; + rig.fields.heading_smoothing=1.0-(-h.mul_add(h,-1.0)); + rig.fields.elevation_smoothing=1.0-(-e.mul_add(e,-1.0)); + orientation.pan_smoothing=-pan.mul_add(pan,-1.0); + orientation.tilt_smoothing=-tilt.mul_add(tilt,-1.0); + orientation.roll=previous_roll; + heading_mirror + } +} diff --git a/crates/skate-core/src/camera/manager_state.rs b/crates/skate-core/src/camera/manager_state.rs new file mode 100644 index 00000000..e0aaa0ef --- /dev/null +++ b/crates/skate-core/src/camera/manager_state.rs @@ -0,0 +1,88 @@ +//! Normal CameraMan persistent state, constructor/reset82DFDA60/82DFDFA0. +use super::{ScalarTracker, ScalarTrackerParameters}; + +pub(super) const STEP: f32 = f32::from_bits(0x3c888889); +pub(super) const HALF_PI: f32 = f32::from_bits(0x3fc90fdb); +pub(super) const DEGREES: f32 = f32::from_bits(0x42652ee1); +pub(super) const UP: [f32;4] = [0.0,1.0,0.0,0.0]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManagerState { + pub anchor: [f32;4], //624 + pub anchor_velocity: [f32;4], //656 + pub landing_normal: [f32;4], //720 + pub launch_position: [f32;4], //736 + pub ground_normal: [f32;4], //752 + pub previous_velocity: [f32;4], //768 + pub incline_normal: [f32;4], //784 + pub selected_anchor: u32, //800 + pub frontside_angle: f32, //808 + pub ground_heading: f32, //812 + pub velocity_incline: f32, //816 + pub absolute_velocity_incline: f32, //820 + pub launch_incline: f32, //824 + pub signed_landing_incline: f32, //828 + pub landing_incline: f32, //832 + pub direction_incline: f32, //836 + pub field_of_view: f32, //844 + pub apex_height: f32, //848 + pub apex_time: f32, //852 + pub time_without_trajectory: f32, //856 + pub landing_height_delta: f32, //860 + pub apex_height_ratio: f32, //864 + pub launch_landing_heading_delta: f32, //868 + pub trajectory_camera_distance: f32, //872 + pub steering_time: f32, //876 + pub centred_time: f32, //880 + pub opacity: f32, //884 + pub aspect_ratio: f32, //888 + pub blur: f32, //896 + pub effect_weight: f32, //900 + pub collision_elevation: f32, //904 + pub frames: u32, //908 + pub slowmo_frames: u32, //912 + pub instant_frames: u32, //916 + pub flags: u8, //920 + pub options: u8, //921 + pub heading_mirror: ScalarTracker, //464 + pub framing_mirror: ScalarTracker, //524 +} + +impl ManagerState { + pub fn new() -> Self { + Self { + anchor:[0.0;4], anchor_velocity:[0.0;4], landing_normal:UP, + launch_position:[0.0;4], ground_normal:UP, previous_velocity:[0.0;4], + incline_normal:[0.0;4], selected_anchor:0, frontside_angle:0.0, + ground_heading:0.0, velocity_incline:0.0, absolute_velocity_incline:0.0, + launch_incline:0.0,signed_landing_incline:0.0,landing_incline:0.0, + direction_incline:0.0,field_of_view:0.0,apex_height:0.0,apex_time:0.0, + time_without_trajectory:f32::MAX,landing_height_delta:0.0, + apex_height_ratio:0.0,launch_landing_heading_delta:0.0,trajectory_camera_distance:0.0,steering_time:0.0, + centred_time:0.0,opacity:1.0,aspect_ratio:f32::from_bits(0x3fe38e39), + blur:1.0,effect_weight:1.0,collision_elevation:0.0,frames:0, + slowmo_frames:0,instant_frames:3,flags:2,options:0x60, + heading_mirror:mirror_tracker(),framing_mirror:mirror_tracker(), + } + } + + pub fn reset_requested(&self) -> bool { self.options & 0x80 != 0 } + pub fn request_reset(&mut self) { self.options |= 0x80; } + pub fn mirrored(&self) -> bool { self.flags & 8 != 0 } +} + +pub(super) fn clamp(value:f32, low:f32, high:f32)->f32 { + let value=if low-value>=0.0 {low} else {value}; + if high-value>=0.0 {value} else {high} +} + +fn mirror_tracker()->ScalarTracker { + ScalarTracker {target:1.0,position:1.0,velocity:0.0,acceleration:0.0, + acceleration_clamp:0.0,smoothing:0.0} +} + +pub(super) fn mirror_parameters(speed:f32,acceleration:f32)->ScalarTrackerParameters { + ScalarTrackerParameters {delta_umbra:0.0,delta_penumbra:0.0,speed_clamp:speed, + acceleration_clamp_min:acceleration,acceleration_clamp_max:acceleration, + smoothing_min:0.85,smoothing_max:0.85,overshoot_zeroes_velocity:true} +} diff --git a/crates/skate-core/src/camera/manager_subject.rs b/crates/skate-core/src/camera/manager_subject.rs new file mode 100644 index 00000000..50df3d9a --- /dev/null +++ b/crates/skate-core/src/camera/manager_subject.rs @@ -0,0 +1,82 @@ +//! CameraMan inputs published by the normal subject, TU3 82DF69C0. +//! The physics host supplies these independently produced values; camera code +//! does not replace trajectory, stance or animation outputs with pose guesses. +use super::{AnchorState, Subject}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManagerSubject { + pub rig: Subject, + pub anchors: [AnchorState; 7], + /// Subject compass getter364, filled by82DF7B90. + pub compass: [f32; 9], + pub board_offset_direction: [f32; 4], //456 + pub ground_normal: [f32; 4], //448 + pub launch_position: [f32; 4], //400 + pub launch_normal: [f32; 4], //404 + pub landing_position: [f32; 4], //408 + pub landing_normal: [f32; 4], //412 + pub apex_position: [f32; 4], //416 + pub direction_424: [f32; 4], + pub look: [f32; 2], //472 + /// Getters484,488,492,496 are distinct source producers. + pub steering: [f32; 4], + pub trajectory_time: f32, //500 + pub trajectory_duration: f32, //504 + pub apex_time: f32, //508 + pub value_512: f32, + pub value_516: f32, + pub reset: u8, //544 + pub flag_556: u8, + pub stance_560: u8, + pub stance_592: u8, + pub flag_652: u8, + pub shake_variant: u8, //672 + pub special_effect: u8, //676 + pub flag_684: u8, +} + +impl ManagerSubject { + ///82E00040: this camera predicate is distinct from grounded physics. + pub fn is_ground_camera(&self, height_mode: u32) -> bool { + height_mode == 2 || (self.flag_556 == 0 && self.rig.trajectory_valid == 0 + && self.trajectory_duration <= f32::from_bits(0x3ecccccd)) + } + + ///82E00980. Sign disagreement retains the primary steering channel; + /// agreement selects the greater magnitude. Equality preserves492. + pub fn steering_for_turn(&self, mirrored: bool) -> f32 { + let [primary, _, first, second] = self.steering; + let mut selected = if second.abs() > first.abs() { second } else { first }; + if sign(selected) != sign(primary) || selected.abs() < primary.abs() { + selected = primary; + } + if u8::from(mirrored) == self.stance_560 { selected } else { -selected } + } + + ///82E00790. This variant starts with channel488 and keeps the secondary + /// channel on sign disagreement; it is not identical to82E00980. + pub fn steering_for_blend(&self, mirrored: bool) -> f32 { + let [_, primary, first, second] = self.steering; + let secondary = if second.abs() > first.abs() { second } else { first }; + let selected = if sign(primary) != sign(secondary) || primary.abs() < secondary.abs() { + secondary + } else { primary }; + if u8::from(mirrored) == self.stance_560 { selected } else { -selected } + } + + ///82E00EB0. + pub fn valid_trajectory_duration(&self) -> f32 { + if self.rig.trajectory_valid != 0 { self.trajectory_duration } else { 0.0 } + } + + ///82E00F78; normalize before the native direction-to-angles helper. + pub fn look_heading(&self) -> f32 { + let direction = super::orientation_math::normalize([self.look[0], 0.0, self.look[1], 0.0]); + -super::direction_to_angles(direction)[1] + } +} + +// Vector comparisons distinguish zero from positive, unlike signum(). +fn sign(value: f32) -> i8 { + if value >= 0.0 { if value > 0.0 { 1 } else { 0 } } else { -1 } +} diff --git a/crates/skate-core/src/camera/mod.rs b/crates/skate-core/src/camera/mod.rs new file mode 100644 index 00000000..1280f414 --- /dev/null +++ b/crates/skate-core/src/camera/mod.rs @@ -0,0 +1,84 @@ +//! Readable camera implementation candidates. Prior generated-code validation +//! is withdrawn; native correctness and full gameplay integration are unverified. + +mod angle_tracker; +mod anchors; +mod avoidance; +mod direction; +mod drop_predictor; +mod look_input; +mod manager_subject; +mod manager; +mod manager_output; +mod compass; +mod compass_movement; +mod compass_orbit; +mod manager_state; +mod manager_blends; +mod manager_controls; +mod manager_incline; +mod manager_air; +mod manager_rig; +mod manager_frame; +mod shake; +mod shake_curve; +mod shake_update; +mod frame_composer; +mod orientation_math; +mod path_evaluator; +mod path_prediction; +mod trajectory_query; +mod slow_motion; +mod positioner; +mod rig; +mod rig_initialization; +mod rig_orientation; +mod rig_framing; +mod rig_positioning; +mod rig_tracking; +mod subject; +mod subject_pose; +mod shot; +mod shot_manager; +mod shot_orientation; +mod tracker; +mod vector_tracker; + +pub use angle_tracker::{AngleTracker, normalize_angle}; +pub use anchors::{AnchorInputs, AnchorState, Anchors}; +pub use avoidance::{AvoidancePath, AvoidanceSettings, AvoidanceSubject, RigAvoidance}; +pub use direction::direction_to_angles; +pub use drop_predictor::{DropCollisionProvider, DropPredictor, DropSettings}; +pub use look_input::{LookInput, LookSettings}; +pub use manager_subject::ManagerSubject; +pub use manager::{CameraMan, ManagerSettings}; +pub use compass::{Compass, CompassInputs, CompassPoseInputs, CompassSettings}; +pub use manager_state::ManagerState; +pub use manager_frame::CameraFrame; +pub use shake::{ShakeEffect,ShakeSamples,ShakeSettings}; +pub use frame_composer::{FrameComposer, FrameSettings, FrameSubject}; +pub use path_evaluator::{MovingObstacleProvider, PathEvaluator, TrajectoryCollisionRequest}; +pub use path_prediction::{PathObstacle, PredictionPath, candidate_collision_time}; +pub use trajectory_query::TrajectoryQuery; +pub use slow_motion::{SimulationRateRequest, SlowMotionController, SlowMotionSettings}; +pub use positioner::{ + FatLine, FatLineResult, Positioner, PositionerCollisionProvider, PositionerConfig, + position_from_angles, project_hit, +}; +pub use rig::{Breadcrumbs, Rig, RigFields, RigSettings}; +pub use rig_orientation::{OrientationSettings, OrientationTrackerSettings, RigOrientation}; +pub use rig_framing::{RigFraming, RigMode, RigModeSubject}; +pub use rig_positioning::{ + DistanceTrackingSettings, RigPositioning, RigPositioningSettings, RigPositioningSubject, +}; +pub use rig_tracking::{ + AnchorRigTracking, AnchorTrackingSettings, AnchorTrackingSubject, AngleTrackingSettings, + AngularRigTracking, +}; +pub use rig_tracking::{ReferenceHeightSubject, ReferenceHeightTracking}; +pub use tracker::{ScalarTracker, ScalarTrackerParameters}; +pub use subject::{ReferencePointInputs, Subject}; +pub use subject_pose::{PublishedSubjectPose, SubjectPoseInputs, SubjectPosePublisher}; +pub use shot::{Shot, blend_interval, filter_blend_value, interpolate_float}; +pub use shot_manager::{ShotDatabase, ShotDefinition, ShotEnvironment, ShotManager, ShotPlacement}; +pub use vector_tracker::{VectorTracker, clamp_magnitude}; diff --git a/crates/skate-core/src/camera/orientation_math.rs b/crates/skate-core/src/camera/orientation_math.rs new file mode 100644 index 00000000..f1fc27ed --- /dev/null +++ b/crates/skate-core/src/camera/orientation_math.rs @@ -0,0 +1,96 @@ +//! Native camera Euler conversions. These preserve the products and fused sums +//! from TU3 82E06CB8 and 82E045E0 rather than passing through renderer math. +use crate::{math::Basis3, trigonometry::sin_cos}; + +pub(super) fn quaternion_from_angles(pitch: f32, yaw: f32, roll: f32) -> [f32; 4] { + let (sp, cp) = sin_cos(pitch * 0.5); + let (sy, cy) = sin_cos(yaw * 0.5); + let (sr, cr) = sin_cos(roll * 0.5); + let cp_cr = cp * cr; + let sp_cr = sp * cr; + let sp_sr = sp * sr; + let cp_sr = cp * sr; + [ + cy * sp_cr - sy * cp_sr, + cy.mul_add(sp_sr, sy * cp_cr), + cy * cp_sr - sy * sp_cr, + cy.mul_add(cp_cr, sy * sp_sr), + ] +} + +/// TU3 82E04AD4..82E04BC4. The native matrix's three spatial columns are +/// retained; its unused fourth lanes are not part of the host Basis3 type. +pub(super) fn basis_from_angles(pitch: f32, yaw: f32, roll: f32) -> Basis3 { + let (sp, cp) = sin_cos(pitch); + let (sy, cy) = sin_cos(yaw); + let (sr, cr) = sin_cos(roll); + let cp_sr = cp * sr; + let cp_cr = cp * cr; + let sp_sr = sp * sr; + let sp_cr = sp * cr; + Basis3 { + columns: [ + [cy * cr, cy * sr, -sy], + [sy * sp_cr - cp_sr, sy.mul_add(sp_sr, cp_cr), cy * sp], + [sy.mul_add(cp_cr, sp_sr), sy * cp_sr - sp_cr, cy * cp], + ], + } +} + +/// Complete axis rotation82BD35B8. Standalone sine/cosine and the matrix +/// product's X,Y,Z accumulation order are observable in the camera output. +pub(super) fn rotate_about_axis(basis: Basis3, axis: [f32; 3], angle: f32) -> Basis3 { + let cosine = crate::trigonometry::cos(angle); + let sine = crate::trigonometry::sin(angle); + let [x, y, z] = axis; + let sx = sine * x; + let sy = sine * y; + let sz = sine * z; + let one_minus_cosine = 1.0 - cosine; + let tx = one_minus_cosine * x; + let ty = one_minus_cosine * y; + let tz = one_minus_cosine * z; + let rotation = [ + [x.mul_add(tx, cosine), tx.mul_add(y, sz), tx * z - sy], + [ty * x - sz, ty.mul_add(y, cosine), ty.mul_add(z, sx)], + [tz.mul_add(x, sy), tz * y - sx, z.mul_add(tz, cosine)], + ]; + Basis3 { + columns: basis.columns.map(|column| { + core::array::from_fn(|i| { + rotation[2][i].mul_add( + column[2], + rotation[1][i].mul_add(column[1], rotation[0][i] * column[0]), + ) + }) + }), + } +} + +/// Look basis8296EF20: normalize world-up cross At, then normalize At cross +/// Right. The caller has already handled a zero direction and its pitch cap. +pub(super) fn look_basis(at: [f32; 3]) -> Basis3 { + let right = normalize(cross([0.0, 1.0, 0.0], at)); + let up = normalize(cross(at, right)); + Basis3 { + columns: [right, up, at], + } +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + (-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]), + ] +} + +pub(super) fn normalize(v: [f32; N]) -> [f32; N] { + let square = + crate::physics::native_arithmetic::dot3([v[0], v[1], v[2], 0.0], [v[0], v[1], v[2], 0.0]); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + v.map(|lane| lane * inverse) +} diff --git a/crates/skate-core/src/camera/path_evaluator.rs b/crates/skate-core/src/camera/path_evaluator.rs new file mode 100644 index 00000000..8a27fcf4 --- /dev/null +++ b/crates/skate-core/src/camera/path_evaluator.rs @@ -0,0 +1,122 @@ +//! Complete TU3 82E023D0/82E02490 orchestration. Backend adapters are external; +//! the trajectory request and moving-sphere provider are distinct native objects. + +use super::{PathObstacle, PredictionPath, candidate_collision_time}; + +/// Native request vtable slots +4/+8/+12 (82DF9270/82DF9338/82DF9340). +/// A nonzero flag selects the manager acceleration vector in the stock adapter. +pub trait TrajectoryCollisionRequest { + fn submit(&mut self, path: PredictionPath, context: u32, acceleration_flag: u8); + fn is_ready(&mut self) -> bool; + fn collision_time(&mut self) -> f32; +} + +/// Native 82DF9400 provider. Implementations preserve native actor filtering and +/// sphere generation; the returned count must be at most the 50-record capacity. +pub trait MovingObstacleProvider { + fn collect( + &mut self, + position: [f32; 4], + velocity: [f32; 4], + radius: f32, + output: &mut [PathObstacle; 50], + ) -> usize; +} + +/// Fields touched by the two evaluator routines. The complete native constructor +/// and path setter own initialization; omitted native padding/fields are untouched. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PathEvaluator { + /// Native word +8: only 0 and 1 have update behavior. + pub request_state: u32, + pub path: PredictionPath, + /// Native byte +57, saved when submitting a request. + pub acceleration_flag: u8, + pub collision_time: f32, + pub last_valid_time: f32, + /// Native byte +72. Readiness counts even for a negative/no-hit result. + pub found: u8, + /// Native byte +73; only a ready asynchronous result replaces it. + pub result_acceleration_flag: u8, + /// Native byte +76. + pub submitted_acceleration_flag: u8, + /// Native word +80, compared as signed after wrapping increment. + pub pending_polls: i32, +} + +impl PathEvaluator { + /// Complete 82E02490. Does not clear current-frame result fields on entry. + pub fn update_request(&mut self, context: u32, request: &mut impl TrajectoryCollisionRequest) { + match self.request_state { + 0 => { + request.submit(self.path, context, self.acceleration_flag); + self.submitted_acceleration_flag = self.acceleration_flag; + self.request_state = 1; + self.pending_polls = 0; + } + 1 => { + if request.is_ready() { + let result = request.collision_time(); + let result = if result < 0.0 { f32::MAX } else { result }; + self.found = 1; + self.request_state = 0; + self.result_acceleration_flag = self.submitted_acceleration_flag; + self.collision_time = select_time(self.collision_time, result); + } else { + self.pending_polls = self.pending_polls.wrapping_add(1); + if self.pending_polls > 3 { + self.request_state = 0; + self.last_valid_time = f32::MAX; + } + } + } + _ => {} + } + } + + /// Complete 82E023D0, including the native asynchronous-before-moving order. + pub fn update( + &mut self, + context: u32, + request: &mut impl TrajectoryCollisionRequest, + moving: &mut impl MovingObstacleProvider, + ) { + self.found = 0; + self.collision_time = f32::MAX; + self.update_request(context, request); + + // Host scratch storage; only the provider's returned records are consumed. + let mut obstacles = [PathObstacle { + position: [0.0; 4], + velocity: [0.0; 4], + radius: 0.0, + }; 50]; + let count = moving.collect( + self.path.position, + self.path.velocity, + self.path.radius, + &mut obstacles, + ); + assert!( + count <= obstacles.len(), + "native moving-sphere capacity exceeded" + ); + let time = candidate_collision_time(self.path, &obstacles[..count]); + if time < self.path.horizon { + self.found = 1; + self.collision_time = select_time(self.collision_time, time); + } + if self.found != 0 { + self.last_valid_time = self.collision_time; + } + } +} + +// Preserve the native fsubs/fsel ordering (including unordered operands and ties). +fn select_time(current: f32, candidate: f32) -> f32 { + if current - candidate >= 0.0 { + candidate + } else { + current + } +} diff --git a/crates/skate-core/src/camera/path_prediction.rs b/crates/skate-core/src/camera/path_prediction.rs new file mode 100644 index 00000000..bd6252f2 --- /dev/null +++ b/crates/skate-core/src/camera/path_prediction.rs @@ -0,0 +1,49 @@ +//! Complete candidate-list evaluator TU3 82E02608, used by avoidance82E023D0. +//! Query enumeration and its separate asynchronous world provider are upstream. + +/// Fields consumed from each native 48-byte candidate; remaining words are not +/// interpreted by this evaluator. Velocity is in the same world frame as path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PathObstacle { + pub position: [f32; 4], + pub velocity: [f32; 4], + pub radius: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PredictionPath { + pub position: [f32; 4], + pub velocity: [f32; 4], + pub radius: f32, + pub horizon: f32, +} + +/// Returns the earliest accepted closest-approach time, or native FLT_MAX. +/// Native does not solve first surface entry time; tangent cases are rejected. +pub fn candidate_collision_time(path: PredictionPath, obstacles: &[PathObstacle]) -> f32 { + let mut result = f32::MAX; + for obstacle in obstacles { + let separation = core::array::from_fn(|i| obstacle.position[i] - path.position[i]); + let relative_velocity = core::array::from_fn(|i| obstacle.velocity[i] - path.velocity[i]); + let radius = obstacle.radius + path.radius; + let clearance = super::vector_tracker::dot(separation, separation) - radius * radius; + let time = if clearance < 0.0 { + 0.0 + } else { + let approach = super::vector_tracker::dot(separation, relative_velocity); + if !(approach < 0.0) { + continue; + } + let speed_squared = super::vector_tracker::dot(relative_velocity, relative_velocity); + let time = (-1.0 / speed_squared) * approach; + if !(approach.mul_add(time, clearance) < 0.0) { + continue; + } + time + }; + if time < path.horizon && time < result { + result = time; + } + } + result +} diff --git a/crates/skate-core/src/camera/positioner.rs b/crates/skate-core/src/camera/positioner.rs new file mode 100644 index 00000000..e498bc7e --- /dev/null +++ b/crates/skate-core/src/camera/positioner.rs @@ -0,0 +1,289 @@ +//! TU3 normal-camera positioner 82E01948 and its complete collision/geometry +//! callees. The caller supplies recovered state/configuration and the native +//! six-fat-line backend; this module does not choose camera settings. + +use super::vector_tracker::{length, refined_reciprocal}; +use super::{ScalarTracker, ScalarTrackerParameters}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FatLine { + pub start: [f32; 4], + pub end: [f32; 4], + pub radius: f32, +} + +/// Native 48-byte result: vectors0/16, fraction32, hit byte36, surface word40. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FatLineResult { + pub position: [f32; 4], + pub normal: [f32; 4], + pub fraction: f32, + pub hit: u8, + pub surface: u32, +} + +/// Camera server slots+8/+12, implemented natively by82DF31C0/82DF3290. +/// Results preserve segment order; native submission always contains six lines. +pub trait PositionerCollisionProvider { + fn submit(&mut self, lines: [FatLine; 6], context: u32); + fn results(&mut self) -> [FatLineResult; 6]; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PositionerConfig { + pub anchor: [f32; 4], + pub offset: [f32; 4], + pub reference_heading: f32, + pub reference_elevation: f32, + pub heading: f32, + pub elevation: f32, + pub distance: f32, + pub minimum_distance: f32, + pub collision_enabled: u8, + pub bypass_distance_tracker: u8, + pub clamp_height: u8, + pub floor_height: f32, + pub speed_clamp: f32, + pub acceleration_clamp: f32, + pub smoothing: f32, + pub query_starts: [[f32; 4]; 3], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Positioner { + pub distance_tracker: ScalarTracker, + pub tracker_parameters: ScalarTrackerParameters, + pub offset: [f32; 4], + pub previous_valid_position: [f32; 4], + pub reference_position: [f32; 4], + pub collision_clear_time: f32, + pub config: PositionerConfig, + pub position: [f32; 4], + pub velocity: [f32; 4], + pub distance: f32, + pub heading: f32, + pub elevation: f32, + pub radius: f32, + pub available_distance: f32, + pub flags: u8, +} + +impl Positioner { + /// Complete82E01948. Negative/zero dt follows native tracker/velocity gates. + pub fn update( + &mut self, + dt: f32, + context: u32, + config: PositionerConfig, + collision: &mut impl PositionerCollisionProvider, + ) -> u32 { + self.config = config; + self.offset = config.offset; + if self.collision_clear_time < 5.0 { + let scale = self.collision_clear_time * f32::from_bits(0x3e4ccccd); + self.offset = self.offset.map(|v| v * scale); + } + if config.bypass_distance_tracker != 0 { + self.distance_tracker.target = config.distance; + self.distance_tracker.position = config.distance; + } else { + let acceleration = if self.distance_tracker.position < f32::from_bits(0x3f866666) { + 100.0 + } else { + config.acceleration_clamp + }; + self.tracker_parameters.acceleration_clamp_min = acceleration; + self.tracker_parameters.acceleration_clamp_max = acceleration; + self.tracker_parameters.speed_clamp = config.speed_clamp; + self.tracker_parameters.smoothing_min = config.smoothing; + self.tracker_parameters.smoothing_max = config.smoothing; + self.distance_tracker + .update(dt, config.distance, self.tracker_parameters); + } + self.radius = f32::from_bits(0x3e051eb8); + let previous = self.position; + let result = self.resolve(context, collision); + if dt > 0.0 { + let inverse = refined_reciprocal(dt); + self.velocity = core::array::from_fn(|i| inverse * (self.position[i] - previous[i])); + } + if self.flags & 0xc0 != 0 { + self.collision_clear_time = 0.0; + } else { + self.collision_clear_time += dt; + } + result + } + + /// Complete82E01BA0. Query status 3 preserves previous-valid state; the + /// current complete82E01EA8 body produces only statuses0,1,2. + pub fn resolve( + &mut self, + context: u32, + collision: &mut impl PositionerCollisionProvider, + ) -> u32 { + self.available_distance = self.config.distance; + self.flags = (self.flags & 0xc1) | 0x0c; + self.heading = self.config.heading; + self.elevation = self.config.elevation; + self.distance = self.distance_tracker.position; + self.position = self.position_at(self.elevation, self.heading, self.distance); + let result = self.resolve_collision(context, collision); + if result == 1 || result == 2 { + let distance = length(core::array::from_fn(|i| { + self.position[i] - self.config.anchor[i] + })); + self.distance_tracker.target = distance; + self.distance_tracker.position = distance; + self.distance_tracker.velocity = 0.0; + self.distance_tracker.acceleration = 0.0; + self.distance = distance; + } + if result == 3 { + self.position = self.previous_valid_position; + self.flags &= !8; + } else { + self.previous_valid_position = self.position; + self.flags = (self.flags & !0x20) | if result == 2 { 0x20 } else { 0 }; + } + self.flags = (self.flags & 0x3f) | if result != 0 { 0x80 } else { 0 }; + result + } + + /// Complete82E01D30. Submit happens even when collision_enabled is zero. + pub fn submit_collision( + &mut self, + context: u32, + collision: &mut impl PositionerCollisionProvider, + ) { + self.reference_position = self.position_at( + self.config.reference_elevation, + self.config.reference_heading, + self.config.distance, + ); + let lines = core::array::from_fn(|i| FatLine { + start: self.config.query_starts[i % 3], + end: if i < 3 { + self.position + } else { + self.reference_position + }, + radius: self.radius, + }); + collision.submit(lines, context); + } + + /// Complete82E01EA8, preserving first-three/last-three result roles and order. + pub fn resolve_collision( + &mut self, + context: u32, + collision: &mut impl PositionerCollisionProvider, + ) -> u32 { + self.submit_collision(context, collision); + let results = collision.results(); + if self.config.collision_enabled != 0 { + self.flags |= 2; + self.available_distance = 0.0; + for result in &results[3..6] { + if result.hit != 0 { + let point = project_hit( + self.reference_position, + self.config.query_starts[0], + *result, + ); + let distance = + length(core::array::from_fn(|i| point[i] - self.config.anchor[i])); + if !(self.available_distance - distance >= 0.0) { + self.available_distance = distance; + } + } else { + self.available_distance = self.config.distance; + self.flags &= !2; + } + } + if results[..3].iter().all(|r| r.hit != 0) { + self.position = project_hit(self.position, self.config.query_starts[0], results[0]); + let distance = length(core::array::from_fn(|i| { + self.position[i] - self.config.query_starts[0][i] + })); + return if distance < self.radius + self.config.minimum_distance { + 2 + } else { + 1 + }; + } + } + 0 + } + + fn position_at(&self, elevation: f32, heading: f32, distance: f32) -> [f32; 4] { + position_from_angles( + self.config.anchor, + self.offset, + elevation, + heading, + distance, + self.config.clamp_height, + self.radius + self.config.floor_height, + ) + } +} + +/// Complete82E020F0. Invalid hit positions or a zero hit byte preserve the end. +pub fn project_hit(end: [f32; 4], first_query_start: [f32; 4], result: FatLineResult) -> [f32; 4] { + if result.position[..3].iter().all(|v| !v.is_nan()) && result.hit != 0 { + let lower = if -result.fraction >= 0.0 { + 0.0 + } else { + result.fraction + }; + let fraction = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + core::array::from_fn(|i| { + (end[i] - first_query_start[i]).mul_add(fraction, first_query_start[i]) + }) + } else { + end + } +} + +/// Complete82E02190, including native four-lane basis construction and ordering. +pub fn position_from_angles( + anchor: [f32; 4], + offset: [f32; 4], + elevation: f32, + heading: f32, + distance: f32, + clamp_height: u8, + minimum_height: f32, +) -> [f32; 4] { + let (s_e, c_e) = crate::trigonometry::sin_cos(-elevation); + let (s_h, c_h) = crate::trigonometry::sin_cos(-heading); + // 822FB890 permutation and vrlimi lane insertion, in guest memory order. + let first = [ + [1.0, 0.0, 0.0, 1.0], + [0.0, c_e, s_e, 0.0], + [0.0, -s_e, c_e, 0.0], + ]; + let second = [ + [c_h, 0.0, -s_h, c_h], + [0.0, 1.0, 0.0, 0.0], + [s_h, 0.0, c_h, s_h], + ]; + let product: [[f32; 4]; 3] = core::array::from_fn(|column| { + core::array::from_fn(|i| { + let value = second[0][i] * first[column][0]; + let value = second[1][i].mul_add(first[column][1], value); + second[2][i].mul_add(first[column][2], value) + }) + }); + let mut point = core::array::from_fn(|i| { + let value = product[0][i] * 0.0; + let value = product[1][i].mul_add(0.0, value); + let value = product[2][i].mul_add(distance, value); + (value + offset[i]) + anchor[i] + }); + if clamp_height != 0 && minimum_height > point[1] { + point[1] = minimum_height; + } + point +} diff --git a/crates/skate-core/src/camera/rig.rs b/crates/skate-core/src/camera/rig.rs new file mode 100644 index 00000000..85fcf0ef --- /dev/null +++ b/crates/skate-core/src/camera/rig.rs @@ -0,0 +1,312 @@ +//! Native normal-camera rig update82E03300. The rig owns one copy of each +//! shared field; operation adapters below cannot retain inconsistent snapshots. +use super::*; + +#[derive(Clone, Debug, PartialEq)] +pub struct Breadcrumbs { + pub positions: Vec<[f32; 4]>, + pub index: usize, + pub distance_squared_threshold: f32, +} +impl Breadcrumbs { + fn last(&self) -> [f32; 4] { + self.positions[(self.positions.len() + self.index - 1) % self.positions.len()] + } + fn update(&mut self, position: [f32; 4]) { + let delta = core::array::from_fn(|i| position[i] - self.positions[self.index][i]); + if super::vector_tracker::dot(delta, delta) > self.distance_squared_threshold { + self.index += 1; + if self.index >= self.positions.len() { + self.index = 0; + } + self.positions[self.index] = position; + } + } +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigSettings { + pub heading: AngleTrackingSettings, + pub elevation: AngleTrackingSettings, + pub anchor: AnchorTrackingSettings, + pub avoidance: AvoidanceSettings, + pub positioning: RigPositioningSettings, + /// Parameter620 and native global830BD350 respectively. + pub collision_hold_duration: f32, + pub normalization_threshold: [f32; 4], +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigFields { + pub anchor: [f32; 4], + pub velocity: [f32; 4], + pub acceleration: [f32; 4], + pub offset: [f32; 4], + pub heading_target: f32, + pub heading_reference: f32, + pub elevation_reference: f32, + pub elevation_target: f32, + pub elevation_offset: f32, + pub additional_elevation: f32, + pub heading_offset: f32, + pub avoidance_heading: f32, + pub avoidance_elevation: f32, + pub smoothed_acceleration: f32, + pub previous_acceleration: f32, + pub collision_hold_time: f32, + pub reset_time: f32, + pub disable_avoidance_time: f32, + pub height_transition_start: f32, + pub height_transition_end: f32, + pub height_transition_weight: f32, + pub height_transition_duration: f32, + pub height_transition_time: f32, + pub reference_height: f32, + pub previous_height: f32, + pub height_offset: f32, + pub latch_time: f32, + pub input_time: f32, + pub distance: f32, + pub heading_smoothing: f32, + pub elevation_smoothing: f32, + pub heading_weight: f32, + pub elevation_weight: f32, + pub mode_time: f32, + pub frame_counter: u32, + pub collision_mode: u32, + pub avoidance_mode: u32, + pub height_mode: u32, + pub previous_height_mode: u32, + pub flags_516: u8, + pub flags_517: u8, +} +#[derive(Clone, Debug, PartialEq)] +pub struct Rig { + pub fields: RigFields, + pub heading: AngleTracker, + pub heading_parameters: ScalarTrackerParameters, + pub elevation: AngleTracker, + pub elevation_parameters: ScalarTrackerParameters, + pub anchor: VectorTracker, + pub anchor_parameters: ScalarTrackerParameters, + pub positioner: Positioner, + pub paths: [AvoidancePath; 3], + pub breadcrumbs: Breadcrumbs, +} +impl Rig { + pub fn update( + &mut self, + dt: f32, + settings: RigSettings, + subject: &mut S, + requests: &mut [impl TrajectoryCollisionRequest; 3], + moving: &mut impl MovingObstacleProvider, + collision: &mut impl PositionerCollisionProvider, + ) where + S: RigModeSubject + + AvoidanceSubject + + ReferenceHeightSubject + + AnchorTrackingSubject + + RigPositioningSubject, + { + self.fields.reset_time = floor_zero(self.fields.reset_time - dt); + self.fields.disable_avoidance_time = floor_zero(self.fields.disable_avoidance_time - dt); + self.fields.frame_counter = self.fields.frame_counter.wrapping_add(1); + let mut mode = RigMode { + current: self.fields.height_mode, + previous: self.fields.previous_height_mode, + elapsed: self.fields.mode_time, + }; + mode.update(subject); + self.fields.height_mode = mode.current; + self.fields.previous_height_mode = mode.previous; + self.fields.mode_time = mode.elapsed; + let f = self.fields; + let mut avoidance = RigAvoidance { + anchor: f.anchor, + velocity: f.velocity, + tracked_anchor: self.anchor.position, + heading_target: f.heading_target, + heading_reference: f.heading_reference, + elevation_target: f.elevation_target, + avoidance_heading: f.avoidance_heading, + avoidance_elevation: f.avoidance_elevation, + reset_time: f.reset_time, + disable_time: f.disable_avoidance_time, + height_transition_start: f.height_transition_start, + height_transition_end: f.height_transition_end, + height_transition_duration: f.height_transition_duration, + height_transition_time: f.height_transition_time, + reference_height: f.reference_height, + prediction_distance: f.distance, + heading_weight: f.heading_weight, + elevation_weight: f.elevation_weight, + avoidance_mode: f.avoidance_mode, + height_mode: f.height_mode, + flags_516: f.flags_516, + paths: self.paths, + }; + avoidance.update( + dt, + settings.avoidance, + &self.positioner, + self.breadcrumbs.last(), + subject, + requests, + moving, + ); + self.paths = avoidance.paths; + self.fields.avoidance_heading = avoidance.avoidance_heading; + self.fields.avoidance_elevation = avoidance.avoidance_elevation; + self.fields.heading_weight = avoidance.heading_weight; + self.fields.elevation_weight = avoidance.elevation_weight; + self.fields.flags_516 = avoidance.flags_516; + let f = self.fields; + let mut height = ReferenceHeightTracking { + anchor_height: f.anchor[1], + transition_start: f.height_transition_start, + transition_end: f.height_transition_end, + transition_weight: f.height_transition_weight, + transition_duration: f.height_transition_duration, + transition_time: f.height_transition_time, + height: f.reference_height, + previous_height: f.previous_height, + height_offset: f.height_offset, + mode: f.height_mode, + flags_516: f.flags_516, + flags_517: f.flags_517, + }; + height.update(dt, subject); + self.fields.height_transition_duration = height.transition_duration; + self.fields.height_transition_time = height.transition_time; + self.fields.reference_height = height.height; + self.fields.previous_height = height.previous_height; + self.fields.height_offset = height.height_offset; + let mut angular = self.angular(); + angular.update_heading(dt, settings.heading); + angular.update_elevation(dt, settings.elevation); + self.store_angular(angular); + let f = self.fields; + let square = super::vector_tracker::dot(f.velocity, f.velocity); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + let magnitude = if square == 0.0 { 0.0 } else { square * inverse }; + // Mapped TU3 read-only constant82139A30. + let fallback = [0.0, 0.0, 1.0, 0.0]; + let direction = core::array::from_fn(|i| { + if magnitude > settings.normalization_threshold[i] { + f.velocity[i] * inverse + } else { + fallback[i] + } + }); + let acceleration = super::vector_tracker::dot(f.acceleration, direction); + self.fields.smoothed_acceleration = (f.previous_acceleration + acceleration) * 0.5; + self.fields.previous_acceleration = acceleration; + let mut anchor = AnchorRigTracking { + tracker: self.anchor, + parameters: self.anchor_parameters, + anchor: f.anchor, + velocity: f.velocity, + reference_height: f.reference_height, + latch_time: f.latch_time, + input_time: f.input_time, + flags_516: f.flags_516, + flags_517: f.flags_517, + }; + anchor.update(dt, settings.anchor, subject); + self.anchor = anchor.tracker; + self.anchor_parameters = anchor.parameters; + self.fields.latch_time = anchor.latch_time; + self.fields.input_time = anchor.input_time; + self.fields.flags_516 = anchor.flags_516; + self.fields.collision_hold_time = if self.positioner.flags & 0x80 != 0 { + settings.collision_hold_duration + } else { + floor_zero(self.fields.collision_hold_time - dt) + }; + let mut breadcrumb = self.anchor.position; + breadcrumb[1] = self.fields.reference_height; + self.breadcrumbs.update(breadcrumb); + let mut framing = self.framing(); + let mut angular = self.angular(); + framing.update( + dt, + &mut angular, + self.anchor.position, + &self.positioner, + settings.normalization_threshold, + ); + self.fields.anchor = framing.anchor; + self.fields.distance = framing.distance; + let f = self.fields; + let mut positioning = RigPositioning { + heading_reference: f.heading_reference, + elevation_reference: f.elevation_reference, + avoidance_heading: f.avoidance_heading, + avoidance_elevation: f.avoidance_elevation, + elevation_weight: f.elevation_weight, + collision_mode: f.collision_mode, + flags_516: f.flags_516, + }; + positioning.update( + dt, + settings.positioning, + &mut angular, + &framing, + self.anchor.position, + &mut self.positioner, + &mut self.breadcrumbs.positions, + subject, + collision, + ); + self.store_angular(angular); + self.fields.heading_reference = positioning.heading_reference; + self.fields.elevation_reference = positioning.elevation_reference; + self.fields.avoidance_heading = positioning.avoidance_heading; + self.fields.avoidance_elevation = positioning.avoidance_elevation; + self.fields.elevation_weight = positioning.elevation_weight; + self.fields.flags_516 = positioning.flags_516; + } + pub(super) fn angular(&self) -> AngularRigTracking { + let f = self.fields; + AngularRigTracking { + heading: self.heading, + heading_parameters: self.heading_parameters, + elevation: self.elevation, + elevation_parameters: self.elevation_parameters, + heading_target: f.heading_target, + elevation_target: f.elevation_target, + heading_offset: f.heading_offset, + reset_time: f.reset_time, + heading_smoothing: f.heading_smoothing, + elevation_smoothing: f.elevation_smoothing, + heading_acceleration_state: f.heading_weight, + flags_517: f.flags_517, + } + } + pub(super) fn store_angular(&mut self, a: AngularRigTracking) { + self.heading = a.heading; + self.heading_parameters = a.heading_parameters; + self.elevation = a.elevation; + self.elevation_parameters = a.elevation_parameters; + self.fields.heading_target = a.heading_target; + self.fields.elevation_target = a.elevation_target; + self.fields.heading_weight = a.heading_acceleration_state; + self.fields.flags_517 = a.flags_517; + } + fn framing(&self) -> RigFraming { + let f = self.fields; + RigFraming { + anchor: f.anchor, + offset: f.offset, + elevation_offset: f.elevation_offset, + additional_elevation: f.additional_elevation, + reference_height: f.reference_height, + distance: f.distance, + } + } +} +fn floor_zero(value: f32) -> f32 { + if -value >= 0.0 { 0.0 } else { value } +} diff --git a/crates/skate-core/src/camera/rig_framing.rs b/crates/skate-core/src/camera/rig_framing.rs new file mode 100644 index 00000000..75700f2c --- /dev/null +++ b/crates/skate-core/src/camera/rig_framing.rs @@ -0,0 +1,90 @@ +//! Complete TU3 framing82E03560 and subject-driven mode selection82E053E0. +use super::{AngularRigTracking, Positioner, direction_to_angles}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigFraming { + pub anchor: [f32; 4], + pub offset: [f32; 4], + pub elevation_offset: f32, + pub additional_elevation: f32, + pub reference_height: f32, + pub distance: f32, +} + +impl RigFraming { + /// The threshold is the native global vector830BD350. Its runtime producer + /// must be bound by the caller; mapped-image zeros are not a tuning default. + pub fn update( + &mut self, + dt: f32, + angular: &mut AngularRigTracking, + tracked_anchor: [f32; 4], + positioner: &Positioner, + normalization_threshold: [f32; 4], + ) { + if angular.flags_517 & 0x40 == 0 { + return; + } + let mut camera = positioner.position; + if positioner.flags & 0x80 == 0 { + camera = core::array::from_fn(|i| positioner.velocity[i].mul_add(dt, camera[i])); + } + let mut anchor = tracked_anchor; + anchor[1] = self.reference_height; + let delta = core::array::from_fn(|i| (camera[i] - anchor[i]) - self.offset[i]); + let square = super::vector_tracker::dot(delta, delta); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + let magnitude = if square == 0.0 { 0.0 } else { square * inverse }; + let direction = core::array::from_fn(|i| { + if magnitude > normalization_threshold[i] { + delta[i] * inverse + } else { + 0.0 + } + }); + let angles = direction_to_angles(direction); + angular.heading_target = -angles[1] - angular.heading_offset; + angular.elevation_target = (angles[0] - self.elevation_offset) - self.additional_elevation; + self.distance = magnitude; + self.anchor[1] = self.reference_height; + angular.reset_heading(false); + angular.reset_elevation(false); + } +} + +pub trait RigModeSubject { + fn flag_568(&mut self) -> u8; + fn flag_596(&mut self) -> u8; + fn flag_680(&mut self) -> u8; + fn flag_540(&mut self) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigMode { + pub current: u32, + pub previous: u32, + pub elapsed: f32, +} +impl RigMode { + /// Complete82E053E0. No graph topology or skater-state mapping is inferred + /// from the native getter slots. Preserve their conditional call order. + pub fn update(&mut self, subject: &mut impl RigModeSubject) { + let next = if subject.flag_568() != 0 { + 0 + } else if subject.flag_596() != 0 || subject.flag_680() != 0 { + 2 + } else if subject.flag_540() != 0 { + 1 + } else { + return; + }; + if next != self.current { + self.previous = self.current; + self.current = next; + self.elapsed = 0.0; + } + } +} diff --git a/crates/skate-core/src/camera/rig_initialization.rs b/crates/skate-core/src/camera/rig_initialization.rs new file mode 100644 index 00000000..5fe1f018 --- /dev/null +++ b/crates/skate-core/src/camera/rig_initialization.rs @@ -0,0 +1,219 @@ +//! State construction82E02738, reset82E02E58 and teleport82E031D8. +use super::*; + +impl Rig { + pub fn new(avoidance_mode: u32) -> Self { + let parameters = tracker_parameters(); + let mut rig = Self { + fields: fields(avoidance_mode), + heading: AngleTracker(scalar(0.0)), + heading_parameters: parameters, + elevation: AngleTracker(scalar(0.0)), + elevation_parameters: parameters, + anchor: VectorTracker { + target: [0.0; 4], + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + acceleration_clamp: 0.0, + smoothing: 0.0, + }, + anchor_parameters: parameters, + positioner: Positioner { + distance_tracker: scalar(1.0), + tracker_parameters: parameters, + offset: [0.0; 4], + previous_valid_position: [0.0; 4], + reference_position: [0.0; 4], + collision_clear_time: f32::MAX, + // Host storage for parameters fully supplied by the first rig update. + config: PositionerConfig { + anchor: [0.0; 4], + offset: [0.0; 4], + reference_heading: 0.0, + reference_elevation: 0.0, + heading: 0.0, + elevation: 0.0, + distance: 0.0, + minimum_distance: 0.0, + collision_enabled: 0, + bypass_distance_tracker: 0, + clamp_height: 0, + floor_height: 0.0, + speed_clamp: 0.0, + acceleration_clamp: 0.0, + smoothing: 0.0, + query_starts: [[0.0; 4]; 3], + }, + position: [0.0; 4], + velocity: [0.0; 4], + distance: 0.0, + heading: 0.0, + elevation: 0.0, + radius: f32::from_bits(0x3e051eb8), + available_distance: 0.0, + flags: 0, + }, + paths: [AvoidancePath { + prediction_flag: 0, + evaluator: PathEvaluator { + request_state: 0, + path: PredictionPath { + position: [0.0; 4], + velocity: [0.0; 4], + radius: 0.0, + horizon: 0.0, + }, + acceleration_flag: 0, + collision_time: f32::MAX, + last_valid_time: f32::MAX, + found: 0, + result_acceleration_flag: 0, + submitted_acceleration_flag: 0, + pending_polls: 0, + }, + }; 3], + breadcrumbs: Breadcrumbs { + positions: vec![[0.0; 4]; 2], + index: 0, + distance_squared_threshold: 1.0, + }, + }; + rig.reset_angles(); + rig + } + + /// Reset preserves native subobject history, avoidance mode and the four + /// low flag bits in517. Orientation is reset separately by its owner. + pub fn reset(&mut self) { + let previous = self.fields; + self.fields = fields(previous.avoidance_mode); + self.fields.height_transition_weight = previous.height_transition_weight; + self.fields.flags_517 = (previous.flags_517 & 0x0f) | 0x30; + self.breadcrumbs.distance_squared_threshold = 1.0; + self.reset_angles(); + } + + /// Teleport to this frame's native anchor and shot distance. + pub fn teleport(&mut self) { + self.fields.avoidance_heading = 0.0; + self.fields.avoidance_elevation = 0.0; + self.fields.heading_weight = 0.0; + self.fields.elevation_weight = 0.0; + self.fields.flags_516 &= !1; + self.reset_angles(); + self.anchor.target = self.fields.anchor; + self.anchor.position = self.fields.anchor; + self.anchor.velocity = [0.0; 4]; + self.anchor.acceleration = [0.0; 4]; + self.fields.reference_height = self.fields.anchor[1]; + self.fields.height_offset = 0.0; + //82E03288 clears the preceding frame's world-space shake offset. + self.fields.offset = [0.0; 4]; + self.positioner.distance_tracker.target = self.fields.distance; + self.positioner.distance_tracker.position = self.fields.distance; + self.positioner.distance_tracker.velocity = 0.0; + self.positioner.distance_tracker.acceleration = 0.0; + self.fields.reset_time = f32::from_bits(0x3dcccccd); + self.fields.disable_avoidance_time = 2.0; + self.fields.collision_hold_time = 5.0; + self.fields.height_transition_duration = 0.0; + self.fields.flags_517 &= !0x40; + self.fields.previous_acceleration = 0.0; + self.fields.flags_516 = (self.fields.flags_516 & 0xb3) | 8; + self.fields.input_time = 0.0; + self.fields.latch_time = 0.0; + } + + /// SetElevation82E03150: obstruction is latched until the native reset. + pub fn set_elevation(&mut self, elevation: f32, use_avoidance: bool) { + self.fields.elevation_target = elevation; + self.fields.elevation_reference = elevation; + if use_avoidance { + self.fields.flags_516 |= u8::from(self.positioner.flags & 0x10 != 0); + if self.fields.flags_516 & 1 != 0 { + let delta = + super::rig_tracking::wrap_vmx(self.fields.avoidance_elevation - elevation); + self.fields.elevation_target = + super::rig_tracking::wrap_vmx(super::rig_tracking::wrap_vmx( + delta.mul_add(self.fields.elevation_weight, elevation), + )); + } + } + } + + fn reset_angles(&mut self) { + let mut angular = self.angular(); + angular.reset_heading(true); + angular.reset_elevation(true); + self.store_angular(angular); + } +} + +fn scalar(position: f32) -> ScalarTracker { + ScalarTracker { + target: position, + position, + velocity: 0.0, + acceleration: 0.0, + acceleration_clamp: 0.0, + smoothing: 0.0, + } +} +fn tracker_parameters() -> ScalarTrackerParameters { + ScalarTrackerParameters { + delta_umbra: 0.0, + delta_penumbra: 0.0, + speed_clamp: 0.0, + acceleration_clamp_min: 0.0, + acceleration_clamp_max: 0.0, + smoothing_min: 0.0, + smoothing_max: 0.0, + overshoot_zeroes_velocity: true, + } +} +fn fields(avoidance_mode: u32) -> RigFields { + RigFields { + anchor: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + offset: [0.0; 4], + heading_target: f32::from_bits(0x40490fdb), + heading_reference: f32::from_bits(0x40490fdb), + elevation_reference: 0.0, + elevation_target: 0.0, + elevation_offset: 0.0, + additional_elevation: 0.0, + heading_offset: 0.0, + avoidance_heading: 0.0, + avoidance_elevation: 0.0, + smoothed_acceleration: 0.0, + previous_acceleration: 0.0, + collision_hold_time: 5.0, + reset_time: 2.0, + disable_avoidance_time: 0.0, + height_transition_start: 0.0, + height_transition_end: 0.0, + height_transition_weight: 0.0, + height_transition_duration: 0.0, + height_transition_time: 0.0, + reference_height: 1000.0, + previous_height: f32::MAX, + height_offset: 0.0, + latch_time: 0.0, + input_time: 0.0, + distance: 1.0, + heading_smoothing: 1.0, + elevation_smoothing: 1.0, + heading_weight: 0.0, + elevation_weight: 0.0, + mode_time: 0.0, + frame_counter: 0, + collision_mode: 0, + avoidance_mode, + height_mode: 0, + previous_height_mode: 0, + flags_516: 0x88, + flags_517: 0x30, + } +} diff --git a/crates/skate-core/src/camera/rig_orientation.rs b/crates/skate-core/src/camera/rig_orientation.rs new file mode 100644 index 00000000..aa1ac553 --- /dev/null +++ b/crates/skate-core/src/camera/rig_orientation.rs @@ -0,0 +1,203 @@ +//! Normal gameplay rig pitch/yaw tracking and roll, TU3 82E045E0. +use super::{ + AngleTracker, ScalarTracker, ScalarTrackerParameters, direction_to_angles, normalize_angle, +}; +use crate::{ + math::Basis3, + physics::rigid_body::{RetailQuaternion, basis_from_quaternion}, + point_graph::PointGraph, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OrientationTrackerSettings { + pub acceleration_min_degrees: f32, + pub acceleration_max_degrees: f32, + pub smoothing_min: f32, + pub delta_umbra_degrees: f32, + pub delta_penumbra_degrees: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OrientationSettings { + pub pan_umbra_degrees: f32, + pub pan_penumbra_degrees: f32, + pub pan_smoothing_curve: PointGraph<8>, + pub pan_minimum_smoothing: f32, + pub pan: OrientationTrackerSettings, + pub tilt: OrientationTrackerSettings, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigOrientation { + pub target: [f32; 4], + pub basis: Basis3, + pub pitch: AngleTracker, + pub yaw: AngleTracker, + pub pitch_parameters: ScalarTrackerParameters, + pub yaw_parameters: ScalarTrackerParameters, + pub pan_smoothing: f32, + pub tilt_smoothing: f32, + pub roll: f32, + pub framing_mirror: f32, +} + +impl RigOrientation { + /// Construction82E02738 and reset82E02E58. The angle parameters are host + /// storage: every consumed numerical field is rebound before tracking. + pub fn new() -> Self { + let tracker = AngleTracker(ScalarTracker { + target: 0.0, + position: 0.0, + velocity: 0.0, + acceleration: 0.0, + acceleration_clamp: 0.0, + smoothing: 0.0, + }); + let parameters = ScalarTrackerParameters { + delta_umbra: 0.0, + delta_penumbra: 0.0, + speed_clamp: 0.0, + acceleration_clamp_min: 0.0, + acceleration_clamp_max: 0.0, + smoothing_min: 0.0, + smoothing_max: 0.0, + overshoot_zeroes_velocity: true, + }; + Self { + target: [0.0; 4], + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + pitch: tracker, + yaw: tracker, + pitch_parameters: parameters, + yaw_parameters: parameters, + pan_smoothing: 0.0, + tilt_smoothing: 0.0, + roll: 0.0, + framing_mirror: 1.0, + } + } + + pub fn update( + &mut self, + dt: f32, + settings: OrientationSettings, + reset_time: f32, + flags_516: &mut u8, + flags_517: u8, + ) { + // The same sqrt(2)-scaled quaternion expansion occurs in the rigid + // body's basis helper and82E0466C..82E046AC. + let [x, y, z, w] = self.target; + let target = basis_from_quaternion(RetailQuaternion { x, y, z, w }); + let at = target.columns[2]; + let angles = direction_to_angles([at[0], at[1], at[2], 0.0]); + let pitch = -angles[0]; + let yaw = angles[1]; + let error = normalize_angle(normalize_angle(yaw) - self.yaw.0.position); + let error = if error < normalize_angle(0.0) { + normalize_angle(normalize_angle(-error)) + } else { + error + }; + let radians = f32::from_bits(0x3c8efa35); + let umbra = settings.pan_umbra_degrees * radians; + let penumbra = settings.pan_penumbra_degrees * radians; + let fraction = if error < umbra { + 1.0 + } else if error < penumbra { + 1.0 - super::rig_tracking::wrap_vmx(error - umbra) + / super::rig_tracking::wrap_vmx(penumbra - umbra) + } else { + 0.0 + }; + let response = settings.pan_smoothing_curve.evaluate(fraction); + let minimum = 1.0 - settings.pan_minimum_smoothing; + let minimum = -(minimum * minimum - 1.0); + let pan = if self.pan_smoothing >= minimum { + (self.pan_smoothing - minimum) * response + minimum + } else { + self.pan_smoothing + }; + let pan = cap_smoothing(pan); + self.tilt_smoothing = cap_smoothing(self.tilt_smoothing); + if *flags_516 & 8 != 0 || reset_time > 0.0 { + reset(&mut self.pitch, pitch); + reset(&mut self.yaw, yaw); + *flags_516 &= !8; + } + if *flags_516 & 0x40 != 0 { + bind( + &mut self.pitch_parameters, + settings.tilt, + self.tilt_smoothing, + flags_517 & 0x10 != 0, + ); + self.pitch + .update(dt, normalize_angle(pitch), self.pitch_parameters); + } + bind(&mut self.yaw_parameters, settings.pan, pan, true); + self.yaw + .update(dt, normalize_angle(yaw), self.yaw_parameters); + let basis = super::orientation_math::basis_from_angles( + self.pitch.0.position, + self.yaw.0.position, + 0.0, + ); + self.basis = super::orientation_math::rotate_about_axis( + basis, + basis.columns[2], + self.roll * self.framing_mirror, + ); + } +} + +fn reset(tracker: &mut AngleTracker, target: f32) { + tracker.0.target = normalize_angle(target); + tracker.0.position = normalize_angle(target); + tracker.0.velocity = 0.0; + tracker.0.acceleration = 0.0; +} + +fn cap_smoothing(value: f32) -> f32 { + if value >= 1.0 { + 1.0 + } else if f32::from_bits(0x3f666666) - value >= 0.0 { + value + } else { + f32::from_bits(0x3f666666) + } +} + +fn bind( + p: &mut ScalarTrackerParameters, + s: OrientationTrackerSettings, + smoothing: f32, + clamp_acceleration: bool, +) { + let radians = f32::from_bits(0x3c8efa35); + p.speed_clamp = f32::MAX; + p.acceleration_clamp_min = if clamp_acceleration { + s.acceleration_min_degrees * radians + } else { + f32::MAX + }; + p.acceleration_clamp_max = if clamp_acceleration { + s.acceleration_max_degrees * radians + } else { + f32::MAX + }; + p.smoothing_min = if smoothing < 1.0 { + if s.smoothing_min - smoothing >= 0.0 { + smoothing + } else { + s.smoothing_min + } + } else { + 1.0 + }; + p.smoothing_max = if smoothing < 1.0 { smoothing } else { 1.0 }; + p.delta_umbra = s.delta_umbra_degrees * radians; + p.delta_penumbra = s.delta_penumbra_degrees * radians; +} diff --git a/crates/skate-core/src/camera/rig_positioning.rs b/crates/skate-core/src/camera/rig_positioning.rs new file mode 100644 index 00000000..7c48a701 --- /dev/null +++ b/crates/skate-core/src/camera/rig_positioning.rs @@ -0,0 +1,176 @@ +//! TU3 rig-to-positioner operation82E037F0. Authored settings and the subject +//! adapter are inputs; the world provider retains the positioner's six-line ABI. +use super::{ + AngularRigTracking, Positioner, PositionerCollisionProvider, PositionerConfig, RigFraming, +}; + +pub trait RigPositioningSubject { + fn flag_668(&mut self) -> u8; + fn flag_548(&mut self) -> u8; + fn flag_660(&mut self) -> u8; + fn value_524(&mut self) -> f32; + fn transform_384(&mut self) -> [[f32; 4]; 4]; + fn position_360(&mut self, index: u32) -> [f32; 4]; + fn context_532(&mut self) -> u32; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DistanceTrackingSettings { + pub speed_clamp: f32, + pub smoothing: f32, + pub acceleration_clamp: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigPositioningSettings { + /// Parameter fields536/540/544 and548/552/556, selected by subject668. + pub normal: DistanceTrackingSettings, + pub alternate: DistanceTrackingSettings, + /// Parameter fields908 and912, selected by subject548. + pub minimum_distance: f32, + pub alternate_minimum_distance: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RigPositioning { + pub heading_reference: f32, + pub elevation_reference: f32, + pub avoidance_heading: f32, + pub avoidance_elevation: f32, + pub elevation_weight: f32, + pub collision_mode: u32, + pub flags_516: u8, +} +impl RigPositioning { + pub fn update( + &mut self, + dt: f32, + settings: RigPositioningSettings, + angular: &mut AngularRigTracking, + framing: &RigFraming, + tracked_anchor: [f32; 4], + positioner: &mut Positioner, + breadcrumbs: &mut [[f32; 4]], + subject: &mut impl RigPositioningSubject, + collision: &mut impl PositionerCollisionProvider, + ) { + let alternate = subject.flag_668() != 0; + let mut anchor = tracked_anchor; + anchor[1] = framing.reference_height; + let reference_heading = angular.current_heading(); + let reference_elevation = + clamp_elevation(framing.elevation_offset + angular.elevation.0.position); + let heading = angular.current_heading(); + let elevation = clamp_elevation( + (framing.additional_elevation + framing.elevation_offset) + + angular.elevation.0.position, + ); + let distance = if framing.distance.is_nan() { + 1.0 + } else { + framing.distance + }; + let minimum_distance = if subject.flag_548() != 0 { + settings.alternate_minimum_distance + } else { + settings.minimum_distance + }; + let clamp_height = subject.flag_660(); + let floor_height = subject.value_524(); + let tracking = if alternate { + settings.alternate + } else { + settings.normal + }; + let up = subject.transform_384()[1]; + let first = subject.position_360(3); + let second = subject.position_360(0); + let third = subject.position_360(7); + let third = core::array::from_fn(|i| up[i].mul_add(f32::from_bits(0x3e19999a), third[i])); + let config = PositionerConfig { + anchor, + offset: framing.offset, + reference_heading, + reference_elevation, + heading, + elevation, + distance, + minimum_distance, + collision_enabled: u8::from(self.collision_mode != 2), + bypass_distance_tracker: u8::from(angular.flags_517 & 0x40 != 0), + clamp_height, + floor_height, + speed_clamp: tracking.speed_clamp, + acceleration_clamp: tracking.acceleration_clamp, + smoothing: tracking.smoothing, + query_starts: [first, second, third], + }; + // Native BLE skips reset; unordered reset_time proceeds into the reset. + if !(angular.reset_time <= 0.0) { + positioner.distance_tracker.target = distance; + positioner.distance_tracker.position = distance; + positioner.distance_tracker.velocity = 0.0; + positioner.distance_tracker.acceleration = 0.0; + } + angular.flags_517 &= !0x40; + let context = subject.context_532(); + positioner.update(dt, context, config, collision); + if positioner.flags & 4 == 0 { + angular.heading_acceleration_state = 1.0; + self.elevation_weight = 1.0; + self.avoidance_heading = positioner.heading; + self.avoidance_elevation = positioner.elevation; + breadcrumbs.fill(positioner.position); + self.heading_reference = positioner.heading; + angular.heading_target = blend_angle( + self.avoidance_heading, + self.heading_reference, + 1.0 - angular.heading_acceleration_state, + ); + self.set_elevation(positioner.elevation, true, angular, positioner.flags); + angular.reset_heading(true); + angular.reset_elevation(true); + self.flags_516 |= 8; + } + } + + /// Complete82E03150, including the optional avoidance-angle blend. + pub fn set_elevation( + &mut self, + elevation: f32, + avoidance: bool, + angular: &mut AngularRigTracking, + positioner_flags: u8, + ) { + angular.elevation_target = elevation; + self.elevation_reference = elevation; + if avoidance { + self.flags_516 |= (positioner_flags >> 4) & 1; + if self.flags_516 & 1 != 0 { + angular.elevation_target = blend_angle( + angular.elevation_target, + self.avoidance_elevation, + self.elevation_weight, + ); + } + } + angular.elevation_target = super::rig_tracking::wrap_vmx(angular.elevation_target); + } +} +/// Complete82DF3410: wrap delta, fused interpolation, then wrap result. +fn blend_angle(current: f32, target: f32, weight: f32) -> f32 { + super::rig_tracking::wrap_vmx( + weight.mul_add(super::rig_tracking::wrap_vmx(target - current), current), + ) +} +fn clamp_elevation(value: f32) -> f32 { + let maximum = f32::from_bits(0x3fb2b8c2); + let value = if -maximum - value >= 0.0 { + -maximum + } else { + value + }; + if maximum - value >= 0.0 { + value + } else { + maximum + } +} diff --git a/crates/skate-core/src/camera/rig_tracking.rs b/crates/skate-core/src/camera/rig_tracking.rs new file mode 100644 index 00000000..0f223dc6 --- /dev/null +++ b/crates/skate-core/src/camera/rig_tracking.rs @@ -0,0 +1,317 @@ +//! Complete normal-rig tracking stages. Runtime parameters are explicit native +//! bindings; this does not initialize a shot or infer stock camera settings. + +use super::vector_tracker::{length, refined_reciprocal}; +use super::{AngleTracker, ScalarTrackerParameters, VectorTracker, normalize_angle}; +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AngleTrackingSettings { + pub speed_clamp_degrees: f32, + pub acceleration_min_degrees: f32, + pub acceleration_max_degrees: f32, + pub delta_umbra_degrees: f32, + pub delta_penumbra_degrees: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AngularRigTracking { + pub heading: AngleTracker, + pub heading_parameters: ScalarTrackerParameters, + pub elevation: AngleTracker, + pub elevation_parameters: ScalarTrackerParameters, + pub heading_target: f32, + pub elevation_target: f32, + pub heading_offset: f32, + pub reset_time: f32, + pub heading_smoothing: f32, + pub elevation_smoothing: f32, + /// Native rig484 controls both the heading gate and acceleration doubling. + pub heading_acceleration_state: f32, + /// Native rig517; bit0x20 enables heading update independently of rig484. + pub flags_517: u8, +} + +impl AngularRigTracking { + /// Complete82E030C0. This VMX wrap differs from scalar8258DB98. + pub fn current_heading(&self) -> f32 { + wrap_vmx(self.heading.0.position + self.heading_offset) + } + + /// Complete82E054D8, including the preserve-velocity reset branch. + pub fn reset_heading(&mut self, clear_motion: bool) { + self.heading.0.target = normalize_angle(self.heading_target); + self.heading.0.position = normalize_angle(self.heading_target); + if clear_motion { + self.heading.0.velocity = 0.0; + self.heading.0.acceleration = 0.0; + } + } + + /// Complete82E05558. + pub fn reset_elevation(&mut self, clear_motion: bool) { + self.elevation.0.target = normalize_angle(self.elevation_target); + self.elevation.0.position = normalize_angle(self.elevation_target); + if clear_motion { + self.elevation.0.velocity = 0.0; + self.elevation.0.acceleration = 0.0; + } + } + + /// Complete82E03C18 with parameter-block fields516..532. + pub fn update_heading(&mut self, dt: f32, settings: AngleTrackingSettings) { + if self.flags_517 & 0x20 == 0 && self.heading_acceleration_state == 0.0 { + return; + } + let error = wrap_vmx(self.heading_target - self.current_heading()).abs(); + let smoothing = 1.0 - self.heading_smoothing; + let capped = if f32::from_bits(0x3f4ccccd) - smoothing >= 0.0 { + smoothing + } else { + f32::from_bits(0x3f4ccccd) + }; + let smoothing = if error < f32::from_bits(0x3f490fdb) { + (capped - smoothing).mul_add(error * f32::from_bits(0x3fa2f984), smoothing) + } else { + capped + }; + if self.reset_time > 0.0 { + self.reset_heading(true); + } + bind_angle_settings(&mut self.heading_parameters, settings, smoothing); + if self.heading_acceleration_state > 0.0 { + self.heading_parameters.acceleration_clamp_min *= 2.0; + self.heading_parameters.acceleration_clamp_max *= 2.0; + } + self.heading_parameters.overshoot_zeroes_velocity = false; + self.heading.update( + dt, + normalize_angle(self.heading_target), + self.heading_parameters, + ); + } + + /// Complete82E03E28 with parameter-block fields560..576. + pub fn update_elevation(&mut self, dt: f32, settings: AngleTrackingSettings) { + if self.reset_time > 0.0 { + self.reset_elevation(true); + } + bind_angle_settings( + &mut self.elevation_parameters, + settings, + 1.0 - self.elevation_smoothing, + ); + self.elevation.update( + dt, + normalize_angle(self.elevation_target), + self.elevation_parameters, + ); + } +} + +fn bind_angle_settings(p: &mut ScalarTrackerParameters, s: AngleTrackingSettings, smoothing: f32) { + let radians = f32::from_bits(0x3c8efa35); + p.speed_clamp = s.speed_clamp_degrees * radians; + p.acceleration_clamp_min = s.acceleration_min_degrees * radians; + p.acceleration_clamp_max = s.acceleration_max_degrees * radians; + p.delta_umbra = s.delta_umbra_degrees * radians; + p.delta_penumbra = s.delta_penumbra_degrees * radians; + p.smoothing_min = smoothing; + p.smoothing_max = smoothing; +} + +pub(super) fn wrap_vmx(angle: f32) -> f32 { + let tau = f32::from_bits(0x40c90fdb); + let pi = f32::from_bits(0x40490fdb); + let turns = (refined_reciprocal(tau) * angle).trunc(); + let reduced = (-turns).mul_add(tau, angle); + let lower = if reduced + pi >= 0.0 { + reduced + } else { + reduced + tau + }; + if reduced - pi >= 0.0 { + reduced - tau + } else { + lower + } +} + +/// Native subject getter slots used by82E04360/82E04478/82E041B8. +/// Slot numbers remain explicit until the coordinator binds the subject producers. +pub trait AnchorTrackingSubject { + fn flag_584(&mut self) -> u8; + fn flag_588(&mut self) -> u8; + fn value_476(&mut self) -> f32; + fn flag_548(&mut self) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchorTrackingSettings { + /// Native X320/Y352, eight points. + pub latch_curve: PointGraph<8>, + /// Native X448/Y480, eight points. + pub input_curve: PointGraph<8>, + pub speed_clamp: f32, + pub acceleration_clamp: f32, + /// Native fields600,624,808. + pub latch_scale: f32, + pub input_threshold: f32, + pub input_scale: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnchorRigTracking { + pub tracker: VectorTracker, + pub parameters: ScalarTrackerParameters, + pub anchor: [f32; 4], + pub velocity: [f32; 4], + pub reference_height: f32, + pub latch_time: f32, + pub input_time: f32, + pub flags_516: u8, + pub flags_517: u8, +} + +impl AnchorRigTracking { + /// Complete82E04360, including the conditional second subject getter. + pub fn latch_smoothing( + &mut self, + dt: f32, + s: AnchorTrackingSettings, + subject: &mut impl AnchorTrackingSubject, + ) -> f32 { + self.latch_time = floor_zero(self.latch_time - dt); + if subject.flag_584() == 0 { + self.flags_516 &= !4; + } else if subject.flag_588() != 0 { + self.flags_516 |= 4; + } + if self.flags_516 & 4 != 0 { + self.latch_time = 0.5; + } + clamp_fraction(s.latch_curve.evaluate((-self.latch_time).mul_add(2.0, 1.0))) * s.latch_scale + } + + /// Complete82E04478. + pub fn input_smoothing( + &mut self, + dt: f32, + s: AnchorTrackingSettings, + subject: &mut impl AnchorTrackingSubject, + ) -> f32 { + self.input_time = floor_zero(self.input_time - dt); + if subject.value_476() > s.input_threshold { + self.input_time = 0.5; + } + clamp_fraction(s.input_curve.evaluate((-self.input_time).mul_add(2.0, 1.0))) * s.input_scale + } + + /// Complete82E041B8. Both smoothing histories update before the disable gate. + pub fn update( + &mut self, + dt: f32, + s: AnchorTrackingSettings, + subject: &mut impl AnchorTrackingSubject, + ) { + let latch = self.latch_smoothing(dt, s, subject); + let input = self.input_smoothing(dt, s, subject); + let smoothing = if subject.flag_548() != 0 || self.flags_517 & 0x40 != 0 { + 0.0 + } else { + let maximum = if latch - input >= 0.0 { latch } else { input }; + maximum * clamp_fraction((length(self.velocity) - 8.0) * -0.25) + }; + self.parameters.speed_clamp = s.speed_clamp; + self.parameters.acceleration_clamp_min = s.acceleration_clamp; + self.parameters.acceleration_clamp_max = s.acceleration_clamp; + self.parameters.smoothing_min = smoothing; + self.parameters.smoothing_max = smoothing; + let mut target = self.anchor; + target[1] = self.reference_height; + self.tracker.update(dt, target, self.parameters); + } +} + +fn floor_zero(value: f32) -> f32 { + if -value >= 0.0 { 0.0 } else { value } +} +fn clamp_fraction(value: f32) -> f32 { + let v = floor_zero(value); + if 1.0 - v >= 0.0 { v } else { 1.0 } +} + +/// Subject getter slots consumed by the complete reference-height stage82E03F20. +pub trait ReferenceHeightSubject { + fn flag_656(&mut self) -> u8; + fn flag_608(&mut self) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ReferenceHeightTracking { + pub anchor_height: f32, + pub transition_start: f32, + pub transition_end: f32, + pub transition_weight: f32, + pub transition_duration: f32, + pub transition_time: f32, + pub height: f32, + pub previous_height: f32, + pub height_offset: f32, + pub mode: u32, + pub flags_516: u8, + pub flags_517: u8, +} + +impl ReferenceHeightTracking { + /// Complete82E03F20, including zero/initial height, transitions and the + /// conditional subject-query order. No mode is invented or selected here. + pub fn update(&mut self, dt: f32, subject: &mut impl ReferenceHeightSubject) { + let immediate = self.flags_516 & 0x40 != 0; + if immediate { + self.transition_duration = 0.0; + } + let old_height = self.height; + let mut target = old_height; + let blending = !immediate && self.transition_time < self.transition_duration; + if blending { + let fraction = clamp_fraction(self.transition_time / self.transition_duration); + target = fraction.mul_add( + self.transition_end - self.transition_start, + self.transition_start, + ); + if self.flags_517 & 8 != 0 { + let weight = self.transition_weight * self.transition_weight; + target = (1.0 - weight).mul_add(target, self.anchor_height * weight); + } + } else if self.mode == 0 || self.mode == 2 { + self.previous_height = old_height; + target = self.anchor_height; + } else if immediate || old_height > self.anchor_height { + target = self.anchor_height; + } + let mut smoothing = 1.0; + if old_height != f32::MAX && !blending && self.mode != 2 && subject.flag_656() != 0 { + smoothing = f32::from_bits(0x3c23d70a); + } + self.height = (target - self.height).mul_add(smoothing, self.height); + if subject.flag_608() != 0 { + let delta = self.height - self.anchor_height; + if delta.abs() > f32::from_bits(0x38d1b717) { + self.height_offset = delta; + if delta > 0.0 { + self.height = self.anchor_height; + self.height_offset = 0.0; + } + } else if self.height_offset.abs() > f32::from_bits(0x3ba3d70a) { + self.height_offset *= f32::from_bits(0x3f666666); + self.height += self.height_offset; + } else { + self.height_offset = 0.0; + } + } else { + self.height_offset = self.height - self.anchor_height; + } + self.transition_time += dt; + } +} diff --git a/crates/skate-core/src/camera/shake.rs b/crates/skate-core/src/camera/shake.rs new file mode 100644 index 00000000..893ee7c1 --- /dev/null +++ b/crates/skate-core/src/camera/shake.rs @@ -0,0 +1,85 @@ +//! Stock normal-camera shake data and impact response82E05DC0/82E055D8. +use super::manager_state::clamp; +use super::vector_tracker::{dot,length,refined_reciprocal}; + +#[derive(Clone,Debug,PartialEq)] +pub struct ShakeSamples { + pub(super) rotations:Vec<[f32;4]>, + pub(super) translations:Vec<[f32;4]>, +} +impl ShakeSamples { + /// Eight floats per .shk record. Native consumes XYZ of both records and + /// discards each fourth token; it subtracts each complete sequence's mean. + pub fn from_rows(rows:&[[f32;8]])->Result { + if rows.is_empty() {return Err("Stock camera shake has no samples".into());} + if rows.iter().flatten().any(|v|!v.is_finite()) { + return Err("Stock camera shake contains a non-finite sample".into()); + } + let mut rotations=Vec::with_capacity(rows.len()); + let mut translations=Vec::with_capacity(rows.len()); + let mut rotation_sum=[0.0_f32;4];let mut translation_sum=[0.0_f32;4]; + for row in rows { + let rotation=[row[0],row[1],row[2],0.0]; + let translation=[row[4],row[5],row[6],0.0]; + rotation_sum=core::array::from_fn(|i|rotation_sum[i]+rotation[i]); + translation_sum=core::array::from_fn(|i|translation_sum[i]+translation[i]); + rotations.push(rotation);translations.push(translation); + } + let inverse=1.0/(rows.len() as f32); + let rotation_mean=rotation_sum.map(|v|v*inverse); + let translation_mean=translation_sum.map(|v|v*inverse); + for (rotation,translation) in rotations.iter_mut().zip(&mut translations) { + *rotation=core::array::from_fn(|i|rotation[i]-rotation_mean[i]); + *translation=core::array::from_fn(|i|translation[i]-translation_mean[i]); + } + Ok(Self {rotations,translations}) + } +} + +#[derive(Clone,Copy,Debug,PartialEq)] +pub struct ShakeSettings { + pub amplitude_curve:[[f32;4];4], //parameter0 + pub frequency_curve:[[f32;4];4], //64 + pub impulse_magnitude:f32, //744 + pub impulse_minimum_velocity:f32, //748 + pub impulse_maximum_velocity:f32, //752 + pub impulse_frequency:f32, //756 + pub impulse_decay:f32, //760 + pub amplitude_minimum:f32, //764 + pub amplitude_maximum:f32, //768 + pub amplitude_top_speed:f32, //772 + pub frequency_minimum:f32, //776 + pub frequency_maximum:f32, //780 + pub frequency_top_speed:f32, //784 + pub data_frames_per_second:f32, //788 + pub translation_multiplier:f32, //792 + pub rotation_multiplier:f32, //796 + pub dutch_multiplier:f32, //800 +} + +#[derive(Clone,Copy,Debug,PartialEq)] +pub struct ShakeEffect { + pub translation:[f32;4], + pub time:f32, + pub impulse:f32, + pub weight:f32, +} +impl ShakeEffect { + pub fn new()->Self {Self {translation:[0.0;4],time:0.0,impulse:0.0,weight:0.0}} + + ///82E055D8, including grazing-angle attenuation before velocity shaping. + pub fn landing_impulse(&mut self,normal:[f32;4],velocity:[f32;4],settings:ShakeSettings)->bool { + let speed=length(velocity); + if f32::from_bits(0x3727c5ac)>speed {return false;} + let inverse=refined_reciprocal(speed); + let direction=velocity.map(|v|inverse*v); + let cosine=clamp(dot(normal.map(|v|-v),direction),-1.0,1.0); + let angle=crate::trigonometry::acos(cosine)*f32::from_bits(0x3f22f983); + let weighted_speed=speed*(1.0-clamp(angle,0.0,1.0)); + if weighted_speed<=0.0 {return false;} + let fraction=clamp((weighted_speed-settings.impulse_minimum_velocity) + /(settings.impulse_maximum_velocity-settings.impulse_minimum_velocity),0.0,1.0); + let impulse=(fraction*fraction)*settings.impulse_magnitude; + if impulse>self.impulse {self.impulse=impulse;true} else {false} + } +} diff --git a/crates/skate-core/src/camera/shake_curve.rs b/crates/skate-core/src/camera/shake_curve.rs new file mode 100644 index 00000000..725db872 --- /dev/null +++ b/crates/skate-core/src/camera/shake_curve.rs @@ -0,0 +1,23 @@ +//! Camera shake Bezier inversion8296F530 and evaluation8296F388. +//! Basis initialized82F82738 from8232FB00/FAF0/FAE0/F660. +pub(super) fn sample(points:[[f32;4];4],input:f32)->f32 { + let basis=[[-1.0,3.0,-3.0,1.0],[3.0,-6.0,3.0,0.0],[-3.0,3.0,0.0,0.0],[1.0,0.0,0.0,0.0]]; + let coefficients:[[f32;4];4]=basis.map(|row|core::array::from_fn(|lane| + points[3][lane].mul_add(row[3],points[2][lane].mul_add(row[2], + points[1][lane].mul_add(row[1],points[0][lane]*row[0]))))); + let mut t=0.5_f32; + for iteration in 0..10 { + let square=t*t; + let evaluate=|powers:[f32;4],lane:usize|coefficients[3][lane].mul_add(powers[3], + coefficients[2][lane].mul_add(powers[2],coefficients[1][lane].mul_add(powers[1], + coefficients[0][lane]*powers[0]))); + let value=evaluate([square*t,square,t,1.0],0); + let result=evaluate([square*t,square,t,1.0],1); + let derivative=evaluate([square*3.0,t*2.0,1.0,0.0],0); + let residual=input-value; + if iteration==9 || !(residual.abs()>f32::from_bits(0x3c23d70a) + && derivative.abs()>f32::from_bits(0x34000000)) {return result;} + t=residual/derivative+t; + } + unreachable!() +} diff --git a/crates/skate-core/src/camera/shake_update.rs b/crates/skate-core/src/camera/shake_update.rs new file mode 100644 index 00000000..0fa1eb60 --- /dev/null +++ b/crates/skate-core/src/camera/shake_update.rs @@ -0,0 +1,56 @@ +//! Complete normal-camera ShakeEffect update82E05800. +use super::{ShakeEffect,ShakeSamples,ShakeSettings}; +use super::manager_state::clamp; +use crate::math::Basis3; + +impl ShakeEffect { + pub fn update(&mut self,dt:f32,speed:f32,distance:f32,basis:Basis3,enabled:bool, + samples:&ShakeSamples,settings:ShakeSettings)->Basis3 { + self.weight=clamp(if enabled {dt.mul_add(5.0,self.weight)} + else {-dt.mul_add(5.0,-self.weight)},0.0,1.0); + let amplitude=if settings.amplitude_top_speed!=0.0 { + super::shake_curve::sample(settings.amplitude_curve, + clamp(speed/settings.amplitude_top_speed,0.0,1.0)) + .mul_add(settings.amplitude_maximum-settings.amplitude_minimum,settings.amplitude_minimum) + } else {settings.amplitude_minimum}; + let amplitude=(self.impulse+amplitude)*self.weight; + let mut frequency=if settings.frequency_top_speed!=0.0 { + super::shake_curve::sample(settings.frequency_curve, + clamp(speed/settings.frequency_top_speed,0.0,1.0)) + .mul_add(settings.frequency_maximum-settings.frequency_minimum,settings.frequency_minimum) + } else {settings.frequency_minimum}; + if self.impulse>0.0 {frequency=settings.impulse_frequency;} + self.time=(settings.data_frames_per_second*frequency).mul_add(dt,self.time); + let inverse=super::vector_tracker::refined_reciprocal(1.0); + let whole=(inverse*self.time).trunc(); + let fraction=(-whole).mul_add(1.0,self.time); + let left=(self.time-fraction) as u32 as usize % samples.rotations.len(); + let right=(left+1)%samples.rotations.len(); + let distance_weight=clamp((f32::from_bits(0x4059999a)-distance) + *f32::from_bits(0x3fd55552),0.0,1.0); + let inverse_fraction=1.0-fraction; + let mut angles:[f32;3]=core::array::from_fn(|i| { + let interpolated=samples.rotations[left][i].mul_add(inverse_fraction, + samples.rotations[right][i]*fraction); + ((interpolated*amplitude)*distance_weight)*settings.rotation_multiplier + }); + // vrlimi mask2 selects Z (Dutch/roll), not Y. + angles[2] *=settings.dutch_multiplier; + let rotation=super::orientation_math::basis_from_angles(angles[0],angles[1],angles[2]); + let local_translation:[f32;3]=core::array::from_fn(|i| { + let interpolated=samples.translations[left][i].mul_add(inverse_fraction, + samples.translations[right][i]*fraction); + ((interpolated*settings.translation_multiplier)*amplitude)*distance_weight + }); + self.translation=core::array::from_fn(|i| if i==3 {0.0} else { + basis.columns[2][i].mul_add(local_translation[2],basis.columns[1][i].mul_add(local_translation[1], + basis.columns[0][i].mul_add(local_translation[0],0.0))) + }); + let result=Basis3 {columns:rotation.columns.map(|column|core::array::from_fn(|i| + column[2].mul_add(basis.columns[2][i],column[1].mul_add(basis.columns[1][i], + column[0]*basis.columns[0][i]))))}; + self.impulse=(1.0-settings.impulse_decay)*self.impulse; + if self.impulse Self { + Self { + distance: 2.0, + lens_length: 12.0, + smoothing: [f32::from_bits(0x3f4ccccd); 4], + reference_weights: [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], + board_offset: 0.0, + position_heading: f32::from_bits(0x40490fdb), + position_elevation: 0.0, + framing: [0.0; 3], + follow_subject_in_air: 1, + mirror_for_stance: 1, + snap_to_reference_point: 0, + use_previous_shot: 0, + use_drop_predictor: 0, + use_free_camera_stick: 0, + avoidance_override: 0, + blur: 1.0, + transition_blur: 1.0, + subject_opacity: 1.0, + collision_hint: 0, + anchor: 0, + compass_north: 0, + world_heading: 0.0, + arm_orientation: [0.0, 0.0, 0.0, 1.0], + camera_orientation: [0.0, 0.0, 0.0, 1.0], + } + } + + /// Normal-shot evaluation82E06CB8/82E00D70. `north` is the subject's + /// selected compass entry (the manager applies the special 6->5 case). + /// UsePreviousShot preserves the already copied arm orientation. + pub fn update_normal(&mut self, north: f32, heading_mirror: f32) { + if self.use_previous_shot != 0 { + return; + } + let from = self.position_heading; + let delta = super::rig_tracking::wrap_vmx(-self.position_heading - from); + let heading = + super::rig_tracking::wrap_vmx(delta.mul_add((1.0 - heading_mirror) * 0.5, from)); + let heading = super::rig_tracking::wrap_vmx(heading + north); + self.arm_orientation = + super::orientation_math::quaternion_from_angles(self.position_elevation, heading, 0.0); + } + + /// Complete weighted reference-point calculation82E01070. The board + /// offset follows subject getter456 and is applied to every weighted + /// reference point before summation, including points with zero weight. + pub fn reference_point( + &self, + positions: &[[f32; 4]; 10], + board_offset_direction: [f32; 4], + fallback: [f32; 4], + ) -> [f32; 4] { + let offset = board_offset_direction.map(|v| v * self.board_offset); + let mut sum = [0.0_f32; 4]; + let mut weight_sum = 0.0_f32; + for (position, weight) in positions.iter().zip(self.reference_weights) { + weight_sum = weight + weight_sum; + sum = core::array::from_fn(|i| (position[i] + offset[i]).mul_add(weight, sum[i])); + } + if f32::from_bits(0x38d1b717) > weight_sum.abs() { + fallback + } else { + let inverse = super::vector_tracker::refined_reciprocal(weight_sum); + sum.map(|v| inverse * v) + } + } + + /// Complete field publication82E06980, also used by82E06E38 after it + /// applies its first sine-shaped blend. Discrete options come from `to` + /// even at fraction zero. Heading and shot metadata are not written. + pub fn interpolate_from(&mut self, from: Self, to: Self, fraction: f32) { + let blend = |a, b| interpolate_float(a, b, 0, fraction); + self.distance = blend(from.distance, to.distance); + self.lens_length = blend(from.lens_length, to.lens_length); + self.smoothing = core::array::from_fn(|i| blend(from.smoothing[i], to.smoothing[i])); + self.reference_weights = + core::array::from_fn(|i| blend(from.reference_weights[i], to.reference_weights[i])); + self.board_offset = blend(from.board_offset, to.board_offset); + self.position_elevation = blend(from.position_elevation, to.position_elevation); + self.framing = core::array::from_fn(|i| blend(from.framing[i], to.framing[i])); + self.follow_subject_in_air = to.follow_subject_in_air; + self.mirror_for_stance = to.mirror_for_stance; + self.snap_to_reference_point = to.snap_to_reference_point; + self.use_previous_shot = to.use_previous_shot; + self.use_drop_predictor = to.use_drop_predictor; + self.use_free_camera_stick = to.use_free_camera_stick; + self.avoidance_override = to.avoidance_override; + self.blur = blend(from.blur, to.blur); + self.transition_blur = to.transition_blur; + self.subject_opacity = blend(from.subject_opacity, to.subject_opacity); + self.collision_hint = to.collision_hint; + self.anchor = to.anchor; + self.compass_north = to.compass_north; + self.world_heading = blend(from.world_heading, to.world_heading); + self.arm_orientation = + interpolate_orientation(from.arm_orientation, to.arm_orientation, fraction); + self.camera_orientation = + interpolate_orientation(from.camera_orientation, to.camera_orientation, fraction); + } +} + +/// TU3 82E074A8. Style0 eases with native sine; style1 takes the target; +/// other styles preserve the source. The caller owns fraction clamping. +pub fn interpolate_float(from: f32, to: f32, style: u32, fraction: f32) -> f32 { + match style { + 0 => (to - from).mul_add(sine_blend(fraction), from), + 1 => to, + _ => from, + } +} + +pub(super) fn sine_blend(fraction: f32) -> f32 { + let angle = fraction.mul_add(f32::from_bits(0x40490fdb), -f32::from_bits(0x3fc90fdb)); + crate::trigonometry::sin(angle).mul_add(0.5, 0.5) +} + +/// Complete blend-value filtering82E07378 after82E07708 produces the raw +/// subject-dependent value. This filter intentionally does not multiply by dt. +pub fn filter_blend_value( + previous: f32, + raw: f32, + smoothing: f32, + minimum: f32, + maximum: f32, +) -> f32 { + let response = 1.0 - smoothing; + let next = (response * response).mul_add(raw - previous, previous); + let lower = if minimum - next >= 0.0 { minimum } else { next }; + if maximum - lower >= 0.0 { + lower + } else { + maximum + } +} + +/// Exact82E07240 interval choice. Entries stop at the first absent child; +/// the returned indices may match at the ends. Zero entries publishes nothing. +pub fn blend_interval( + points: [f32; 3], + present: [bool; 3], + value: f32, +) -> Option<(usize, usize, f32)> { + let count = present.iter().take_while(|present| **present).count(); + if count == 0 { + return None; + } + for i in 0..count { + if points[i] > value { + return Some(if i == 0 { + (0, 0, 1.0) + } else { + ( + i - 1, + i, + (value - points[i - 1]) / (points[i] - points[i - 1]), + ) + }); + } + } + Some((count.saturating_sub(2), count - 1, 1.0)) +} diff --git a/crates/skate-core/src/camera/shot_manager.rs b/crates/skate-core/src/camera/shot_manager.rs new file mode 100644 index 00000000..8e290602 --- /dev/null +++ b/crates/skate-core/src/camera/shot_manager.rs @@ -0,0 +1,307 @@ +//! Normal camera ShotManager82E06370/82E064B0/82E068B8/82E06E38. +//! The host owns immutable stock definitions and tree allocation. The authored +//! child order, filter history and transition calculations retain TU3 behavior. +use super::{Shot, blend_interval, filter_blend_value, position_from_angles}; + +#[derive(Clone, Debug, PartialEq)] +pub struct ShotDefinition { + pub name: String, + pub shot_type: u32, + pub shot: Shot, + pub transition_time: f32, + pub transition_units: u32, + pub children: [Option; 3], + pub blend_points: [f32; 3], + pub blend_value: f32, + pub blend_type: u32, + pub blend_smoothing: f32, +} + +impl ShotDefinition { + fn initial() -> Self { + Self { + name: String::new(), + shot_type: 0, + shot: Shot::new(), + transition_time: 0.5, + transition_units: 0, + children: [None, None, None], + blend_points: [0.0; 3], + blend_value: 0.0, + blend_type: 0, + blend_smoothing: 0.0, + } + } +} + +pub trait ShotDatabase { + fn load(&self, name: &str) -> Result; +} + +/// Runtime values are produced by CameraMan82E07708 and the subject compass. +/// The 6->5 compass override is applied here only when its subject flag is set. +pub trait ShotEnvironment { + fn heading_mirror(&self) -> f32; + fn compass_north(&self, entry: u32) -> f32; + fn special_camera_flag(&self) -> bool; + fn raw_blend_value(&mut self, kind: u32, authored: f32, dt: f32) -> f32; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShotPlacement { + /// Native rig transform+48, not the anchor tracker's smoothed position. + pub anchor: [f32; 4], + pub camera_position: [f32; 4], + pub clamp_height: u8, + pub floor_height: f32, +} + +#[derive(Clone, Debug, PartialEq)] +struct ShotNode { + definition: ShotDefinition, + children: Vec, +} + +impl ShotNode { + fn new(definition: ShotDefinition) -> Self { + Self { + definition, + children: Vec::new(), + } + } + + fn setup( + &mut self, + database: &impl ShotDatabase, + env: &mut impl ShotEnvironment, + ancestry: &mut Vec, + count: &mut usize, + ) -> Result<(), String> { + if self.definition.shot_type != 1 { + return Ok(()); + } + ancestry.push(self.definition.name.clone()); + for name in self + .definition + .children + .iter() + .take_while(|name| name.is_some()) + .flatten() + { + if ancestry + .iter() + .any(|parent| parent.eq_ignore_ascii_case(name)) + { + return Err(format!("Cyclic stock camera shot {name}")); + } + *count += 1; + if *count > 64 { + return Err("Stock camera shot exceeds its 64-child capacity".into()); + } + let mut child = Self::new(database.load(name)?); + child.setup(database, env, ancestry, count)?; + self.children.push(child); + } + ancestry.pop(); + self.definition.blend_value = + env.raw_blend_value(self.definition.blend_type, self.definition.blend_value, 0.0); + Ok(()) + } + + fn update(&mut self, dt: f32, env: &mut impl ShotEnvironment) { + if self.definition.shot_type == 0 { + let entry = self.definition.shot.compass_north; + let entry = if entry == 6 && env.special_camera_flag() { + 5 + } else { + entry + }; + self.definition + .shot + .update_normal(env.compass_north(entry), env.heading_mirror()); + return; + } + let definition = &mut self.definition; + // Empty blended shots never reach the table indexing below in authored + // stock data. Preserve their native no-publication result. + if self.children.is_empty() { + return; + } + let raw = env.raw_blend_value(definition.blend_type, definition.blend_value, dt); + definition.blend_value = filter_blend_value( + definition.blend_value, + raw, + definition.blend_smoothing, + definition.blend_points[0], + definition.blend_points[self.children.len() - 1], + ); + let Some((left, right, fraction)) = blend_interval( + definition.blend_points, + core::array::from_fn(|i| i < self.children.len()), + definition.blend_value, + ) else { + return; + }; + // 82E06E98 and82E06EC4 are separate multiply/subtract/add operations. + let fraction = crate::trigonometry::sin( + fraction * f32::from_bits(0x40490fdb) - f32::from_bits(0x3fc90fdb), + ) * 0.5 + + 0.5; + self.children[left].update(dt, env); + // Native updates the same child twice when both selected pointers match. + self.children[right].update(dt, env); + definition.shot.interpolate_from( + self.children[left].definition.shot, + self.children[right].definition.shot, + fraction, + ); + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ShotManager { + current: ShotNode, + pub previous: Shot, + pub interpolated: Shot, + pub elapsed: f32, + pub transition_distance: f32, + pub transition_duration: f32, + pub transition_override: f32, + pub instant_changes: bool, +} + +impl ShotManager { + pub fn new() -> Self { + Self { + current: ShotNode::new(ShotDefinition::initial()), + previous: Shot::new(), + interpolated: Shot::new(), + elapsed: 0.0, + transition_distance: 0.0, + transition_duration: 0.0, + transition_override: -1.0, + instant_changes: false, + } + } + pub fn current(&self) -> &ShotDefinition { + &self.current.definition + } + + /// CameraChooseShot::Begin82DF5C60, after SetShot. The previous behavior's + /// outgoing override is used only when this one has no incoming override. + pub fn apply_graph_transition(&mut self, incoming: f32, outgoing: f32) { + if incoming >= 0.0 { + self.current.definition.transition_time = incoming; + } else if self.transition_override >= 0.0 { + self.current.definition.transition_time = self.transition_override; + } + self.transition_override = if outgoing >= 0.0 { outgoing } else { -1.0 }; + } + + /// CameraMan reset follows child0 pointers to the authored leaf. + pub fn first_leaf(&self) -> Shot { + let mut node=&self.current; + while let Some(child)=node.children.first() {node=child;} + node.definition.shot + } + + pub fn set_shot( + &mut self, + name: &str, + force: bool, + database: &impl ShotDatabase, + env: &mut impl ShotEnvironment, + placement: ShotPlacement, + ) -> Result { + // 82AE8B40 lowercases ASCII A..Z before the native comparison. + if !force && self.current.definition.name.eq_ignore_ascii_case(name) { + return Ok(false); + } + let mut current = ShotNode::new(database.load(name)?); + self.previous = self.interpolated; + if current.definition.shot.use_previous_shot != 0 { + let shot = &mut current.definition.shot; + shot.distance = self.interpolated.distance; + shot.lens_length = self.interpolated.lens_length; + shot.position_heading = self.interpolated.position_heading; + shot.position_elevation = self.interpolated.position_elevation; + shot.framing = self.interpolated.framing; + shot.arm_orientation = self.interpolated.arm_orientation; + } + self.current = current; + // Native calculates instant time before publishing the new distance. + if self.instant_changes { + self.make_instant(); + } else { + self.elapsed = 0.0; + } + self.current.setup(database, env, &mut Vec::new(), &mut 0)?; + let shot = self.current.definition.shot; + let target = position_from_angles( + placement.anchor, + [0.0; 4], + shot.position_elevation, + shot.position_heading, + shot.distance, + placement.clamp_height, + placement.floor_height, + ); + self.transition_distance = super::vector_tracker::length(core::array::from_fn(|i| { + target[i] - placement.camera_position[i] + })); + Ok(true) + } + + pub fn update(&mut self, dt: f32, env: &mut impl ShotEnvironment) -> f32 { + self.elapsed = dt + self.elapsed; + self.current.update(dt, env); + self.transition_duration = self.duration(false); + let fraction = if self.transition_duration <= 0.0 { + 1.0 + } else { + self.elapsed / self.transition_duration + }; + let fraction = if -fraction >= 0.0 { 0.0 } else { fraction }; + let fraction = if 1.0 - fraction >= 0.0 { fraction } else { 1.0 }; + self.interpolated + .interpolate_from(self.previous, self.current.definition.shot, fraction); + fraction + } + + pub fn make_instant(&mut self) { + self.transition_override = -1.0; + self.elapsed = self.duration(false); + } + + /// CameraMan82DFF174 clamps a distance-transition speed <=0 to0.0001; + /// ShotManager82E06418 and MakeInstant82E07450 do not apply that clamp. + pub fn is_transitioning(&self) -> bool { + self.elapsed < self.duration(true) + } + + /// CameraMan82DFF964..82DFFA08 uses a separate sine-eased framing blend. + pub fn framing_fraction(&self) -> f32 { + let duration = self.duration(true); + if duration <= 0.0 { return 1.0; } + let fraction = super::manager_state::clamp(self.elapsed / duration, 0.0, 1.0); + crate::trigonometry::sin(fraction.mul_add( + f32::from_bits(0x40490fdb), -f32::from_bits(0x3fc90fdb))) + .mul_add(0.5, 0.5) + } + + fn duration(&self, camera_man: bool) -> f32 { + let definition = &self.current.definition; + match definition.transition_units { + 1 => definition.transition_time * f32::from_bits(0x3c888889), + 2 => { + let speed = if camera_man && definition.transition_time <= 0.0 { + f32::from_bits(0x38d1b717) + } else { + definition.transition_time + }; + self.transition_distance / speed + } + _ => definition.transition_time, + } + } +} diff --git a/crates/skate-core/src/camera/shot_orientation.rs b/crates/skate-core/src/camera/shot_orientation.rs new file mode 100644 index 00000000..65042550 --- /dev/null +++ b/crates/skate-core/src/camera/shot_orientation.rs @@ -0,0 +1,38 @@ +//! Quaternion interpolation82E07540, with the native sign choice and near-angle +//! branch. Shared Xenon arithmetic remains approximation-derived; no generated +//! implementation is linked or imported. +use super::{shot::sine_blend, vector_tracker::refined_reciprocal}; + +pub(super) fn interpolate_orientation(from: [f32; 4], to: [f32; 4], fraction: f32) -> [f32; 4] { + slerp(from, to, sine_blend(fraction)) +} + +/// FrameComposer82E01740 uses the same interpolation without sine-easing its +/// input. Native negates the first quaternion when the dot product is negative. +pub(super) fn slerp(from: [f32; 4], to: [f32; 4], weight: f32) -> [f32; 4] { + let dot = crate::physics::native_arithmetic::dot4(from, to); + let signed_from = if 0.0 > dot { from.map(|v| -v) } else { from }; + let absolute_dot = if 0.0 > dot { -dot } else { dot }; + if absolute_dot > f32::from_bits(0x3f7f069e) { + let same_direction = crate::physics::native_arithmetic::dot4(signed_from, to) > 0.0; + let value = core::array::from_fn(|i| { + if same_direction { + (to[i] - signed_from[i]).mul_add(weight, signed_from[i]) + } else { + (-(to[i] + signed_from[i])).mul_add(weight, signed_from[i]) + } + }); + let square = crate::physics::native_arithmetic::dot4(value, value); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + value.map(|v| v * inverse) + } else { + let angle = crate::trigonometry::acos(absolute_dot); + let inverse_sine = refined_reciprocal(crate::trigonometry::sin(angle)); + let first = crate::trigonometry::sin((1.0 - weight) * angle) * inverse_sine; + let second = crate::trigonometry::sin(weight * angle) * inverse_sine; + core::array::from_fn(|i| to[i].mul_add(second, signed_from[i] * first)) + } +} diff --git a/crates/skate-core/src/camera/slow_motion.rs b/crates/skate-core/src/camera/slow_motion.rs new file mode 100644 index 00000000..cc71dc07 --- /dev/null +++ b/crates/skate-core/src/camera/slow_motion.rs @@ -0,0 +1,71 @@ +//! SlowMotionController TU382BB1DD8/82BB1EE0/82BB20A8. The host owns +//! message delivery; this retains the curve, latching and timestep calculation. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct SlowMotionSettings { + pub timescale: PointGraph<16>, + pub fps_at_scale_one: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct SimulationRateRequest { + pub timestep: f32, + pub ticks: u32, +} +#[derive(Clone, Copy, Debug)] +pub struct SlowMotionController { + elapsed: f32, + air_duration: f32, + time_before_prediction: f32, + latched_prediction: bool, +} +impl SlowMotionController { + pub fn begin(settings: SlowMotionSettings) -> (Self, SimulationRateRequest) { + ( + Self { + elapsed: 0.0, + air_duration: 0.0, + time_before_prediction: 0.0, + latched_prediction: false, + }, + settings.request(0.0), + ) + } + pub fn update( + &mut self, + dt: f32, + air_duration: f32, + settings: SlowMotionSettings, + ) -> SimulationRateRequest { + self.elapsed += dt; + //82BB1FC8 uses ble (not GT), which also takes the unordered case. + let progress = if !(air_duration > 0.0) { + if !self.latched_prediction { + self.time_before_prediction += dt; + } + f32::from_bits(0x3dcccccd) + } else { + if !self.latched_prediction { + self.air_duration = air_duration; + self.latched_prediction = true; + } + self.elapsed / (self.time_before_prediction + self.air_duration) + }; + settings.request(progress) + } + pub fn end() -> SimulationRateRequest { + SimulationRateRequest { + timestep: f32::from_bits(0x3c888889), + ticks: 0, + } + } +} +impl SlowMotionSettings { + fn request(self, progress: f32) -> SimulationRateRequest { + let curve = self.timescale.evaluate(progress); + SimulationRateRequest { + timestep: 1.0 / curve.mul_add(self.fps_at_scale_one - 60.0, 60.0), + ticks: 1, + } + } +} diff --git a/crates/skate-core/src/camera/subject.rs b/crates/skate-core/src/camera/subject.rs new file mode 100644 index 00000000..1f2d27d1 --- /dev/null +++ b/crates/skate-core/src/camera/subject.rs @@ -0,0 +1,161 @@ +//! Normal camera subject accessors and reference points, TU3 82DF69C0/82DF78A8. +//! Physical and animation producers supply this snapshot together, after their +//! tick. No grounded state, bone position, or camera anchor is inferred here. +use super::{ + AnchorTrackingSubject, AvoidanceSubject, ReferenceHeightSubject, RigModeSubject, + RigPositioningSubject, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Subject { + /// 82DF80D8 -> setter24. This is the native camera subject basis. + pub transform: [[f32; 4]; 4], + /// Physical output bundle5+432 -> setter32. + pub skeleton_root: [[f32; 4]; 4], + /// Physical output bundle5+64 -> setter92, getter444. + pub hips_position: [f32; 4], + /// Physical output bundle8+96 -> setter100, getter452. + pub last_valid_ground_up: [f32; 4], + pub reference_positions: [[f32; 4]; 10], + pub context: u32, + /// Physical output bundle8+268 -> setter124, getter476. + pub pumping_acceleration: f32, + /// Physical output bundle7+32 -> setter172, getter524. + pub state_height_32: f32, + pub in_ground_physics: u8, + pub grinding: u8, + pub trajectory_valid: u8, + pub wiping_out: u8, + pub physically_pushing: u8, + pub at_pushable_speed: u8, + pub off_board: u8, + /// Bundle2 byte452 -> setter304, getter656. Its producer owns the meaning. + pub air_flag_452: u8, + /// Bundle7 byte81 -> setter308, getter660. + pub state_flag_81: u8, + /// Bundle15 float200 > 0 and flags204 bit2 -> setter316, getter668. + pub broken_bone_slowmo: u8, + /// Setter328 is cleared by82DF69C0; later subject writers remain explicit. + pub subject_flag_328: u8, +} + +/// Exact inputs used by82DF78A8; names identify their role in the camera's +/// reference-point table, while comments retain the physical output binding. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ReferencePointInputs { + pub head: [f32; 4], // skeleton+32 + pub hips: [f32; 4], // skeleton+64 + pub left_foot: [f32; 4], // skeleton+144 + pub right_foot: [f32; 4], // skeleton+160 + pub board: [f32; 4], // skeleton+480 + pub centre_of_mass: [f32; 4], // bundle9+64 + pub damped_centre_of_mass: [f32; 4], // subject464 (bundle9+80) + pub grind_position: [f32; 4], // bundle4+96 + pub tracked_anchor: [f32; 4], // CameraMan604 -> Rig184 -> tracker16 + pub incline_normal: [f32; 4], // CameraMan784 + pub grinding: u8, +} + +impl ReferencePointInputs { + /// The stock list has ten entries. The collision positioner requests + /// entries3,0,7, so substituting one board point for this list changes it. + pub fn positions(self) -> [[f32; 4]; 10] { + let feet = core::array::from_fn(|i| (self.left_foot[i] + self.right_foot[i]) * 0.5); + [ + self.head, + self.hips, + if self.grinding != 0 { + self.grind_position + } else { + self.board + }, + self.centre_of_mass, + self.board, + self.centre_of_mass, + self.tracked_anchor, + feet, + self.damped_centre_of_mass, + self.incline_normal, + ] + } +} + +impl RigModeSubject for Subject { + fn flag_568(&mut self) -> u8 { + self.in_ground_physics + } + fn flag_596(&mut self) -> u8 { + self.grinding + } + fn flag_680(&mut self) -> u8 { + self.subject_flag_328 + } + fn flag_540(&mut self) -> u8 { + self.trajectory_valid + } +} +impl AvoidanceSubject for Subject { + fn transform_376(&mut self) -> [[f32; 4]; 4] { + self.transform + } + fn vector_452(&mut self) -> [f32; 4] { + self.last_valid_ground_up + } + fn vector_444(&mut self) -> [f32; 4] { + self.hips_position + } + fn context_532(&mut self) -> u32 { + self.context + } + fn flag_608(&mut self) -> u8 { + self.off_board + } + fn flag_548(&mut self) -> u8 { + self.wiping_out + } +} +impl AnchorTrackingSubject for Subject { + fn flag_584(&mut self) -> u8 { + self.physically_pushing + } + fn flag_588(&mut self) -> u8 { + self.at_pushable_speed + } + fn value_476(&mut self) -> f32 { + self.pumping_acceleration + } + fn flag_548(&mut self) -> u8 { + self.wiping_out + } +} +impl ReferenceHeightSubject for Subject { + fn flag_656(&mut self) -> u8 { + self.air_flag_452 + } + fn flag_608(&mut self) -> u8 { + self.off_board + } +} +impl RigPositioningSubject for Subject { + fn flag_668(&mut self) -> u8 { + self.broken_bone_slowmo + } + fn flag_548(&mut self) -> u8 { + self.wiping_out + } + fn flag_660(&mut self) -> u8 { + self.state_flag_81 + } + fn value_524(&mut self) -> f32 { + self.state_height_32 + } + fn transform_384(&mut self) -> [[f32; 4]; 4] { + self.skeleton_root + } + fn position_360(&mut self, index: u32) -> [f32; 4] { + self.reference_positions[index as usize] + } + fn context_532(&mut self) -> u32 { + self.context + } +} diff --git a/crates/skate-core/src/camera/subject_pose.rs b/crates/skate-core/src/camera/subject_pose.rs new file mode 100644 index 00000000..37d2460b --- /dev/null +++ b/crates/skate-core/src/camera/subject_pose.rs @@ -0,0 +1,51 @@ +//! Pose publication82DF80D8 and82DF7558..82DF76B4. Physical outputs remain +//! authoritative; this owns only the camera's proven one-frame pose history. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SubjectPoseInputs { + pub physical_transform: [[f32; 4]; 4], + pub skeleton_root: [[f32; 4]; 4], + pub center_of_mass: [f32; 4], + pub reckoned_center_of_mass: [f32; 4], + /// State output7 bytes59 and75 select the skeleton root. + pub wiping_out: bool, + pub state_flag_75: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PublishedSubjectPose { + pub transform: [[f32; 4]; 4], + pub damped_center_of_mass: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SubjectPosePublisher { + previous_physical_transform: [[f32; 4]; 4], +} + +impl SubjectPosePublisher { + pub fn new() -> Self { + // Subject constructor82E07C08 initializes its three transform matrices + // from the native unit-axis constants, with a zero translation vector. + Self { previous_physical_transform: [ + [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], + ] } + } + + pub fn publish(&mut self, input: SubjectPoseInputs) -> PublishedSubjectPose { + let transform = if input.wiping_out || input.state_flag_75 { + input.skeleton_root + } else { self.previous_physical_transform }; + // 82DF80D8 executes before publisher setter28, preserving that delay. + self.previous_physical_transform = input.physical_transform; + let mut damped = input.reckoned_center_of_mass; + let delta = core::array::from_fn(|i| damped[i] - input.center_of_mass[i]); + let distance = super::vector_tracker::length(delta); + let maximum = f32::from_bits(0x3e147ae1); + if distance > maximum { + let inverse = super::vector_tracker::refined_reciprocal(distance); + damped = core::array::from_fn(|i| (inverse * delta[i]).mul_add(maximum, input.center_of_mass[i])); + } + PublishedSubjectPose { transform, damped_center_of_mass: damped } + } +} diff --git a/crates/skate-core/src/camera/tracker.rs b/crates/skate-core/src/camera/tracker.rs new file mode 100644 index 00000000..0fc014ae --- /dev/null +++ b/crates/skate-core/src/camera/tracker.rs @@ -0,0 +1,90 @@ +//! Complete scalar tracker update at TU3 0x82E08640 (0x190 bytes). +//! Callers supply native state, parameters and timestep; no camera tuning or +//! initialization policy is inferred here. See docs/agents/camera/CAM-01.md. + +/// The six scalar words written/read at tracker offsets +0 through +20. +/// Acceleration clamp retains its prior value when acceleration opposes error. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScalarTracker { + pub target: f32, + pub position: f32, + pub velocity: f32, + pub acceleration: f32, + pub acceleration_clamp: f32, + pub smoothing: f32, +} + +/// Effective tracker fields +28 through +56, after the native caller binds +/// stock settings and applies its dynamic overrides. These are not defaults. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScalarTrackerParameters { + pub delta_umbra: f32, + pub delta_penumbra: f32, + pub speed_clamp: f32, + pub acceleration_clamp_min: f32, + pub acceleration_clamp_max: f32, + pub smoothing_min: f32, + pub smoothing_max: f32, + pub overshoot_zeroes_velocity: bool, +} + +impl ScalarTracker { + /// Advance the complete native update. TU3 does not clamp dt, sanitize + /// parameters or special-case dt == 0 in this function. + pub fn update(&mut self, dt: f32, target: f32, parameters: ScalarTrackerParameters) { + let position = self.position; + let delta = target - position; + self.target = target; + if parameters.overshoot_zeroes_velocity && self.velocity * delta < 0.0 { + // Multiplication preserves negative zero; assignment to +0 does not. + self.velocity *= 0.0; + } + let velocity = self.velocity; + let reciprocal_dt = 1.0 / dt; + self.acceleration = reciprocal_dt.mul_add(delta, -velocity) * reciprocal_dt; + + let absolute_delta = delta.abs(); + let fraction = if absolute_delta < parameters.delta_umbra { + 1.0 + } else if absolute_delta < parameters.delta_penumbra { + 1.0 - (absolute_delta - parameters.delta_umbra) + / (parameters.delta_penumbra - parameters.delta_umbra) + } else { + 0.0 + }; + + if self.acceleration * delta > 0.0 { + let limit = interpolate( + parameters.acceleration_clamp_min, + parameters.acceleration_clamp_max, + fraction, + ); + self.acceleration_clamp = limit; + let absolute_acceleration = self.acceleration.abs(); + if absolute_acceleration > limit { + self.acceleration = (self.acceleration / absolute_acceleration) * limit; + } + } + + self.velocity = self.acceleration.mul_add(dt, velocity); + let absolute_velocity = self.velocity.abs(); + if absolute_velocity > parameters.speed_clamp { + self.velocity = (self.velocity / absolute_velocity) * parameters.speed_clamp; + } + + let next_position = self.velocity.mul_add(dt, position); + self.smoothing = interpolate(parameters.smoothing_min, parameters.smoothing_max, fraction); + // Smoothing changes position only. Do not derive velocity back from it. + self.position = (next_position - position).mul_add(1.0 - self.smoothing, position); + } +} + +pub(super) fn interpolate(minimum: f32, maximum: f32, fraction: f32) -> f32 { + // The equal-parameter branch avoids a multiply/add, including for NaNs in + // the unused fraction. Native otherwise uses a VMX fused multiply-add. + if minimum == maximum { + maximum + } else { + (maximum - minimum).mul_add(fraction, minimum) + } +} diff --git a/crates/skate-core/src/camera/trajectory_query.rs b/crates/skate-core/src/camera/trajectory_query.rs new file mode 100644 index 00000000..53561dbd --- /dev/null +++ b/crates/skate-core/src/camera/trajectory_query.rs @@ -0,0 +1,107 @@ +//! Camera-observable part of the TU3 trajectory batch. The variable-step walk +//! is 82770910, step sizing 8276C940 and collision time 8276C558. World storage +//! and scheduling are host-owned; the native segment sequence is retained. +use super::vector_tracker::{dot, length, refined_reciprocal}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TrajectoryQuery { + pub position: [f32; 4], + pub velocity: [f32; 4], + pub gravity: [f32; 4], + pub duration: f32, + pub radius: f32, + pub start_error: f32, + pub end_error: f32, +} + +impl TrajectoryQuery { + /// Return the first trajectory-segment collision's reconstructed native + /// time, or -1. The callback returns the closest swept-line contact point. + /// The extra normal/mesh/frame fields of the native batch are not consumed + /// by the gameplay camera request's GetCollisionTime accessor82DF9340. + pub fn collision_time( + self, + mut line: impl FnMut([f32; 4], [f32; 4], f32) -> Result, E>, + ) -> Result { + let mut start_step = self.duration; + let mut end_step = self.duration; + if self.gravity[1] < 0.0 { + start_step = self.step_size(self.start_error * self.radius); + end_step = self.step_size(self.end_error * self.radius); + } + let minimum_square = + ((self.start_error * self.radius) * self.start_error) * self.radius; + let mut time = 0.0; + let mut start = self.evaluate(time); + let mut end = self.evaluate(time + start_step); + while self.duration > time { + let delta = core::array::from_fn(|i| end[i] - start[i]); + if dot(delta, delta) > minimum_square { + // 82770B40 rejects only segments with every XYZ component at + // or below this native threshold, before world enumeration. + let visible = delta[..3] + .iter() + .any(|v| v.abs() > f32::from_bits(0x3780_0000)); + if visible { + if let Some(position) = line(start, end, self.radius)? { + return Ok(self.time_at_contact(position, time)); + } + } + start = end; + } + let fraction = refined_reciprocal(self.duration) * time; + let step = end_step.mul_add(fraction, (1.0 - fraction) * start_step); + time += step; + // The stock walk evaluates one step ahead and deliberately does + // not clamp that point to duration. Do not replace with a uniform + // sample count or an end-clamped parabola. + end = self.evaluate(time + step); + } + Ok(-1.0) + } + + fn evaluate(self, time: f32) -> [f32; 4] { + let square = time * time; + core::array::from_fn(|i| { + (self.gravity[i] * 0.5) + .mul_add(square, self.velocity[i].mul_add(time, self.position[i])) + }) + } + + fn step_size(self, error_radius: f32) -> f32 { + let square = refined_reciprocal(self.gravity[1]) * (-8.0 * error_radius); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5) + .mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + if square == 0.0 { 0.0 } else { square * inverse } + } + + fn time_at_contact(self, position: [f32; 4], mut time: f32) -> f32 { + const FRAME: f32 = f32::from_bits(0x3c88_8889); + const EPSILON: f32 = f32::from_bits(0x38d1_b717); + if EPSILON >= (refined_reciprocal(2.0) * self.gravity[1]).abs() { + let distance = length(core::array::from_fn(|i| position[i] - self.position[i])); + if !(distance.abs() > EPSILON) { return 0.0; } + let speed = length(self.velocity); + if !(speed.abs() > EPSILON) { return 0.0; } + // Preserve8276C794/7B4, including its frame factor. This branch + // does not use the segment's fractional intersection parameter. + return (refined_reciprocal(speed) * distance) * FRAME; + } + let mut closest_square = f32::MAX; + while self.duration >= time { + let point = self.evaluate(time); + let delta = core::array::from_fn(|i| position[i] - point[i]); + let square = dot(delta, delta); + if closest_square >= square { + closest_square = square; + } else if square > closest_square { + return time - FRAME; + } + time += FRAME; + } + self.duration + } +} diff --git a/crates/skate-core/src/camera/vector_tracker.rs b/crates/skate-core/src/camera/vector_tracker.rs new file mode 100644 index 00000000..252a4204 --- /dev/null +++ b/crates/skate-core/src/camera/vector_tracker.rs @@ -0,0 +1,92 @@ +//! TU3 vector-tracker candidates. +use super::{ScalarTrackerParameters, tracker::interpolate}; + +/// Four lanes are retained because native vector stores update all four. +/// Magnitudes and direction tests use xyz only. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VectorTracker { + pub target: [f32; 4], + pub position: [f32; 4], + pub velocity: [f32; 4], + pub acceleration: [f32; 4], + pub acceleration_clamp: f32, + pub smoothing: f32, +} + +impl VectorTracker { + pub fn update(&mut self, dt: f32, target: [f32; 4], p: ScalarTrackerParameters) { + self.target = target; + let old = self.position; + let delta = core::array::from_fn(|i| target[i] - old[i]); + if p.overshoot_zeroes_velocity && dot(delta, self.velocity) < 0.0 { + self.velocity = self.velocity.map(|v| v * 0.0); + } + let inverse_dt = refined_reciprocal(dt); + self.acceleration = core::array::from_fn(|i| { + // Separate VMX multiply/subtract/multiply, unlike scalar FMSUBS. + (inverse_dt * delta[i] - self.velocity[i]) * inverse_dt + }); + let magnitude = length(delta); + let fraction = if magnitude < p.delta_umbra { + 1.0 + } else if magnitude < p.delta_penumbra { + 1.0 - (magnitude - p.delta_umbra) / (p.delta_penumbra - p.delta_umbra) + } else { + 0.0 + }; + if dot(self.acceleration, delta) > 0.0 { + self.acceleration_clamp = + interpolate(p.acceleration_clamp_min, p.acceleration_clamp_max, fraction); + self.acceleration = clamp_magnitude(self.acceleration, self.acceleration_clamp); + } + self.velocity = + core::array::from_fn(|i| self.acceleration[i].mul_add(dt, self.velocity[i])); + self.velocity = clamp_magnitude(self.velocity, p.speed_clamp); + let next: [f32; 4] = core::array::from_fn(|i| self.velocity[i].mul_add(dt, old[i])); + self.smoothing = interpolate(p.smoothing_min, p.smoothing_max, fraction); + self.position = + core::array::from_fn(|i| (next[i] - old[i]).mul_add(1.0 - self.smoothing, old[i])); + } +} + +/// TU3 82E08D18 preserves the fourth lane unless the xyz magnitude is capped; +/// then every lane receives the same multiplier. +pub fn clamp_magnitude(value: [f32; 4], limit: f32) -> [f32; 4] { + let magnitude = length(value); + if magnitude > limit { + let scale = limit / magnitude; + value.map(|v| v * scale) + } else { + value + } +} + +pub(super) fn refined_reciprocal(value: f32) -> f32 { + let mut estimate = reciprocal_estimate(value); + for _ in 0..2 { + estimate = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + } + estimate +} + +fn reciprocal_estimate(value: f32) -> f32 { + crate::physics::native_arithmetic::reciprocal_estimate(value) +} + +pub(super) fn length(value: [f32; 4]) -> f32 { + let square = dot(value, value); + let mut estimate = crate::physics::reciprocal_sqrt::estimate(square); + for _ in 0..2 { + let residual = (-square).mul_add(estimate * estimate, 1.0); + estimate = (estimate * 0.5).mul_add(residual, estimate); + } + if square == 0.0 { + 0.0 + } else { + square * estimate + } +} + +pub(super) fn dot(a: [f32; 4], b: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} diff --git a/crates/skate-core/src/graph/activation.rs b/crates/skate-core/src/graph/activation.rs new file mode 100644 index 00000000..bc7c62c8 --- /dev/null +++ b/crates/skate-core/src/graph/activation.rs @@ -0,0 +1,213 @@ +//! Data-driven activation for the stock state graph. +//! +//! Node::GetActivation 82C14C28 delegates to its expression with the caller's +//! mask. Condition::GetActivationMasked 82C12D48 excludes disabled conditions +//! and conditions whose authored mask does not intersect that mask. Expression +//! combination and its excluded-child rules are implemented in `expression`. + +use super::{controller::Frame, expression::evaluate_operator, selection::NodeId}; + +pub type ConditionId = usize; +pub type ExpressionId = usize; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Child { + Expression(ExpressionId), + Condition(ConditionId), +} + +#[derive(Clone, Debug)] +pub struct Expression { + pub operator: u32, + /// Native child vector order. Evaluation is lazy, so this order is visible. + pub children: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Condition { + pub enabled: bool, + /// Parsed CondMask bits from the stock condition's `mask` attribute. + pub mask: u32, +} + +#[derive(Clone, Debug)] +pub struct ActivationProgram { + pub state_expressions: Vec>, + pub transition_expressions: Vec>, + pub expressions: Vec, + pub conditions: Vec, +} + +/// Concrete condition implementations are registered by the game. The graph +/// runtime decides whether they run; the host only evaluates an included leaf. +pub trait ConditionHost { + fn condition_activation(&mut self, condition: ConditionId, frame: &Frame) -> u32; +} + +impl ActivationProgram { + /// Complete Node::GetActivation boundary (82C14C28). + pub fn node_activation( + &self, + node: NodeId, + mask: u32, + frame: &Frame, + host: &mut impl ConditionHost, + ) -> bool { + let expression = match node { + NodeId::State(id) => self.state_expressions[id], + NodeId::Transition(id) => self.transition_expressions[id], + }; + let Some(expression) = expression else { + return true; + }; + let mut excluded = 1; + let result = self.expression_activation(expression, mask, frame, &mut excluded, host); + excluded != 0 || result as u8 != 0 + } + + fn expression_activation( + &self, + id: ExpressionId, + mask: u32, + frame: &Frame, + excluded: &mut u8, + host: &mut impl ConditionHost, + ) -> u32 { + let expression = &self.expressions[id]; + evaluate_operator( + expression.operator, + expression.children.len(), + excluded, + |index, child_excluded| match expression.children[index] { + Child::Expression(child) => { + self.expression_activation(child, mask, frame, child_excluded, host) + } + Child::Condition(child) => { + self.condition_activation(child, mask, frame, child_excluded, host) + } + }, + ) + } + + /// Complete Condition::GetActivationMasked gate (82C12D48). + fn condition_activation( + &self, + id: ConditionId, + mask: u32, + frame: &Frame, + excluded: &mut u8, + host: &mut impl ConditionHost, + ) -> u32 { + let condition = self.conditions[id]; + if !condition.enabled || condition.mask & mask == 0 { + *excluded = 1; + 1 + } else { + *excluded = 0; + host.condition_activation(id, frame) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct Host { + values: Vec, + calls: Vec, + } + + impl ConditionHost for Host { + fn condition_activation(&mut self, condition: ConditionId, _: &Frame) -> u32 { + self.calls.push(condition); + self.values[condition] + } + } + + fn frame() -> Frame { + Frame { + dt: 0.0, + current: None, + last: None, + state_times: Vec::new(), + } + } + + fn program(operator: u32, conditions: Vec) -> ActivationProgram { + ActivationProgram { + state_expressions: vec![Some(0)], + transition_expressions: Vec::new(), + expressions: vec![Expression { + operator, + children: (0..conditions.len()).map(Child::Condition).collect(), + }], + conditions, + } + } + + #[test] + fn excluded_conditions_do_not_participate_and_all_excluded_is_true() { + let graph = program( + 1, + vec![ + Condition { + enabled: false, + mask: 1, + }, + Condition { + enabled: true, + mask: 2, + }, + ], + ); + let mut host = Host { + values: vec![0, 0], + ..Default::default() + }; + assert!(graph.node_activation(NodeId::State(0), 1, &frame(), &mut host)); + assert!(host.calls.is_empty()); + } + + #[test] + fn included_false_condition_controls_result_and_or_short_circuits() { + let graph = program( + 2, + vec![ + Condition { + enabled: true, + mask: 1, + }, + Condition { + enabled: true, + mask: 1, + }, + ], + ); + let mut host = Host { + values: vec![1, 0], + ..Default::default() + }; + assert!(graph.node_activation(NodeId::State(0), 1, &frame(), &mut host)); + assert_eq!(host.calls, [0]); + + host.values[0] = 0; + host.calls.clear(); + assert!(!graph.node_activation(NodeId::State(0), 1, &frame(), &mut host)); + assert_eq!(host.calls, [0, 1]); + } + + #[test] + fn nodes_without_expressions_are_active() { + let graph = ActivationProgram { + state_expressions: vec![None], + transition_expressions: vec![None], + expressions: Vec::new(), + conditions: Vec::new(), + }; + let mut host = Host::default(); + assert!(graph.node_activation(NodeId::State(0), 1, &frame(), &mut host)); + assert!(graph.node_activation(NodeId::Transition(0), 2, &frame(), &mut host)); + } +} diff --git a/crates/skate-core/src/graph/conditions.rs b/crates/skate-core/src/graph/conditions.rs new file mode 100644 index 00000000..9e7805b0 --- /dev/null +++ b/crates/skate-core/src/graph/conditions.rs @@ -0,0 +1,206 @@ +//! ActionGraph condition leaves recovered from Skate 3 TU3. +//! Physical values are published by their real producers. An absent producer +//! is an integration error, never an implicit ground state or neutral speed. +use super::intents::IntentMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Comparison { + None, + Equal, + NotEqual, + Greater, + Less, + GreaterEqual, + LessEqual, + GreaterAbsolute, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NumericCondition { + pub comparison: Comparison, + pub threshold: f32, + pub absolute: bool, +} +impl NumericCondition { + /// NumericCondition::Compare82C12AB8. The bge/ble branches test the + /// absence of LT/GT, including unordered inputs; do not change to >=/<=. + pub fn matches(self, value: f32) -> bool { + let value = if self.absolute { value.abs() } else { value }; + match self.comparison { + Comparison::None => false, + Comparison::Equal => value == self.threshold, + Comparison::NotEqual => value != self.threshold, + Comparison::Greater => value > self.threshold, + Comparison::Less => value < self.threshold, + Comparison::GreaterEqual => !(value < self.threshold), + Comparison::LessEqual => !(value > self.threshold), + Comparison::GreaterAbsolute => value.abs() > self.threshold, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpeedInputs { + /// PhysOut skateboard+164/+168/+192, respectively. + pub speed: f32, + pub forward_speed: f32, + pub speed_and_slope: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PhysicalStateInputs { + /// PhysOut filtered-state+0/+80/+8, respectively. + pub category: u32, + pub grinding: bool, + pub grind_name: String, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushBrakeInputs { + /// Y of ground-output+80, used by82BA5150. Its producer must be supplied; + /// this is not interchangeable with deck up or a selected contact normal. + pub ground_axis_y: f32, + /// PhysOut skeleton+598. + pub skeleton_disables_push_brake: bool, + /// Selected global collection316, layout+1844. + pub maximum_ground_angle_degrees: f32, +} +impl PushBrakeInputs { + /// Complete scalar result of DisablePushBrake82BA5150. The inverse-sine + /// estimate retains the explicitly documented numerical validation boundary. + pub fn disabled(self) -> bool { + let degrees = crate::trigonometry::asin(self.ground_axis_y) * f32::from_bits(0x4265_2EE1); + 90.0 - degrees > self.maximum_ground_angle_degrees || self.skeleton_disables_push_brake + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ConditionInputs { + pub speeds: Option, + pub physical_state: Option, + /// PhysOut miscellaneous+128, read by82BA15F8. + pub time_since_last_input: Option, + /// Actor stance accessors82BA4C30/82BA4C78, through component+1804. + pub mirrored: Option, + pub riding_fakie: Option, + pub push_brake: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ActionCondition { + HasIntent { + name: String, + numeric: NumericCondition, + }, + TimeSinceLastInput(NumericCondition), + Speed { + along_skate_z: bool, + numeric: NumericCondition, + }, + SpeedAndSlope(NumericCondition), + FilteredState(u32), + Grinding { + name: Option, + }, + Mirrored, + RidingFakie, + DisablePushBrake, + /// Name resolves relative to the containing state, using82C16C10. The + /// host binds target once because the loaded hierarchy is immutable. + CurrentState { + name: String, + target: Option, + }, +} +impl ActionCondition { + pub fn evaluate( + &self, + input: &ConditionInputs, + action_intents: &IntentMap, + current: Option, + parents: &[Option], + ) -> Result { + Ok(match self { + Self::HasIntent { name, numeric } => action_intents.get(name).is_some_and(|&value| { + // HasAGIntent82BA0EA8 distinguishes presence-only from numeric. + numeric.comparison == Comparison::None || numeric.matches(value) + }), + Self::TimeSinceLastInput(numeric) => numeric.matches( + input + .time_since_last_input + .ok_or("TimeSinceLastInput needs PhysOut miscellaneous+128")?, + ), + Self::Speed { + along_skate_z, + numeric, + } => { + let speeds = input + .speeds + .ok_or("PhysicsSpeedCompare needs skateboard output")?; + numeric.matches(if *along_skate_z { + speeds.forward_speed + } else { + speeds.speed + }) + } + Self::SpeedAndSlope(numeric) => numeric.matches( + input + .speeds + .ok_or("PhysicsSpeedAndSlopeCompare needs skateboard output+192")? + .speed_and_slope, + ), + Self::FilteredState(expected) => { + input + .physical_state + .as_ref() + .ok_or("PhysFilteredState needs filtered physical state")? + .category + == *expected + } + Self::Grinding { name } => { + let state = input + .physical_state + .as_ref() + .ok_or("IsGrinding needs filtered physical state")?; + //824714D0 compares the five encoded words, including the + //case/truncation semantics already used by skeleton attributes. + state.grinding + && name.as_ref().is_none_or(|name| { + crate::animation::skeleton_input::name::encode(name.as_bytes()) + == crate::animation::skeleton_input::name::encode( + state.grind_name.as_bytes(), + ) + }) + } + Self::Mirrored => input + .mirrored + .ok_or("IsMirrored needs the motion-graph stance owner")?, + Self::RidingFakie => input + .riding_fakie + .ok_or("IsRidingFakie needs the motion-graph stance owner")?, + Self::DisablePushBrake => input + .push_brake + .ok_or("DisablePushBrake needs ground/skeleton outputs and stock angle")? + .disabled(), + Self::CurrentState { target, .. } => { + //82C13820 includes the current state itself, then its ancestors. + let mut cursor = current; + let mut matches = false; + if let Some(target) = target { + while let Some(state) = cursor { + if state == *target { + matches = true; + break; + } + cursor = parents[state]; + } + } + matches + } + }) + } +} + +#[cfg(test)] +#[path = "tests/conditions.rs"] +mod tests; diff --git a/crates/skate-core/src/graph/controller.rs b/crates/skate-core/src/graph/controller.rs new file mode 100644 index 00000000..05dab4b9 --- /dev/null +++ b/crates/skate-core/src/graph/controller.rs @@ -0,0 +1,268 @@ +//! Dynamic controller 82C13260, list preparation 82C13A00, Enter 82C13B20 and +//! Exit 82C13E58. The host owns concrete behavior/condition implementations and +//! instance storage. Graph topology is the processed stock asset. + +use super::{ + activation::{ActivationProgram, ConditionHost}, + selection::{Selection, StateId, Topology, TransitionId}, +}; + +pub type BehaviorId = usize; +pub type HookId = usize; + +#[derive(Clone, Debug)] +pub struct Behavior { + pub owner: StateId, + pub enabled: bool, +} + +#[derive(Clone, Debug)] +pub struct Program { + pub topology: Topology, + pub root: StateId, + pub activation: ActivationProgram, + pub behaviors: Vec, + /// State+64 vector, in authored order. + pub state_behaviors: Vec>, + /// Transition+44 vector, in authored order. + pub transition_hooks: Vec>, +} + +#[derive(Clone, Debug)] +pub struct Frame { + pub dt: f32, + pub current: Option, + pub last: Option, + /// Missing entries correspond to absent native state-time map entries. + pub state_times: Vec>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ActiveBehavior { + pub behavior: BehaviorId, + /// Opaque host instance handle; zero is the native null instance. + pub instance: u32, +} + +/// Concrete nodes run immediately. Effects of End, hooks and Begin are visible +/// to subsequent calls in this tick. A context is the native six-word value; +/// its pointer words are opaque handles at this platform-independent boundary. +/// The host resolves them and owns instance lifetime (including external refs). +pub trait Host: ConditionHost { + fn context(&self) -> [u32; 6]; + fn allocate(&mut self, behavior: BehaviorId, frame: &Frame) -> u32; + fn begin(&mut self, behavior: BehaviorId, context: [u32; 6], frame: &Frame); + fn update(&mut self, behavior: BehaviorId, context: [u32; 6], frame: &Frame); + fn end(&mut self, behavior: BehaviorId, context: [u32; 6], frame: &Frame); + fn hook(&mut self, hook: HookId, frame: &Frame); + /// Release the controller's instance ownership after its End callback. + fn release(&mut self, instance: u32); +} + +#[derive(Clone, Debug)] +pub struct Controller { + pub frame: Frame, + pub active: Vec, +} + +impl Controller { + pub fn new(state_count: usize) -> Self { + Self { + frame: Frame { + dt: 0.0, + current: None, + last: None, + state_times: vec![None; state_count], + }, + active: Vec::new(), + } + } + + pub fn update(&mut self, program: &Program, dt: f32, host: &mut impl Host) { + self.frame.dt = dt; + let mut next = None; + let transition = Selection { + graph: &program.topology, + root: program.root, + current: self.frame.current, + } + .next(&mut next, |node, mask| { + program + .activation + .node_activation(node, mask, &self.frame, host) + }); + let (exiting, entering) = self.prepare_lists(program, transition, next); + + for time in self.frame.state_times.iter_mut().flatten() { + *time += dt; + } + self.exit(program, &exiting, host); + if let Some(id) = transition { + for &hook in &program.transition_hooks[id] { + host.hook(hook, &self.frame); + } + } + self.enter(program, &entering, host); + for active in &self.active { + let mut context = host.context(); + context[2] = active.instance; + host.update(active.behavior, context, &self.frame); + } + } + + /// DynamicHierarchicalController::EndAllBehaviours (82C13498). Native + /// shutdown visits active behaviours in reverse order and does not alter + /// the current state or state-time map; graph reset remains a separate act. + pub fn end_all_behaviors(&mut self, host: &mut impl Host) { + while let Some(&active) = self.active.last() { + let mut context = host.context(); + context[2] = active.instance; + host.end(active.behavior, context, &self.frame); + self.active.pop(); + host.release(active.instance); + } + } + + /// Complete 82C13A00 / 82C122E8 / 82C12260. Explicit transitions to a + /// current ancestor re-enter that ancestor, including self transitions. + fn prepare_lists( + &mut self, + program: &Program, + transition: Option, + next: Option, + ) -> (Vec, Vec) { + let old_path = path(&program.topology, self.frame.current); + let boundary = match transition { + None => next, + Some(id) => { + let target = program.topology.transitions[id].target; + if old_path.contains(&target) { + program.topology.states[target].parent + } else { + Some(target) + } + } + }; + let boundary_path = path(&program.topology, boundary); + let common = old_path + .iter() + .zip(&boundary_path) + .take_while(|(a, b)| a == b) + .count(); + let exiting = old_path[common..].to_vec(); + let entering = path(&program.topology, next)[common..].to_vec(); + self.frame.last = self.frame.current; + self.frame.current = next; + (exiting, entering) + } + + fn exit(&mut self, program: &Program, exiting: &[StateId], host: &mut impl Host) { + for index in (0..self.active.len()).rev() { + let active = self.active[index]; + if exiting.contains(&program.behaviors[active.behavior].owner) { + let mut context = host.context(); + context[2] = active.instance; + host.end(active.behavior, context, &self.frame); + self.active.remove(index); + host.release(active.instance); + } + } + // End observes the old state's final incremented time. Erasure follows + // every End callback, before transition hooks and new state timers. + for &state in exiting { + self.frame.state_times[state] = None; + } + } + + fn enter(&mut self, program: &Program, entering: &[StateId], host: &mut impl Host) { + // All new state timers exist before the first allocation or Begin. + for &state in entering { + self.frame.state_times[state] = Some(0.0); + } + for &state in entering { + for &behavior in &program.state_behaviors[state] { + if program.behaviors[behavior].enabled { + let instance = host.allocate(behavior, &self.frame); + self.active.push(ActiveBehavior { behavior, instance }); + let mut context = host.context(); + context[2] = instance; + host.begin(behavior, context, &self.frame); + } + } + } + } +} + +fn path(graph: &Topology, mut state: Option) -> Vec { + let mut result = Vec::new(); + while let Some(id) = state { + result.push(id); + state = graph.states[id].parent; + } + result.reverse(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::activation::ConditionId; + + #[derive(Default)] + struct HostLog(Vec); + + impl ConditionHost for HostLog { + fn condition_activation(&mut self, _: ConditionId, _: &Frame) -> u32 { + unreachable!() + } + } + + impl Host for HostLog { + fn context(&self) -> [u32; 6] { + [1, 2, 0, 4, 5, 6] + } + + fn allocate(&mut self, _: BehaviorId, _: &Frame) -> u32 { + unreachable!() + } + + fn begin(&mut self, _: BehaviorId, _: [u32; 6], _: &Frame) { + unreachable!() + } + + fn update(&mut self, _: BehaviorId, _: [u32; 6], _: &Frame) { + unreachable!() + } + + fn end(&mut self, behavior: BehaviorId, context: [u32; 6], _: &Frame) { + self.0.push(format!("end:{behavior}:{}", context[2])); + } + + fn hook(&mut self, _: HookId, _: &Frame) { + unreachable!() + } + + fn release(&mut self, instance: u32) { + self.0.push(format!("release:{instance}")); + } + } + + #[test] + fn end_all_behaviors_uses_reverse_order_and_instance_context() { + let mut controller = Controller::new(0); + controller.active = vec![ + ActiveBehavior { + behavior: 4, + instance: 40, + }, + ActiveBehavior { + behavior: 7, + instance: 70, + }, + ]; + let mut host = HostLog::default(); + controller.end_all_behaviors(&mut host); + assert_eq!(host.0, ["end:7:70", "release:70", "end:4:40", "release:40"]); + assert!(controller.active.is_empty()); + } +} diff --git a/crates/skate-core/src/graph/expression.rs b/crates/skate-core/src/graph/expression.rs new file mode 100644 index 00000000..e760579e --- /dev/null +++ b/crates/skate-core/src/graph/expression.rs @@ -0,0 +1,60 @@ +//! TU3 ExpressionOperator::GetActivationMasked (82C12508). +//! Excluded children do not participate in AND/OR/NOT. Preserve lazy calls: +//! condition operations may read or modify their own instance state. + +/// ParseOperator 82C12438: case-sensitive strings, zero for missing/unknown. +pub fn parse_operator(name: Option<&str>) -> u32 { + match name { + Some("and") => 1, + Some("or") => 2, + Some("not") => 3, + _ => 0, + } +} + +/// Execute the complete operator with the native virtual child-call boundary. +/// The callback receives its ordered child index and the initially true +/// excluded byte; it returns the native result word. Context/mask are supplied +/// by the caller's closure, without an invented default condition result. +/// NOT requires a first child, as in the native caller contract. +pub fn evaluate_operator( + operator: u32, + child_count: usize, + excluded: &mut u8, + mut child: impl FnMut(usize, &mut u8) -> u32, +) -> u32 { + if operator > 3 { + return 1; // Native default leaves the caller's excluded byte untouched. + } + if operator == 3 { + assert!(child_count > 0, "stock NOT expression requires a child"); + let mut child_excluded = 1; + let value = child(0, &mut child_excluded) as u8; + *excluded = child_excluded; + return if child_excluded != 0 { + 1 + } else { + u32::from(value == 0) + }; + } + let is_or = operator == 2; + let mut value = if is_or { 0_u32 } else { 1_u32 }; + let mut all_excluded = 1; + for index in 0..child_count { + if (is_or && value as u8 != 0) || (!is_or && value as u8 == 0) { + break; + } + let mut child_excluded = 1; + let result = child(index, &mut child_excluded); + if child_excluded == 0 { + all_excluded = 0; + value = if is_or { + result + } else { + u32::from((result as u8) & (value as u8)) + }; + } + } + *excluded = all_excluded; + if all_excluded != 0 { 1 } else { value } +} diff --git a/crates/skate-core/src/graph/intent_handlers.rs b/crates/skate-core/src/graph/intent_handlers.rs new file mode 100644 index 00000000..91b395bf --- /dev/null +++ b/crates/skate-core/src/graph/intent_handlers.rs @@ -0,0 +1,86 @@ +//! Source-backed constant and clock-derived MotionGraph intent behaviours. +//! +//! These are the small stateful operation boundaries used by the stock graph +//! host. They deliberately return the existing `IntentMutation` protocol so +//! the host remains the sole owner of the intent map. + +use crate::input::graph_intents::IntentMutation; + +/// TU3 `CreateConstMGIntent` (factory 0x82BC26D8, ctor 0x82BA1768, +/// Begin 0x82BA18A0, Update 0x82BA1938, End 0x82BA1988/0x82BA1990). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ConstMgIntent { + pub value: f32, + pub on_update: bool, + created: bool, +} + +impl ConstMgIntent { + pub const fn new(value: f32, on_update: bool) -> Self { + Self { + value, + on_update, + created: false, + } + } + + /// Begin publishes the authored constant and marks the instance alive. + pub fn begin(&mut self) -> IntentMutation { + self.created = true; + IntentMutation::Set(self.value) + } + + /// TU3 does not republish the constant when `onUpdate` is true. It keeps + /// the existing map value. A false `onUpdate` instance removes its value + /// only after the begin frame has elapsed. + pub fn update(&mut self) -> IntentMutation { + let mutation = if self.on_update || self.created { + IntentMutation::None + } else { + IntentMutation::Remove + }; + self.created = false; + mutation + } + + /// End removes the owned intent unconditionally. Native End only emits + /// the remove operation; instance storage is released by the host. + pub const fn end(&self) -> IntentMutation { + IntentMutation::Remove + } +} + +/// TU3 `CreateMGTimeIntentFromAGIntent` (factory 0x82BC2778, ctor 0x82BA3040, +/// Update 0x82BA3158, End 0x82BA28B8). The graph-clock value is supplied by +/// the host; this operation only applies the verified dt accumulation rule. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TimeMgIntent { + pub elapsed: f32, +} + +impl TimeMgIntent { + /// The AG lookup is an existence check only. Any present value advances + /// the instance clock by the caller's dt; its amplitude is not consumed. + /// An absent AG key resets the clock and removes the MG entry. + pub fn update(&mut self, action_value: Option, dt: f32) -> IntentMutation { + match action_value { + Some(_) => { + self.elapsed += dt; + IntentMutation::Set(self.elapsed) + } + None => { + self.elapsed = 0.0; + IntentMutation::Remove + } + } + } + + /// Native End removes the map entry and does not reset elapsed state. + pub const fn end(&self) -> IntentMutation { + IntentMutation::Remove + } +} + +#[cfg(test)] +#[path = "tests/intent_handlers.rs"] +mod tests; diff --git a/crates/skate-core/src/graph/intents.rs b/crates/skate-core/src/graph/intents.rs new file mode 100644 index 00000000..8bd4ca94 --- /dev/null +++ b/crates/skate-core/src/graph/intents.rs @@ -0,0 +1,47 @@ +//! Canonical ActionGraph/MotionGraph intent identity. +//! TU3 FastString constructor823C3BF8 uses six words of82382B50 encoding. +//! Storage is ours; aliases share one value and the most recent write wins. +use crate::animation::playback_parameters::intent_key; +use std::collections::BTreeMap; + +/// Decoded names cannot bypass native case folding, NUL termination or the +/// 36-byte limit on insert, read or removal. Kept separate from five-word +/// animation attribute names and graph time tags. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct IntentMap { + values: BTreeMap<[u32; 6], f32>, +} + +impl IntentMap { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, name: &str, value: f32) -> Option { + self.values.insert(intent_key(name), value) + } + + pub fn get(&self, name: &str) -> Option<&f32> { + self.values.get(&intent_key(name)) + } + + pub fn remove(&mut self, name: &str) -> Option { + self.values.remove(&intent_key(name)) + } + + pub fn contains_key(&self, name: &str) -> bool { + self.values.contains_key(&intent_key(name)) + } + + pub fn clear(&mut self) { + self.values.clear(); + } + + pub fn len(&self) -> usize { + self.values.len() + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } +} diff --git a/crates/skate-core/src/graph/mod.rs b/crates/skate-core/src/graph/mod.rs new file mode 100644 index 00000000..08898f2f --- /dev/null +++ b/crates/skate-core/src/graph/mod.rs @@ -0,0 +1,8 @@ +//! Recovered generic graph operations. Stock topology is supplied as data. +pub mod activation; +pub mod conditions; +pub mod controller; +pub mod expression; +pub mod intent_handlers; +pub mod intents; +pub mod selection; diff --git a/crates/skate-core/src/graph/selection.rs b/crates/skate-core/src/graph/selection.rs new file mode 100644 index 00000000..955f46b5 --- /dev/null +++ b/crates/skate-core/src/graph/selection.rs @@ -0,0 +1,204 @@ +//! Native dynamic-controller selection: 82C140D8 and its complete state-search +//! helpers. Topology and flags come from loaded graph objects, never a riding +//! state machine. The activation callback is Node::GetActivation's boundary. + +pub type StateId = usize; +pub type TransitionId = usize; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NodeId { + State(StateId), + Transition(TransitionId), +} + +#[derive(Clone, Debug)] +pub struct State { + pub parent: Option, + pub enabled: bool, + pub active: bool, + /// Native state+36: 0=false, 1=true, 2=resolved ancestor name. + pub interruptibility: u32, + pub interrupt_ancestor: Option, + pub children: Vec, + pub transitions: Vec, +} + +#[derive(Clone, Debug)] +pub struct Transition { + pub enabled: bool, + pub target: StateId, + pub priority: u32, +} + +#[derive(Clone, Debug)] +pub struct Topology { + pub states: Vec, + pub transitions: Vec, +} + +pub struct Selection<'a> { + pub graph: &'a Topology, + pub root: StateId, + pub current: Option, +} + +impl Selection<'_> { + /// Complete GetNextState 82C140D8. The output slot is explicit because an + /// unrecognized interruptibility ordinal leaves it untouched in TU3. + pub fn next( + &self, + target: &mut Option, + mut activate: impl FnMut(NodeId, u32) -> bool, + ) -> Option { + let Some(current) = self.current else { + *target = self.descend(self.root, 1, &mut activate); + return None; + }; + for priority in (0..=3).rev() { + if let Some((transition, state)) = + self.search_transitions(current, priority, &mut activate) + { + *target = Some(state); + return Some(transition); + } + } + // Complete retained/reselected-state path 82C14248. + match self.graph.states[current].interruptibility { + 0 if self.ancestors_active(current, 2, &mut activate) => *target = Some(current), + 0 | 1 => *target = self.descend(self.root, 2, &mut activate), + 2 => { + let ancestor = self.graph.states[current] + .interrupt_ancestor + .expect("native processed interrupt ancestor must resolve"); + if self.ancestors_active(ancestor, 2, &mut activate) { + *target = self.descend(ancestor, 2, &mut activate); + if target.is_some() { + return None; + } + } + *target = self.descend(self.root, 2, &mut activate); + } + _ => {} + } + None + } + + /// Complete 82C14710 (82C14708 is its thunk). + fn descend( + &self, + state: StateId, + mask: u32, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> Option { + let effective_mask = if mask == 2 && self.is_ancestor(state, self.current) { + 2 + } else { + 1 + }; + if !activate(NodeId::State(state), effective_mask) { + return None; + } + self.first_child_or_leaf(state, mask, activate) + } + + fn first_child_or_leaf( + &self, + state: StateId, + mask: u32, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> Option { + let children = &self.graph.states[state].children; + if children.is_empty() { + return Some(state); + } + for &child in children { + let node = &self.graph.states[child]; + if node.enabled && node.active { + if let Some(leaf) = self.descend(child, mask, activate) { + return Some(leaf); + } + } + } + None + } + + /// Complete priority-specific SearchTransitions 82C14810, leaf to root. + fn search_transitions( + &self, + mut state: StateId, + priority: u32, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> Option<(TransitionId, StateId)> { + loop { + for &id in &self.graph.states[state].transitions { + let transition = &self.graph.transitions[id]; + let target = transition.target; + if transition.priority != priority + || !transition.enabled + || !self.graph.states[target].enabled + { + continue; + } + if !activate(NodeId::Transition(id), 1) + || !self.valid_target(self.current, target, activate) + || !activate(NodeId::State(target), 1) + { + continue; + } + if let Some(leaf) = self.first_child_or_leaf(target, 1, activate) { + return Some((id, leaf)); + } + } + state = self.graph.states[state].parent?; + } + } + + /// Complete ValidTarget 82C14978. Target and new ancestors use preconditions; + /// a shared ancestor (other than target) and its parents use mask 2. + fn valid_target( + &self, + current: Option, + target: StateId, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> bool { + let mut next = Some(target); + while let Some(state) = next { + if state != target && self.is_ancestor(state, current) { + return self.ancestors_active(state, 2, activate); + } + if !activate(NodeId::State(state), 1) { + return false; + } + next = self.graph.states[state].parent; + } + true + } + + /// Complete state-parent activation chain 82C16B88 for State objects. + fn ancestors_active( + &self, + mut state: StateId, + mask: u32, + activate: &mut impl FnMut(NodeId, u32) -> bool, + ) -> bool { + loop { + if !activate(NodeId::State(state), mask) { + return false; + } + let Some(parent) = self.graph.states[state].parent else { + return true; + }; + state = parent; + } + } + + fn is_ancestor(&self, ancestor: StateId, mut state: Option) -> bool { + while let Some(id) = state { + if id == ancestor { + return true; + } + state = self.graph.states[id].parent; + } + false + } +} diff --git a/crates/skate-core/src/graph/tests/conditions.rs b/crates/skate-core/src/graph/tests/conditions.rs new file mode 100644 index 00000000..8a20647f --- /dev/null +++ b/crates/skate-core/src/graph/tests/conditions.rs @@ -0,0 +1,102 @@ +use super::*; + +fn numeric(comparison: Comparison, threshold: f32) -> NumericCondition { + NumericCondition { + comparison, + threshold, + absolute: false, + } +} + +#[test] +fn comparison_preserves_presence_and_unordered_branch_semantics() { + let none = numeric(Comparison::None, 0.0); + let condition = ActionCondition::HasIntent { + name: "Pushing".into(), + numeric: none, + }; + let mut intents = IntentMap::new(); + let input = ConditionInputs::default(); + assert!(!condition.evaluate(&input, &intents, None, &[]).unwrap()); + intents.insert("Pushing", 0.0); + assert!(condition.evaluate(&input, &intents, None, &[]).unwrap()); + assert!(!none.matches(0.0)); + assert!(numeric(Comparison::GreaterAbsolute, 0.5).matches(-0.75)); + assert!(numeric(Comparison::GreaterEqual, 1.0).matches(f32::NAN)); + assert!(numeric(Comparison::LessEqual, 1.0).matches(f32::NAN)); + assert!(!numeric(Comparison::Greater, 1.0).matches(f32::NAN)); + assert!(!numeric(Comparison::Less, 1.0).matches(f32::NAN)); +} + +#[test] +fn speed_and_state_conditions_require_their_real_distinct_outputs() { + let mut input = ConditionInputs::default(); + let speed = ActionCondition::Speed { + along_skate_z: true, + numeric: numeric(Comparison::Less, 0.0), + }; + assert!( + speed + .evaluate(&input, &IntentMap::new(), None, &[]) + .is_err() + ); + input.speeds = Some(SpeedInputs { + speed: 4.0, + forward_speed: -3.0, + speed_and_slope: 2.0, + }); + assert!( + speed + .evaluate(&input, &IntentMap::new(), None, &[]) + .unwrap() + ); + let state = ActionCondition::CurrentState { + name: "Riding".into(), + target: Some(1), + }; + let parents = [None, Some(0), Some(1), Some(0)]; + assert!( + state + .evaluate(&input, &IntentMap::new(), Some(1), &parents) + .unwrap() + ); + assert!( + state + .evaluate(&input, &IntentMap::new(), Some(2), &parents) + .unwrap() + ); + assert!( + !state + .evaluate(&input, &IntentMap::new(), Some(3), &parents) + .unwrap() + ); + assert!( + !state + .evaluate(&input, &IntentMap::new(), None, &parents) + .unwrap() + ); +} + +#[test] +fn push_brake_uses_ground_angle_and_skeleton_override() { + let ground = PushBrakeInputs { + ground_axis_y: 1.0, + skeleton_disables_push_brake: false, + maximum_ground_angle_degrees: 45.0, + }; + assert!(!ground.disabled()); + assert!( + PushBrakeInputs { + ground_axis_y: 0.0, + ..ground + } + .disabled() + ); + assert!( + PushBrakeInputs { + skeleton_disables_push_brake: true, + ..ground + } + .disabled() + ); +} diff --git a/crates/skate-core/src/graph/tests/intent_handlers.rs b/crates/skate-core/src/graph/tests/intent_handlers.rs new file mode 100644 index 00000000..83a9fa88 --- /dev/null +++ b/crates/skate-core/src/graph/tests/intent_handlers.rs @@ -0,0 +1,44 @@ +use super::{ConstMgIntent, TimeMgIntent}; +use crate::input::graph_intents::IntentMutation; + +#[test] +fn constant_keeps_begin_value_for_one_frame_then_removes() { + let mut intent = ConstMgIntent::new(2.5, false); + assert_eq!(intent.begin(), IntentMutation::Set(2.5)); + assert_eq!(intent.update(), IntentMutation::None); + assert_eq!(intent.update(), IntentMutation::Remove); +} + +#[test] +fn constant_on_update_does_not_republish() { + let mut intent = ConstMgIntent::new(-1.0, true); + assert_eq!(intent.begin(), IntentMutation::Set(-1.0)); + assert_eq!(intent.update(), IntentMutation::None); + assert_eq!(intent.update(), IntentMutation::None); +} + +#[test] +fn constant_end_removes_without_changing_instance_state() { + let mut intent = ConstMgIntent::new(2.5, false); + intent.begin(); + let previous = intent; + assert_eq!(intent.end(), IntentMutation::Remove); + assert_eq!(intent, previous); +} + +#[test] +fn time_uses_presence_and_accumulates_instance_clock() { + let mut intent = TimeMgIntent::default(); + assert_eq!(intent.update(Some(0.0), 0.25), IntentMutation::Set(0.25)); + assert_eq!(intent.update(Some(99.0), 0.5), IntentMutation::Set(0.75)); + assert_eq!(intent.update(None, 0.5), IntentMutation::Remove); + assert_eq!(intent.elapsed, 0.0); +} + +#[test] +fn time_end_removes_without_resetting_instance_clock() { + let mut intent = TimeMgIntent::default(); + assert_eq!(intent.update(Some(0.0), 0.25), IntentMutation::Set(0.25)); + assert_eq!(intent.end(), IntentMutation::Remove); + assert_eq!(intent.elapsed, 0.25); +} diff --git a/crates/skate-core/src/input/angle.rs b/crates/skate-core/src/input/angle.rs new file mode 100644 index 00000000..b02d80a5 --- /dev/null +++ b/crates/skate-core/src/input/angle.rs @@ -0,0 +1,78 @@ +//! Scalar lane of native SIMD atan 82473B98 and intention left-stick angle. +pub(super) fn reciprocal_estimate(value: f32) -> f32 { + crate::physics::native_arithmetic::reciprocal_estimate(value) +} + +fn refined_reciprocal(value: f32) -> f32 { + let initial = reciprocal_estimate(value); + let first = initial.mul_add((-value).mul_add(initial, 1.0), initial); + let second = first.mul_add((-value).mul_add(first, 1.0), first); + if first.is_nan() { initial } else { second } +} +pub fn atan(value: f32) -> f32 { + let absolute = value.abs(); + let invert = absolute > 1.0; + let reduced = if invert { + refined_reciprocal(absolute) + } else { + absolute + }; + let transform = reduced > f32::from_bits(0x3e8930a3); + let offset = if transform { + if invert { + f32::from_bits(0x3f860a92) + } else { + f32::from_bits(0x3f060a92) + } + } else if invert { + f32::from_bits(0x3fc90fdb) + } else { + 0.0 + }; + let transformed = (reduced.mul_add(f32::from_bits(0x3f3b67af), reduced) - 1.0) + * refined_reciprocal(reduced + f32::from_bits(0x3fddb3d7)); + let t = if transform { transformed } else { reduced }; + let square = t * t; + let numerator = square.mul_add(f32::from_bits(0xbf566bd7), f32::from_bits(0xc107e9fb)); + let numerator = square.mul_add(numerator, f32::from_bits(0xc1a40bfe)); + let numerator = square.mul_add(numerator, f32::from_bits(0xc15b0533)); + let denominator = square.mul_add( + square + f32::from_bits(0x4170624f), + f32::from_bits(0x426e5052), + ); + let denominator = square.mul_add(denominator, f32::from_bits(0x42ac5090)); + let denominator = square.mul_add(denominator, f32::from_bits(0x422443e6)); + let polynomial = t.mul_add((numerator * square) * refined_reciprocal(denominator), t); + let result = if f32::from_bits(0x39800000) > t.abs() { + t + } else { + polynomial + }; + let result = (if invert { -result } else { result }) + offset; + let result = if value < 0.0 { -result } else { result }; + let result = if value > f32::from_bits(0x7e800000) { + f32::from_bits(0x3fc90fdb) + } else { + result + }; + if -f32::from_bits(0x7e800000) > value { + -f32::from_bits(0x3fc90fdb) + } else { + result + } +} + +pub(super) fn left_stick_angle(x: f32, y: f32) -> f32 { + if x == 0.0 && y == 0.0 { + return 0.0; + } + let vertical = -y; + let reciprocal = reciprocal_estimate(vertical); + let refined = reciprocal.mul_add((-reciprocal).mul_add(vertical, 1.0), reciprocal); + let basic = atan(x.mul_add(refined, 0.0)); + let sign = x.to_bits() & 0x80000000; + let pi = f32::from_bits(0x40490fdb | sign); + let half_pi = f32::from_bits(0x3fc90fdb | sign); + let angle = if 0.0 > vertical { pi + basic } else { basic }; + if vertical == 0.0 { half_pi } else { angle } +} diff --git a/crates/skate-core/src/input/animation_packet.rs b/crates/skate-core/src/input/animation_packet.rs new file mode 100644 index 00000000..2593384a --- /dev/null +++ b/crates/skate-core/src/input/animation_packet.rs @@ -0,0 +1,58 @@ +//! Complete TU3 82DB5BE0: AnimOutPhysIn -> ProcessedPhysIn publication. +//! Called by physical input stage 82DB4048 before board/attribute processing. +//! Inputs are already produced by animation; this is not a gamepad mapping. + +/// Source packet fields. Byte flags use their low bit, including noncanonical +/// byte values. Matrix/vector words are copied without numerical conversion. +pub struct AnimationPacketFields { + /// Packet +10368 -> processed +2468 bit 20. + pub stance_byte: u8, + /// Packet +10384 / +10388. + pub timestep: f32, + pub scalar_10388: f32, + /// Packet +10375, +10496, +10784 -> processed +2468 bits 3, 2, 1. + pub flags_10375_10496_10784: [u8; 3], + pub vector_10480: [u32; 4], + pub matrix_10704: [[u32; 4]; 4], + pub byte_10768: u8, + /// Packet +10788 / +10792. + pub truck_tightness: f32, + pub scalar_10792: f32, + /// Packet +10371 -> processed +2476 bit 20. + pub flag_10371: u8, +} + +/// All fields written by 82DB5BE0. Seed flag words from the preceding native +/// reset/publication stages: this helper preserves their unrelated bits. +pub struct ProcessedPacketFields { + pub flags_2468: u32, + pub flags_2476: u32, + pub timestep: f32, + pub scalar_2668: f32, + pub vector_1520: [u32; 4], + pub matrix_1536: [[u32; 4]; 4], + pub byte_1600: u8, + pub truck_tightness: f32, + pub scalar_2764: f32, +} + +/// Publish the complete function's writes into distinct source/destination +/// records. The source packet must remain valid through physical processing. +pub fn publish(source: &AnimationPacketFields, output: &mut ProcessedPacketFields) { + replace_bit(&mut output.flags_2468, 20, source.stance_byte); + output.timestep = source.timestep; + output.scalar_2668 = source.scalar_10388; + replace_bit(&mut output.flags_2468, 3, source.flags_10375_10496_10784[0]); + replace_bit(&mut output.flags_2468, 2, source.flags_10375_10496_10784[1]); + output.vector_1520 = source.vector_10480; + replace_bit(&mut output.flags_2468, 1, source.flags_10375_10496_10784[2]); + output.matrix_1536 = source.matrix_10704; + output.byte_1600 = source.byte_10768; + output.truck_tightness = source.truck_tightness; + output.scalar_2764 = source.scalar_10792; + replace_bit(&mut output.flags_2476, 20, source.flag_10371); +} + +fn replace_bit(word: &mut u32, bit: u32, byte: u8) { + *word = (*word & !(1 << bit)) | (u32::from(byte & 1) << bit); +} diff --git a/crates/skate-core/src/input/body_flip_signal.rs b/crates/skate-core/src/input/body_flip_signal.rs new file mode 100644 index 00000000..da4b5b21 --- /dev/null +++ b/crates/skate-core/src/input/body_flip_signal.rs @@ -0,0 +1,44 @@ +//! BodyFlippingSignal82BA3380/33B8/35E0/3790. Both windows update on +//! every call before deciding whether to create or remove the motion intent. + +#[derive(Clone,Copy,Debug)] +pub struct Settings { + /// anim_motion.extend_bodyflip_gesture and extend_takeoff_point. + pub gesture_window:f32, + pub takeoff_window:f32, +} + +#[derive(Clone,Copy,Debug,Default)] +pub struct State { + gesture_time:f32, + takeoff_time:f32, + /// Ordered native keys830C01B4 then830BE3A8. None is the constructor's + /// null name. The host binds their recovered text to its intent storage. + selected:Option, +} + +impl State { + pub fn begin(&mut self,settings:Settings) { + self.gesture_time=settings.gesture_window; + self.takeoff_time=settings.takeoff_window; + } + + /// Some(index) sets only that intent to1. None removes BOTH keys. + /// Incoming intentions are tested for presence, not their scalar value. + pub fn update(&mut self,present:[bool;2],filtered_category:u32,dt:f32,settings:Settings)->Option { + if let Some(index)=present.iter().position(|&value|value) { + self.gesture_time=0.0; + self.selected=Some(index); + } else { + self.gesture_time=cap(self.gesture_time+dt,settings.gesture_window); + } + let gesture=self.gesture_timef32 {if value-limit>=0.0 {limit} else {value}} diff --git a/crates/skate-core/src/input/controller.rs b/crates/skate-core/src/input/controller.rs new file mode 100644 index 00000000..27e1b8ab --- /dev/null +++ b/crates/skate-core/src/input/controller.rs @@ -0,0 +1,166 @@ +//! TU3 RawControllerInput 82598FE0 and DerivedControllerInput 825992D8. +//! Native action-map evaluation is an explicit boundary, not a platform mapping. +use crate::physics::reciprocal_sqrt::estimate; + +/// Evaluated native cInputMap actions. IDs are TU3 map indices, not XInput +/// buttons. Implementations must preserve the call order; the stock expression +/// evaluator 8296C350 and loaded mapping still determine deadzones and scaling. +pub trait ActionMap { + fn value(&mut self, action: u32) -> f32; + fn state(&mut self, action: u32) -> u8; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RawControllerInput { + /// Six binary32 axes/triggers followed by native packed button bits. + words: [u32; 7], +} + +impl RawControllerInput { + pub fn from_words(words: [u32; 7]) -> Self { + Self { words } + } + pub fn words(&self) -> &[u32; 7] { + &self.words + } + + /// Complete 82598FE0, including simultaneous bumper arbitration and + /// retention of the low twenty bits of the destination flags word. + pub fn update( + &mut self, + previous: &Self, + map: &mut impl ActionMap, + state_502: bool, + state_104: bool, + ) { + for (slot, action) in [64, 65, 67, 68, 70, 71].into_iter().enumerate() { + self.words[slot] = map.value(action).to_bits(); + } + for (action, bit) in [(66, 31), (69, 30), (72, 29), (73, 28)] { + self.set_bit(bit, map.state(action)); + } + let both = (1 << 29) | (1 << 28); + if self.words[6] & both == both { + let prior = previous.words[6] & both; + if (prior == 1 << 29 || prior == 1 << 28) && (state_502 || state_104) { + self.words[6] = (self.words[6] & !both) | prior; + } else { + self.words[6] &= !(1 << 28); + } + } + for action in 74..=81 { + self.set_bit(101 - action, map.state(action)); + } + } + + fn set_bit(&mut self, bit: u32, value: u8) { + self.words[6] = (self.words[6] & !(1 << bit)) | (u32::from(value & 1) << bit); + } +} + +/// Actual inputlistener attribute result for key 1F3C1C1C68AF4C23. +/// Missing collection/attribute uses the live 830D0850 fallback. +pub struct MagnitudeHeldSettings { + pub attribute: Option, + pub missing_attribute_value: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DerivedControllerInput { + words: [u32; 26], +} + +impl DerivedControllerInput { + /// Requires native initialized state. No reset or inferred default supplied. + pub fn from_words(words: [u32; 26]) -> Self { + Self { words } + } + pub fn words(&self) -> &[u32; 26] { + &self.words + } + + /// Prefix construction recovered from TU3 `82599718`. The native + /// constructor clears the controller axes and timers, preserves the low + /// twenty flag bits in the two packed raw words, and seeds the four + /// edge-timer fields from `flt_822F8B34` (`0x7effffff`). This is only the + /// controller prefix; listener registration/reset belongs to its owner. + pub fn initialize(&mut self) { + let flags_last = self.words[6] & 0x000f_ffff; + let flags_current = self.words[13] & 0x000f_ffff; + self.words = [0; 26]; + self.words[6] = flags_last; + self.words[13] = flags_current; + self.words[16..20].fill(0x7eff_ffff); + } + + /// Complete 825992D8: raw history, trigger/button edge timers and stick + /// magnitude-held timers. Timing uses caller dt without an imposed rate. + pub fn update( + &mut self, + map: &mut impl ActionMap, + timestep: f32, + state_502: bool, + state_104: bool, + settings: &MagnitudeHeldSettings, + ) { + let previous = RawControllerInput::from_words(self.words[7..14].try_into().unwrap()); + self.words[..7].copy_from_slice(previous.words()); + let mut current = previous; + current.update(&previous, map, state_502, state_104); + self.words[7..14].copy_from_slice(current.words()); + for (slot, axis) in [(14, 4), (15, 5)] { + self.advance_or_reset(slot, f32::from_bits(current.words[axis]) == 1.0, timestep); + } + let before = previous.words[6]; + let now = current.words[6]; + for (bit, rise, fall) in [(31, 16, 17), (30, 18, 19)] { + let was_down = before & (1 << bit) != 0; + let is_down = now & (1 << bit) != 0; + self.advance_or_reset(rise, !(!was_down && is_down), timestep); + self.advance_or_reset(fall, !(was_down && !is_down), timestep); + } + for (slot, action) in [(20, 80), (21, 81), (22, 78), (23, 79)] { + self.advance_or_reset(slot, map.state(action) != 0, timestep); + } + // Native evaluates these actions again after the button timers. + let lx = map.value(64); + let ly = map.value(65); + let rx = map.value(67); + let ry = map.value(68); + let right = magnitude(ry.mul_add(ry, rx * rx)); + let left = magnitude(ly.mul_add(ly, lx * lx)); + let threshold = settings + .attribute + .unwrap_or(settings.missing_attribute_value); + // PPC ble treats unordered as false; retain that branch direction. + self.advance_or_reset(24, !(left <= threshold), timestep); + self.advance_or_reset(25, !(right <= threshold), timestep); + } + + fn advance_or_reset(&mut self, slot: usize, advance: bool, timestep: f32) { + self.words[slot] = if advance { + (f32::from_bits(self.words[slot]) + timestep).to_bits() + } else { + 0 + }; + } +} + +#[cfg(test)] +#[path = "controller_startup.rs"] +mod startup_tests; + +/// Independent reciprocal-square-root estimate followed by the original two +/// refinement steps. Host arithmetic is not claimed to be bit-exact Xenon math. +pub(super) fn magnitude(squared: f32) -> f32 { + 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); + } + if squared == 0.0 { + 0.0 + } else { + squared * inverse + } +} diff --git a/crates/skate-core/src/input/controller_startup.rs b/crates/skate-core/src/input/controller_startup.rs new file mode 100644 index 00000000..45c899a7 --- /dev/null +++ b/crates/skate-core/src/input/controller_startup.rs @@ -0,0 +1,20 @@ +use super::DerivedControllerInput; + +#[test] +fn initialize_preserves_only_native_low_twenty_flag_bits() { + let mut words = [0u32; 26]; + words[6] = 0xfff0_1234; + words[13] = 0xabcd_5678; + words[0] = 1; + words[24] = 2; + let mut input = DerivedControllerInput::from_words(words); + input.initialize(); + assert_eq!(input.words()[6], 0x0000_1234); + assert_eq!(input.words()[13], 0x000d_5678); + for (index, &word) in input.words().iter().enumerate() { + if !(index == 6 || index == 13 || (16..20).contains(&index)) { + assert_eq!(word, 0, "word {index}"); + } + } + assert_eq!(&input.words()[16..20], &[0x7eff_ffff; 4]); +} diff --git a/crates/skate-core/src/input/gameplay_map.rs b/crates/skate-core/src/input/gameplay_map.rs new file mode 100644 index 00000000..84ecfb38 --- /dev/null +++ b/crates/skate-core/src/input/gameplay_map.rs @@ -0,0 +1,52 @@ +//! Specialization of the 18 stock gameplay expressions registered by82697740. +//! Config identity and VM operand semantics are recorded in STEERING_INPUT.md. +use super::{controller::ActionMap, pad::Pad}; + +pub struct GameplayActions { + values: [f32; 18], +} +impl GameplayActions { + /// The native pad retains storage even while count is zero. With a nonzero + /// count, stock Button operands read their fixed index, without a per-index + /// count check; the caller must provide the native 24-button storage. + pub fn from_pad(pad: &Pad) -> Self { + let value = |slot: usize| { + if pad.count() == 0 { + 0.0 + } else { + f32::from_bits(pad.records()[slot][0]) + } + }; + Self { + values: [ + value(16) - value(17), + value(18) - value(19), + value(6), + value(20) - value(21), + value(22) - value(23), + value(7), + value(10), + value(11), + value(8), + value(9), + value(0), + value(1), + value(2), + value(3), + value(14), + value(15), + value(12), + value(13), + ], + } + } +} +impl ActionMap for GameplayActions { + fn value(&mut self, action: u32) -> f32 { + self.values + [usize::try_from(action.checked_sub(64).expect("gameplay action below 64")).unwrap()] + } + fn state(&mut self, action: u32) -> u8 { + u8::from(self.value(action) != 0.0) + } +} diff --git a/crates/skate-core/src/input/graph_intents.rs b/crates/skate-core/src/input/graph_intents.rs new file mode 100644 index 00000000..d277158b --- /dev/null +++ b/crates/skate-core/src/input/graph_intents.rs @@ -0,0 +1,134 @@ +//! Native CreateMGIntentFromAGIntent and AttachIntent behavior boundaries. + +/// Complete82BA0C20. Ordinals come from82BA0A48 and stock filter names. +pub fn apply_filter(value: f32, kind: u32) -> f32 { + let pi = f32::from_bits(0x40490fdb); + let half_pi = f32::from_bits(0x3fc90fdb); + match kind { + 0 => value, + 1 => value * -1.0, + 2 => value.abs(), + 3 => { + if value >= 0.0 { + 1.0 - value + } else { + -1.0 - value + } + } + 4 => { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } + } + 5 => { + if value >= 0.0 { + pi - value + } else { + -pi - value + } + } + 6 => { + let rotated = value - half_pi; + if rotated < -pi { + rotated + f32::from_bits(0x40c90fdb) + } else { + rotated + } + } + _ => { + let rotated = value + half_pi; + if rotated > pi { + rotated - f32::from_bits(0x40c90fdb) + } else { + rotated + } + } + } +} + +/// Four source ordinals: filter1, filter2, fakieFilter, mirrorFilter. +/// Missing stance component bypasses every filter, matching82BA2930. +pub fn filter_chain(mut value: f32, filters: [u32; 4], stance: Option<(bool, bool)>) -> f32 { + if let Some((fakie, mirror)) = stance { + for (kind, enabled) in filters.into_iter().zip([true, true, fakie, mirror]) { + if enabled && kind <= 7 { + value = apply_filter(value, kind); + } + } + } + value +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum IntentMutation { + None, + Remove, + Set(f32), +} + +pub struct CreateMgIntent { + pub on_update: bool, + pub default_value: Option, + pub scale: f32, + pub filters: [u32; 4], +} + +impl CreateMgIntent { + /// Complete82BA2790 with container lookup/removal/insertion represented by + /// an explicit mutation. Keys are the caller's decoded MGIntent/AGIntent. + pub fn emit(&self, action_value: Option, stance: Option<(bool, bool)>) -> IntentMutation { + match action_value.or(self.default_value) { + None => IntentMutation::Remove, + Some(value) => { + IntentMutation::Set(filter_chain(self.scale * value, self.filters, stance)) + } + } + } + /// Complete82BA26F0 lifecycle write, including absent-input removal. + pub fn enter( + &self, + created_this_frame: &mut bool, + action_value: Option, + stance: Option<(bool, bool)>, + ) -> IntentMutation { + let result = self.emit(action_value, stance); + *created_this_frame = true; + result + } + /// Complete82BA2728: onUpdate=false removes on subsequent updates. + pub fn update( + &self, + created_this_frame: &mut bool, + action_value: Option, + stance: Option<(bool, bool)>, + ) -> IntentMutation { + let result = if self.on_update { + self.emit(action_value, stance) + } else if !*created_this_frame { + IntentMutation::Remove + } else { + IntentMutation::None + }; + *created_this_frame = false; + result + } + ///82BA2788 ->82BA28B8 unconditionally removes the owned MG intent. + pub fn exit(&self) -> IntentMutation { + IntentMutation::Remove + } +} + +/// Complete82BB5AE0 at resolved lookup/sink boundaries. The packet sink is +/// invoked first; optional skeleton sink receives the same value afterwards. +pub fn attach_intent( + value: Option, + set_skeleton: bool, + mut packet: impl FnMut(f32), + mut skeleton: impl FnMut(f32), +) { + if let Some(value) = value { + packet(value); + if set_skeleton { + skeleton(value); + } + } +} diff --git a/crates/skate-core/src/input/history.rs b/crates/skate-core/src/input/history.rs new file mode 100644 index 00000000..9c396813 --- /dev/null +++ b/crates/skate-core/src/input/history.rs @@ -0,0 +1,98 @@ +//! Native controller history owner (`8296D0D0`, `8296D1F0`, `82699230`). +//! +//! The cache is thirty frame batches. Each batch contains four 100-byte +//! records, one per registered device. A record contains a value count and +//! up to 24 native float values. Startup is read=0 and write=1. + +use super::pad::Pad; + +pub const HISTORY_CAPACITY: usize = 30; +pub const DEVICE_SLOTS: usize = 4; +pub const MAX_VALUES: usize = 24; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HistoryRecord { + values: [f32; MAX_VALUES], + count: usize, +} + +impl HistoryRecord { + pub fn new(values: &[f32]) -> Self { + assert!(values.len() <= MAX_VALUES); + let mut record = Self { + values: [0.0; MAX_VALUES], + count: values.len(), + }; + record.values[..values.len()].copy_from_slice(values); + record + } + pub fn count(&self) -> usize { + self.count + } + pub fn values(&self) -> &[f32] { + &self.values[..self.count] + } + /// Failed device polls clear publication count while retaining value words. + pub fn clear_count(&mut self) { + self.count = 0; + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PadHistory { + batches: [[HistoryRecord; DEVICE_SLOTS]; HISTORY_CAPACITY], + read: usize, + write: usize, +} + +impl PadHistory { + /// Matches `826986A8`: zeroed storage, read index 0, write index 1. + pub fn new() -> Self { + let empty = HistoryRecord::new(&[]); + Self { + batches: [[empty; DEVICE_SLOTS]; HISTORY_CAPACITY], + read: 0, + write: 1, + } + } + pub fn read_index(&self) -> usize { + self.read + } + pub fn write_index(&self) -> usize { + self.write + } + + /// `8296D0D0` copies the registered-device prefix into one frame batch, + /// then advances the producer once. Uncopied slots retain their contents; + /// an empty poll still advances the producer. + pub fn publish(&mut self, records: &[HistoryRecord]) { + assert!(records.len() <= DEVICE_SLOTS); + self.batches[self.write][..records.len()].copy_from_slice(records); + self.write = (self.write + 1) % HISTORY_CAPACITY; + } + + /// `82699230` drains to the newest available batch and updates all Pads + /// exactly once with that batch. + pub fn drain_to_latest(&mut self, pads: &mut [Pad; DEVICE_SLOTS]) -> bool { + if self.is_empty() { + return false; + } + let mut latest = self.read; + while (self.read + 1) % HISTORY_CAPACITY != self.write { + self.read = (self.read + 1) % HISTORY_CAPACITY; + latest = self.read; + } + for (pad, record) in pads.iter_mut().zip(self.batches[latest].iter()) { + pad.update(record.values()); + } + true + } + + fn is_empty(&self) -> bool { + (self.read + 1) % HISTORY_CAPACITY == self.write + } +} + +#[cfg(test)] +#[path = "history_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/input/history_tests.rs b/crates/skate-core/src/input/history_tests.rs new file mode 100644 index 00000000..5e56c02f --- /dev/null +++ b/crates/skate-core/src/input/history_tests.rs @@ -0,0 +1,79 @@ +use super::{DEVICE_SLOTS, HISTORY_CAPACITY, HistoryRecord, PadHistory}; +use crate::input::pad::Pad; + +fn pads() -> [Pad; DEVICE_SLOTS] { + [Pad::new(), Pad::new(), Pad::new(), Pad::new()] +} +fn batch(seed: f32) -> [HistoryRecord; DEVICE_SLOTS] { + [ + HistoryRecord::new(&[seed]), + HistoryRecord::new(&[seed + 1.0]), + HistoryRecord::new(&[seed + 2.0]), + HistoryRecord::new(&[seed + 3.0]), + ] +} + +#[test] +fn drains_newest_batch_once_for_all_devices() { + let mut history = PadHistory::new(); + history.publish(&batch(10.0)); + history.publish(&batch(20.0)); + let mut output = pads(); + assert!(history.drain_to_latest(&mut output)); + for (pad, expected) in output.iter().zip([20.0_f32, 21.0, 22.0, 23.0]) { + assert_eq!(pad.count(), 1); + assert_eq!(pad.records()[0][0], expected.to_bits()); + assert_eq!(pad.records()[0][3], 1, "only the final batch advances Pad"); + } + assert!(!history.drain_to_latest(&mut output)); +} + +#[test] +fn producer_wraps_at_full_lap_without_overflow_policy() { + let mut history = PadHistory::new(); + for i in 0..HISTORY_CAPACITY - 1 { + history.publish(&batch(i as f32)); + } + assert_eq!(history.write_index(), 0); + let mut output = pads(); + assert!(history.drain_to_latest(&mut output)); + history.publish(&batch(99.0)); + assert_eq!(history.write_index(), 1); + assert!(history.drain_to_latest(&mut output)); + let mut full = PadHistory::new(); + for i in 0..HISTORY_CAPACITY { + full.publish(&batch(i as f32)); + } + assert_eq!(full.write_index(), 1); + assert!(!full.drain_to_latest(&mut output)); +} + +#[test] +fn empty_publish_advances_once() { + let mut history = PadHistory::new(); + history.publish(&batch(1.0)); + history.publish(&[]); + assert_eq!(history.write_index(), 3); +} + +#[test] +fn reused_batch_retains_unregistered_slots() { + let mut history = PadHistory::new(); + history.batches[1] = batch(40.0); + let mut output = pads(); + history.publish(&[HistoryRecord::new(&[99.0])]); + assert!(history.drain_to_latest(&mut output)); + assert_eq!(output[0].records()[0][0], 99.0_f32.to_bits()); + assert_eq!(output[1].records()[0][0], 41.0_f32.to_bits()); + assert_eq!(output[2].records()[0][0], 42.0_f32.to_bits()); + assert_eq!(output[3].records()[0][0], 43.0_f32.to_bits()); +} + +#[test] +fn clear_count_preserves_visibility_contract() { + let mut record = HistoryRecord::new(&[3.5, 4.5]); + record.clear_count(); + assert_eq!(record.count(), 0); + assert_eq!(record.values(), &[]); + assert_eq!(&record.values[..2], &[3.5, 4.5]); +} diff --git a/crates/skate-core/src/input/mod.rs b/crates/skate-core/src/input/mod.rs new file mode 100644 index 00000000..536a093b --- /dev/null +++ b/crates/skate-core/src/input/mod.rs @@ -0,0 +1,19 @@ +//! Native input publication. Controller/graph producers remain separate stages. +pub mod angle; +pub mod animation_packet; +pub mod controller; +pub mod gameplay_map; +pub mod graph_intents; +pub mod history; +pub mod pad; +pub mod power_sliding; +pub mod riding_intentions; +pub mod set_turning; +pub mod steering_intentions; +pub mod turn_conditioner; +pub mod turn_remap; +pub mod xbox; +pub mod body_flip_signal; + +pub mod wipeout_intentions; +pub mod offboard_intentions; diff --git a/crates/skate-core/src/input/offboard_intentions.rs b/crates/skate-core/src/input/offboard_intentions.rs new file mode 100644 index 00000000..04e6de31 --- /dev/null +++ b/crates/skate-core/src/input/offboard_intentions.rs @@ -0,0 +1,138 @@ +//! Off-board inputs from original TU3 Fill825999F0 and helper8259C260. +//! The stock ActionGraph owns activation; these inputs do not change state. +use super::controller::DerivedControllerInput; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OffboardIntent { + pub name: &'static str, + pub value: f32, +} + +/// Read the already-updated controller prefix exactly once per input tick. +/// `air_reckoning_active` is actual PhysOut bundle+8 byte452, not a state test. +/// Analog movement has a separate physical-output-dependent producer. +pub fn produce_discrete( + controller: &DerivedControllerInput, + actor_flags: u32, + air_reckoning_active: bool, +) -> Vec { + let words = controller.words(); + let previous = words[6]; + let current = words[13]; + let axis = |slot: usize| f32::from_bits(words[slot]); + let held = |bit: u32| current & (1u32 << bit) != 0; + let rising = |bit: u32| held(bit) && previous & (1u32 << bit) == 0; + let mut output = Vec::with_capacity(12); + let mut emit = |name, value| output.push(OffboardIntent { name, value }); + + //8259AA8C invokes8259C260 before the later toggle/sprint publications. + if rising(23) { + emit("OB_Jump", 1.0); + } + let right_x = axis(9); + let right_y = axis(10); + if right_x != 0.0 { + emit("OB_LookAtX", right_x); + } + if right_y != 0.0 { + emit("OB_LookAtY", right_y); + } + let drop_board = axis(4) != 1.0 && axis(11) == 1.0; + let throw_board = axis(5) != 1.0 && axis(12) == 1.0; + if drop_board { + emit("OB_DropBoard", 1.0); + } + if throw_board { + emit("OB_ThrowBoard", 1.0); + } + if drop_board || throw_board { + emit("OB_RetrieveBoard", 1.0); + } + if held(28) || held(30) { + emit("OB_DoAirBodyTweak", 1.0); + } + emit("OB_AirBodyTweakX", right_x); + emit("OB_AirBodyTweakY", right_y); + + //8259A604..A6C0 computes both;8259AF80 gates only NewToggle by actor bit10. + if !air_reckoning_active && !held(29) && held(22) { + //Native bge does not take the branch for unordered timer values. + if (rising(22) || !(axis(23) >= f32::from_bits(0x3cf5_c28f))) + && actor_flags & (1 << 10) == 0 + { + emit("NewToggleOffBoardState", 1.0); + } + emit("ToggleOffBoardState", 1.0); + } + if !(axis(21) > 0.0) && axis(20) > 0.0 { + emit("OB_Sprint", 1.0); + } + output +} + +/// Completed physical publication read by Fill825999F0. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnalogObservation { + /// PhysOutSkeleton+0: effective animation root Z, including processed2476bit2. + pub effective_skeleton_z: [f32; 4], + /// Some(OffBoard288) iff actual OffBoard333 is nonzero. This vector is not + /// normalized here. A missing Biped producer must not be converted to None. + pub biped_correction: Option<[f32; 4]>, +} + +///8259A7A8..A9BC calculation and8259AFFC..B088 ordered movement publications. +pub fn produce_analog( + controller: &DerivedControllerInput, + observation: AnalogObservation, +) -> [OffboardIntent; 4] { + let words = controller.words(); + let stick = [f32::from_bits(words[7]), 0.0, f32::from_bits(words[8]), 0.0]; + let mut horizontal = observation.effective_skeleton_z; + horizontal[1] = 0.0; + let forward = safe_unit(horizontal); + let magnitude = if let Some(normal) = observation.biped_correction { + let negative = normal.map(|value| f32::from_bits(value.to_bits() ^ 0x8000_0000)); + let projection = dot(stick, negative); + let lower = if -projection >= 0.0 { 0.0 } else { projection }; + let coefficient = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + //8259A994 ble takes only ordered <=. Unordered follows the zero branch. + if !(dot(safe_unit(stick), negative) <= f32::from_bits(0x3f66_6666)) { + 0.0 + } else { + let corrected = std::array::from_fn(|i| normal[i].mul_add(coefficient, stick[i])); + dot(forward, corrected) + } + } else { + dot(forward, stick) + }; + [ + OffboardIntent { name: "OB_Mag", value: magnitude }, + OffboardIntent { name: "OB_BipedWorldZ", value: stick[2] }, + OffboardIntent { name: "OB_BipedWorldX", value: stick[0] }, + OffboardIntent { name: "OB_BipedStickMag", value: super::controller::magnitude(dot(stick, stick)) }, + ] +} + +fn dot(a: [f32; 4], b: [f32; 4]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn safe_unit(vector: [f32; 4]) -> [f32; 4] { + let squared = dot(vector, vector); + let mut inverse = crate::physics::reciprocal_sqrt::estimate(squared); + for _ in 0..2 { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + let length = if squared == 0.0 { 0.0 } else { squared * inverse }; + //Initializer82F826F8 supplies830BD350 from82181A88: LENGTH threshold1e-6. + if length > f32::from_bits(0x3586_37bd) { + vector.map(|value| value * inverse) + } else { + [0.0; 4] + } +} +#[cfg(test)] +#[path = "offboard_intentions/tests.rs"] +mod tests; + diff --git a/crates/skate-core/src/input/offboard_intentions/tests.rs b/crates/skate-core/src/input/offboard_intentions/tests.rs new file mode 100644 index 00000000..d95d1a20 --- /dev/null +++ b/crates/skate-core/src/input/offboard_intentions/tests.rs @@ -0,0 +1,138 @@ +use super::*; + +fn values(words: [u32; 26], flags: u32, blocked: bool) -> Vec { + produce_discrete(&DerivedControllerInput::from_words(words), flags, blocked) +} +fn has(output: &[OffboardIntent], name: &str) -> bool { + output.iter().any(|intent| intent.name == name) +} +fn toggle_words(previous: bool, timer: f32) -> [u32; 26] { + let mut words = [0; 26]; + words[6] = if previous { 1 << 22 } else { 0 }; + words[13] = 1 << 22; + words[23] = timer.to_bits(); + words +} + +#[test] +fn toggle_rise_and_short_hold_are_distinct_from_continuous_hold() { + let new = "NewToggleOffBoardState"; + let held = "ToggleOffBoardState"; + assert!(has(&values(toggle_words(false, 9.0), 0, false), new)); + assert!(has(&values(toggle_words(true, 0.02), 0, false), new)); + let boundary = values(toggle_words(true, f32::from_bits(0x3cf5_c28f)), 0, false); + assert!(!has(&boundary, new)); + assert!(has(&boundary, held)); + let nan_timer = values(toggle_words(true, f32::NAN), 0, false); + assert!(has(&nan_timer, new)); + let mut released = toggle_words(true, 0.0); + released[13] = 0; + let released = values(released, 0, false); + assert!(!has(&released, new)); + assert!(!has(&released, held)); +} + +#[test] +fn physical_and_modifier_gates_block_both_but_actor_gate_only_blocks_new() { + let words = toggle_words(false, 0.0); + let disabled = values(words, 1 << 10, false); + assert!(!has(&disabled, "NewToggleOffBoardState")); + assert!(has(&disabled, "ToggleOffBoardState")); + let mut modifier = words; + modifier[13] |= 1 << 29; + for output in [values(words, 0, true), values(modifier, 0, false)] { + assert!(!has(&output, "NewToggleOffBoardState")); + assert!(!has(&output, "ToggleOffBoardState")); + } +} + +#[test] +fn jump_requires_new_button_and_does_not_inherit_toggle_gates() { + let mut words = [0; 26]; + words[13] = (1 << 23) | (1 << 29); + words[22] = 0.01f32.to_bits(); + assert!(has(&values(words, u32::MAX, true), "OB_Jump")); + words[6] = 1 << 23; + assert!(!has(&values(words, 0, false), "OB_Jump")); +} + +#[test] +fn full_trigger_edges_publish_board_actions_together_and_do_not_repeat() { + let mut words = [0; 26]; + words[11] = 0.99f32.to_bits(); + assert!(!has(&values(words, 0, false), "OB_RetrieveBoard")); + words[11] = 1.0f32.to_bits(); + words[12] = 1.0f32.to_bits(); + let output = values(words, u32::MAX, true); + let board: Vec<_> = output.iter().filter(|i| i.name.ends_with("Board")).map(|i| i.name).collect(); + assert_eq!(board, ["OB_DropBoard", "OB_ThrowBoard", "OB_RetrieveBoard"]); + words[4] = words[11]; + words[5] = words[12]; + assert!(!has(&values(words, 0, false), "OB_RetrieveBoard")); + words[5] = 0; + let right_only = values(words, 0, false); + assert!(!has(&right_only, "OB_DropBoard")); + assert!(has(&right_only, "OB_ThrowBoard")); + assert!(has(&right_only, "OB_RetrieveBoard")); +} + +#[test] +fn sprint_uses_competing_held_timers_and_axes_preserve_zero_presence() { + let mut words = [0; 26]; + words[20] = 0.01f32.to_bits(); + let output = values(words, u32::MAX, true); + assert!(has(&output, "OB_Sprint")); + assert!(!has(&output, "OB_LookAtX")); + assert!(!has(&output, "OB_LookAtY")); + assert!(has(&output, "OB_AirBodyTweakX")); + assert!(has(&output, "OB_AirBodyTweakY")); + words[21] = 0.01f32.to_bits(); + assert!(!has(&values(words, 0, false), "OB_Sprint")); + words[9] = (-0.5f32).to_bits(); + words[13] = 1 << 30; + let output = values(words, 0, false); + assert!(has(&output, "OB_DoAirBodyTweak")); + assert_eq!(output.iter().find(|i| i.name == "OB_LookAtX").unwrap().value, -0.5); + assert_eq!(output.iter().find(|i| i.name == "OB_AirBodyTweakX").unwrap().value, -0.5); +} + +fn analog(x: f32, z: f32, forward: [f32; 4], correction: Option<[f32; 4]>) -> [OffboardIntent; 4] { + let mut words = [0; 26]; + words[7] = x.to_bits(); + words[8] = z.to_bits(); + produce_analog(&DerivedControllerInput::from_words(words), AnalogObservation { + effective_skeleton_z: forward, biped_correction: correction, + }) +} + +#[test] +fn movement_preserves_signed_projection_and_raw_axes() { + let output = analog(0.75, -0.5, [0.0, 8.0, 3.0, 0.0], None); + assert!((output[0].value + 0.5).abs() < 1e-6); + assert_eq!(output[1].value, -0.5); + assert_eq!(output[2].value, 0.75); + assert!((output[3].value - (0.75f32 * 0.75 + 0.25).sqrt()).abs() < 1e-6); + let mirrored = analog(0.75, -0.5, [0.0, 0.0, -3.0, 0.0], None); + assert!((mirrored[0].value - 0.5).abs() < 1e-6); +} + +#[test] +fn obstacle_correction_stops_direct_approach_but_preserves_tangent_motion() { + let forward = [0.0, 0.0, 1.0, 0.0]; + let wall = Some([0.0, 0.0, -1.0, 0.0]); + assert_eq!(analog(0.0, 1.0, forward, wall)[0].value, 0.0); + let tangent = analog(0.6, 0.8, [1.0, 0.0, 0.0, 0.0], wall); + assert!((tangent[0].value - 0.6).abs() < 1e-6); + assert!((analog(0.0, -0.5, forward, wall)[0].value + 0.5).abs() < 1e-6); + //The source intentionally does not normalize the supplied correction vector. + assert!((analog(0.0, 1.0, forward, Some([0.0, 0.0, -0.5, 0.0]))[0].value - 0.75).abs() < 1e-6); +} + +#[test] +fn zero_and_tiny_headings_use_original_length_threshold() { + assert_eq!(analog(0.0, 1.0, [0.0; 4], None)[0].value, 0.0); + assert_eq!(analog(0.0, 1.0, [0.0, 0.0, 0.5e-6, 0.0], None)[0].value, 0.0); + assert!((analog(0.0, 1.0, [0.0, 0.0, 2.0e-6, 0.0], None)[0].value - 1.0).abs() < 1e-6); + let zero = analog(0.0, 0.0, [0.0, 0.0, 1.0, 0.0], None); + assert!(zero.iter().all(|intent| intent.value == 0.0)); +} diff --git a/crates/skate-core/src/input/pad.rs b/crates/skate-core/src/input/pad.rs new file mode 100644 index 00000000..bedde4a7 --- /dev/null +++ b/crates/skate-core/src/input/pad.rs @@ -0,0 +1,79 @@ +//! Native pad publication 82966F30; retained records use their big-endian words. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pad { + records: Vec<[u32; 4]>, + count: usize, +} +impl Pad { + /// An empty native cInputPad. 8296D288/826986A8 initialize the published + /// count to zero; records become initialized only when a larger count is + /// published by 82966F30. + pub fn new() -> Self { + Self { + records: Vec::new(), + count: 0, + } + } + + /// Existing initialized native records. The native count is their length. + pub fn from_records(records: Vec<[u32; 4]>) -> Self { + Self { + count: records.len(), + records, + } + } + pub fn from_storage(records: Vec<[u32; 4]>, count: usize) -> Self { + assert!(count <= records.len()); + Self { records, count } + } + pub fn count(&self) -> usize { + self.count + } + pub fn records(&self) -> &[[u32; 4]] { + &self.records + } + + /// Complete update, including growth reset, updated count on shorter + /// polls, three-call edge suppression and 24/12-call repeat intervals. + pub fn update(&mut self, values: &[f32]) { + if values.len() > self.count { + if values.len() > self.records.len() { + self.records.resize(values.len(), [0; 4]); + } + self.records[self.count..values.len()].fill([0; 4]); + } + self.count = values.len(); + for (record, &value) in self.records.iter_mut().zip(values) { + record[0] = value.to_bits(); + let down = u32::from(value > 0.5); + let held = (record[1] >> 8) & 255; + if (record[3] as i32) < 3 { + record[3] = record[3].wrapping_add(1); + record[1] &= 0xffff; + } else if down == held { + record[1] &= 0xffff; + } else { + record[1] = + (record[1] & 255) | (down << 8) | if down != 0 { 1 << 24 } else { 1 << 16 }; + record[3] = 0; + } + if record[1] & 0xff00 != 0 { + if record[2] == 0 { + record[1] = (record[1] & !255) | 1; + record[2] = 24; + } else { + record[2] = record[2].wrapping_sub(1); + if record[2] == 0 { + record[1] = (record[1] & !255) | 1; + record[2] = 12; + } else { + record[1] &= !255; + } + } + } else { + record[2] = 0; + record[1] &= !255; + } + } + } +} diff --git a/crates/skate-core/src/input/power_sliding.rs b/crates/skate-core/src/input/power_sliding.rs new file mode 100644 index 00000000..1bac78be --- /dev/null +++ b/crates/skate-core/src/input/power_sliding.rs @@ -0,0 +1,119 @@ +//! Complete PowerSliding update82BB28E8 at resolved graph/component boundaries. +//! Registration82F89300; factory82BC9060; vtable82320328. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct State { + pub elapsed: f32, + pub previous_right: f32, + pub previous_left: f32, + pub flags: u32, +} +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// Global slot332 layout1684 and1632. + pub minimum_speed: f32, + pub minimum_slide_time: f32, + /// Graph4 layouts x1408/y1424, x1360/y1376, x1456/y1472. + pub stop_time: PointGraph<4>, + pub speed_response: PointGraph<4>, + pub angle_response: PointGraph<4>, +} +#[derive(Clone, Copy, Debug)] +pub struct Input { + /// PhysOut64->0 and PhysOut4->164. + pub category: u32, + pub speed: f32, + pub right_slide: Option, + pub left_slide: Option, + /// Results of82454FD8 for keys830BE528 and830C0818. + pub right_query: bool, + pub left_query: bool, + /// MotionGraph interface virtual224,82595930. + pub graph_scalar: f32, +} +fn bit(word: &mut u32, mask: u32, value: bool) { + *word = (*word & !mask) | if value { mask } else { 0 }; +} + +/// 82595930 arithmetic. +/// Inputs are PhysOut4->vector80, +/// PhysOut32->basis row32 and PhysOut4->byte273. None means absent Physical. +pub fn alignment(input: Option<([f32; 4], [f32; 4], u8)>) -> f32 { + let Some((velocity, basis, flipped)) = input else { + return 1.0; + }; + fn dot(a: [f32; 4], b: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) + } + let squared = dot(velocity, velocity); + if !(squared > f32::from_bits(0x3a83_126f)) { + return 1.0; + } + let length = super::controller::magnitude(squared); + let mut reciprocal = super::angle::reciprocal_estimate(length); + for _ in 0..2 { + reciprocal = reciprocal.mul_add((-reciprocal).mul_add(length, 1.0), reciprocal); + } + let velocity = velocity.map(|v| reciprocal * v); + let basis = if flipped != 0 { + basis.map(|v| -v) + } else { + basis + }; + dot(velocity, basis) +} + +/// Clock calls preserve the conditional first query and two subsequent timer +/// queries. The native context obtains each through its clock virtual40. +pub fn update( + state: &mut State, + latch: &mut [u32; 5], + input: Input, + settings: &Settings, + mut clock: impl FnMut() -> f32, +) { + let right = input.right_slide.unwrap_or(0.0); + let left = input.left_slide.unwrap_or(0.0); + let active = input.category == 1; + if active && state.flags & 0x2000_0000 == 0 { + state.elapsed = 0.0; + state.previous_right = right; + state.previous_left = left; + state.flags &= 0x3fff_ffff; + } + bit(&mut state.flags, 0x2000_0000, active); + if !active { + return; + } + let fast = input.speed > settings.minimum_speed; + let start_right = + fast && input.right_query && state.flags & 0x8000_0000 == 0 && state.previous_right < right; + bit(&mut latch[3], 0x8000_0000, start_right); + state.previous_right = right; + bit(&mut state.flags, 0x8000_0000, input.right_query); + let start_left = + fast && input.left_query && state.flags & 0x4000_0000 == 0 && state.previous_left > left; + bit(&mut latch[1], 0x8000_0000, start_left); + state.previous_left = left; + bit(&mut state.flags, 0x4000_0000, input.left_query); + let stop_time = settings.stop_time.evaluate(input.graph_scalar); + let response = settings.speed_response.evaluate(input.speed) * (1.0 - input.graph_scalar.abs()); + let threshold = settings.angle_response.evaluate(input.graph_scalar); + state.elapsed = if response < threshold { + state.elapsed + clock() + } else { + 0.0 + }; + let stop = state.elapsed > stop_time; + let right_time = clock() + f32::from_bits(latch[2]); + latch[2] = right_time.to_bits(); + let left_time = clock() + f32::from_bits(latch[0]); + latch[0] = left_time.to_bits(); + let exit_right = + right_time > settings.minimum_slide_time && (!input.right_slide.is_some() || !fast || stop); + let exit_left = + left_time > settings.minimum_slide_time && (!input.left_slide.is_some() || !fast || stop); + bit(&mut latch[3], 0x4000_0000, exit_right); + bit(&mut latch[1], 0x4000_0000, exit_left); +} diff --git a/crates/skate-core/src/input/riding_intentions.rs b/crates/skate-core/src/input/riding_intentions.rs new file mode 100644 index 00000000..33458465 --- /dev/null +++ b/crates/skate-core/src/input/riding_intentions.rs @@ -0,0 +1,139 @@ +//! Push, brake, body-spin, steering, kick-turn and crouch from TU3 Fill825999F0. +//! Trick/gesture, slide, manual and offboard branches remain separate work. +use super::{ + angle::left_stick_angle, + controller::{DerivedControllerInput, magnitude}, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct PushPreferences { + /// Profile component+52, byte58. The host owns profile storage/selection. + pub automatic_push_enabled: bool, + /// Profile component+56, byte156 selects the automatic push foot. + pub automatic_push_right: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RidingIntent { + pub name: &'static str, + pub value: f32, +} + +pub fn produce( + controller: &DerivedControllerInput, + actor_flags: u32, + preferences: PushPreferences, +) -> Vec { + let words = controller.words(); + let previous = words[6]; + let current = words[13]; + let left = [f32::from_bits(words[7]), f32::from_bits(words[8])]; + let allowed = actor_flags & (1 << 11) == 0; + let held = |bit: u32| current & (1u32 << bit) != 0 && allowed; + let new = |bit: u32, timer: usize| { + held(bit) && (previous & (1u32 << bit) == 0 || f32::from_bits(words[timer]) < 0.3) + }; + let mut right = held(21); + let mut left_push = held(23); + let mut new_right = new(21, 20); + let mut new_left = new(23, 22); + let angle = left_stick_angle(left[0], left[1]); + let from_forward = if angle >= 0.0 { + f32::from_bits(0x40490fdb) - angle + } else { + -f32::from_bits(0x40490fdb) - angle + }; + let length = magnitude(left[1].mul_add(left[1], left[0] * left[0])); + if preferences.automatic_push_enabled + && allowed + && !(right || left_push || new_right || new_left) + && from_forward.abs() < 0.6 + && length > 0.8 + { + if preferences.automatic_push_right { + right = true; + new_right = true; + } else { + left_push = true; + new_left = true; + } + } + if actor_flags & (1 << 6) != 0 && (right || new_right) && (left_push || new_left) { + right = false; + left_push = false; + new_right = false; + new_left = false; + } + let mut output = Vec::with_capacity(13); + let mut emit = |name, value| output.push(RidingIntent { name, value }); + if current & (1 << 20) != 0 && actor_flags & (1 << 7) == 0 { + emit("Brake", 1.0); + } + if new_right || new_left { + emit("NewPush", 1.0); + } + if right { + emit("RightPush", 1.0); + emit("Pushing", 1.0); + } + if left_push { + emit("LeftPush", 1.0); + emit("Pushing", 1.0); + } + //8259AC1C..AC54 emits this ordered pair from current left X. Actor1908 + //bit1 gates both; steering's bit0 and the physical state do not gate it. + //BodySpin's later animation-attribute handler owns its sign conversion. + if left[0] != 0.0 && actor_flags & (1 << 1) == 0 { + emit("BodySpin", left[0]); + emit("PhysBodySpin", left[0]); + } + //8259ACDC..ACE8 selects KickTurn from current left X, not shaped Turn. + if left[0] != 0.0 && actor_flags & 1 == 0 { + emit("KickTurn", left[0]); + } + let steering = super::steering_intentions::produce(left, actor_flags); + if let Some(value) = steering.hard_turn_crouch { + emit("HardTurnCrouch", value); + } + if let Some(value) = steering.hard_turn { + emit("HardTurn", value); + } + //8259A398..A424, emission key8259AE30..AE3C: the stick-based crouch ramp is + //multiplied by literal zero (f24). The two fsel instructions select the + //MAXIMUM of that result and the two trigger values, not a subtraction. + //An active gate can therefore intentionally emit Crouch with value zero. + let absolute = angle.abs(); + let start = f32::from_bits(0x3ff5_c28f); //1.92 + let end = f32::from_bits(0x4016_6666); //2.35 + let stick_crouch = if absolute <= start { + 0.0 + } else if absolute < end { + ((absolute - start) * f32::from_bits(0x4014_d655)) * length + } else { + length + } * 0.0; + let [left_trigger, right_trigger] = [words[11], words[12]].map(f32::from_bits); + if absolute > start || left_trigger > 0.0 || right_trigger != 0.0 { + let trigger = if left_trigger - right_trigger >= -0.0 { + left_trigger + } else { + right_trigger + }; + emit( + "Crouch", + if stick_crouch - trigger >= -0.0 { + stick_crouch + } else { + trigger + }, + ); + } + if let Some(value) = steering.turn { + emit("Turn", value); + } + output +} + +#[cfg(test)] +#[path = "riding_intentions_tests.rs"] +mod tests; diff --git a/crates/skate-core/src/input/riding_intentions_tests.rs b/crates/skate-core/src/input/riding_intentions_tests.rs new file mode 100644 index 00000000..170c93d8 --- /dev/null +++ b/crates/skate-core/src/input/riding_intentions_tests.rs @@ -0,0 +1,88 @@ +use super::*; +#[test] +fn crouch_preserves_zero_presence_and_uses_maximum_trigger() { + let mut words = [0u32; 26]; + words[8] = 1.0f32.to_bits(); + let values = produce( + &DerivedControllerInput::from_words(words), + 1, + PushPreferences::default(), + ); + assert_eq!( + values.iter().find(|i| i.name == "Crouch").unwrap().value, + 0.0 + ); + words[11] = 0.4f32.to_bits(); + words[12] = 0.7f32.to_bits(); + words[7] = 0.5f32.to_bits(); + let values = produce( + &DerivedControllerInput::from_words(words), + 0, + PushPreferences::default(), + ); + assert_eq!( + values.iter().find(|i| i.name == "Crouch").unwrap().value, + 0.7 + ); + assert_eq!( + values.iter().find(|i| i.name == "KickTurn").unwrap().value, + 0.5 + ); + assert_ne!(values.iter().find(|i| i.name == "Turn").unwrap().value, 0.5); +} +fn snapshot(previous: u32, current: u32, held: f32) -> DerivedControllerInput { + let mut words = [0; 26]; + words[6] = previous; + words[13] = current; + words[20] = held.to_bits(); + words[22] = held.to_bits(); + DerivedControllerInput::from_words(words) +} +#[test] +fn held_push_continues_after_new_push_allowance_expires() { + let button = 1 << 21; + let first = produce(&snapshot(0, button, 0.5), 0, PushPreferences::default()); + assert!(first.iter().any(|i| i.name == "NewPush")); + let held = produce( + &snapshot(button, button, 0.5), + 0, + PushPreferences::default(), + ); + assert_eq!( + held.iter().map(|i| i.name).collect::>(), + ["RightPush", "Pushing"] + ); + assert!( + produce( + &snapshot(0, button, 0.0), + 1 << 11, + PushPreferences::default() + ) + .is_empty() + ); +} +#[test] +fn dual_push_gate_requires_both_feet_and_brake_uses_actor_mode() { + assert!( + !produce( + &snapshot(0, 1 << 21, 0.0), + 1 << 6, + PushPreferences::default() + ) + .is_empty() + ); + assert!( + produce( + &snapshot(0, (1 << 21) | (1 << 23), 0.0), + 1 << 6, + PushPreferences::default() + ) + .is_empty() + ); + let brake = snapshot(0, 1 << 20, 0.0); + assert_eq!( + produce(&brake, 0, PushPreferences::default())[0].name, + "Brake" + ); + assert!(produce(&brake, 1 << 7, PushPreferences::default()).is_empty()); +} diff --git a/crates/skate-core/src/input/set_turning.rs b/crates/skate-core/src/input/set_turning.rs new file mode 100644 index 00000000..961378de --- /dev/null +++ b/crates/skate-core/src/input/set_turning.rs @@ -0,0 +1,233 @@ +//! SetTurning enter 82BB3810 and update 82BB3830 at resolved component boundaries. +//! This is a reusable node operation; the loaded graph owns its activation. +use super::turn_remap::TurnRemap; +use crate::point_graph::PointGraph; + +/// One retained native slide record (SpecificMotionGraph3204..3220). +/// Both SetTurning and authored slide handlers read and mutate this owner. +/// Reserved bits stay intact; reset82595450..78 only clears the defined bits. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SlideLatch { + words: [u32; 5], +} +impl SlideLatch { + pub fn captured_fakie(&self) -> bool { + self.words[4] & 0x8000_0000 != 0 + } + pub fn candidate_enabled(&self) -> bool { + self.words[4] & 0x4000_0000 != 0 + } + pub fn set_candidate_enabled(&mut self, enabled: bool) { + self.words[4] = (self.words[4] & !0x4000_0000) | ((enabled as u32) << 30); + } + pub fn elapsed(&self, right: bool) -> f32 { + f32::from_bits(self.words[if right { 2 } else { 0 }]) + } + pub fn start(&self, right: bool) -> bool { + self.words[if right { 3 } else { 1 }] & 0x8000_0000 != 0 + } + pub fn end(&self, right: bool) -> bool { + self.words[if right { 3 } else { 1 }] & 0x4000_0000 != 0 + } + pub fn set_start(&mut self, right: bool, value: bool) { + let word = &mut self.words[if right { 3 } else { 1 }]; + *word = (*word & !0x8000_0000) | ((value as u32) << 31); + } + pub fn set_end(&mut self, right: bool, value: bool) { + let word = &mut self.words[if right { 3 } else { 1 }]; + *word = (*word & !0x4000_0000) | ((value as u32) << 30); + } + pub fn advance_elapsed(&mut self, right: bool, dt: f32) { + let word = &mut self.words[if right { 2 } else { 0 }]; + *word = (dt + f32::from_bits(*word)).to_bits(); + } + /// CreateSlide Begin82BB2E88; do not clear start/end/grab/candidate flags. + pub fn begin_slide(&mut self, fakie: bool) { + self.words[0] = 0; + self.words[2] = 0; + self.words[4] = (self.words[4] & !0x8000_0000) | ((fakie as u32) << 31); + } + /// GrabSlide82BBB848 maps authored side through the captured stance. + pub fn grab(&mut self, authored_right: bool) { + let right = authored_right ^ self.captured_fakie(); + self.words[if right { 3 } else { 1 }] |= 0x2000_0000; + } + /// ShouldLeaveSlide82BA7130 uses captured, not current, stance. + pub fn should_leave(&self, authored_right: bool) -> bool { + self.end(authored_right ^ self.captured_fakie()) + } + pub fn reset(&mut self) { + self.words[0] = 0; + self.words[2] = 0; + self.words[1] &= 0x1fff_ffff; + self.words[3] &= 0x1fff_ffff; + self.words[4] &= 0x3fff_ffff; + } +} + +#[derive(Clone, Copy, Debug)] +pub struct State { + pub elapsed: f32, + pub smoothed: f32, + pub mode: u32, +} +impl State { + /// Native enter and allocator 82BB4138 use the same three initial values. + pub fn enter(&mut self) { + self.elapsed = 0.0; + self.smoothed = 0.0; + self.mode = 2; + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// Native helper mode 0 and mode 1, in that order. + pub remaps: [TurnRemap; 2], + pub speed_tuck: PointGraph<8>, + pub blend: PointGraph<8>, + pub speed_threshold: f32, + pub maximum_delta: f32, + /// Global collection slot 332, layout 1680. + pub override_turn: f32, +} + +/// Resolved physical output bundle+56 and bundle+4 inputs. Offset names avoid +/// assigning unverified meanings to upstream physical producer fields. +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub field_32: f32, + pub field_36: f32, + pub field_52: f32, + pub field_56: f32, + pub field_60: f32, + pub body_168: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct Intents { + pub fakie_turn: Option, + /// Native keys 830C067C and 830C05F4, respectively. + pub mode_0_slide: Option, + pub mode_1_slide: Option, +} + +/// Skeleton selectors bind to the decoded node parameter keys. Packet Turn and +/// Slide are native fixed attribute keys. Emissions occur in native call order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Attribute { + Angle, + Direction, + Quickness, + Speed, + Holding, + Turn, + Slide, +} + +/// `latch` preserves all five words returned by motion graph virtual+64. +/// `stance` is the component's virtual+12 and virtual+28 result pair. +/// `dt` is the graph context clock virtual+40 result, not a guessed fixed tick. +pub fn update( + state: &mut State, + latch: &mut SlideLatch, + physical: Physical, + stance: (bool, bool), + dt: f32, + settings: &Settings, + intents: Intents, + mut emit: impl FnMut(Attribute, f32), +) { + let latch = &mut latch.words; + let a = settings.remaps[0].apply([physical.field_32, physical.field_56]); + let _ = settings.remaps[0].apply([physical.field_36, physical.field_56]); + let b = settings.remaps[1].apply([physical.field_32, physical.field_56]); + let _ = settings.remaps[1].apply([physical.field_36, physical.field_56]); + let complement = 1.0 - physical.field_52; + let mut target = -a[0].mul_add(physical.field_52, b[0] * complement); + let direction = -a[1].mul_add(physical.field_52, b[1] * complement); + let old_0 = latch[1]; + let old_1 = latch[3]; + latch[1] &= !0x2000_0000; + latch[3] &= !0x2000_0000; + let prior_0 = old_0 & 0x2000_0000 != 0; + let prior_1 = old_1 & 0x2000_0000 != 0; + let enabled = latch[4] & 0x4000_0000 != 0; + let mut mode = state.mode; + if mode == 2 { + let first = enabled && (latch[3] & 0x8000_0000 != 0 || prior_1); + let second = enabled && (latch[1] & 0x8000_0000 != 0 || prior_0); + if first || second { + if !prior_0 && !prior_1 { + latch[0] = 0; + latch[2] = 0; + latch[4] = (latch[4] & 0x7fff_ffff) | ((stance.0 as u32) << 31); + } + mode = if first { 1 } else { 0 }; + } + } else if (mode == 1 && (!enabled || latch[3] & 0x4000_0000 != 0)) + || (mode == 0 && (!enabled || latch[1] & 0x4000_0000 != 0)) + { + mode = 2; + } + if mode <= 1 { + target = if (mode == 1) ^ stance.1 { 1.0 } else { -1.0 }; + if latch[4] & 0x8000_0000 != 0 { + target = -target; + } + } + if mode != state.mode { + state.elapsed = 0.0; + } + state.mode = mode; + let blend = settings.blend.evaluate(state.elapsed); + let lower = state.smoothed - settings.maximum_delta; + let upper = state.smoothed + settings.maximum_delta; + let value = (1.0 - blend).mul_add(state.smoothed, blend * target); + let value = if lower - value >= 0.0 { lower } else { value }; + state.elapsed += dt; + state.smoothed = if upper - value >= 0.0 { value } else { upper }; + let speed = physical.body_168.abs(); + let tuck = if speed > settings.speed_threshold { + settings + .speed_tuck + .evaluate(speed - settings.speed_threshold) + } else { + 0.0 + }; + emit(Attribute::Angle, state.smoothed); + emit(Attribute::Direction, direction); + emit(Attribute::Quickness, physical.field_52); + emit(Attribute::Speed, tuck); + emit(Attribute::Holding, physical.field_60); + if mode <= 1 { + let flip = latch[4] & 0x8000_0000 != 0; + let turn = if (mode == 1) ^ flip { + settings.override_turn + } else { + -settings.override_turn + }; + emit(Attribute::Turn, turn); + let slide = if mode == 0 { + intents.mode_0_slide + } else { + intents.mode_1_slide + } + .unwrap_or(0.0); + let slide = if flip { + let negative = -slide; + let absolute = negative.abs(); + let value = if absolute > 0.5 { + 1.0 - absolute + } else { + absolute + }; + if negative >= 0.0 { value } else { -value } + } else { + slide + }; + emit(Attribute::Slide, slide); + } else if let Some(value) = intents.fakie_turn { + emit(Attribute::Turn, value); + } +} diff --git a/crates/skate-core/src/input/steering_intentions.rs b/crates/skate-core/src/input/steering_intentions.rs new file mode 100644 index 00000000..ac8f3cb4 --- /dev/null +++ b/crates/skate-core/src/input/steering_intentions.rs @@ -0,0 +1,49 @@ +//! Complete local Turn/HardTurn/HardTurnCrouch emission dataflow in 825999F0. +//! Other intention outputs and the subsequent action/motion graphs are separate. +use super::{angle::left_stick_angle, controller::magnitude}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SteeringIntentions { + /// Absence is retained: native does not emit a zero Turn intention. + pub turn: Option, + pub hard_turn: Option, + pub hard_turn_crouch: Option, +} + +/// Consumes the current record after 825992D8 and actor+1908 captured at +/// 82599A38. No history field affects these three local emission sites. +pub fn produce(left: [f32; 2], actor_flags_1908: u32) -> SteeringIntentions { + let [x, y] = left; + let angle = left_stick_angle(x, y); + let absolute = angle.abs(); + let length = if x == 0.0 && y == 0.0 { + 0.0 + } else { + magnitude(y.mul_add(y, x * x)) + }; + let signed_length = if x >= 0.0 { length } else { -length }; + let turn = if y > 0.0 { + if absolute > f32::from_bits(0x40433333) { + 0.0 + } else if absolute > f32::from_bits(0x40166666) { + ((f32::from_bits(0x40433333) - absolute) * f32::from_bits(0x3fb6db6d)) * signed_length + } else { + signed_length + } + } else { + x + }; + let active = length > f32::from_bits(0x3f666666) + && !(absolute > f32::from_bits(0x3fb1eb85)) + && !(absolute < f32::from_bits(0x3f68f5c3)); + let hard = (absolute - f32::from_bits(0x3fb1eb85)) * f32::from_bits(0xc0055556); + let minimum = f32::from_bits(0x3a83126f); + let hard = if hard - minimum >= 0.0 { hard } else { minimum }; + let hard = if angle >= 0.0 { hard } else { -hard }; + let allowed = actor_flags_1908 & 1 == 0; + SteeringIntentions { + turn: (turn != 0.0 && allowed).then_some(turn), + hard_turn: (active && allowed).then_some(hard), + hard_turn_crouch: (active && allowed).then_some((hard * f32::from_bits(0x3f59999a)).abs()), + } +} diff --git a/crates/skate-core/src/input/turn_conditioner.rs b/crates/skate-core/src/input/turn_conditioner.rs new file mode 100644 index 00000000..e2989baf --- /dev/null +++ b/crates/skate-core/src/input/turn_conditioner.rs @@ -0,0 +1,144 @@ +//! Complete steering conditioner 82DEFF38, called by82DEFB30. +//! The settings decoder supplies global830CFDA4 slot204's verified layout. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct Settings { + /// Layout688/704/720, corresponding to conditioner216/180/144. + pub filter_coefficients: [[f32; 4]; 3], + /// Layout x304/y336, x240/y272, x32/y64, x384/y400. + pub input_curve: PointGraph<8>, + pub quickness_curve: PointGraph<8>, + pub speed_curve: PointGraph<8>, + pub smoothing_curve: PointGraph<4>, + /// Layout768,868,872,896,900,904,908,912,924,928,932,936,940. + pub parameters: [f32; 13], +} + +#[derive(Clone, Copy, Debug)] +pub struct State { + /// Conditioner112..140, in native order. + pub history: [f32; 8], + /// Complete9-word filters at216,180,144; first4 words refresh every call. + pub filters: [[f32; 9]; 3], +} + +impl State { + /// Steering-history portion of native reset82DEFAA0. Cached coefficients + /// survive reset; the update refreshes them from the settings layout. + pub fn reset_history(&mut self) { + self.history.fill(0.0); + for filter in &mut self.filters { + filter[4..].fill(0.0); + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub body_160: f32, + pub body_176: f32, + pub bundle_36_field_160: f32, + pub bundle_32_field_264: f32, + pub animation_152: u8, + pub animation_156: u8, +} + +/// PhysOut_Animation32/36/40/44/48/52/56/60, in native memory order. +pub type Output = [f32; 8]; + +fn clamp(value: f32, low: f32, high: f32) -> f32 { + let value = if low - value >= 0.0 { low } else { value }; + if high - value >= 0.0 { value } else { high } +} + +fn filter(state: &mut [f32; 9], coefficients: [f32; 4], input: f32) -> f32 { + state[..4].copy_from_slice(&coefficients); + let difference = input - state[4]; + let input_difference = input - state[5]; + let complement = 1.0 - state[3]; + state[5] = input; + state[8] = input_difference.mul_add(state[3], state[8] * complement); + state[6] = difference.mul_add(state[3], complement * state[6]); + let term = (state[8] - state[7]).mul_add(state[2], state[1] * state[6]); + let term = difference.mul_add(state[0], term); + let result = term + state[4]; + state[7] = result - state[4]; + state[4] = result; + result +} + +pub fn update(state: &mut State, input: Input, settings: &Settings) -> Output { + let p = settings.parameters; + let sign = if input.animation_156 != 0 { -1.0 } else { 1.0 }; + let scaled = input.bundle_32_field_264 * sign; + let speed_fraction = clamp(input.body_160 / p[11], 0.0, 1.0); + let other_fraction = clamp((input.bundle_36_field_160 / p[10]) * sign, -1.0, 1.0); + let base = settings.input_curve.evaluate(speed_fraction) * scaled; + let attenuated = (-(1.0 - p[8])).mul_add(input.body_176, 1.0) * other_fraction; + let target = attenuated.mul_add(p[9], (1.0 - p[9]) * base); + let first = filter( + &mut state.filters[0], + settings.filter_coefficients[0], + target, + ); + let epsilon = f32::from_bits(0x3c23_d70a); + let first = if first.abs() > epsilon { first } else { 0.0 }; + let second = filter( + &mut state.filters[1], + settings.filter_coefficients[1], + target, + ); + let second = if second.abs() > epsilon { second } else { 0.0 }; + let third = filter( + &mut state.filters[2], + settings.filter_coefficients[2], + (target - second) / p[12], + ); + let movement = if input.animation_152 != 0 { + scaled + } else { + 0.0 + }; + let difference = movement - state.history[0]; + state.history[0] = movement; + state.history[2] = difference + .abs() + .mul_add(p[2], (1.0 - p[2]) * state.history[2]); + let ratio = state.history[2] / p[1]; + let squared = ratio * ratio; + let bounded = if squared - 1.0 >= 0.0 { 1.0 } else { squared }; + state.history[3] = clamp(bounded, state.history[3] - p[0], state.history[3] + p[0]); + let quick = settings.quickness_curve.evaluate(state.history[3]); + let quick = clamp(quick, 0.0, 1.0); + let quick = settings.speed_curve.evaluate(input.body_160) * quick; + let movement = if input.animation_152 != 0 { + second + } else { + 0.0 + }; + let difference = movement - state.history[1]; + state.history[1] = movement; + state.history[5] = difference + .abs() + .mul_add(p[5], (1.0 - p[5]) * state.history[5]); + let hold: f32 = if state.history[5] < p[3] { 1.0 } else { 0.0 }; + let alpha = (1.0 - hold).mul_add(p[7], p[6] * hold); + let next = (1.0 - alpha).mul_add(state.history[4], alpha * hold); + let difference = next - state.history[4]; + state.history[6] = clamp(difference, state.history[6] - p[4], state.history[6] + p[4]); + state.history[4] = clamp(state.history[4] + state.history[6], 0.0, 1.0); + let alpha = settings.smoothing_curve.evaluate(input.body_176); + state.history[7] = (1.0 - alpha).mul_add(state.history[7], alpha * first); + let turn = clamp(state.history[7], -1.0, 1.0); + [ + turn, + clamp(target, -1.0, 1.0), + base, + turn * sign, + base * sign, + quick, + clamp(third, -1.0, 1.0), + clamp(state.history[4], 0.0, 1.0), + ] +} diff --git a/crates/skate-core/src/input/turn_remap.rs b/crates/skate-core/src/input/turn_remap.rs new file mode 100644 index 00000000..2ba56d4a --- /dev/null +++ b/crates/skate-core/src/input/turn_remap.rs @@ -0,0 +1,104 @@ +//! Complete TU3 SetTurning vector remap, 82BB4248. +//! Curves and offsets are supplied from the native animation_turning layout. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct TurnRemap { + pub magnitude: PointGraph<16>, + pub angle: PointGraph<16>, + pub angle_offset: f32, +} + +fn clamp(value: f32) -> f32 { + let lower = if -1.0 - value >= 0.0 { -1.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} + +fn angle(x: f32, y: f32, magnitude: f32) -> f32 { + if !(magnitude > f32::from_bits(0x3780_0000)) { + return 0.0; + } + let reciprocal = super::angle::reciprocal_estimate(x); + let reciprocal = reciprocal.mul_add((-reciprocal).mul_add(x, 1.0), reciprocal); + let basic = super::angle::atan(y.mul_add(reciprocal, 0.0)); + let sign = y.to_bits() & 0x8000_0000; + let result = if x < 0.0 { + f32::from_bits(0x4049_0fdb | sign) + basic + } else { + basic + }; + if x == 0.0 { + f32::from_bits(0x3fc9_0fdb | sign) + } else { + result + } +} + +// Scalar lane of standalone cosine 82473930; its power tree differs from SinCos. +fn cosine(angle: f32) -> f32 { + let turns = (angle * f32::from_bits(0x3e22_f983)).round_ties_even(); + let x = (-f32::from_bits(0x40c9_0fdb)).mul_add(turns, angle); + let x2 = x * x; + let x4 = x2 * x2; + let x6 = x4 * x2; + let x8 = x4 * x4; + let x10 = x6 * x4; + let x12 = x6 * x6; + let x14 = x8 * x6; + let x16 = x8 * x8; + let x18 = x10 * x8; + let x22 = x12 * x10; + let x20 = x10 * x10; + let mut value = (-0.5_f32).mul_add(x2, 1.0); + for (power, coefficient) in [ + (x4, 0x3d2a_aaab), + (x6, 0xbab6_0b61), + (x8, 0x37d0_0d01), + (x10, 0xb493_f27e), + (x12, 0x310f_76c8), + (x14, 0xad49_cba5), + (x16, 0x2957_3f9f), + (x18, 0xa534_13c3), + (x20, 0x20f2_a15d), + (x22, 0x9c86_71cb), + ] { + value = f32::from_bits(coefficient).mul_add(power, value); + } + value +} + +impl TurnRemap { + pub fn apply(&self, input: [f32; 2]) -> [f32; 4] { + let pi = f32::from_bits(0x4049_0fdb); + let magnitude = super::controller::magnitude(input[0] * input[0] + input[1] * input[1]); + let first_angle = angle(input[0], input[1], magnitude); + let graph_input = if first_angle >= 0.0 { + first_angle + } else { + first_angle + pi + }; + let scaled = magnitude * self.magnitude.evaluate(graph_input); + let x = clamp(cosine(first_angle) * scaled); + let y = clamp(crate::trigonometry::sin(first_angle) * scaled); + let magnitude = super::controller::magnitude(x * x + y * y); + let second_angle = angle(x, y, magnitude); + let graph_input = if second_angle >= 0.0 { + second_angle + } else { + second_angle + pi + }; + let mapped = self.angle.evaluate(graph_input); + let mapped = if second_angle >= 0.0 { + mapped + } else { + mapped + pi + }; + let result_angle = self.angle_offset + mapped; + [ + clamp(cosine(result_angle) * magnitude), + clamp(crate::trigonometry::sin(result_angle) * magnitude), + 0.0, + 0.0, + ] + } +} diff --git a/crates/skate-core/src/input/wipeout_intentions.rs b/crates/skate-core/src/input/wipeout_intentions.rs new file mode 100644 index 00000000..01627e1c --- /dev/null +++ b/crates/skate-core/src/input/wipeout_intentions.rs @@ -0,0 +1,51 @@ +//! Wipeout intention helper8259BC28, called by Fill825999F0 at8259AA6C. +//! Uses the existing evaluated stock action map and completed output flags. +use super::{controller::DerivedControllerInput, riding_intentions::RidingIntent}; + +pub fn produce( + controller: &DerivedControllerInput, + actor_flags_1908: u32, + physical_flags_204: u32, +) -> Vec { + let words = controller.words(); + let old_buttons = words[6]; + let buttons = words[13]; + let axis = |slot| f32::from_bits(words[slot]); + let rising = |bit: u32| old_buttons & (1u32 << bit) == 0u32 && buttons & (1u32 << bit) != 0u32; + let trigger_edge = |previous, current| axis(previous) != 1.0 && axis(current) == 1.0; + let mut output = Vec::with_capacity(7); + let mut emit = |name, value| output.push(RidingIntent { name, value }); + + //8259BC7C..BCEC: both names always exist, including disabled/centered zeros. + let gesture = physical_flags_204 & 0x80 != 0; + emit("WipeoutGestureX", if gesture { axis(9) } else { 0.0 }); + emit("WipeoutGestureY", if gesture { axis(10) } else { 0.0 }); + let control = physical_flags_204 & 0x100 != 0; + let x = if control { axis(7) } else { 0.0 }; + let y = if control { axis(8) } else { 0.0 }; + if x != 0.0 { + emit("WipeoutControlX", x); + } + if y != 0.0 { + emit("WipeoutControlY", y); + } + //8259BD28..BDBC: raw rising edges, not push repeat timers or held buttons. + if rising(21) || rising(23) { + emit("WipeOutRecover", 1.0); + } + //8259BDCC..BEF0: exact full trigger endpoints, both stick buttons, and + //a fresh edge from any of the four; actor disablebit6 gates this request. + if actor_flags_1908 & 0x40 == 0 + && axis(11) == 1.0 + && axis(12) == 1.0 + && buttons & 0xC000_0000 == 0xC000_0000 + && (trigger_edge(4, 11) || trigger_edge(5, 12) || rising(31) || rising(30)) + { + emit("WipeOutRequest", 1.0); + } + //8259BEFC..BF3C: only the second trigger's new full endpoint. + if physical_flags_204 & 0x40 != 0 && trigger_edge(5, 12) { + emit("WipeOutPushOff", 1.0); + } + output +} diff --git a/crates/skate-core/src/input/xbox.rs b/crates/skate-core/src/input/xbox.rs new file mode 100644 index 00000000..1e6c946f --- /dev/null +++ b/crates/skate-core/src/input/xbox.rs @@ -0,0 +1,70 @@ +//! Complete TU3 Xbox pad conversion 8296D5F8, after successful platform polling. +use super::controller::magnitude; + +/// Fields of the native Xbox state passed by value to 8296D5F8. +pub struct XboxState { + pub buttons: u16, + pub triggers: [u8; 2], + pub left: [i16; 2], + pub right: [i16; 2], +} + +/// Returns all 24 native button values. `device_byte_13` is explicit because +/// its producer/meaning is not inferred from the conversion's four clear stores. +pub fn convert(state: &XboxState, device_byte_13: u8) -> [f32; 24] { + let mut result = [0.0; 24]; + for (bit, output) in result[..10].iter_mut().enumerate() { + *output = if state.buttons & (1 << bit) != 0 { + 1.0 + } else { + 0.0 + }; + } + result[10] = f32::from(state.triggers[0]) * f32::from_bits(0x3b808081); + result[11] = f32::from(state.triggers[1]) * f32::from_bits(0x3b808081); + for (slot, bit) in [(12, 12), (13, 13), (14, 14), (15, 15)] { + result[slot] = if state.buttons & (1 << bit) != 0 { + 1.0 + } else { + 0.0 + }; + } + let left = condition(state.left); + let right = condition(state.right); + result[16..20].copy_from_slice(&left); + result[20..24].copy_from_slice(&right); + if device_byte_13 != 0 { + for slot in [10, 11, 21, 22] { + result[slot] = 0.0; + } + } + result +} + +fn condition(stick: [i16; 2]) -> [f32; 4] { + let x = f32::from(stick[0]) * f32::from_bits(0x38000000); + let y = f32::from(stick[1]) * f32::from_bits(0x38000000); + let squared = y.mul_add(y, x * x); + let length = magnitude(squared); + let factor = if length < f32::from_bits(0x3a83126f) { + 0.0 + } else { + let scaled = (length - 0.25) * f32::from_bits(0x3fb6db6e); + let bounded = if scaled < 0.0 { + 0.0 + } else if scaled > 1.0 { + 1.0 + } else { + scaled + }; + bounded / length + }; + let x = factor * x; + let y = factor * y; + [positive(x), positive(-x), positive(y), positive(-y)] +} + +fn positive(value: f32) -> f32 { + // fsel(-value, +0, value), including the native signed-zero selection. + if -value >= 0.0 { 0.0 } else { value } +} diff --git a/crates/skate-core/src/lib.rs b/crates/skate-core/src/lib.rs new file mode 100644 index 00000000..d87cc88d --- /dev/null +++ b/crates/skate-core/src/lib.rs @@ -0,0 +1,17 @@ +//! Engine-independent recovered calculations. No rendering, file I/O, or ECS. +//! The game schedules these systems; full gameplay parity remains under review. +//! Arithmetic uses independent Rust implementations. Original-hardware numerical +//! agreement is unverified. See docs/correction for current source coverage, +//! confirmed integration gaps and the evidence still required. +#![forbid(unsafe_code)] +pub mod air; +pub mod animation; +pub mod camera; +pub mod graph; +pub mod input; +pub mod math; +pub mod physics; +pub mod player; +pub mod point_graph; +pub mod riding; +pub mod trigonometry; diff --git a/crates/skate-core/src/math.rs b/crates/skate-core/src/math.rs new file mode 100644 index 00000000..c00f5838 --- /dev/null +++ b/crates/skate-core/src/math.rs @@ -0,0 +1,26 @@ +//! Shared scalar types. Rendering conversions stay outside the core. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Vector3 { + pub x: f32, + pub y: f32, + pub z: f32, +} + +impl Vector3 { + pub const ZERO: Self = Self::new(0.0, 0.0, 0.0); + + pub const fn new(x: f32, y: f32, z: f32) -> Self { + Self { x, y, z } + } +} + +impl Default for Vector3 { + fn default() -> Self { + Self::ZERO + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Basis3 { + pub columns: [[f32; 3]; 3], +} diff --git a/crates/skate-core/src/physics/assembly.rs b/crates/skate-core/src/physics/assembly.rs new file mode 100644 index 00000000..c6a13443 --- /dev/null +++ b/crates/skate-core/src/physics/assembly.rs @@ -0,0 +1,204 @@ +//! Board constraint construction from physical body snapshots. +//! +//! The default TU3 assembly has six joints, two truck drives and a hook drive. Wheel drives +//! are an alternate configuration, disabled in the recovered stock assembly. +//! World collision and gameplay force production do not belong in this module. +use super::{ + board::{BODY_COUNT, BodyId}, + constraint_batch::compile_active, + drive_frames::{RetailAffineTransform, RetailDriveFrames}, + drive_parameters::RetailDriveDynamics, + drive_solver::{RetailDriveBodyState, RetailDriveRows, build_drive_rows}, + joint_builder::{RetailJointBodyInput, RetailJointBuildInput, build_retail_joint_jacobian}, + joint_records::default_joint_records, + rigid_body::{RetailBodyRates, RetailInertiaDynamics, pack_world_inverse_inertia}, + solver::JointConstraint, + truck_frames::steering_drive_frames, +}; + +#[derive(Clone, Copy, Debug)] +pub struct BodySnapshot { + pub state_flags: u32, + pub rates: RetailBodyRates, + pub inertia: RetailInertiaDynamics, +} + +/// Separate animated target body registered by CreateHookDrive 82C0D330. +/// It is not one of the seven physical board parts and must not be substituted +/// for the deck. Startup supplies its native body state explicitly. +#[derive(Clone, Debug)] +pub struct BoardHook { + pub body: BodySnapshot, + pub drive: super::hook_drive::HookDriveState, +} + +pub const HOOK_REACTION: usize = BODY_COUNT; + +/// The caller owns the snapshots, force accumulators and tick timing. No pose +/// is adjusted here to force the board onto a plane or into an animation. +pub struct BoardConstraints { + /// Eligible rows only; retained in native registration order. + pub joints: Vec, + /// Front truck, back truck, then hook; native creation/insertion order. + pub drives: Vec, +} + +impl BoardConstraints { + pub fn build( + bodies: &[BodySnapshot; BODY_COUNT], + hook: &BoardHook, + frames: [RetailDriveFrames; 3], + truck_dynamics: RetailDriveDynamics, + delta_seconds: f32, + ) -> Self { + let joints = compile_active( + default_joint_records(), + |record| { + [ + bodies[record.live_body_a().index()].state_flags, + bodies[record.live_body_b().index()].state_flags, + ] + }, + |record| { + let a = record.live_body_a(); + let b = record.live_body_b(); + JointConstraint { + jacobian: build_retail_joint_jacobian(RetailJointBuildInput { + parameters: record.parameters, + frames: record.frames, + body_a: joint_body(bodies[a.index()]), + body_b: joint_body(bodies[b.index()]), + time_step: delta_seconds, + // Guest pointer metadata is not dereferenced by the host solver. + joint_guest_address: 0, + }), + reaction_a: a.index(), + reaction_b: b.index(), + } + }, + ); + let mut drives = compile_active( + 0..2, + |&index| { + let truck = [BodyId::FrontTruck, BodyId::BackTruck][index]; + [ + bodies[truck.index()].state_flags, + bodies[BodyId::Deck.index()].state_flags, + ] + }, + |index| { + let truck = [BodyId::FrontTruck, BodyId::BackTruck][index]; + build_drive_rows( + drive_body(bodies[truck.index()], truck.index()), + drive_body(bodies[BodyId::Deck.index()], BodyId::Deck.index()), + frames[index], + truck_dynamics, + delta_seconds, + ) + }, + ); + drives.extend(compile_active( + [()], + |_| { + [ + hook.body.state_flags, + bodies[BodyId::Deck.index()].state_flags, + ] + }, + |_| { + build_drive_rows( + drive_body(hook.body, HOOK_REACTION), + drive_body(bodies[BodyId::Deck.index()], BodyId::Deck.index()), + frames[2], + hook.drive.solver_dynamics(), + delta_seconds, + ) + }, + )); + Self { joints, drives } + } +} + +/// SetTruckDriveFrames 82C0B9C0 followed by the active-drive quaternion +/// preparation in Island::Step_Solver2 82763B08. Hook normalization is a live +/// state write; it is not deferred to the animation frame setters. +pub fn prepare_drive_frames( + base: [RetailAffineTransform; 2], + targets: [f32; 2], + hook: &mut BoardHook, +) -> [RetailDriveFrames; 3] { + use super::drive_preparation::normalize_drive_frames; + let trucks = steering_drive_frames(base, targets).map(|frames| { + let mut raw = [0u32; 16]; + for (i, frame) in [frames.body_a, frames.body_b].into_iter().enumerate() { + let q = frame.orientation; + let t = frame.translation; + raw[i * 8..i * 8 + 8] + .copy_from_slice(&[q.x, q.y, q.z, q.w, t.x, t.y, t.z, 0.0].map(f32::to_bits)); + } + normalize_drive_frames(&mut raw); + hook_frames(&raw) + }); + normalize_drive_frames(&mut hook.drive.frames); + [trucks[0], trucks[1], hook_frames(&hook.drive.frames)] +} + +fn hook_frames(words: &[u32; 16]) -> super::drive_frames::RetailDriveFrames { + use super::{ + drive_frames::{RetailDriveFrame, RetailDriveFrames}, + rigid_body::RetailQuaternion, + }; + use crate::math::Vector3; + let f = |i| f32::from_bits(words[i]); + let frame = |offset: usize| RetailDriveFrame { + orientation: RetailQuaternion { + x: f(offset), + y: f(offset + 1), + z: f(offset + 2), + w: f(offset + 3), + }, + translation: Vector3::new(f(offset + 4), f(offset + 5), f(offset + 6)), + }; + RetailDriveFrames { + body_a: frame(0), + body_b: frame(8), + } +} + +fn joint_body(body: BodySnapshot) -> RetailJointBodyInput { + let rates = body.rates; + RetailJointBodyInput { + reaction_guest_address: 0, + state: body.state_flags, + orientation: rates.orientation, + center_of_mass: rates.position, + basis: rates.basis, + linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + inverse_mass: body.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(rates.world_inverse_inertia), + } +} + +fn drive_body(body: BodySnapshot, reaction_index: usize) -> RetailDriveBodyState { + let rates = body.rates; + RetailDriveBodyState { + reaction_index, + state: body.state_flags, + orientation: rates.orientation, + basis: rates.basis, + center_of_mass: rates.position, + linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + inverse_mass: body.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(rates.world_inverse_inertia), + } +} + +#[cfg(test)] +#[path = "tests/assembly.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board.rs b/crates/skate-core/src/physics/board.rs new file mode 100644 index 00000000..dee0f7b6 --- /dev/null +++ b/crates/skate-core/src/physics/board.rs @@ -0,0 +1,35 @@ +//! Physical body indices and stock geometry used by the TU3 board assembly. +pub const BODY_COUNT: usize = 7; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum BodyId { + RightFrontWheel = 0, + LeftFrontWheel = 1, + RightBackWheel = 2, + LeftBackWheel = 3, + FrontTruck = 4, + BackTruck = 5, + Deck = 6, +} +impl BodyId { + pub const ORDER: [Self; BODY_COUNT] = [ + Self::RightFrontWheel, + Self::LeftFrontWheel, + Self::RightBackWheel, + Self::LeftBackWheel, + Self::FrontTruck, + Self::BackTruck, + Self::Deck, + ]; + + pub const fn index(self) -> usize { + self as usize + } +} + +pub const RETAIL_WHEEL_RADIUS: f32 = f32::from_bits(0x3CFD_F3B6); +pub const RETAIL_TRUCK_Y_POSITION: f32 = f32::from_bits(0xBD67_6C8B); +pub const RETAIL_TRUCK_Z_POSITION_BACK: f32 = f32::from_bits(0xBD54_FDF4); +pub const RETAIL_TRUCK_Z_POSITION_FRONT: f32 = f32::from_bits(0xBD54_FDF4); +pub const RETAIL_TRUCK_ROTATION_AXIS_ANGLE_DEGREES: f32 = f32::from_bits(0x41F8_0000); +pub const RETAIL_DECK_MID_LENGTH: f32 = f32::from_bits(0x3F17_0A3D); diff --git a/crates/skate-core/src/physics/board_animation.rs b/crates/skate-core/src/physics/board_animation.rs new file mode 100644 index 00000000..6e17df81 --- /dev/null +++ b/crates/skate-core/src/physics/board_animation.rs @@ -0,0 +1,123 @@ +//! Original TU3 board error capture82C04368 and animation blend82C01878. +//! PC arithmetic and the recovered refinements are used; this is not a claim +//! of instruction-identical Xenon estimates or exceptional-lane execution. +use super::{ + constraint_frames::{basis, reciprocal, reciprocal_sqrt}, + drive_frames::retail_quaternion_from_basis, + native_arithmetic::dot4, + rigid_body::RetailQuaternion, + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY, compose_affine}, + skeleton_root::{inverse_rigid, orthonormalize}, +}; +use crate::{math::Basis3, point_graph::PointGraph, trigonometry}; + +pub struct BoardAnimationSettings { + pub slow: PointGraph<8>, + pub fast: PointGraph<8>, +} + +///Board240 and288. This state must be shared between physical Ground error +///capture and subsequent animated-board updates; it is not a per-state cache. +#[derive(Clone, Debug)] +pub struct BoardAnimation { + pub rotation_error: [f32; 4], + pub blending: bool, +} +impl Default for BoardAnimation { + fn default() -> Self { + //Original constructor82C01100/1104. + Self { + rotation_error: [0.0, 0.0, 0.0, 1.0], + blending: false, + } + } +} +impl BoardAnimation { + ///82C0606C resets the quaternion. The source does not clear byte288. + pub fn reset(&mut self) { + self.rotation_error = [0.0, 0.0, 0.0, 1.0]; + } + + ///82C04368: target inverse * actual DECK PART frame, reverse-axis + ///orthonormalization, quaternion conversion, and unconditional activation. + ///The scalar candidate conversion preserves the recovered comparisons and + ///refinements; exact VMX gather-register encoding remains separately audited. + pub fn capture_physics_error(&mut self, target: &Transform, deck_part: &Transform) { + let relative = orthonormalize(compose_affine(&inverse_rigid(target), deck_part)); + let q = retail_quaternion_from_basis(Basis3 { + columns: std::array::from_fn(|i| relative[i][..3].try_into().unwrap()), + }); + self.rotation_error = [q.x, q.y, q.z, q.w]; + self.blending = true; + } + + ///82C041C0 returns the target unchanged while inactive. Otherwise the + ///slow/fast graph is evaluated once per invocation, without a dt factor. + pub fn apply( + &mut self, + target: &Transform, + fast: bool, + settings: &BoardAnimationSettings, + ) -> Transform { + if !self.blending { + return *target; + } + let angle = trigonometry::acos(self.rotation_error[3]) * 2.0; + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let folded = fraction - if fraction > 0.5 { 1.0 } else { 0.0 }; + let distance = (folded * f32::from_bits(0x40c9_0fdb)).abs(); + let fraction = if fast { settings.fast } else { settings.slow }.evaluate(distance); + //Original fcmpu/blt: unordered graph output also completes the blend. + if !(fraction < f32::from_bits(0x3f73_3333)) { + self.blending = false; + return *target; + } + let identity = [0.0, 0.0, 0.0, 1.0]; + let dot = dot4(self.rotation_error, identity); + let reverse = 0.0 > dot; + let magnitude = if reverse { -dot } else { dot }; + let q = self.rotation_error.map(|v| if reverse { -v } else { v }); + let q = if magnitude > f32::from_bits(0x3f7f_069e) { + let same_sign = dot4(q, identity) > 0.0; + let mixed: [f32; 4] = std::array::from_fn(|lane| { + if same_sign { + fraction.mul_add(identity[lane] - q[lane], q[lane]) + } else { + (-(identity[lane] + q[lane])).mul_add(fraction, q[lane]) + } + }); + let inverse = reciprocal_sqrt(dot4(mixed, mixed)); + mixed.map(|v| v * inverse) + } else { + let theta = trigonometry::acos(magnitude); + let a = trigonometry::sin((1.0 - fraction) * theta); + let b = trigonometry::sin(fraction * theta); + let inverse = reciprocal(trigonometry::sin(theta)); + let weight_a = a * inverse; + let weight_b = b * inverse; + std::array::from_fn(|lane| identity[lane].mul_add(weight_b, q[lane] * weight_a)) + }; + self.rotation_error = q; + //82C01B04..BE0 uses the same sqrt(2) / paired residual basis kernel + //independently present in82AE1CC4. No quaternion normalization here. + let rotation = basis(RetailQuaternion { + x: q[0], + y: q[1], + z: q[2], + w: q[3], + }); + let mut correction = IDENTITY; + for axis in 0..3 { + correction[axis][..3].copy_from_slice(&rotation.columns[axis]); + } + compose_affine(target, &correction) + } +} + +///82C04270: target is compared with actual deck BODY COM, then all seven +///board bodies receive this XYZ velocity, leaving their angular rates alone. +pub fn target_velocity(target: [f32; 4], deck_body_position: [f32; 4], dt: f32) -> [f32; 4] { + let inverse = 1.0 / dt; + std::array::from_fn(|lane| (target[lane] - deck_body_position[lane]) * inverse) +} diff --git a/crates/skate-core/src/physics/board_dynamic_normal.rs b/crates/skate-core/src/physics/board_dynamic_normal.rs new file mode 100644 index 00000000..a7ced8b9 --- /dev/null +++ b/crates/skate-core/src/physics/board_dynamic_normal.rs @@ -0,0 +1,120 @@ +//! Dynamic wheel-response normal, original TU3 82C02388, with acceleration +//! history from82C082AC..8348. This is Ground64, distinct from wheel normals. +use super::{ + board::{BODY_COUNT, BodyId}, + board_ground::BoardGroundState, + board_motion_output::{add, dot, inverse_length_squared, scale, subtract}, + board_runtime::BoardRuntime, +}; +use crate::{math::Vector3, point_graph::PointGraph}; +const UP: Vector3 = Vector3::new(0., 1., 0.); +const ZERO: Vector3 = Vector3::ZERO; +#[derive(Clone, Debug)] +pub struct DynamicNormalSettings { + pub speed_damping: f32, + pub up_vector_damping: f32, + pub maximum_delta: f32, + pub speed_scale: f32, + pub maximum_delta_vs_speed: PointGraph<8>, +} +#[derive(Clone, Debug)] +pub struct BoardDynamicNormal { + /// Skateboard112, published to Ground64. + pub normal: Vector3, + /// Skateboard128. The constructor really initializes this to UP. + pub delta: Vector3, + /// Skateboard144 and160, retained independently by the source. + pub acceleration: Vector3, + pub last_contact_normal: Vector3, + previous_velocities: [Vector3; BODY_COUNT], + pub part_accelerations: [Vector3; BODY_COUNT], +} +impl BoardDynamicNormal { + ///82C00ED0 initializes112/128 to UP and144/160 to zero. The full board + ///reset82C0D680 ->82C00D68 clears all seven previous velocities. + pub fn new() -> Self { + Self { + normal: UP, + delta: UP, + acceleration: ZERO, + last_contact_normal: ZERO, + previous_velocities: [ZERO; BODY_COUNT], + part_accelerations: [ZERO; BODY_COUNT], + } + } + /// Board reset clears acceleration history but preserves wrapper112/128. + pub fn reset_body_history(&mut self) { + self.previous_velocities.fill(ZERO); + self.part_accelerations.fill(ZERO); + } + pub fn update( + &mut self, + board: &BoardRuntime, + contacts: &BoardGroundState, + gravity: Vector3, + dt: f32, + previous_ground_speed: f32, + settings: &DynamicNormalSettings, + ) { + let frequency = 1.0 / dt; + for part in BodyId::ORDER { + let i = part.index(); + let velocity = board.bodies()[i].rates.linear_velocity; + self.part_accelerations[i] = + scale(subtract(velocity, self.previous_velocities[i]), frequency); + self.previous_velocities[i] = velocity; + } + if contacts.wheel_contact_count == 0 { + self.acceleration = ZERO; + return; + } + let mut sum = ZERO; + //The original body tests780,781,782, then normalizes. There is no + //fourth-wheel accumulation in this function; retain that distinction. + for i in 0..3 { + if contacts.parts[i].in_contact { + sum = subtract(add(sum, self.part_accelerations[i]), gravity); + } + } + let target = normalize(sum); + self.delta = scale(self.delta, settings.speed_damping); + let error = scale(subtract(target, self.normal), settings.up_vector_damping); + let (direction, magnitude) = normalize_length(error); + let maximum = settings + .maximum_delta_vs_speed + .evaluate(previous_ground_speed.abs() / settings.speed_scale) + * settings.maximum_delta; + let nonnegative = if -magnitude >= -0.0 { 0.0 } else { magnitude }; + let amount = if maximum - nonnegative >= -0.0 { + nonnegative + } else { + maximum + }; + self.delta = Vector3::new( + direction.x.mul_add(amount, self.delta.x), + direction.y.mul_add(amount, self.delta.y), + direction.z.mul_add(amount, self.delta.z), + ); + self.normal = normalize(add(self.normal, self.delta)); + self.last_contact_normal = contacts.overall_normal; + } +} +fn normalize(v: Vector3) -> Vector3 { + normalize_length(v).0 +} +fn normalize_length(v: Vector3) -> (Vector3, f32) { + let squared = dot(v, v); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + //830BD350's verified initializer is float1e-6. Source compares length. + let normal = if length > f32::from_bits(0x3586_37bd) { + scale(v, inverse) + } else { + ZERO + }; + (normal, length) +} diff --git a/crates/skate-core/src/physics/board_ground.rs b/crates/skate-core/src/physics/board_ground.rs new file mode 100644 index 00000000..ccdee6b4 --- /dev/null +++ b/crates/skate-core/src/physics/board_ground.rs @@ -0,0 +1,312 @@ +//! Board contact normals and wheel-line state used by riding output. +//! +//! TU3 CollisionInfo constructor82C00D68/reset82C00CA0, wheel result +//! publication82C079E0, and world-contact normal selection82C07D20. The +//! postphysics stage consumes actual solver reports, never speculative query +//! contacts. Dynamic-object classification is outside the host's static world. +use crate::{math::Vector3, trigonometry}; +use super::{ + board::{BodyId, BODY_COUNT}, + board_motion_output::{add, dot, inverse_length_squared, length, scale, subtract}, + board_runtime::BoardRuntime, + board_step::CollisionBody, + contact_feedback::BoardContactReport, + native_arithmetic, +}; + +const UP: Vector3 = Vector3::new(0.0, 1.0, 0.0); +pub const WHEEL_LINE_LENGTH: f32 = f32::from_bits(0x3E4C_CCCD); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelLine { + pub start: Vector3, + pub end: Vector3, +} + +/// The four zero-radius queries in82C07788. Up is Reckoning+1152, supplied +/// by PhysicalPlayerHiLOD::StartSkateboardLineTests82DB6310. +pub fn wheel_lines(board: &BoardRuntime, reckoning_up: Vector3) -> [WheelLine; 4] { + let poses = board.part_transforms(); + let delta = scale(reckoning_up, WHEEL_LINE_LENGTH); + core::array::from_fn(|i| WheelLine { + start: poses[i].translation, + end: subtract(poses[i].translation, delta), + }) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelLineHit { + /// Query result+96, before82C079E0 multiplies by the authored line length. + pub fraction: f32, + pub normal: Vector3, + pub surface_tag: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelLineState { + pub normals: [Vector3; 4], + pub distances: [f32; 4], + pub physics_surfaces: [u32; 4], + pub minimum_distance: f32, +} +impl Default for WheelLineState { + fn default() -> Self { + //82C00D68 initializes all four normal/distance/surface records. + Self { + normals: [UP; 4], + distances: [0.0; 4], + physics_surfaces: [0; 4], + minimum_distance: 0.0, + } + } +} +impl WheelLineState { + /// Complete four-wheel branch82C079F8..82C07A9C. A miss clears the + /// surface, while the previous distance and normal deliberately survive. + pub fn publish(&mut self, hits: [Option; 4]) { + self.minimum_distance = WHEEL_LINE_LENGTH; + for (i, hit) in hits.into_iter().enumerate() { + self.physics_surfaces[i] = 0; + if let Some(hit) = hit { + let distance = hit.fraction * WHEEL_LINE_LENGTH; + self.minimum_distance = if distance - self.minimum_distance >= -0.0 { + self.minimum_distance + } else { + distance + }; + self.normals[i] = hit.normal; + self.distances[i] = distance; + self.physics_surfaces[i] = (hit.surface_tag >> 7) & 31; + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PartGroundContact { + pub in_contact: bool, + pub normal: Vector3, + pub point: Vector3, + pub relative_velocity: Vector3, +} +const EMPTY_PART: PartGroundContact = PartGroundContact { + in_contact: false, normal: UP, point: Vector3::ZERO, relative_velocity: Vector3::ZERO, +}; + +#[derive(Clone, Debug, PartialEq)] +pub struct BoardGroundState { + pub parts: [PartGroundContact; BODY_COUNT], + /// BoardBody320..416, retained between postphysics updates. + pub previous_velocities: [Vector3; BODY_COUNT], + /// BoardBody432..528, observed accelerations in actual part order. + pub accelerations: [Vector3; BODY_COUNT], + ///Body656/852: strongest closing velocity against this frame's contacts, + ///computed from retained part velocities before acceleration sampling. + pub closing_velocity: Vector3, + pub maximum_closing_speed: f32, + ///Body860: range of DECK contact normal projections onto Reckoning1152. + pub opposing_contact: f32, + ///Body864/872: static-world contact classifications and surface12 height. + ///Other-assembly group flags require actual dynamic-object reports. + pub surface_twelve_height: f32, + pub collision_flags: u32, + /// CollisionInfo+0. Can use truck/deck normals when the wheel sum fails. + pub overall_normal: Vector3, + /// CollisionInfo+16. Retained until a valid contacting-wheel sum replaces it. + /// FillPhysOut publishes this to Ground+80 and Ground+96. + pub wheel_normal: Vector3, + pub valid_wheel_normals: [bool; 4], + pub part_contact_count: u8, + pub wheel_contact_count: u8, + ///Body7692, accumulated only while all four physical wheels lack contact. + pub time_without_wheel_contact: f32, + /// Native wheel inertia+36 writes made after normal selection. + pub wheel_linear_drag: [f32; 4], +} +impl Default for BoardGroundState { + fn default() -> Self { + Self { + parts: [EMPTY_PART; BODY_COUNT], overall_normal: UP, wheel_normal: UP, + // CollisionInfo ctor82C00DC8/CC zeros both arrays. Board reset + //82C0D758 calls that constructor again; per-frame reset retains velocities. + previous_velocities: [Vector3::ZERO; BODY_COUNT], + accelerations: [Vector3::ZERO; BODY_COUNT], + closing_velocity: Vector3::ZERO, + maximum_closing_speed: 0.0, + opposing_contact: 0.0, + surface_twelve_height: 0.0, + collision_flags: 0, + valid_wheel_normals: [false; 4], part_contact_count: 0, + wheel_contact_count: 0, time_without_wheel_contact: 0.0, wheel_linear_drag: [0.0; 4], + } + } +} +impl BoardGroundState { + /// Original PostPhysics82C082B8..8444. The scalar reciprocal timestep is + /// shared by all seven parts, then each previous velocity is replaced. + pub fn sample_accelerations(&mut self, velocities: [Vector3; BODY_COUNT], time_step: f32) { + let inverse_dt = 1.0 / time_step; + for (i, current) in velocities.into_iter().enumerate() { + self.accelerations[i] = scale(subtract(current, self.previous_velocities[i]), inverse_dt); + self.previous_velocities[i] = current; + } + } + + ///82C087D8..87F4 after contact publication. The incoming f1 is the + ///actual physics timestep; reset82C0D680 deliberately preserves this field. + pub fn advance_contact_time(&mut self, time_step: f32) { + self.time_without_wheel_contact = if self.wheel_contact_count == 0 { + self.time_without_wheel_contact + time_step + } else { 0.0 }; + } + /// Complete contact-normal/count/drag subpipeline of82C07D20 for the + /// static world. Reports must already satisfy82AE1608/827682B0 eligibility. + /// `maximum_ground_angle_degrees` is physics_reckoning. + /// MaxAllowedGroundNormalFromUp (full64 0E508708CC7F5249). + pub fn update( + &mut self, + reports: &[BoardContactReport], + lines: &WheelLineState, + reckoning_up: Vector3, + maximum_ground_angle_degrees: f32, + board_wiping_out: bool, + ) { + self.parts.fill(EMPTY_PART); + //CollisionInfo Reset82C00CA0 clears per-frame impacts and high flags. + self.closing_velocity = Vector3::ZERO; + self.maximum_closing_speed = 0.0; + self.surface_twelve_height = 0.0; + self.collision_flags &= 0x01ff_ffff; + self.overall_normal = UP; + self.valid_wheel_normals.fill(true); + let mut highest_y = -2.0; + let mut highest_part = None; + //82C07E24..30 seeds min/max; only deck reports enter82C07EAC..CC. + let mut minimum_projection = 1.0; + let mut maximum_projection = -1.0; + let mut surfaces = [0; BODY_COUNT]; + surfaces[..4].copy_from_slice(&lines.physics_surfaces); + for report in reports { + assert_eq!(report.other, CollisionBody::StaticWorld, + "dynamic object contact classification needs its recovered owner"); + let i = report.part.index(); + if report.part == BodyId::Deck { + let projection = dot(report.normal, reckoning_up); + minimum_projection = if minimum_projection - projection >= 0.0 { + projection + } else { minimum_projection }; + maximum_projection = if maximum_projection - projection >= 0.0 { + maximum_projection + } else { projection }; + } + //82C07ED0..7F2C: this is the OLD part velocity, not the report's + //post-solve relative velocity or the finite-difference acceleration. + let closing = -dot(report.normal, self.previous_velocities[i]); + if !(closing <= self.maximum_closing_speed) { + self.maximum_closing_speed = closing; + self.closing_velocity = scale(report.normal, -closing); + } + let surface = (u32::from(report.other_surface) >> 7) & 31; + if surface == 12 { + self.collision_flags |= 1 << 25; //82C080D4..80F0. + self.surface_twelve_height = report.position.y; + } + if i >= 4 { + surfaces[i] = surface; //82C081B4..81E0, last report wins. + } + let contact = &mut self.parts[i]; + if !contact.in_contact || report.normal.y > contact.normal.y { + contact.normal = report.normal; + contact.point = report.position; + contact.relative_velocity = report.relative_linear_velocity; + } + // Selection occurs after the per-part maximum, in report order. + if contact.normal.y > highest_y { + highest_y = contact.normal.y; + highest_part = Some(i); + } + contact.in_contact = true; + } + //82C082AC/E8/F4/F8; no deck reports produce max(-2,0)=0. + let range = maximum_projection - minimum_projection; + self.opposing_contact = if -range >= 0.0 { 0.0 } else { range }; + //82C0834C..836C combines physical contact with the wheel query's + //surface (wheels), or the last actual contact tag (trucks/deck). + for (part, surface) in self.parts.iter().zip(surfaces) { + if part.in_contact && surface == 8 { + self.collision_flags |= 1 << 31; + } + } + // The source's -1 best-part index addresses the retained wheel normal. + let reference_normal = highest_part.map_or(self.wheel_normal, |i| self.parts[i].normal); + for i in 0..4 { + if !self.parts[i].in_contact { + if lines.distances[i] < f32::from_bits(0x3D8F_5C29) { + self.parts[i].normal = lines.normals[i]; + } else { + self.valid_wheel_normals[i] = false; + } + } + } + self.part_contact_count = self.parts.iter().filter(|part| part.in_contact).count() as u8; + self.wheel_contact_count = self.parts[..4].iter().filter(|part| part.in_contact).count() as u8; + let angle_radians = maximum_ground_angle_degrees * f32::from_bits(0x3C8E_FA35); + let minimum_up_dot = trigonometry::cos(angle_radians); + let mut sum = Vector3::ZERO; + for i in 0..4 { + let contact = self.parts[i]; + if self.valid_wheel_normals[i] + && dot(contact.normal, reckoning_up) > minimum_up_dot + && angle_between(reference_normal, contact.normal) < f32::from_bits(0x3F49_0FDB) + { + sum = add(sum, contact.normal); + } + let drag = if contact.in_contact { + if board_wiping_out { f32::from_bits(0x3D23_D70A) } else { 0.0 } + } else { + f32::from_bits(0x3BC4_9BA6) + }; + self.wheel_linear_drag[i] = drag * f32::from_bits(0x426F_FFFF); + } + let squared = dot(sum, sum); + if self.wheel_contact_count > 0 && squared > f32::from_bits(0x3780_0000) { + self.overall_normal = scale(sum, inverse_length_squared(squared, 2)); + self.wheel_normal = self.overall_normal; + } else { + let mut support = Vector3::ZERO; + // Native sum order: deck, front truck, back truck. + for i in [BodyId::Deck.index(), BodyId::FrontTruck.index(), BodyId::BackTruck.index()] { + if self.parts[i].in_contact { support = add(support, self.parts[i].normal); } + } + let magnitude = length(support); + if magnitude > f32::from_bits(0x3C23_D70A) { + let mut inverse = native_arithmetic::reciprocal_estimate(magnitude); + for _ in 0..2 { + let error = (-inverse).mul_add(magnitude, 1.0); + inverse = inverse.mul_add(error, inverse); + } + self.overall_normal = scale(support, inverse); + } + } + } +} + +/// Complete8296EBB0, including its small-vector gate and ONE reciprocal-square- +/// root refinement per vector. Native82C07D20 compares this angle with pi/4. +pub(crate) fn angle_between(a: Vector3, b: Vector3) -> f32 { + let a_squared = dot(a, a); + let b_squared = dot(b, b); + let epsilon = f32::from_bits(0x38D1_B717); + if !(a_squared > epsilon && b_squared > epsilon) { return 0.0; } + let a = scale(a, inverse_length_squared(a_squared, 1)); + let b = scale(b, inverse_length_squared(b_squared, 1)); + let cosine = native_arithmetic::vector_min( + native_arithmetic::vector_max(dot(a, b), -1.0), 1.0, + ); + trigonometry::acos(cosine) +} + + +#[cfg(test)] +#[path = "tests/board_ground.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board_input_output.rs b/crates/skate-core/src/physics/board_input_output.rs new file mode 100644 index 00000000..c65cf22c --- /dev/null +++ b/crates/skate-core/src/physics/board_input_output.rs @@ -0,0 +1,111 @@ +//! Board-owned fields consumed by PhysicalPlayerHiLOD::Input, from original +//! TU3 Skateboard::FillPhysOut82C02A80. Other physical components publish into +//! the same packet after its native per-frame reset; their fields are retained. +use super::{ + board::BodyId, + board_ground::BoardGroundState, + board_motion_output::{BoardMotionOutput, dot, scale, subtract}, +}; +use crate::{ + math::Vector3, player::input_phase::PhysicalPlayerInput, + riding::collision_response::signed_angle, +}; + +/// Processed96 was captured by PrepareBoardToolkit before simulation. It is +/// deliberately separate from the current solved board transform. Ground64 is +/// the retained normal calculated by82C02388, not the wheel-contact normal. +pub struct BoardInputFrame { + pub processed_forward: Vector3, + pub reckoning_normal_1216: Vector3, + pub reckoning_ground_up: Vector3, + pub retained_ground_normal_112: Vector3, + pub processed_flags_2476: u32, +} + +pub fn publish_board_input( + out: &mut PhysicalPlayerInput, + motion: &BoardMotionOutput, + contacts: &BoardGroundState, + frame: BoardInputFrame, +) { + let side = motion.effective_basis.columns[0]; + let sign = if frame.processed_flags_2476 & 4 != 0 { + -1.0 + } else { + 1.0 + }; + out.board_reckoning_side_176 = + [side[0] * sign, side[1] * sign, side[2] * sign, 0.0].map(f32::to_bits); + out.skateboard.vector_64 = bits(motion.angular_velocity); + out.skateboard.vector_80 = bits(motion.linear_velocity); + out.skateboard.vector_96 = bits(motion.ground_velocity); + out.skateboard.scalar_160 = motion.speed; + out.skateboard.scalar_164 = motion.ground_speed; + out.skateboard.scalar_168 = motion.forward_speed; + out.skateboard.scalar_172 = travel_angle( + frame.processed_forward, + motion.ground_velocity, + frame.reckoning_normal_1216, + frame.reckoning_ground_up, + ); + //82C02A80 never writes Motion128/144. Preserve the values from the + //output reset and any other physical component, rather than inventing them. + out.ground.vector_64 = bits(frame.retained_ground_normal_112); + out.ground.vector_96 = bits(contacts.wheel_normal); + out.collision.wheel_contact_3296_3299 = + core::array::from_fn(|i| u8::from(contacts.parts[i].in_contact)); + out.collision.wheel_count_0 = u32::from(contacts.wheel_contact_count); + out.collision.flag_3472 = u8::from( + contacts.parts[BodyId::FrontTruck.index()].in_contact + || contacts.parts[BodyId::BackTruck.index()].in_contact, + ); + out.collision.flag_3475 = u8::from(contacts.parts[BodyId::Deck.index()].in_contact); + out.collision.flag_3477 = u8::from( + out.collision.wheel_count_0 > 0 + || out.collision.flag_3472 != 0 + || out.collision.flag_3475 != 0, + ); +} + +///82C030B8..322C, including8286CD88's product gate and plane rejection. +fn travel_angle(forward: Vector3, velocity: Vector3, normal: Vector3, ground_up: Vector3) -> f32 { + let mut projected_forward = reject(forward, normal); + if !(dot(projected_forward, velocity) > 0.0) { + projected_forward = scale(projected_forward, -1.0); + } + let angle = if dot(projected_forward, projected_forward) * dot(velocity, velocity) + > f32::from_bits(0x3780_0000) + { + signed_angle( + reject(projected_forward, ground_up), + reject(velocity, ground_up), + ground_up, + ) + } else { + 0.0 + }; + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c9_0fdb) +} +fn reject(vector: Vector3, normal: Vector3) -> Vector3 { + subtract(vector, scale(normal, dot(vector, normal))) +} +fn bits(vector: Vector3) -> [u32; 4] { + [vector.x, vector.y, vector.z, 0.0].map(f32::to_bits) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn forward_and_reverse_motion_use_the_same_travel_axis() { + let forward = Vector3::new(0., 0., 1.); + let normal = Vector3::new(0., 1., 0.); + let a = travel_angle(forward, Vector3::new(1., 0., 4.), normal, normal); + let b = travel_angle(forward, Vector3::new(-1., 0., -4.), normal, normal); + assert!((a - b).abs() < 0.0001); + assert!(a > 0. && a < 0.5); + assert_eq!(travel_angle(forward, Vector3::ZERO, normal, normal), 0.); + } +} diff --git a/crates/skate-core/src/physics/board_motion_output.rs b/crates/skate-core/src/physics/board_motion_output.rs new file mode 100644 index 00000000..1d05494d --- /dev/null +++ b/crates/skate-core/src/physics/board_motion_output.rs @@ -0,0 +1,78 @@ +//! Observed deck motion from TU3 Skateboard::FillPhysOut82C02A80. +//! These calculations consume the live solver body and the actual Reckoning +//! normal. They do not infer contact state from height or vertical velocity. +use crate::math::{Basis3, Vector3}; +use super::{board::BodyId, board_runtime::BoardRuntime, native_arithmetic}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardMotionOutput { + pub angular_velocity: Vector3, + pub linear_velocity: Vector3, + pub ground_velocity: Vector3, + /// SkateboardMotion+160: magnitude of the unprojected deck velocity. + pub speed: f32, + /// SkateboardMotion+164: magnitude after rejecting the Reckoning normal. + pub ground_speed: f32, + /// SkateboardMotion+168: raw deck velocity projected onto effective Z. + pub forward_speed: f32, + pub effective_basis: Basis3, +} + +impl BoardMotionOutput { + pub fn from_board( + board: &BoardRuntime, + reckoning_ground_normal: Vector3, + processed_flags_2468: u32, + ) -> Self { + let deck = board.bodies()[BodyId::Deck.index()].rates; + let mut effective_basis = board.part_transforms()[BodyId::Deck.index()].basis; + // Complete GetEffectiveTransform82C01BF8 axis adjustment; translation + // and Y remain the physical part's values. + if processed_flags_2468 & 0x0010_0000 != 0 { + for axis in [0, 2] { + effective_basis.columns[axis] = effective_basis.columns[axis].map(|v| -v); + } + } + let normal_speed = dot(deck.linear_velocity, reckoning_ground_normal); + let ground_velocity = subtract( + deck.linear_velocity, + scale(reckoning_ground_normal, normal_speed), + ); + let z = effective_basis.columns[2]; + Self { + angular_velocity: deck.angular_velocity, + linear_velocity: deck.linear_velocity, + ground_velocity, + speed: length(deck.linear_velocity), + ground_speed: length(ground_velocity), + forward_speed: dot(deck.linear_velocity, Vector3::new(z[0], z[1], z[2])), + effective_basis, + } + } +} + +pub fn dot(a: Vector3, b: Vector3) -> f32 { + native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]) +} +pub(crate) fn scale(v: Vector3, s: f32) -> Vector3 { + Vector3::new(v.x * s, v.y * s, v.z * s) +} +pub(crate) fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +pub(crate) fn subtract(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(crate) fn inverse_length_squared(squared: f32, refinements: usize) -> f32 { + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..refinements { + let correction = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(correction, inverse); + } + inverse +} +pub fn length(v: Vector3) -> f32 { + let squared = dot(v, v); + let value = squared * inverse_length_squared(squared, 2); + if squared == 0.0 { 0.0 } else { value } +} diff --git a/crates/skate-core/src/physics/board_pose.rs b/crates/skate-core/src/physics/board_pose.rs new file mode 100644 index 00000000..7405cfd7 --- /dev/null +++ b/crates/skate-core/src/physics/board_pose.rs @@ -0,0 +1,25 @@ +//! Complete TU3 board SetTransform 82C0B2C8 and its part/pose dependencies. +//! This changes poses only; it does not infer startup, settle joints or reset rates. +mod arithmetic; +mod normalization; +mod part; +use arithmetic::*; +pub use normalization::{orthonormalize_part_basis, orthonormalize_rotation}; +pub use part::{PartPose, part_transform, set_part_transform}; + +/// Guest-order Ri/Up/At/translation vectors, including all packed fourth lanes. +pub type PoseMatrix = [u32; 16]; + +/// Set deck part 6 to the orthonormalized request, then move parts 0..5 by its +/// common rigid delta. The separate hook receives the ORIGINAL requested pose. +pub fn set_board_transform(parts: &mut [PartPose; 7], hook: &mut PartPose, requested: PoseMatrix) { + let old_deck = matrix(part_transform(&parts[6])); + let normalized = orthonormalize_rotation(requested); + let delta = compose(matrix(normalized), inverse_rigid(old_deck)); + set_part_transform(&mut parts[6], normalized); + for part in &mut parts[..6] { + let moved = compose(delta, matrix(part_transform(part))); + set_part_transform(part, orthonormalize_rotation(words(moved))); + } + set_part_transform(hook, requested); +} diff --git a/crates/skate-core/src/physics/board_pose/arithmetic.rs b/crates/skate-core/src/physics/board_pose/arithmetic.rs new file mode 100644 index 00000000..c8c35bc0 --- /dev/null +++ b/crates/skate-core/src/physics/board_pose/arithmetic.rs @@ -0,0 +1,79 @@ +//! Arithmetic order from the retained TU3 pose functions; guest lane order. +use super::PoseMatrix; +pub(super) type V = [f32; 4]; +pub(super) type M = [V; 4]; +pub(super) fn matrix(w: PoseMatrix) -> M { + core::array::from_fn(|i| core::array::from_fn(|j| f32::from_bits(w[i * 4 + j]))) +} +pub(super) fn words(m: M) -> PoseMatrix { + core::array::from_fn(|i| m[i / 4][i % 4].to_bits()) +} +pub(super) fn add(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn mul(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] * b[i]) +} +pub(super) fn madd(a: V, b: V, c: V) -> V { + core::array::from_fn(|i| a[i].mul_add(b[i], c[i])) +} +pub(super) fn nmsub(a: V, b: V, c: V) -> V { + core::array::from_fn(|i| (-a[i]).mul_add(b[i], c[i])) +} +pub(super) fn perm(a: V, p: [usize; 4]) -> V { + p.map(|i| a[i]) +} +pub(super) fn dot(a: V, b: V, four: bool) -> f32 { + if four { + crate::physics::native_arithmetic::dot4(a, b) + } else { + crate::physics::native_arithmetic::dot3(a, b) + } +} +pub(super) fn rsqrt(v: f32) -> f32 { + let mut e = super::super::reciprocal_sqrt::estimate(v); + for _ in 0..2 { + let s = e * e; + let h = e * 0.5; + e = h.mul_add((-v).mul_add(s, 1.0), e); + } + e +} +pub(super) fn normalize(v: V, four: bool) -> V { + mul(v, [rsqrt(dot(v, v, four)); 4]) +} +pub(super) fn cross(a: V, b: V) -> V { + perm( + nmsub(perm(a, [1, 2, 0, 3]), b, mul(a, perm(b, [1, 2, 0, 3]))), + [1, 2, 0, 3], + ) +} +pub(super) fn compose(a: M, b: M) -> M { + core::array::from_fn(|i| { + let first = if i == 3 { + madd([b[i][0]; 4], a[0], a[3]) + } else { + mul([b[i][0]; 4], a[0]) + }; + madd([b[i][2]; 4], a[2], madd([b[i][1]; 4], a[1], first)) + }) +} +pub(super) fn inverse_rigid(m: M) -> M { + let mut out: [[f32; 4]; 4] = core::array::from_fn(|i| { + if i < 3 { + [m[0][i], m[1][i], m[2][i], 0.0] + } else { + [0.0; 4] + } + }); + let neg = sub([0.0; 4], m[3]); + out[3] = madd( + [neg[0]; 4], + out[0], + madd([neg[1]; 4], out[1], mul([neg[2]; 4], out[2])), + ); + out +} diff --git a/crates/skate-core/src/physics/board_pose/normalization.rs b/crates/skate-core/src/physics/board_pose/normalization.rs new file mode 100644 index 00000000..414c4a36 --- /dev/null +++ b/crates/skate-core/src/physics/board_pose/normalization.rs @@ -0,0 +1,54 @@ +use super::{PoseMatrix, arithmetic::*}; + +/// 825C5710: z/y/x projection against ORIGINAL input axes, then normalization. +/// Translation is copied verbatim. Degenerate inputs receive no invented repair. +pub fn orthonormalize_rotation(input: PoseMatrix) -> PoseMatrix { + let original = matrix(input); + let mut out = original; + for axis in (0..3).rev() { + let mut residual = original[axis]; + for other in (axis + 1..3).rev() { + residual = sub( + residual, + mul(out[other], [dot(out[other], original[axis], false); 4]), + ); + } + out[axis] = normalize(residual, false); + } + words(out) +} + +/// 82AE0A40: native magnitude gates and least-parallel pair selection followed +/// by two normalized cross products. This differs from 825C5710 above. +pub fn orthonormalize_part_basis(input: PoseMatrix) -> PoseMatrix { + let mut m = matrix(input); + let squares: [f32; 3] = core::array::from_fn(|i| dot(m[i], m[i], false)); + let magnitudes = squares.map(|v| { + let root = v * rsqrt(v); + if v == 0.0 { 0.0 } else { root } + }); + for axis in 0..3 { + m[axis] = mul(m[axis], [rsqrt(squares[axis]); 4]); + } + let (u, v, w) = if !(magnitudes[0] > 0.0) { + (1, 2, 0) + } else if !(magnitudes[1] > 0.0) { + (2, 0, 1) + } else if !(magnitudes[2] > 0.0) { + (0, 1, 2) + } else { + let ca = dot(m[2], m[0], false).abs(); + let bc = dot(m[1], m[2], false).abs(); + let ab = dot(m[0], m[1], false).abs(); + if ca > bc { + if ab > bc { (1, 2, 0) } else { (0, 1, 2) } + } else if ab > ca { + (2, 0, 1) + } else { + (0, 1, 2) + } + }; + m[w] = normalize(cross(m[u], m[v]), false); + m[v] = normalize(cross(m[w], m[u]), false); + words(m) +} diff --git a/crates/skate-core/src/physics/board_pose/part.rs b/crates/skate-core/src/physics/board_pose/part.rs new file mode 100644 index 00000000..de65272d --- /dev/null +++ b/crates/skate-core/src/physics/board_pose/part.rs @@ -0,0 +1,107 @@ +use super::{PoseMatrix, arithmetic::*, orthonormalize_part_basis}; + +/// Explicit referenced records for a native 96-byte Part. Optional fields model +/// the actual null-pointer branches; callers own loading and activation policy. +#[derive(Clone, Debug)] +pub struct PartPose { + pub transform: PoseMatrix, + /// Record addressed by Part+68 (the local mass-frame transform). + pub local_mass_frame: Option, + pub body: Option<[u32; 44]>, + pub inertia: Option<[u32; 10]>, +} + +/// 82585CB0: return the Part transform from its body, or its cached part matrix +/// if no body exists. This is not the whole game Body::GetPosition method. +pub fn part_transform(part: &PartPose) -> PoseMatrix { + let Some(body) = part.body.as_ref() else { + return part.transform; + }; + let m = [ + load(body, 16), + load(body, 20), + load(body, 24), + load(body, 4), + ]; + words(if let Some(local) = part.local_mass_frame { + compose(m, matrix(local)) + } else { + m + }) +} + +/// 82BD4318: update a live body's pose/caches, then always copy the original +/// requested matrix to the part. Velocity, forces and other packed lanes survive. +pub fn set_part_transform(part: &mut PartPose, requested: PoseMatrix) { + if let Some(body) = part.body.as_mut() { + let m = if let Some(local) = part.local_mass_frame { + words(compose(matrix(requested), inverse_rigid(matrix(local)))) + } else { + requested + }; + let m = matrix(orthonormalize_part_basis(m)); + for (off, v) in [(16, m[0]), (20, m[1]), (24, m[2]), (4, m[3])] { + store_xyz(body, off, v); + } + body[..4].copy_from_slice(&quaternion(m).map(f32::to_bits)); + if let Some(inertia) = part.inertia { + let [ri, up, at, _] = m; + let r = mul(ri, [f32::from_bits(inertia[0]); 4]); + let u = mul(up, [f32::from_bits(inertia[1]); 4]); + let a = mul(at, [f32::from_bits(inertia[2]); 4]); + let full = madd(a, [at[0]; 4], madd(r, [ri[0]; 4], mul(u, [up[0]; 4]))); + let p = |v| perm(v, [2, 1, 1, 3]); + let s = |v| perm(v, [2, 1, 2, 3]); + let split = madd(p(at), s(a), madd(p(ri), s(r), mul(p(up), s(u)))); + store_xyz(body, 28, full); + store_xyz(body, 32, split); + body[31] = inertia[4]; + } + } + part.transform = requested; +} + +fn quaternion(m: M) -> V { + let [ri, up, at, _] = m; + // Sign masks are initialized by TU3 82F83360/90/C0, not image defaults. + let rx = [ri[0], ri[0], -ri[0], -ri[0]]; + let uy = [up[1], -up[1], up[1], -up[1]]; + let az = [at[2], -at[2], -at[2], at[2]]; + let first = add(rx, uy); + let d = add(first, add(az, [1.0; 4])); + let inv = d.map(rsqrt); + let roots = mul(d, inv); + let halves = mul([0.5; 4], inv); + let a = [up[2], at[0], ri[1], 0.5]; + let b = [at[1], ri[2], up[0], 0.0]; + let minus = sub(a, b); + let plus = add(a, b); + let y = mul( + [plus[2], minus[3], plus[0], minus[1]], + [halves[2], roots[2], halves[2], halves[2]], + ); + let z = mul( + [plus[1], plus[0], minus[3], minus[2]], + [halves[3], halves[3], roots[3], halves[3]], + ); + let x = mul( + [minus[3], plus[2], plus[1], minus[0]], + [roots[1], halves[1], halves[1], halves[1]], + ); + let w = mul(minus, [halves[0], halves[0], halves[0], roots[0]]); + let yz = if up[1] > at[2] { y } else { z }; + let xyz = if ri[0] > up[1] && ri[0] > at[2] { + x + } else { + yz + }; + normalize(if first[0] + at[2] > 0.0 { w } else { xyz }, true) +} +fn load(w: &[u32], off: usize) -> V { + core::array::from_fn(|i| f32::from_bits(w[off + i])) +} +fn store_xyz(w: &mut [u32], off: usize, v: V) { + for i in 0..3 { + w[off + i] = v[i].to_bits(); + } +} diff --git a/crates/skate-core/src/physics/board_probes.rs b/crates/skate-core/src/physics/board_probes.rs new file mode 100644 index 00000000..0e9730d0 --- /dev/null +++ b/crates/skate-core/src/physics/board_probes.rs @@ -0,0 +1,154 @@ +//! Deck and wall probes in TU3 82C07788/82C079E0, with the actual optional +//! wall-line producer82C01F10. Wheel probes remain in board_ground. +use super::{ + board_ground::WheelLine, + board_motion_output::{dot, inverse_length_squared}, +}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardProbeHit { + pub point: Vector3, + pub normal: Vector3, + pub surface_tag: u32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardProbeState { + pub start: Vector3, + pub point: Vector3, + pub normal: Vector3, + pub surface_tag: u32, + pub hit: bool, +} +impl Default for BoardProbeState { + fn default() -> Self { + // SkateboardBody ctor82C06614..82C0675C clears both complete records. + Self { + start: Vector3::ZERO, + point: Vector3::ZERO, + normal: Vector3::ZERO, + surface_tag: 0, + hit: false, + } + } +} +impl BoardProbeState { + pub fn start(&mut self, position: Vector3) { + self.start = position; + self.hit = false; + } + /// A miss only clears the hit byte. Previously published point, normal and + /// surface deliberately remain, unlike a disabled optional query. + pub fn publish(&mut self, hit: Option) { + self.hit = hit.is_some(); + if let Some(hit) = hit { + self.point = hit.point; + self.normal = hit.normal; + self.surface_tag = hit.surface_tag; + } + } + pub fn disable(&mut self) { + *self = Self::default(); + } +} + +pub const DECK_PROBE_RADIUS: f32 = f32::from_bits(0x3e19_999a); +/// Fifth query always runs from the physical deck origin100m down, radius0.15. +/// Native start is copied to body7968, and result to7984/8000/8016/8020. +pub fn deck_probe(position: Vector3) -> WheelLine { + WheelLine { + start: position, + end: Vector3::new( + position.x, + position.y + f32::from_bits(0xc2c8_0000), + position.z, + ), + } +} + +#[derive(Clone, Copy, Debug)] +pub struct WallLineInput { + /// Processed2508: current physical state (100 or101 admitted). + pub state: u32, + /// Processed464,544,112,2752. + pub contact_normal: Vector3, + pub skater_up: Vector3, + pub deck_position: Vector3, + pub time: f32, +} +/// Source82C01F10 clears endpoints208/224 on every call, then enables byte291 +/// only for the three physical gates below. No guessed grounded condition. +pub fn wall_probe(input: WallLineInput) -> Option { + if !matches!(input.state, 100 | 101) + || !(input.contact_normal.y.abs() < 0.5) + || !(dot(input.skater_up, input.contact_normal) > f32::from_bits(0x3f35_c28f)) + || !(input.time > f32::from_bits(0x3c23_d70a)) + { + return None; + } + let normal = input.contact_normal; + let start = Vector3::new( + normal.x.mul_add(0.05, input.deck_position.x), + normal.y.mul_add(0.05, input.deck_position.y), + normal.z.mul_add(0.05, input.deck_position.z), + ); + let first = cross(Vector3::new(0., 1., 0.), normal); + let down = cross(first, normal); + let squared = dot(down, down); + let inverse = inverse_length_squared(squared, 2); + let magnitude = if squared == 0. { 0. } else { squared * inverse }; + let down = if magnitude > f32::from_bits(0x3586_37bd) { + Vector3::new(down.x * inverse, down.y * inverse, down.z * inverse) + } else { + Vector3::ZERO + }; + Some(WheelLine { + start, + end: Vector3::new( + down.x.mul_add(4., start.x), + down.y.mul_add(4., start.y), + down.z.mul_add(4., start.z), + ), + }) +} +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn wall_probe_gates_and_miss_retention_follow_source() { + let mut input = WallLineInput { + state: 100, + contact_normal: Vector3::new(1., 0., 0.), + skater_up: Vector3::new(1., 0., 0.), + deck_position: Vector3::new(2., 3., 4.), + time: 0.02, + }; + let line = wall_probe(input).unwrap(); + assert_eq!(line.start, Vector3::new(2.05, 3., 4.)); + assert_eq!(line.end, Vector3::new(2.05, -1., 4.)); + input.state = 200; + assert!(wall_probe(input).is_none()); + let mut output = BoardProbeState::default(); + output.start(line.start); + output.publish(Some(BoardProbeHit { + point: line.end, + normal: Vector3::new(0., 1., 0.), + surface_tag: 7, + })); + output.start(line.start); + output.publish(None); + assert!(!output.hit); + assert_eq!(output.point, line.end); + assert_eq!(output.surface_tag, 7); + output.disable(); + assert_eq!(output, BoardProbeState::default()); + } +} diff --git a/crates/skate-core/src/physics/board_runtime.rs b/crates/skate-core/src/physics/board_runtime.rs new file mode 100644 index 00000000..92a3ee66 --- /dev/null +++ b/crates/skate-core/src/physics/board_runtime.rs @@ -0,0 +1,403 @@ +//! Persistent board physics for the host game. +//! +//! Ownership and spawning are host policy. Physical initialization follows TU3 +//! AddRigidBody 82AE5928 and Reset 82776140: dynamic bodies start at rest with +//! gravity accumulated, while the separate hook has zero inverse mass/tensor. +//! Part pose conversion reuses 82BD4318/82C0B2C8; no packed body is retained +//! beside the state consumed and updated by BoardStep. +use crate::math::{Basis3, Vector3}; + +use super::{ + assembly::{BoardHook, BodySnapshot}, + board::{BODY_COUNT, BodyId}, + board_pose::{PartPose, PoseMatrix, part_transform, set_board_transform, set_part_transform}, + board_step::{AttachedStep, BoardCollision, BoardStep, BoardStepSettings}, + drive_frames::RetailAffineTransform, + force_queue::BoardForceQueue, + hook_drive::HookDriveState, + rigid_body::{ + RetailBodyMassProperties, RetailBodyRates, RetailInertiaDynamics, RetailLocalMassFrame, + RetailQuaternion, RetailSimulationStep, world_inverse_inertia, + }, +}; + +/// The host explicitly chooses the board's initial physical mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BoardMotion { + Active, + Frozen, + Static, +} + +impl BoardMotion { + fn flags(self) -> u32 { + match self { + Self::Active => 4, + Self::Frozen => 2, + Self::Static => 1, + } + } +} + +pub struct BoardRuntime { + bodies: [BodySnapshot; BODY_COUNT], + mass_frames: [RetailLocalMassFrame; BODY_COUNT], + hook: BoardHook, + step: BoardStep, + forces: BoardForceQueue, + ///Original assembly/part group: standard82C090C0=4, wipeout82C09160=7. + collision_group: u32, +} + +impl BoardRuntime { + /// `authored` contains part poses in board space. `spawn` places the deck + /// part in world space, and moves the other parts by the same rigid delta. + /// All mass properties come from the caller's physical data producer. + pub fn new( + masses: [RetailBodyMassProperties; BODY_COUNT], + authored: [RetailAffineTransform; BODY_COUNT], + spawn: RetailAffineTransform, + simulation: RetailSimulationStep, + mode: BoardMotion, + ) -> Self { + let mut parts = core::array::from_fn(|i| { + let mut part = PartPose { + transform: pose_words(authored[i]), + local_mass_frame: Some(mass_frame_words(masses[i].local_mass_frame)), + body: Some([0; 44]), + inertia: (mode != BoardMotion::Static).then(|| inertia_words(masses[i].dynamics)), + }; + set_part_transform(&mut part, pose_words(authored[i])); + part + }); + let mut hook_part = PartPose { + transform: pose_words(RetailAffineTransform::IDENTITY), + local_mass_frame: None, + body: Some([0; 44]), + inertia: None, + }; + set_board_transform(&mut parts, &mut hook_part, pose_words(spawn)); + let bodies = core::array::from_fn(|i| { + initialized_body(&parts[i], masses[i].dynamics, simulation, mode) + }); + // CreateHookDrive 82C0D330 creates its own STATIC_BODY target. It does + // not alias the deck or a global world body. Its mass calculation is + // irrelevant to physical response because this body has no inertia. + let hook = BoardHook { + body: initialized_body(&hook_part, ZERO_INERTIA, simulation, BoardMotion::Static), + drive: HookDriveState::initial(), + }; + Self { + bodies, + mass_frames: masses.map(|mass| mass.local_mass_frame), + hook, + step: BoardStep::default(), + forces: BoardForceQueue::default(), + collision_group: 4, + } + } + + pub fn collision_group(&self) -> u32 { self.collision_group } + ///All seven native board parts share the current assembly group. + pub fn set_collision_group(&mut self, group: u32) { self.collision_group = group } + + pub fn bodies(&self) -> &[BodySnapshot; BODY_COUNT] { + &self.bodies + } + + /// Physical portion of82C05F50/82C0D680. Restore the authored part poses, + /// apply the requested stance and board transform, then clear rates. State + /// flags, cooldown, inertias and target-body rates retain their own owners. + pub fn reset_physical( + &mut self, + authored: [RetailAffineTransform; BODY_COUNT], + mut target: RetailAffineTransform, + processed_flags: u32, + gravity: Vector3, + ) { + let mut parts = core::array::from_fn(|i| PartPose { + transform: pose_words(authored[i]), + local_mass_frame: Some(mass_frame_words(self.mass_frames[i])), + body: Some(body_pose_words(self.bodies[i].rates)), + inertia: Some(inertia_words(self.bodies[i].inertia)), + }); + for i in [6, 0, 1, 2, 3, 4, 5] { + set_part_transform(&mut parts[i], pose_words(authored[i])); + } + if processed_flags & 0x0010_0000 != 0 { + for i in [0, 2] { + target.basis.columns[i] = target.basis.columns[i].map(|v| -v); + } + } + let mut hook = PartPose { + transform: pose_words(self.hook_transform()), + local_mass_frame: None, + body: Some(body_pose_words(self.hook.body.rates)), + inertia: None, + }; + set_board_transform(&mut parts, &mut hook, pose_words(target)); + for (body, part) in self.bodies.iter_mut().zip(&parts) { + copy_pose(part, &mut body.rates); + body.rates.linear_velocity = Vector3::ZERO; + body.rates.angular_velocity = Vector3::ZERO; + body.rates.force_acceleration = gravity; + body.rates.torque_acceleration = Vector3::ZERO; + body.rates.world_inverse_inertia = world_inverse_inertia( + body.rates.basis, body.inertia.inverse_tensor, + ); + } + copy_pose(&hook, &mut self.hook.body.rates); + self.forces.clear(); + // Completed contacts belong to the old pose; host storage can be + // discarded without rebuilding physical bodies or their constraints. + self.step = BoardStep::default(); + } + + /// Gameplay may publish actual velocities/accumulators or physical mode + /// changes here. Rendering should use the immutable view or part poses. + pub fn bodies_mut(&mut self) -> &mut [BodySnapshot; BODY_COUNT] { + &mut self.bodies + } + + pub fn hook(&self) -> &BoardHook { + &self.hook + } + + /// Animation/state services own the hook target and live drive parameters. + pub fn hook_mut(&mut self) -> &mut BoardHook { + &mut self.hook + } + + pub fn forces(&self) -> &BoardForceQueue { + &self.forces + } + + pub fn contact_reports(&self) -> &[super::contact_feedback::BoardContactReport] { + self.step.contact_reports() + } + + /// Original post-solver observers consume these completed shared rows. + pub fn solved_contacts(&self) -> &[super::contact_solver::RetailContactJacobian] { + self.step.solved_contacts() + } + + pub fn forces_mut(&mut self) -> &mut BoardForceQueue { + &mut self.forces + } + + /// Queue clearing is a separate gameplay phase, as in GeneralUpdate + /// 82C02360. Advancing physics does not silently consume its ownership. + pub fn clear_forces(&mut self) { + self.forces.clear(); + } + + pub fn advance( + &mut self, + collisions: &[BoardCollision], + truck_targets: [f32; 2], + settings: BoardStepSettings, + ) { + self.step.advance( + &mut self.bodies, + &mut self.hook, + &self.forces, + collisions, + truck_targets, + settings, + ); + } + + ///The skater, board and hook constraints exchange reactions in every + ///iteration; this never advances the skeleton in a separate solver. + pub fn advance_attached( + &mut self, + collisions: &[BoardCollision], + truck_targets: [f32; 2], + settings: BoardStepSettings, + attached: AttachedStep<'_>, + ) { + self.step.advance_attached( + &mut self.bodies, + &mut self.hook, + &self.forces, + collisions, + truck_targets, + settings, + attached, + ); + } + + /// Live center-of-mass pose for collision and other physical consumers. + pub fn body_transform(&self, id: BodyId) -> RetailAffineTransform { + rates_transform(self.bodies[id.index()].rates) + } + + /// Live visual/collision part poses, including each mass-frame offset. + /// This derives poses from the integrated bodies rather than stale spawn + /// matrices, and never writes physics from presentation. + pub fn part_transforms(&self) -> [RetailAffineTransform; BODY_COUNT] { + core::array::from_fn(|i| { + let part = PartPose { + transform: pose_words(rates_transform(self.bodies[i].rates)), + local_mass_frame: Some(mass_frame_words(self.mass_frames[i])), + body: Some(body_pose_words(self.bodies[i].rates)), + inertia: None, + }; + transform_from_words(part_transform(&part)) + }) + } + + pub fn hook_transform(&self) -> RetailAffineTransform { + rates_transform(self.hook.body.rates) + } + + /// Publish an animation target while retaining the hook's zero physical + /// response and its separate drive state. + pub fn set_hook_transform(&mut self, requested: RetailAffineTransform) { + let mut part = PartPose { + transform: pose_words(self.hook_transform()), + local_mass_frame: None, + body: Some(body_pose_words(self.hook.body.rates)), + inertia: None, + }; + set_part_transform(&mut part, pose_words(requested)); + copy_pose(&part, &mut self.hook.body.rates); + } +} + +const ZERO_INERTIA: RetailInertiaDynamics = RetailInertiaDynamics { + inverse_tensor: Vector3::ZERO, + inverse_mass: 0.0, + spherical: 0.0, + maximum_linear_velocity: 0.0, + maximum_angular_velocity: 0.0, + linear_drag: 0.0, + angular_drag: 0.0, +}; + +pub(crate) fn initialized_body( + part: &PartPose, + inertia: RetailInertiaDynamics, + simulation: RetailSimulationStep, + mode: BoardMotion, +) -> BodySnapshot { + let is_static = mode == BoardMotion::Static; + let inertia = if is_static { ZERO_INERTIA } else { inertia }; + let mut rates = RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: RetailAffineTransform::IDENTITY.basis, + world_inverse_inertia: Basis3 { + columns: [[0.0; 3]; 3], + }, + position: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: if is_static { + Vector3::ZERO + } else { + simulation.gravity_acceleration + }, + torque_acceleration: Vector3::ZERO, + kinetic_energy: if is_static { 0.0 } else { f32::MAX }, + cool_down: if mode == BoardMotion::Active { + 0 + } else { + simulation.cool_down + }, + }; + copy_pose(part, &mut rates); + rates.world_inverse_inertia = world_inverse_inertia(rates.basis, inertia.inverse_tensor); + BodySnapshot { + state_flags: mode.flags(), + rates, + inertia, + } +} + +fn copy_pose(part: &PartPose, rates: &mut RetailBodyRates) { + let words = part + .body + .as_ref() + .expect("transient pose adapter has a body"); + let f = |i| f32::from_bits(words[i]); + rates.orientation = RetailQuaternion { + x: f(0), + y: f(1), + z: f(2), + w: f(3), + }; + rates.position = Vector3::new(f(4), f(5), f(6)); + rates.basis = Basis3 { + columns: [ + [f(16), f(17), f(18)], + [f(20), f(21), f(22)], + [f(24), f(25), f(26)], + ], + }; +} + +fn rates_transform(rates: RetailBodyRates) -> RetailAffineTransform { + RetailAffineTransform { + basis: rates.basis, + translation: rates.position, + } +} + +fn body_pose_words(rates: RetailBodyRates) -> [u32; 44] { + let mut words = [0; 44]; + let q = rates.orientation; + words[..4].copy_from_slice(&[q.x, q.y, q.z, q.w].map(f32::to_bits)); + words[4..7] + .copy_from_slice(&[rates.position.x, rates.position.y, rates.position.z].map(f32::to_bits)); + for (axis, column) in rates.basis.columns.into_iter().enumerate() { + words[16 + axis * 4..19 + axis * 4].copy_from_slice(&column.map(f32::to_bits)); + } + words +} + +fn inertia_words(inertia: RetailInertiaDynamics) -> [u32; 10] { + [ + inertia.inverse_tensor.x, + inertia.inverse_tensor.y, + inertia.inverse_tensor.z, + 0.0, + inertia.inverse_mass, + inertia.spherical, + inertia.maximum_linear_velocity, + inertia.maximum_angular_velocity, + inertia.linear_drag, + inertia.angular_drag, + ] + .map(f32::to_bits) +} + +fn mass_frame_words(frame: RetailLocalMassFrame) -> PoseMatrix { + pose_words(RetailAffineTransform { + basis: frame.basis, + translation: frame.translation, + }) +} + +fn pose_words(transform: RetailAffineTransform) -> PoseMatrix { + let mut words = [0; 16]; + for (axis, column) in transform.basis.columns.into_iter().enumerate() { + words[axis * 4..axis * 4 + 3].copy_from_slice(&column.map(f32::to_bits)); + } + let t = transform.translation; + words[12..15].copy_from_slice(&[t.x, t.y, t.z].map(f32::to_bits)); + words +} + +fn transform_from_words(words: PoseMatrix) -> RetailAffineTransform { + let f = |i| f32::from_bits(words[i]); + RetailAffineTransform { + basis: Basis3 { + columns: [[f(0), f(1), f(2)], [f(4), f(5), f(6)], [f(8), f(9), f(10)]], + }, + translation: Vector3::new(f(12), f(13), f(14)), + } +} + +#[cfg(test)] +#[path = "tests/board_runtime.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board_step.rs b/crates/skate-core/src/physics/board_step.rs new file mode 100644 index 00000000..f61023da --- /dev/null +++ b/crates/skate-core/src/physics/board_step.rs @@ -0,0 +1,320 @@ +//! The connected-board path through TU3's simulation frame. +//! +//! Sources: simulation_job_setup_resolution.json (82DC35E0), +//! drive_solver_handoff_resolution.json (82DC3098 / 82AE27D0 / 82AE6590). +//! This owns compilation, shared reactions and integration. Collision queries +//! and ground/action-state force production have separate owners. +use crate::math::Vector3; + +use super::{ + assembly::{BoardConstraints, BoardHook, BodySnapshot, prepare_drive_frames}, + board::{BODY_COUNT, BodyId}, + contact::{RetailContactBodyState, RetailContactInput, generate_contact}, + contact_feedback::{BoardContactReport, board_reports}, + contact_solver::{RetailContactJacobian, build_contact_jacobian}, + drive_frames::RetailAffineTransform, + drive_parameters::RetailDriveDynamics, + drive_solver::RetailDriveRows, + force_queue::BoardForceQueue, + point_force::RetailForceAccumulator, + rigid_body::{ + RetailReactionCorrections, RetailSimulationStep, integrate_body_rates, + pack_world_inverse_inertia, + }, + solver::{JointConstraint, solve_constraints}, +}; + +#[cfg(test)] +const WORLD_REACTION: usize = BODY_COUNT + 1; +#[cfg(test)] +const REACTION_COUNT: usize = BODY_COUNT + 2; +///Board parts0..6 and their separate hook7 precede the physical skater. +pub const ATTACHED_REACTION_BASE: usize = BODY_COUNT + 1; + +///Other assemblies participating in this SAME solve. References point to the +///actual bodies owned by the physical skeleton and its hooks. The caller builds +///their rows after publishing this tick's physical drive targets and forces. +pub struct AttachedStep<'a> { + pub bodies: Vec<&'a mut BodySnapshot>, + pub contacts: &'a mut [RetailContactJacobian], + pub joints: &'a mut [JointConstraint], + pub drives: &'a mut [RetailDriveRows], +} +impl AttachedStep<'_> { + pub fn world_reaction(&self) -> usize { + ATTACHED_REACTION_BASE + self.bodies.len() + } +} + +/// Narrow-phase output, in the collision provider's original A/B order. +/// Materials must already be combined by `combine_contact_materials`. +#[derive(Clone, Copy, Debug)] +pub struct BoardCollision { + pub body_a: CollisionBody, + pub body_b: CollisionBody, + pub contact: RetailContactInput, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CollisionBody { + Board(BodyId), + /// Index in AttachedStep::bodies, referencing the actual physical body. + Attached(usize), + StaticWorld, +} + +impl CollisionBody { + pub fn contact_id(self) -> u32 { + match self { + Self::Board(id) => id.index() as u32, + Self::Attached(index) => u32::try_from(ATTACHED_REACTION_BASE + index) + .expect("attached contact body index exceeds host storage"), + Self::StaticWorld => u32::MAX, + } + } + + pub fn from_contact_id(id: u32) -> Self { + if id == u32::MAX { + Self::StaticWorld + } else if id < BODY_COUNT as u32 { + Self::Board(BodyId::ORDER[id as usize]) + } else { + assert!(id >= ATTACHED_REACTION_BASE as u32, "board target has no collision volume"); + Self::Attached(id as usize - ATTACHED_REACTION_BASE) + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct BoardStepSettings { + pub simulation: RetailSimulationStep, + /// Loaded from Simulation+176 by 82DC3098. Not a fixed solver constant. + pub iterations: u32, + pub base_truck_transforms: [RetailAffineTransform; 2], + pub truck_dynamics: RetailDriveDynamics, + pub force_point_y_offset: f32, +} + +/// Reuses the contact workspace between ticks. Seven board bodies and the +/// separate hook share solver reactions. This does not assign islands or implement the +/// native activation partition for multiple disconnected assemblies. +#[derive(Default)] +pub struct BoardStep { + contacts: Vec, + reports: Vec, + reactions: Vec, +} + +impl BoardStep { + pub fn contact_reports(&self) -> &[BoardContactReport] { + &self.reports + } + pub fn solved_contacts(&self) -> &[RetailContactJacobian] { + &self.contacts + } + /// The caller supplies current poses, processed steering targets, queued + /// forces and narrow-phase contacts. It owns the separate force-queue reset + /// in GeneralUpdate (82C02360); consuming forces does not clear the queue. + pub fn advance( + &mut self, + bodies: &mut [BodySnapshot; BODY_COUNT], + hook: &mut BoardHook, + forces: &BoardForceQueue, + collisions: &[BoardCollision], + truck_targets: [f32; 2], + settings: BoardStepSettings, + ) { + self.advance_attached( + bodies, + hook, + forces, + collisions, + truck_targets, + settings, + AttachedStep { + bodies: Vec::new(), + contacts: &mut [], + joints: &mut [], + drives: &mut [], + }, + ); + } + + pub fn advance_attached( + &mut self, + bodies: &mut [BodySnapshot; BODY_COUNT], + hook: &mut BoardHook, + forces: &BoardForceQueue, + collisions: &[BoardCollision], + truck_targets: [f32; 2], + settings: BoardStepSettings, + mut attached: AttachedStep<'_>, + ) { + // StartFrame writes both timing fields (82DC2FB8). Use one dt for row + // construction and integration, and derive the reciprocal from it. + let mut simulation = settings.simulation; + assert!(simulation.time_step.is_finite() && simulation.time_step > 0.0); + simulation.frequency = 1.0 / simulation.time_step; + for collision in collisions { + assert_ne!(collision.body_a, collision.body_b); + } + + apply_deck_forces(bodies, forces, settings.force_point_y_offset); + let frames = prepare_drive_frames(settings.base_truck_transforms, truck_targets, hook); + let world_reaction = attached.world_reaction(); + self.reactions + .resize(world_reaction + 1, RetailReactionCorrections::default()); + self.reactions.fill(RetailReactionCorrections::default()); + self.contacts.clear(); + self.reports.clear(); + if bodies + .iter() + .chain(core::iter::once(&hook.body)) + .chain(attached.bodies.iter().map(|body| &**body)) + .all(|body| body.state_flags & 4 == 0) + { + return; + } + for collision in collisions { + // Copy force/torque workspaces AFTER force accumulation. Otherwise + // ContactBatchBuild cannot oppose this frame's applied forces. + let contact = generate_contact( + collision.contact, + contact_body(collision.body_a, bodies, &attached.bodies, world_reaction), + contact_body(collision.body_b, bodies, &attached.bodies, world_reaction), + ); + self.contacts + .push(build_contact_jacobian(contact, simulation.time_step)); + } + let mut constraints = BoardConstraints::build( + bodies, + hook, + frames, + settings.truck_dynamics, + simulation.time_step, + ); + let board_contacts = self.contacts.len(); + let board_joints = constraints.joints.len(); + let board_drives = constraints.drives.len(); + self.contacts.extend_from_slice(attached.contacts); + constraints.joints.extend_from_slice(attached.joints); + constraints.drives.extend_from_slice(attached.drives); + solve_constraints( + &mut self.contacts, + &mut constraints.joints, + &mut constraints.drives, + &mut self.reactions, + settings.iterations, + ); + // Every solver family finishes before ANY body integrates. The native + // job tree places BatchIntegrator after all island solver jobs. + for (body, reaction) in bodies + .iter_mut() + .chain(core::iter::once(&mut hook.body)) + .chain(attached.bodies.iter_mut().map(|body| &mut **body)) + .zip(&mut self.reactions) + { + if body.state_flags & 4 != 0 { + body.rates = + integrate_body_rates(body.rates, body.inertia, simulation, *reaction).state; + } + // DynamicUpdate clears all four working vectors, not just the + // velocity-producing pair. Accumulated rows are rebuilt next tick. + *reaction = RetailReactionCorrections::default(); + } + // ContactSpiesJob follows integration (82DC35E0). Step_Solver3 then + // publishes reports before the physical Adjust/Output phases. + board_reports::collect( + &mut self.reports, + &self.contacts, + bodies, + simulation.frequency, + ); + //Physical output/spy consumers observe the rows from the shared solve. + attached + .contacts + .clone_from_slice(&self.contacts[board_contacts..]); + attached + .joints + .clone_from_slice(&constraints.joints[board_joints..]); + attached + .drives + .clone_from_slice(&constraints.drives[board_drives..]); + } +} + +fn apply_deck_forces( + bodies: &mut [BodySnapshot; BODY_COUNT], + forces: &BoardForceQueue, + y_offset: f32, +) { + let deck = &mut bodies[BodyId::Deck.index()]; + let result = forces.apply_to_deck( + RetailForceAccumulator { + force_acceleration: deck.rates.force_acceleration, + torque_acceleration: deck.rates.torque_acceleration, + cool_down: deck.rates.cool_down, + }, + deck.rates.basis, + deck.inertia.inverse_mass, + deck.rates.world_inverse_inertia, + y_offset, + ); + deck.rates.force_acceleration = result.force_acceleration; + deck.rates.torque_acceleration = result.torque_acceleration; + deck.rates.cool_down = result.cool_down; +} + +fn contact_body( + body: CollisionBody, + bodies: &[BodySnapshot; BODY_COUNT], + attached: &[&mut BodySnapshot], + world_reaction: usize, +) -> RetailContactBodyState { + if body == CollisionBody::StaticWorld { + return RetailContactBodyState { + contact_body_id: u32::MAX, + reaction_id: world_reaction as u32, + center_of_mass: Vector3::ZERO, + inverse_inertia_full: Vector3::ZERO, + inverse_inertia_split: Vector3::ZERO, + inverse_mass: 0.0, + state: 0, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }; + }; + let contact_id = body.contact_id(); + let body = match body { + CollisionBody::Board(id) => &bodies[id.index()], + CollisionBody::Attached(index) => &*attached[index], + CollisionBody::StaticWorld => unreachable!(), + }; + let rates = body.rates; + let inertia = pack_world_inverse_inertia(rates.world_inverse_inertia); + RetailContactBodyState { + contact_body_id: contact_id, + reaction_id: contact_id, + center_of_mass: rates.position, + inverse_inertia_full: inertia.full, + inverse_inertia_split: inertia.split, + inverse_mass: body.inertia.inverse_mass, + // The physical assemblies subscribe to post-solver feedback. Bit 8 + // requests contact observations; it does not alter dynamic eligibility. + state: body.state_flags | 8, + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, + kinetic_energy: rates.kinetic_energy, + cool_down: rates.cool_down, + } +} + +#[cfg(test)] +#[path = "tests/board_step.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board_toolkit.rs b/crates/skate-core/src/physics/board_toolkit.rs new file mode 100644 index 00000000..f387f4eb --- /dev/null +++ b/crates/skate-core/src/physics/board_toolkit.rs @@ -0,0 +1,144 @@ +//! Skateboard::PrepareBoardToolkit82C013F0, TU3. Derived physical axes and +//! filtered normal are calculated from the same live deck used by the solver. +use super::{ + board::BodyId, board_motion_output::inverse_length_squared, board_runtime::BoardRuntime, + force_queue::total_body_mass, native_arithmetic::dot3, +}; + +#[derive(Clone, Copy, Debug)] +pub struct BoardToolkit { + pub deck: [[f32; 4]; 4], + pub effective: [[f32; 4]; 4], + pub inverse_effective: [[f32; 4]; 4], + pub side: [f32; 4], + pub up: [f32; 4], + pub forward: [f32; 4], + pub horizontal_forward: [f32; 4], + pub transverse_up: [f32; 4], + pub forward_velocity: [f32; 4], + pub travel_direction: [f32; 4], + pub filtered_normal: [f32; 4], + pub absolute_speed: f32, + pub control_sign: f32, + pub total_mass: f32, +} +impl BoardToolkit { + /// Board192 is retained by the owner and replaced by filtered_normal after + /// this call. Reset must supply the recovered retained normal, not derive + /// grounded state from board height. + pub fn from_board( + board: &BoardRuntime, + flags_2468: u32, + speed_2612: f32, + ground_normal_464: [f32; 4], + retained_normal: [f32; 4], + ) -> Self { + let part = board.part_transforms()[BodyId::Deck.index()]; + let axis = |i: usize| { + let c = part.basis.columns[i]; + [c[0], c[1], c[2], 0.0] + }; + let deck = [ + axis(0), + axis(1), + axis(2), + [ + part.translation.x, + part.translation.y, + part.translation.z, + 1.0, + ], + ]; + let masses = board.bodies().map(|body| body.inertia.inverse_mass); + Self::calculate( + deck, + &masses, + flags_2468, + speed_2612, + ground_normal_464, + retained_normal, + ) + } + pub fn calculate( + deck: [[f32; 4]; 4], + inverse_masses: &[f32], + flags: u32, + speed: f32, + normal: [f32; 4], + retained_normal: [f32; 4], + ) -> Self { + let control_sign = if flags & 0x0010_0000 != 0 { -1.0 } else { 1.0 }; + let mut effective = deck; + for axis in [0, 2] { + effective[axis] = deck[axis].map(|v| v * control_sign); + } + //82C0149C..150C transposes XYZ, clears all W lanes, and composes the + //negative translation in Z,Y,X fused order. Native inverse W is zero. + let mut inverse_effective = [[0.0; 4]; 4]; + for axis in 0..3 { + for lane in 0..3 { + inverse_effective[axis][lane] = effective[lane][axis]; + } + } + for lane in 0..4 { + let z = (0.0 - effective[3][2]) * inverse_effective[2][lane]; + let yz = (0.0 - effective[3][1]).mul_add(inverse_effective[1][lane], z); + inverse_effective[3][lane] = + (0.0 - effective[3][0]).mul_add(inverse_effective[0][lane], yz); + } + let side = effective[0]; + let up = deck[1]; + let forward = effective[2]; + let mut horizontal_forward = forward; + horizontal_forward[1] = 0.0; + let horizontal_squared = dot3(horizontal_forward, horizontal_forward); + if horizontal_squared > f32::from_bits(0x3727_c5ac) { + horizontal_forward = + horizontal_forward.map(|v| v * inverse_length_squared(horizontal_squared, 2)); + } + let parallel = dot3(up, horizontal_forward); + let rejected = std::array::from_fn(|i| up[i] - horizontal_forward[i] * parallel); + let inverse = inverse_length_squared(dot3(rejected, rejected), 2); + let transverse_up = rejected.map(|v| v * inverse); + //The bgt takes only ordered positive values; zero and NaN use -1. + let travel_sign = if speed > 0.0 { 1.0 } else { -1.0 }; + let filtered_normal = if !(dot3(retained_normal, normal) <= f32::from_bits(0x3f6f_5c29)) { + normal + } else { + let blended = std::array::from_fn(|i| { + retained_normal[i].mul_add( + f32::from_bits(0x3f66_6666), + normal[i] * f32::from_bits(0x3dcc_cccd), + ) + }); + let squared = dot3(blended, blended); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + if length > f32::from_bits(0x3586_37bd) { + blended.map(|v| v * inverse) + } else { + [0.0; 4] + } + }; + Self { + deck, + effective, + inverse_effective, + side, + up, + forward, + horizontal_forward, + transverse_up, + forward_velocity: forward.map(|v| v * speed), + travel_direction: forward.map(|v| v * travel_sign), + filtered_normal, + absolute_speed: speed * travel_sign, + control_sign, + total_mass: total_body_mass(inverse_masses), + } + } +} diff --git a/crates/skate-core/src/physics/board_world.rs b/crates/skate-core/src/physics/board_world.rs new file mode 100644 index 00000000..7aba963b --- /dev/null +++ b/crates/skate-core/src/physics/board_world.rs @@ -0,0 +1,336 @@ +//! Contacts for every board primitive against the host triangle world. +//! Rust owns geometry storage and traversal. Recovered query8277B720, +//! triangle fixup82AD3130, material combine82763078 and contact retention +//! determine the physical result, in world-triangle then moving-volume order. +pub mod query_metadata; +use crate::math::Vector3; +use query_metadata::QueryMetadata; + +use super::{ + board::BodyId, + board_runtime::BoardRuntime, + board_step::{BoardCollision, CollisionBody}, + collision::{Sphere, Triangle, WorldContactSettings}, + contact::{RetailContactInput, RetailContactMaterial, combine_contact_materials}, + triangle_query::{TriangleLineHit, triangle_segment}, + world_contact::{ + ContactBuffer, ContactPrimitive, ContactRecord, primitive_triangle_world_contacts, + triangle_from_volume, + }, +}; + +#[derive(Clone, Copy, Debug)] +pub struct WorldTriangle { + pub triangle: Triangle, + pub material: RetailContactMaterial, + pub tag: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WorldLineHit { + pub geometry: TriangleLineHit, + pub tag: u32, +} + +impl WorldTriangle { + /// Host geometry preparation, with explicit adjacency and sidedness. + /// Uses the native Volume constructor and its authored winding. Invalid + /// host geometry is rejected without substituting a repaired triangle. + pub fn from_vertices( + vertices: [Vector3; 3], + material: RetailContactMaterial, + tag: u32, + flags: u32, + edge_cosines: [f32; 3], + fatness: f32, + ) -> Option { + if vertices + .iter() + .any(|v| !v.x.is_finite() || !v.y.is_finite() || !v.z.is_finite()) + { + return None; + } + let triangle = triangle_from_volume(vertices, fatness, edge_cosines, flags); + let normal = triangle.feature.normal; + if triangle + .edge_lengths + .iter() + .any(|&v| !v.is_finite() || v <= 0.0) + || !normal.x.is_finite() + || !normal.y.is_finite() + || !normal.z.is_finite() + || normal == Vector3::ZERO + { + return None; + } + Some(Self { + triangle, + material, + tag, + }) + } +} + +/// Caller-resolved TempContactBuffer settings. Retention changes solver rows, +/// so its thresholds/capacity remain explicit instead of using tuned defaults. +#[derive(Clone, Copy, Debug)] +pub struct ContactRetentionSettings { + pub capacity: u32, + pub duplicate_distance_squared: f32, + pub deferred_reduction: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct WheelWorldSettings { + /// Sphere shared by wheel definitions in82C0AA78. + pub radius: f32, + pub material: RetailContactMaterial, + pub query: WorldContactSettings, + pub retention: ContactRetentionSettings, +} + +/// One enabled moving primitive, with its owning solver body and material. +#[derive(Clone, Copy, Debug)] +pub struct BoardWorldVolume { + pub body: CollisionBody, + pub primitive: ContactPrimitive, + pub linear_velocity: Vector3, + pub material: RetailContactMaterial, +} + +/// World geometry remains in supplied order. Each query reuses output storage; +/// the returned contacts are valid until the next mutable call. +pub struct BoardWorld { + triangles: Vec, + query_metadata: Option, + contacts: Vec, + buffer: ContactBuffer, +} + +impl BoardWorld { + pub fn new(triangles: Vec) -> Self { + Self { + triangles, + query_metadata: None, + contacts: Vec::new(), + buffer: ContactBuffer { + count: 0, + flushed: 0, + capacity: 0, + dropped: 0, + distance_squared_threshold: 0.0, + allow_flush: 1, + deferred_reduction: 0, + full: 0, + records: [[0; 64]; 50], + }, + } + } + + pub fn with_query_metadata( + triangles: Vec, + metadata: QueryMetadata, + ) -> Result { + metadata.validate(&triangles)?; + let mut world = Self::new(triangles); + world.query_metadata = Some(metadata); + Ok(world) + } + + pub fn query_metadata(&self) -> Result<&QueryMetadata, &'static str> { + self.query_metadata + .as_ref() + .ok_or("Canonical world has no authored query metadata") + } + + pub fn triangles(&self) -> &[WorldTriangle] { + &self.triangles + } + + /// Board wheel segments use zero query radius; rounded world triangles + /// still select the native swept branch through their own fatness. + pub fn query_thin_line( + &self, + start: Vector3, + end: Vector3, + ) -> Result, &'static str> { + self.query_swept_line(start, end, 0.0) + } + + /// Ordered nearest-hit traversal for wheel and camera queries. The host + /// owns traversal; each candidate uses the complete TU3 triangle dispatcher. + pub fn query_swept_line( + &self, + start: Vector3, + end: Vector3, + radius: f32, + ) -> Result, &'static str> { + if !radius.is_finite() || radius < 0.0 { + return Err("line query radius must be finite and nonnegative"); + } + let direction = Vector3::new(end.x - start.x, end.y - start.y, end.z - start.z); + let mut nearest: Option = None; + for entry in &self.triangles { + let mut geometry = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.0; 3], + }; + if triangle_segment( + &mut geometry, + start, + direction, + entry.triangle.vertices, + radius, + entry.triangle.fatness, + ) { + if nearest + .as_ref() + .is_none_or(|hit| geometry.fraction < hit.geometry.fraction) + { + nearest = Some(WorldLineHit { + geometry, + tag: entry.tag, + }); + } + } + } + Ok(nearest) + } + + /// Sphere-only convenience for callers querying wheels. The game uses + /// query_primitives with all enabled deck/truck/wheel children. + pub fn query( + &mut self, + board: &BoardRuntime, + settings: WheelWorldSettings, + ) -> &[BoardCollision] { + let poses = board.part_transforms(); + let volumes: Vec<_> = BodyId::ORDER[..4] + .iter() + .filter_map(|&id| { + let body = &board.bodies()[id.index()]; + (body.state_flags != 1).then_some(BoardWorldVolume { + body: CollisionBody::Board(id), + primitive: ContactPrimitive::Sphere(Sphere { + center: poses[id.index()].translation, + radius: settings.radius, + }), + linear_velocity: body.rates.linear_velocity, + material: settings.material, + }) + }) + .collect(); + self.query_primitives(&volumes, settings.query, settings.retention) + } + + /// World-space shapes come from the live part poses and authored children. + /// The host's small level visits every pair, preserving source query order + /// without a broad-phase bound that could drop valid predictive contacts. + pub fn query_primitives( + &mut self, + volumes: &[BoardWorldVolume], + query: WorldContactSettings, + retention: ContactRetentionSettings, + ) -> &[BoardCollision] { + self.contacts.clear(); + self.buffer.count = 0; + self.buffer.flushed = 0; + self.buffer.dropped = 0; + self.buffer.full = 0; + self.buffer.capacity = retention.capacity; + self.buffer.distance_squared_threshold = retention.duplicate_distance_squared; + self.buffer.deferred_reduction = u8::from(retention.deferred_reduction); + let output = &mut self.contacts; + let mut publish = |records: &[ContactRecord]| { + output.extend(records.iter().map(collision_from_record)); + }; + for entry in &self.triangles { + for volume in volumes { + let Some(manifold) = primitive_triangle_world_contacts( + volume.primitive, + entry.triangle, + volume.linear_velocity, + query, + ) else { + continue; + }; + let material = combine_contact_materials(volume.material, entry.material); + for pair in &manifold.points[..manifold.count] { + let contact = RetailContactInput { + position_on_a: pair.a, + position_on_b: pair.b, + normal: manifold.normal, + restitution: material.restitution, + static_friction: material.static_friction, + dynamic_friction: material.dynamic_friction, + tag: entry.tag, + }; + let Some(slot) = self.buffer.allocate(&mut publish) else { + self.buffer.flush(&mut publish); + return &self.contacts; + }; + self.buffer.records[slot] = retention_record(volume.body, contact); + //8277C23C removes the most recent record on rejection. + if self.buffer.last_is_duplicate() { + self.buffer.count -= 1; + } + } + } + } + self.buffer.flush(&mut publish); + &self.contacts + } + + pub fn contacts(&self) -> &[BoardCollision] { + &self.contacts + } + + pub fn dropped_contacts(&self) -> u32 { + self.buffer.dropped + } +} + +/// Retention reads only contact header geometry, body IDs and material/tag. +/// BoardStep builds the actual body workspaces after this frame's force queue +/// has been applied, preventing stale acceleration copies in solver contacts. +fn retention_record(id: CollisionBody, contact: RetailContactInput) -> ContactRecord { + let mut record = [0; 64]; + for (offset, v) in [ + (0, contact.position_on_a), + (4, contact.position_on_b), + (8, contact.normal), + ] { + record[offset..offset + 3].copy_from_slice(&[v.x, v.y, v.z].map(f32::to_bits)); + } + record[3] = id.contact_id(); + record[7] = u32::MAX; + record[11] = contact.restitution.to_bits(); + record[15] = contact.static_friction.to_bits(); + record[19] = contact.dynamic_friction.to_bits(); + record[23] = contact.tag; + record +} + +fn collision_from_record(record: &ContactRecord) -> BoardCollision { + let f = |i| f32::from_bits(record[i]); + let vector = |i| Vector3::new(f(i), f(i + 1), f(i + 2)); + BoardCollision { + body_a: CollisionBody::from_contact_id(record[3]), + body_b: CollisionBody::StaticWorld, + contact: RetailContactInput { + position_on_a: vector(0), + position_on_b: vector(4), + normal: vector(8), + restitution: f(11), + static_friction: f(15), + dynamic_friction: f(19), + tag: record[23], + }, + } +} + +#[cfg(test)] +#[path = "tests/board_world.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/board_world/query_metadata.rs b/crates/skate-core/src/physics/board_world/query_metadata.rs new file mode 100644 index 00000000..e307f432 --- /dev/null +++ b/crates/skate-core/src/physics/board_world/query_metadata.rs @@ -0,0 +1,139 @@ +//! Canonical authored scene metadata; independent of rendering tags and actors. +use super::WorldTriangle; +use crate::{math::Vector3, physics::drive_frames::RetailAffineTransform}; +use std::ops::Range; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QueryPool { + Ground, + Island, + Conditional, +} + +#[derive(Clone, Copy, Debug)] +pub struct Bounds { + pub min: Vector3, + pub max: Vector3, +} +impl Bounds { + pub fn from_points(points: impl IntoIterator) -> Option { + let mut points = points.into_iter(); + let first = points.next()?; + if !finite(first) { + return None; + } + let mut bounds = Self { + min: first, + max: first, + }; + for p in points { + if !finite(p) { + return None; + } + bounds.min = Vector3::new( + bounds.min.x.min(p.x), + bounds.min.y.min(p.y), + bounds.min.z.min(p.z), + ); + bounds.max = Vector3::new( + bounds.max.x.max(p.x), + bounds.max.y.max(p.y), + bounds.max.z.max(p.z), + ); + } + Some(bounds) + } + fn contains(self, p: Vector3) -> bool { + finite(p) + && p.x >= self.min.x + && p.x <= self.max.x + && p.y >= self.min.y + && p.y <= self.max.y + && p.z >= self.min.z + && p.z <= self.max.z + } + fn valid(self) -> bool { + finite(self.min) + && finite(self.max) + && self.min.x <= self.max.x + && self.min.y <= self.max.y + && self.min.z <= self.max.z + } +} + +#[derive(Clone, Copy, Debug)] +pub struct EdgeSegment { + pub start: Vector3, + pub end: Vector3, + pub local_bounds: Bounds, +} + +#[derive(Clone, Debug)] +pub struct QueryMesh { + pub triangle_range: Range, + pub local_to_world: RetailAffineTransform, + pub world_to_local: RetailAffineTransform, + pub local_bounds: Bounds, + pub matching_group: i32, + pub pool: QueryPool, +} + +#[derive(Clone, Debug)] +pub struct QueryMetadata { + /// Exact authored uint16 codes, in canonical triangle order. Low7 material, + ///next5 category, upper4 flags; never synthesized from WorldTriangle::tag. + pub packed_surfaces: Vec, + pub meshes: Vec, + /// Explicit level query edges; triangulation diagonals are not query edges. + pub static_edges: Vec, + pub island_flags: u32, +} +impl QueryMetadata { + pub(super) fn validate(&self, triangles: &[WorldTriangle]) -> Result<(), &'static str> { + if self.packed_surfaces.len() != triangles.len() { + return Err("Query surface count must equal canonical triangle count"); + } + let mut next = 0; + for mesh in &self.meshes { + if mesh.triangle_range.start != next + || mesh.triangle_range.end <= next + || mesh.triangle_range.end > triangles.len() + { + return Err("Query meshes must partition canonical triangles in supplied order"); + } + //BoardWorld collision currently owns world-space static triangles. + //Reject an unsupported scene transform rather than move query-only geometry. + if mesh.local_to_world != RetailAffineTransform::IDENTITY + || mesh.world_to_local != RetailAffineTransform::IDENTITY + { + return Err("BoardWorld static query meshes require identity transforms"); + } + if !mesh.local_bounds.valid() { + return Err("Invalid query mesh bounds"); + } + if triangles[mesh.triangle_range.clone()] + .iter() + .flat_map(|entry| entry.triangle.vertices) + .any(|p| !mesh.local_bounds.contains(p)) + { + return Err("Query mesh bounds exclude canonical triangle geometry"); + } + next = mesh.triangle_range.end; + } + if next != triangles.len() { + return Err("Query metadata leaves canonical triangles unassigned"); + } + for edge in &self.static_edges { + if !edge.local_bounds.valid() + || !edge.local_bounds.contains(edge.start) + || !edge.local_bounds.contains(edge.end) + { + return Err("Invalid authored query edge"); + } + } + Ok(()) + } +} +fn finite(p: Vector3) -> bool { + p.x.is_finite() && p.y.is_finite() && p.z.is_finite() +} diff --git a/crates/skate-core/src/physics/centre_of_mass_filter.rs b/crates/skate-core/src/physics/centre_of_mass_filter.rs new file mode 100644 index 00000000..01878704 --- /dev/null +++ b/crates/skate-core/src/physics/centre_of_mass_filter.rs @@ -0,0 +1,83 @@ +//! Physical-output COM conditioning: original TU3 82DE56B0/82DE5978. +//! Advances once after physical Skeleton publishes position64 and velocity16. +use super::native_arithmetic::{dot3, reciprocal_estimate}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CentreOfMassOutput { + /// PhysOutSystemReckoning0, used by animation and camera consumers. + pub velocity: [f32; 4], + /// PhysOutSystemReckoning32. + pub acceleration: [f32; 4], + /// PhysOutSystemReckoning80. + pub position: [f32; 4], +} + +#[derive(Debug)] +pub struct CentreOfMassFilter { + velocity: [f32; 4], + position: [f32; 4], + position_velocity: [f32; 4], + accumulated_error: [f32; 4], + position_valid: bool, +} + +impl Default for CentreOfMassFilter { + fn default() -> Self { + // Original Reset82DE5588 clears vectors16/96/112/128 and byte191. + Self { + velocity: [0.0; 4], + position: [0.0; 4], + position_velocity: [0.0; 4], + accumulated_error: [0.0; 4], + position_valid: false, + } + } +} + +impl CentreOfMassFilter { + pub fn reset(&mut self) { + *self = Self::default(); + } + + pub fn update(&mut self, position: [f32; 4], velocity: [f32; 4]) -> CentreOfMassOutput { + let dt = f32::from_bits(0x3c888889); + let old_velocity = self.velocity; + self.velocity = std::array::from_fn(|i| old_velocity[i].mul_add(0.75, velocity[i] * 0.25)); + let mut inverse_dt = reciprocal_estimate(dt); + inverse_dt = inverse_dt.mul_add((-dt).mul_add(inverse_dt, 1.0), inverse_dt); + inverse_dt = inverse_dt.mul_add((-dt).mul_add(inverse_dt, 1.0), inverse_dt); + let acceleration = + std::array::from_fn(|i| (self.velocity[i] - old_velocity[i]) * inverse_dt); + if !self.position_valid { + self.position = position; + self.position_velocity = [0.0; 4]; + self.position_valid = true; + } else { + let coefficient: [f32; 4] = [0.05, 0.03, 0.05, 0.0]; + let unity: [f32; 4] = [1.0, 1.0, 1.0, 0.0]; + self.position_velocity = std::array::from_fn(|i| { + (unity[i] - coefficient[i]) + .mul_add(self.position_velocity[i], coefficient[i] * velocity[i]) + }); + let predicted: [f32; 4] = + std::array::from_fn(|i| self.position_velocity[i].mul_add(dt, self.position[i])); + self.position = std::array::from_fn(|i| { + (unity[i] - coefficient[i]).mul_add(predicted[i], coefficient[i] * position[i]) + }); + let error = std::array::from_fn(|i| position[i] - self.position[i]); + if dot3(error, self.accumulated_error) > 0.0 { + self.position = std::array::from_fn(|i| { + self.accumulated_error[i].mul_add(0.08, self.position[i]) + }); + } + self.accumulated_error = std::array::from_fn(|i| { + self.accumulated_error[i].mul_add(0.95, (position[i] - self.position[i]) * 0.05) + }); + } + CentreOfMassOutput { + velocity: self.velocity, + acceleration, + position: self.position, + } + } +} diff --git a/crates/skate-core/src/physics/collision/arithmetic.rs b/crates/skate-core/src/physics/collision/arithmetic.rs new file mode 100644 index 00000000..ad28763c --- /dev/null +++ b/crates/skate-core/src/physics/collision/arithmetic.rs @@ -0,0 +1,39 @@ +use crate::math::Vector3 as V; +use crate::physics::reciprocal_sqrt::estimate; + +pub(super) fn dot(a: V, b: V) -> f32 { + crate::physics::native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]) +} +pub(super) fn cross(a: V, b: V) -> V { + V::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +pub(super) fn scale(a: V, b: f32) -> V { + V::new(a.x * b, a.y * b, a.z * b) +} +pub(super) fn neg(a: V) -> V { + V::new(-a.x, -a.y, -a.z) +} +pub(super) fn sub(a: V, b: V) -> V { + V::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(super) fn madd(a: V, b: f32, c: V) -> V { + V::new( + a.x.mul_add(b, c.x), + a.y.mul_add(b, c.y), + a.z.mul_add(b, c.z), + ) +} +pub(super) fn inverse_length_squared(value: f32, refinements: u32) -> f32 { + let mut r = estimate(value); + for _ in 0..refinements { + r = (r * 0.5).mul_add((-value).mul_add(r * r, 1.0), r); + } + r +} +pub(super) fn normalize(a: V, refinements: u32) -> V { + scale(a, inverse_length_squared(dot(a, a), refinements)) +} diff --git a/crates/skate-core/src/physics/collision/edge.rs b/crates/skate-core/src/physics/collision/edge.rs new file mode 100644 index 00000000..79f6e12b --- /dev/null +++ b/crates/skate-core/src/physics/collision/edge.rs @@ -0,0 +1,21 @@ +//! TU3 EdgeCosTest / EdgeCosTestFull, 82AD2860 / 82AD2928. +use super::arithmetic::*; +use crate::math::Vector3; + +pub(super) fn edge_cos_test( + edge: Vector3, + face: Vector3, + normal: Vector3, + cosine: f32, + convex: bool, + two_sided: bool, +) -> bool { + if dot(normal, cross(edge, face)) < 0.0 { + return true; + } + if !convex && !two_sided { + return false; + } + let projected = normalize(sub(normal, scale(edge, dot(normal, edge))), 2); + dot(projected, if convex { face } else { neg(face) }) >= cosine +} diff --git a/crates/skate-core/src/physics/collision/fixup.rs b/crates/skate-core/src/physics/collision/fixup.rs new file mode 100644 index 00000000..8c8eac97 --- /dev/null +++ b/crates/skate-core/src/physics/collision/fixup.rs @@ -0,0 +1,238 @@ +//! Complete TU3 FixUpTriangleResult (82AD3130), including 82AD2CB0, +//! 82AD2E00 and contact-point reprojection (82AD2BF0). +use super::{arithmetic::*, edge::edge_cos_test}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TriangleRegion { + Face, + Edge0, + Edge1, + Vertex1, + Edge2, + Vertex0, + Vertex2, +} + +/// Native GPTriangle fields +16, +64..96, +148 and +152..160. +/// Direction order is native +64, +80, +96. Adjacency cosine indices +/// refer to the reversed direction order; these arrays are not interchangeable. +#[derive(Clone, Copy, Debug)] +pub struct TriangleFeature { + pub normal: Vector3, + pub edges: [Vector3; 3], + pub flags: u32, + pub edge_cosines: [f32; 3], +} +impl TriangleFeature { + pub const ONE_SIDED: u32 = 0x10; + pub const USE_EDGE_COSINES: u32 = 0x100; + pub const fn edge_convex(self, edge: usize) -> bool { + self.flags & (0x20 << edge) != 0 + } + pub const fn vertex_disabled(self, vertex: usize) -> bool { + self.flags & (0x200 << vertex) != 0 + } + + /// ComputeTriangleFeatureTypeFromNormal (82AD2A50), taking the normalized + /// in-plane direction produced by FixUpTriangleResult. + pub fn classify(self, direction: Vector3) -> TriangleRegion { + use TriangleRegion::*; + let [a, b, c] = self.edges.map(|e| dot(direction, e)); + let t = f32::from_bits(0x3D4C_CCCD); + if a > t && b < -t { + Vertex2 + } else if b > t && c < -t { + Vertex1 + } else if c > t && a < -t { + Vertex0 + } else if a > -c && -b >= a { + Edge2 + } else if b > -a && -c >= b { + Edge1 + } else if c > -b && -a >= c { + Edge0 + } else { + Face + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContactPair { + pub a: Vector3, + pub b: Vector3, +} + +#[derive(Clone, Copy, Debug)] +pub struct TriangleFixup { + /// Whether the triangle is the other side of the pair. This flips the + /// input for classification and chooses which contact points to project. + pub reverse: bool, + pub edge_cos_bend_normal_threshold: f32, + pub convexity_epsilon: f32, + /// Native r9 flag: relaxes disabled vertices and near-flat edge handling. + pub is_object: bool, +} + +/// Returns native acceptance. A successful fixup can change both the normal +/// and one side of every contact pair. Pair count is capped at native capacity. +pub fn fix_up_triangle( + triangle: TriangleFeature, + normal: &mut Vector3, + contacts: &mut [ContactPair], + settings: TriangleFixup, +) -> bool { + assert!(contacts.len() <= 16); + let toward = if settings.reverse { + neg(*normal) + } else { + *normal + }; + let projection = dot(toward, triangle.normal); + let face_tolerance = f32::from_bits(0x3F7F_F62B); + let region = if projection.abs() < face_tolerance { + triangle.classify(normalize( + sub(toward, scale(triangle.normal, projection)), + 2, + )) + } else { + TriangleRegion::Face + }; + let one_sided = triangle.flags & TriangleFeature::ONE_SIDED != 0; + use TriangleRegion::*; + let edge = match region { + Edge0 => Some(0), + Edge1 => Some(1), + Edge2 => Some(2), + _ => None, + }; + let vertex = match region { + Vertex0 => Some(0), + Vertex1 => Some(1), + Vertex2 => Some(2), + _ => None, + }; + if triangle.flags & TriangleFeature::USE_EDGE_COSINES == 0 { + if region == Face { + return !one_sided || projection > 0.0; + } + if one_sided { + if projection > face_tolerance { + return true; + } + if projection < 0.0 { + return false; + } + } else if projection.abs() > face_tolerance { + return true; + } + return edge + .map(|i| triangle.edge_convex(i)) + .unwrap_or_else(|| !triangle.vertex_disabled(vertex.unwrap())); + } + if region == Face { + return !one_sided || projection > 0.0; + } + if let Some(i) = edge { + if !one_sided { + return (if triangle.edge_convex(i) { + projection + } else { + -projection + }) >= triangle.edge_cosines[i]; + } + return one_sided_edge(triangle, i, projection, normal, contacts, settings); + } + let vertex = vertex.unwrap(); + // Native vertex adjacency: (edge2,edge0), (edge0,edge1), (edge1,edge2). + let first = (vertex + 2) % 3; + let second = vertex; + if !one_sided { + return !triangle.vertex_disabled(vertex) + && [first, second].into_iter().all(|i| { + edge_cos_test( + neg(triangle.edges[2 - i]), + triangle.normal, + toward, + triangle.edge_cosines[i], + triangle.edge_convex(i), + true, + ) + }); + } + let disabled = triangle.vertex_disabled(vertex) && !settings.is_object; + if projection > 0.0 && disabled { + // TU3-only recovery for a disabled vertex bordering exactly one convex + // edge. Skate 2's corresponding routine does not have this path. + let edge = match (triangle.edge_convex(first), triangle.edge_convex(second)) { + (true, false) => second, + (false, true) => first, + _ => return false, + }; + let cosine = triangle.edge_cosines[edge]; + if !(cosine <= f32::from_bits(0x3F78_51EC) && cosine > f32::from_bits(0x3A83_126F)) { + return false; + } + let direction = triangle.edges[2 - if edge == second { first } else { second }]; + let bent = normalize(cross(cross(direction, toward), direction), 1); + bend(normal, contacts, bent, settings.reverse); + return true; + } + if disabled { + return false; + } + [first, second].into_iter().all(|i| { + edge_cos_test( + neg(triangle.edges[2 - i]), + triangle.normal, + toward, + triangle.edge_cosines[i], + triangle.edge_convex(i), + false, + ) + }) +} + +fn one_sided_edge( + t: TriangleFeature, + edge: usize, + projection: f32, + normal: &mut Vector3, + contacts: &mut [ContactPair], + s: TriangleFixup, +) -> bool { + let cosine = t.edge_cosines[edge]; + // Scalar fadds, not an epsilon comparison on a vector dot product. + if t.edge_convex(edge) + || (s.is_object && ((cosine - 1.0) as f64).abs() < f64::from_bits(0x3EE4_F8B5_88E3_68F1)) + { + return projection + s.convexity_epsilon >= cosine; + } + if cosine > s.edge_cos_bend_normal_threshold || cosine <= f32::from_bits(0x3A83_126F) { + return false; + } + let squared = 1.0 - cosine * cosine; + let threshold = if squared == 0.0 { + 0.0 + } else { + squared * inverse_length_squared(squared, 2) + }; + if projection > threshold { + bend(normal, contacts, t.normal, s.reverse); + true + } else { + false + } +} + +fn bend(normal: &mut Vector3, contacts: &mut [ContactPair], direction: Vector3, reverse: bool) { + *normal = if reverse { neg(direction) } else { direction }; + for pair in contacts { + if reverse { + pair.b = madd(direction, dot(sub(pair.b, pair.a), direction), pair.a); + } else { + pair.a = madd(direction, dot(sub(pair.a, pair.b), direction), pair.b); + } + } +} diff --git a/crates/skate-core/src/physics/collision/mod.rs b/crates/skate-core/src/physics/collision/mod.rs new file mode 100644 index 00000000..03d30f27 --- /dev/null +++ b/crates/skate-core/src/physics/collision/mod.rs @@ -0,0 +1,13 @@ +//! TU3 feature-prism collision. Geometry and triangle adjacency come from the +//! collision mesh, independently of rendering and material response. +mod arithmetic; +mod edge; +mod fixup; +mod sphere_triangle; +mod world_contact; + +pub use fixup::{ContactPair, TriangleFeature, TriangleFixup, TriangleRegion, fix_up_triangle}; +pub use sphere_triangle::{Sphere, SphereTrianglePair, Triangle, intersect_sphere_triangle}; +pub use world_contact::{ + WorldContact, WorldContactSettings, sphere_triangle_world_contact, world_separation_limit, +}; diff --git a/crates/skate-core/src/physics/collision/sphere_triangle.rs b/crates/skate-core/src/physics/collision/sphere_triangle.rs new file mode 100644 index 00000000..6bbad434 --- /dev/null +++ b/crates/skate-core/src/physics/collision/sphere_triangle.rs @@ -0,0 +1,249 @@ +//! Sphere/triangle execution path of PrimitivePairIntersect (82AD3CD8). +//! Both primitive orders, two-sided geometry, finite edges, vertices, fatness, +//! separating-direction refinement and complete triangle fixups are preserved. +use super::{ContactPair, TriangleFeature, TriangleFixup, arithmetic::*, fix_up_triangle}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug)] +pub struct Sphere { + pub center: Vector3, + pub radius: f32, +} + +/// A world-space GPTriangle. Normal, unit directions and lengths are produced +/// by the native collision-geometry loader; rendering normals are not suitable. +#[derive(Clone, Copy, Debug)] +pub struct Triangle { + pub vertices: [Vector3; 3], + pub feature: TriangleFeature, + pub edge_lengths: [f32; 3], + pub fatness: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SphereTrianglePair { + pub separating_direction: Vector3, + pub separating_distance: f32, + /// Pair normal points from primitive A toward primitive B. The downstream + /// physics contact producer owns its conversion to solver normal convention. + pub normal: Vector3, + pub points: ContactPair, + pub distance: f32, + pub triangle_region: u32, +} + +/// Native pair query on already-built GP primitives. `triangle_is_a` selects +/// primitive order. An accepted candidate can have positive distance: filtering +/// candidate contacts belongs to the caller, just as in TU3. +pub fn intersect_sphere_triangle( + sphere: Sphere, + triangle: Triangle, + minimum_separating_distance: f32, + triangle_is_a: bool, +) -> Option { + let prism = build_prism( + sphere, + triangle, + minimum_separating_distance, + triangle_is_a, + false, + )?; + let SphereTrianglePrism { + separating_direction, + separating_distance, + mut normal, + raw_points, + triangle_region, + } = prism; + let (fat_a, fat_b) = if triangle_is_a { + (triangle.fatness, sphere.radius) + } else { + (sphere.radius, triangle.fatness) + }; + let points = ContactPair { + a: madd(normal, fat_a, raw_points.a), + b: sub(raw_points.b, scale(normal, fat_b)), + }; + let distance = dot(sub(points.b, points.a), normal); + // 82AD40C0..4308 publishes points/fatness/distances BEFORE fixup. The + // fixup's reprojection edits its local prism; TU3 does not recopy those + // points into this result. Only the result normal is shared with fixup. + let settings = TriangleFixup { + reverse: !triangle_is_a, + edge_cos_bend_normal_threshold: -1.0, + convexity_epsilon: 0.0, + is_object: false, + }; + if !fix_up_triangle(triangle.feature, &mut normal, &mut [raw_points], settings) { + return None; + } + Some(SphereTrianglePair { + separating_direction, + separating_distance, + normal, + points, + distance, + triangle_region, + }) +} + +pub(super) struct SphereTrianglePrism { + pub separating_direction: Vector3, + pub separating_distance: f32, + pub normal: Vector3, + pub raw_points: ContactPair, + pub triangle_region: u32, +} + +/// Shared SAT/features/prism work. The two native callers have different +/// single-point refinement gates and publish their results in different order. +pub(super) fn build_prism( + sphere: Sphere, + triangle: Triangle, + minimum_separating_distance: f32, + triangle_is_a: bool, + contact_query: bool, +) -> Option { + let axis = triangle.feature.normal; + let (a, b) = interval_gaps(sphere, triangle, axis, triangle_is_a); + // 82ACF070 chooses the second gap on ties. + let (separating_direction, separating_distance) = + if a > b { (neg(axis), a) } else { (axis, b) }; + let (fat_a, fat_b) = if triangle_is_a { + (triangle.fatness, sphere.radius) + } else { + (sphere.radius, triangle.fatness) + }; + if separating_distance > (fat_b + minimum_separating_distance) + fat_a { + return None; + } + let (face_point, triangle_region) = + point_face(sphere.center, triangle, separating_direction, triangle_is_a); + let raw_points = if triangle_is_a { + ContactPair { + a: face_point, + b: sphere.center, + } + } else { + ContactPair { + a: sphere.center, + b: face_point, + } + }; + let delta = sub(raw_points.b, raw_points.a); + let squared = dot(delta, delta); + let inverse_length = inverse_length_squared(squared, 2); + let gate = if contact_query { + // 8277B830..B8A8 tests the refined LENGTH, whereas 82AD3CD8 tests + // squared length. Both compare against the literal 821408EC. + if squared == 0.0 { + 0.0 + } else { + squared * inverse_length + } + } else { + squared + }; + let mut normal = separating_direction; + if gate > f32::from_bits(0x3400_0000) { + let candidate = scale(delta, inverse_length); + let (a, b) = interval_gaps(sphere, triangle, candidate, triangle_is_a); + let maximum = if a > b { a } else { b }; + if separating_distance <= maximum { + normal = if a > b { neg(candidate) } else { candidate }; + } + } + Some(SphereTrianglePrism { + separating_direction, + separating_distance, + normal, + raw_points, + triangle_region, + }) +} + +fn interval_gaps( + sphere: Sphere, + triangle: Triangle, + direction: Vector3, + triangle_is_a: bool, +) -> (f32, f32) { + // 82ADD800 / 82ADE3B8. GP intervals exclude primitive fatness. + let s = dot(direction, sphere.center); + let p = triangle.vertices.map(|v| dot(direction, v)); + let lo = min(min(p[0], p[1]), p[2]); + let hi = max(max(p[0], p[1]), p[2]); + if triangle_is_a { + (lo - s, s - hi) + } else { + (s - hi, lo - s) + } +} +fn min(a: f32, b: f32) -> f32 { + if a < b { a } else { b } +} +fn max(a: f32, b: f32) -> f32 { + if a > b { a } else { b } +} + +fn point_face(point: Vector3, t: Triangle, axis: Vector3, triangle_is_a: bool) -> (Vector3, u32) { + // The sphere contributes no axes or edges (82ACEA30), so the selected + // direction is the triangle face normal. Thus GetMaximumFeature's face + // branch is the complete reachable branch for this primitive pair. + let query = if triangle_is_a { axis } else { neg(axis) }; + let same = (dot(query, t.feature.normal) < 0.0) == triangle_is_a; + let indices = if same { [0, 1, 2] } else { [2, 1, 0] }; + let vertices = if same { [0, 2, 1] } else { [0, 1, 2] }; + let mut region = if same { 8 } else { 0 }; + let mut projected = madd(axis, dot(axis, sub(t.vertices[0], point)), point); + let mut best = 0usize; + let mut violation = 0.0; + let planes = indices.map(|i| { + let direction = if same { + t.feature.edges[i] + } else { + neg(t.feature.edges[i]) + }; + let plane = cross(direction, axis); + let squared = dot(plane, plane); + // Feature::BuildEdgePlanes (82AC6F88): one refinement and select-zero. + // CRT initializer 82F837D8 copies 8212BF78 into 830BDD40. The mapped + // image alone contains zero here; live TU3 uses 0x34000000. + scale( + plane, + if squared > f32::from_bits(0x3400_0000) { + inverse_length_squared(squared, 1) + } else { + 0.0 + }, + ) + }); + for i in 0..3 { + let d = dot(planes[i], sub(projected, t.vertices[vertices[i]])); + if i == 0 || d > violation { + best = i; + violation = d; + } + } + if violation > 0.0 { + projected = sub(projected, scale(planes[best], violation)); + let i = indices[best]; + let base = t.vertices[vertices[best]]; + let direction = if same { + t.feature.edges[i] + } else { + neg(t.feature.edges[i]) + }; + let along = dot(sub(projected, base), direction); + let (p, r) = if along < 0.0 { + (base, 1) + } else if along > t.edge_lengths[i] { + (madd(direction, t.edge_lengths[i], base), 3) + } else { + (madd(direction, along, base), 2) + }; + projected = p; + region += best as u32 * 2 + r; + } + (projected, region) +} diff --git a/crates/skate-core/src/physics/collision/world_contact.rs b/crates/skate-core/src/physics/collision/world_contact.rs new file mode 100644 index 00000000..9db8bd22 --- /dev/null +++ b/crates/skate-core/src/physics/collision/world_contact.rs @@ -0,0 +1,95 @@ +//! Physics/world pair query 8277B720, called by triangle dispatch 8277BC58. +//! This is distinct from the general Volume query (82AD3CD8). +use super::{ + ContactPair, Sphere, Triangle, TriangleFixup, arithmetic::*, fix_up_triangle, + sphere_triangle::build_prism, +}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug)] +pub struct WorldContactSettings { + /// Per-volume padding at cached primitive +112, excluding its radius. + pub volume_padding: f32, + /// Query settings +0. Bounds the approaching velocity's prediction. + pub maximum_separating_distance: f32, + /// Query settings +4 and +8, passed directly to triangle fixup. + pub edge_cos_bend_normal_threshold: f32, + pub convexity_epsilon: f32, + /// World-query context byte +61, forwarded to TU3 vertex handling. + pub is_object: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct WorldContact { + /// B (world) toward A (moving primitive), ready for contact construction. + pub normal: Vector3, + pub points: ContactPair, +} + +/// 8277BF0C..BF8C. The original predicts using the literal 1/60, independent +/// of the simulation timestep. Do not replace it with a caller's frame dt. +pub fn world_separation_limit( + velocity: Vector3, + triangle_normal: Vector3, + padding: f32, + maximum: f32, +) -> f32 { + let approach = dot( + neg(triangle_normal), + scale(velocity, f32::from_bits(0x3C88_8889)), + ); + let positive = if -approach >= 0.0 { 0.0 } else { approach }; + let limited = if maximum - positive >= 0.0 { + positive + } else { + maximum + }; + limited + padding +} + +/// Complete sphere branch of the physics/world pair query. Input GP geometry +/// is in world space; world broad phase, exclusion tables and duplicate-contact +/// buffering remain the producer's responsibilities. +pub fn sphere_triangle_world_contact( + sphere: Sphere, + triangle: Triangle, + velocity: Vector3, + settings: WorldContactSettings, +) -> Option { + let separation = world_separation_limit( + velocity, + triangle.feature.normal, + settings.volume_padding, + settings.maximum_separating_distance, + ); + let prism = build_prism(sphere, triangle, separation, false, true)?; + let mut normal = prism.normal; + let mut points = [prism.raw_points]; + if !fix_up_triangle( + triangle.feature, + &mut normal, + &mut points, + TriangleFixup { + reverse: true, + edge_cos_bend_normal_threshold: settings.edge_cos_bend_normal_threshold, + convexity_epsilon: settings.convexity_epsilon, + is_object: settings.is_object, + }, + ) { + return None; + } + // 8277B9E0..BA7C: fixup precedes publication. Both fatness operations + // use separate multiply and add/subtract (the Volume query uses an FMA). + let a = scale(normal, sphere.radius); + Some(WorldContact { + normal: neg(normal), + points: ContactPair { + a: Vector3::new( + points[0].a.x + a.x, + points[0].a.y + a.y, + points[0].a.z + a.z, + ), + b: sub(points[0].b, scale(normal, triangle.fatness)), + }, + }) +} diff --git a/crates/skate-core/src/physics/constraint_batch.rs b/crates/skate-core/src/physics/constraint_batch.rs new file mode 100644 index 00000000..2f31bbbc --- /dev/null +++ b/crates/skate-core/src/physics/constraint_batch.rs @@ -0,0 +1,20 @@ +//! Joint/drive batch eligibility: TU3 82AE39D0 and 82AE1778. +//! +//! Native clears the output counts, traverses the registered list in order, +//! and compiles a row only when at least one endpoint has state bit 4. This +//! does not register, wake, freeze or reorder bodies. + +pub fn compile_active( + registered: impl IntoIterator, + endpoint_states: impl Fn(&T) -> [u32; 2], + mut compile: impl FnMut(T) -> R, +) -> Vec { + registered + .into_iter() + .filter(|constraint| { + let [a, b] = endpoint_states(constraint); + (a | b) & 4 != 0 + }) + .map(&mut compile) + .collect() +} diff --git a/crates/skate-core/src/physics/constraint_frames.rs b/crates/skate-core/src/physics/constraint_frames.rs new file mode 100644 index 00000000..a4f5d2b3 --- /dev/null +++ b/crates/skate-core/src/physics/constraint_frames.rs @@ -0,0 +1,190 @@ +//! Original TU3 constraint-frame arithmetic shared by joint and drive builders. +//! 82AE0DB0 constructs quaternion-component rows directly from bilinear products. +//! The builders compose frames and construct bases without normalizing inputs. +use super::{ + native_arithmetic, + rigid_body::{RetailPackedWorldInverseInertia, RetailQuaternion}, +}; +use crate::math::{Basis3, Vector3}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct QuaternionRows { + pub axes: [Vector3; 3], + pub relative: RetailQuaternion, +} + +/// 82AE0DB0..0F88. Keep each rounded product and the original addition order; +/// rotating a relative quaternion's axes is only algebraically equivalent for +/// exact unit quaternions and has different finite-precision behavior. +pub(crate) fn quaternion_rows(a: RetailQuaternion, b: RetailQuaternion) -> QuaternionRows { + let xx = a.x * b.x; + let xy = a.x * b.y; + let xz = a.x * b.z; + let xw = a.x * b.w; + let yx = a.y * b.x; + let yy = a.y * b.y; + let yz = a.y * b.z; + let yw = a.y * b.w; + let zx = a.z * b.x; + let zy = a.z * b.y; + let zz = a.z * b.z; + let zw = a.z * b.w; + let wx = a.w * b.x; + let wy = a.w * b.y; + let wz = a.w * b.z; + let ww = a.w * b.w; + QuaternionRows { + axes: [ + Vector3::new( + ((ww + xx) - yy) - zz, + ((yx + xy) + wz) + zw, + ((xz + zx) - wy) - yw, + ), + Vector3::new( + ((yx + xy) - wz) - zw, + ((ww + yy) - zz) - xx, + ((yz + zy) + wx) + xw, + ), + Vector3::new( + ((xz + zx) + yw) + wy, + ((zy + yz) - wx) - xw, + ((ww + zz) - xx) - yy, + ), + ], + relative: RetailQuaternion { + x: ((wx - xw) + zy) - yz, + y: ((wy - yw) + xz) - zx, + z: ((wz - zw) + yx) - xy, + w: ((ww + zz) + yy) + xx, + }, + } +} + +/// Original quaternion multiplication at 82AE3C10..3C60 / 82AE1B94..1C4C. +pub(crate) fn compose(a: RetailQuaternion, b: RetailQuaternion) -> RetailQuaternion { + let cross = cross(Vector3::new(a.x, a.y, a.z), Vector3::new(b.x, b.y, b.z)); + RetailQuaternion { + x: a.x.mul_add(b.w, b.x.mul_add(a.w, cross.x)), + y: a.y.mul_add(b.w, b.y.mul_add(a.w, cross.y)), + z: a.z.mul_add(b.w, b.z.mul_add(a.w, cross.z)), + w: a.w * b.w - native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]), + } +} + +/// Independently verified at Drive Build 82AE1CC4..1D90: sqrt(2) scaling, +/// 0.5 - component squared, paired residuals, and masks 822FB8A0/B0/C0. +/// This does not depend on Joint Build's unresolved RQD-gather shuffle at +/// 82AE3D98; it accepts an already known quaternion. +pub(crate) fn basis(q: RetailQuaternion) -> Basis3 { + let root_two = f32::from_bits(0x3fb5_04f3); + let x = q.x * root_two; + let y = q.y * root_two; + let z = q.z * root_two; + let w = q.w * root_two; + let dx = (-x).mul_add(x, 0.5); + let dy = (-y).mul_add(y, 0.5); + let dz = (-z).mul_add(z, 0.5); + let xy = x * y; + let yz = y * z; + let zx = z * x; + let wz = w * z; + let wx = w * x; + let wy = w * y; + Basis3 { + columns: [ + [dy + dz, xy + wz, zx - wy], + [xy - wz, dz + dx, yz + wx], + [zx + wy, yz - wx, dx + dy], + ], + } +} + +pub(crate) fn transform_direction(b: Basis3, v: Vector3) -> Vector3 { + let c = b.columns; + Vector3::new( + c[2][0].mul_add(v.z, c[1][0].mul_add(v.y, c[0][0] * v.x)), + c[2][1].mul_add(v.z, c[1][1].mul_add(v.y, c[0][1] * v.x)), + c[2][2].mul_add(v.z, c[1][2].mul_add(v.y, c[0][2] * v.x)), + ) +} + +pub(crate) fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} + +/// Source adds both cross-product terms directly into the incoming rate. +pub(crate) fn point_rate(linear: Vector3, angular: Vector3, arm: Vector3) -> Vector3 { + Vector3::new( + arm.z + .mul_add(angular.y, (-arm.y).mul_add(angular.z, linear.x)), + arm.x + .mul_add(angular.z, (-arm.z).mul_add(angular.x, linear.y)), + arm.y + .mul_add(angular.x, (-arm.x).mul_add(angular.y, linear.z)), + ) +} + +pub(crate) fn multiply_add(v: Vector3, scalar: f32, addend: Vector3) -> Vector3 { + Vector3::new( + v.x.mul_add(scalar, addend.x), + v.y.mul_add(scalar, addend.y), + v.z.mul_add(scalar, addend.z), + ) +} + +pub(crate) fn reciprocal(value: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + let residual = (-r).mul_add(value, 1.0); + r = r.mul_add(residual, r); + } + r +} + +pub(crate) fn reciprocal_sqrt(value: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_square_root_estimate(value); + for _ in 0..2 { + let squared = r * r; + let half = r * 0.5; + let residual = (-value).mul_add(squared, 1.0); + r = half.mul_add(residual, r); + } + r +} + +/// Drive 82AE2430..2490: X product, then the Y and Z fused additions. +pub(crate) fn multiply_inertia(t: RetailPackedWorldInverseInertia, v: Vector3) -> Vector3 { + inertia_tail( + t, + v, + Vector3::new(t.full.x * v.x, t.full.y * v.x, t.full.z * v.x), + ) +} + +/// Joint 82AE470C..4758 starts the same contraction with a fused +zero. +pub(crate) fn multiply_inertia_from_zero( + t: RetailPackedWorldInverseInertia, + v: Vector3, +) -> Vector3 { + inertia_tail( + t, + v, + Vector3::new( + t.full.x.mul_add(v.x, 0.0), + t.full.y.mul_add(v.x, 0.0), + t.full.z.mul_add(v.x, 0.0), + ), + ) +} + +fn inertia_tail(t: RetailPackedWorldInverseInertia, v: Vector3, first: Vector3) -> Vector3 { + Vector3::new( + t.full.z.mul_add(v.z, t.full.y.mul_add(v.y, first.x)), + t.split.z.mul_add(v.z, t.split.y.mul_add(v.y, first.y)), + t.split.x.mul_add(v.z, t.split.z.mul_add(v.y, first.z)), + ) +} diff --git a/crates/skate-core/src/physics/construction/authored_transforms.rs b/crates/skate-core/src/physics/construction/authored_transforms.rs new file mode 100644 index 00000000..e1d62c2f --- /dev/null +++ b/crates/skate-core/src/physics/construction/authored_transforms.rs @@ -0,0 +1,85 @@ +//! Authored seven-part matrices passed to Part::SetTransform by TU3 82C0ADF0. +//! This function is called by construction/reset before world-pose publication. +use super::RetailAffineTransform; +use crate::math::{Basis3, Vector3}; + +/// Resolved stock attributes read by InitializeTransforms. Attribute lookup +/// failure selects the engine's runtime fallback scalar at830D0850; callers +/// resolving absent attributes must supply that actual initialized value. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AuthoredTransformInputs { + /// physicsdeck.DeckMidLength; full attribute key C94D65091DCB828B. + pub deck_mid_length: f32, + /// physicswheels.WheelXDist; full attribute key 5A84C435DC340AF8. + pub wheel_x_distance: f32, + /// physicstrucks layout+4, +8, +16. + pub truck_z_position_front: f32, + pub truck_z_position_back: f32, + pub truck_y_position: f32, +} + +impl AuthoredTransformInputs { + /// Actual stock XML scalar words, not constructor output snapshots. + pub const STOCK: Self = Self { + deck_mid_length: f32::from_bits(0x3f17_0a3d), + wheel_x_distance: f32::from_bits(0x3dc2_8f5c), + truck_z_position_front: f32::from_bits(0xbd54_fdf4), + truck_z_position_back: f32::from_bits(0xbd54_fdf4), + truck_y_position: f32::from_bits(0xbd67_6c8b), + }; +} + +/// Complete authored matrix calculation, in body order wheels0..3, trucks4..5, +/// deck6. Native publication order is deck6 first, then bodies0..5. Front truck +/// is identity; back truck uses the actual SinCos(pi/2) result about Y. The +/// tiny polynomial cosine residual is retained, never replaced by exact zero. +pub fn authored_body_transforms(input: AuthoredTransformInputs) -> [RetailAffineTransform; 7] { + // fmadds82C0AEE4 and fnmsubs82C0AF60: one rounded multiply/add each. + let front_z = input + .deck_mid_length + .mul_add(0.5, input.truck_z_position_front); + let back_z = -input + .deck_mid_length + .mul_add(0.5, input.truck_z_position_back); + let y = input.truck_y_position; + let x = input.wheel_x_distance; + let mut transforms = [RetailAffineTransform::IDENTITY; 7]; + for (part, translation) in [ + Vector3::new(-x, y, front_z), + Vector3::new(x, y, front_z), + Vector3::new(-x, y, back_z), + Vector3::new(x, y, back_z), + Vector3::new(0.0, y, front_z), + Vector3::new(0.0, y, back_z), + ] + .into_iter() + .enumerate() + { + transforms[part].translation = translation; + } + // Literal pi/2 word at820C69C8. Shared polynomial82473A08 was re-audited + // against fresh disassembly and constants822F97C0..822F985F for this path. + let (sin, cos) = crate::trigonometry::sin_cos(f32::from_bits(0x3fc9_0fdb)); + transforms[5].basis = Basis3 { + columns: [[cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]], + }; + transforms +} + +/// Native affine rows, ready for reset_skateboard's `authored` argument. All +/// fourth lanes are zero, including the affine translation:82C0ADF0 explicitly +/// clears them. This does not perform the downstream COM/quaternion conversion. +pub fn authored_body_pose_records(input: AuthoredTransformInputs) -> [[u32; 16]; 7] { + authored_body_transforms(input).map(|transform| { + let mut words = [0; 16]; + for (axis, column) in transform.basis.columns.into_iter().enumerate() { + for (component, value) in column.into_iter().enumerate() { + words[axis * 4 + component] = value.to_bits(); + } + } + words[12] = transform.translation.x.to_bits(); + words[13] = transform.translation.y.to_bits(); + words[14] = transform.translation.z.to_bits(); + words + }) +} diff --git a/crates/skate-core/src/physics/construction/board_parts.rs b/crates/skate-core/src/physics/construction/board_parts.rs new file mode 100644 index 00000000..97211ffb --- /dev/null +++ b/crates/skate-core/src/physics/construction/board_parts.rs @@ -0,0 +1,104 @@ +//! Wheel/truck mass-input producers in TU3 82C0AA78 and 82C0A6F8. +//! Allocation, material publication and final inverse inertia are separate stages. + +use super::{MassShape, PrimitiveMass, primitive_mass}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WheelMassSettings { + /// physicswheels.WheelRadius; 82C0AB44..82C0AB8C. + pub radius: f32, + /// physicswheels.WheelMass; 82C0ACC8..82C0AD04. + pub mass: f32, + /// physics_world.SkateboardMassFactor; 82C0AD0C..82C0AD58. + pub mass_factor: f32, +} + +impl WheelMassSettings { + /// Decoded stock collection inputs, not cached mass-property outputs. + pub const STOCK: Self = Self { + radius: f32::from_bits(0x3cfd_f3b6), + mass: f32::from_bits(0x3da9_fbe7), + mass_factor: f32::from_bits(0x40a0_0000), + }; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TruckMassSettings { + pub wheel_radius: f32, + pub wheel_x_distance: f32, + /// physicstrucks layout +52 and +56. + pub radius_scalar: f32, + pub half_height_scalar: f32, + /// physicstrucks layout +40 and physics_world.SkateboardMassFactor. + pub mass: f32, + pub mass_factor: f32, +} + +impl TruckMassSettings { + pub const STOCK: Self = Self { + wheel_radius: f32::from_bits(0x3cfd_f3b6), + wheel_x_distance: f32::from_bits(0x3dc2_8f5c), + radius_scalar: f32::from_bits(0x3f09_999a), + half_height_scalar: f32::from_bits(0x3fac_cccd), + mass: f32::from_bits(0x3ec7_ae14), + mass_factor: f32::from_bits(0x40a0_0000), + }; +} + +/// Actual shape dimensions and requested mass passed to 82AE7770. Its inverse +/// principal moments still require the unresolved native reciprocal estimate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PartMassInput { + pub shape: MassShape, + pub requested_mass: f32, +} + +/// 82C0AA78 creates one shared sphere and applies the same mass inputs to all +/// four wheel definitions. WheelCapsuleLength is not read by this function. +pub fn wheel_mass_input(settings: WheelMassSettings) -> PartMassInput { + PartMassInput { + shape: MassShape::Sphere { + radius: settings.radius, + }, + requested_mass: settings.mass * settings.mass_factor, + } +} + +/// 82C0A6F8 -> 82AD9708: capsule radius and half-length each include the final +/// multiplication by one half. Both truck definitions share this shape. +pub fn truck_mass_input(settings: TruckMassSettings) -> PartMassInput { + PartMassInput { + shape: MassShape::Capsule { + radius: (settings.wheel_radius * settings.radius_scalar) * 0.5, + half_length: (settings.wheel_x_distance * settings.half_height_scalar) * 0.5, + }, + requested_mass: settings.mass * settings.mass_factor, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ForwardMassProperties { + pub primitive: PrimitiveMass, + pub mass: f32, + pub principal_moments: Vector3, +} + +/// The supported primitive stage of 82AE7770: shape calculation, density-one +/// mass fallback at 82AE79EC, then principal-moment scaling at 82AE7A24. +/// An unsupported shape needs the aggregate producer and returns no result. +/// This does not supply inverse inertia or claim the part is ready to simulate. +pub fn forward_mass_properties(input: PartMassInput) -> Option { + let primitive = primitive_mass(input.shape)?; + let mass = if input.requested_mass < f32::MIN_POSITIVE { + primitive.volume + } else { + input.requested_mass + }; + let moments = primitive.moments_per_unit_mass; + Some(ForwardMassProperties { + primitive, + mass, + principal_moments: Vector3::new(moments.x * mass, moments.y * mass, moments.z * mass), + }) +} diff --git a/crates/skate-core/src/physics/construction/deck_geometry.rs b/crates/skate-core/src/physics/construction/deck_geometry.rs new file mode 100644 index 00000000..35774ffc --- /dev/null +++ b/crates/skate-core/src/physics/construction/deck_geometry.rs @@ -0,0 +1,331 @@ +//! Deck collision children constructed by TU3 82C09290 and 82C0A2A0/3E0/510. +//! Allocation is host-owned; dimensions, child order, flags and arithmetic are +//! source-derived. Mass uses the actual primitive/triangle-AABB dispatch in +//! 82AE7058, including children whose collision-enable bit is clear. + +use super::{MassMoments, MassShape, primitive_mass}; +use crate::{ + math::Vector3, + physics::{drive_frames::RetailAffineTransform, native_arithmetic}, + trigonometry, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DeckGeometrySettings { + pub width: f32, + pub mid_length: f32, + pub thickness: f32, + /// Both fans read DeckBackEndSize. DeckFrontEndSize is not read here. + pub back_end_size: f32, + pub front_end_angle_degrees: f32, + pub back_end_angle_degrees: f32, + /// DeckEndCapsules (Int32), despite the triangles produced by this loop. + pub end_capsule_count: i32, + pub enable_deck_volume_collisions: bool, + pub enable_end_volume_collisions: bool, +} + +impl DeckGeometrySettings { + /// physicsdeck/default.xml, retaining the original Float words. + pub const STOCK: Self = Self { + width: f32::from_bits(0x3E75_C28F), + mid_length: f32::from_bits(0x3F17_0A3D), + thickness: f32::from_bits(0x3C75_C28F), + back_end_size: f32::from_bits(0x3E28_F5C3), + front_end_angle_degrees: f32::from_bits(0x4150_0000), + back_end_angle_degrees: f32::from_bits(0x4148_0000), + end_capsule_count: 5, + enable_deck_volume_collisions: true, + enable_end_volume_collisions: true, + }; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DeckShape { + RoundedBox { + half_extents: Vector3, + radius: f32, + }, + Capsule { + radius: f32, + half_length: f32, + }, + Sphere { + radius: f32, + }, + Triangle { + vertices: [Vector3; 3], + fatness: f32, + edge_cosines: [f32; 3], + /// Volume flags excluding the separately exposed collision-enable bit. + /// These are Volume flags, not GPTriangle feature flags. + volume_flags: u32, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DeckChild { + pub shape: DeckShape, + pub transform: RetailAffineTransform, + pub collision_enabled: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DeckGeometry { + pub children: Vec, +} + +impl DeckGeometry { + pub fn new(settings: DeckGeometrySettings) -> Self { + let half_width = settings.width * 0.5; + let half_length = settings.mid_length * 0.5; + let half_thickness = settings.thickness * 0.5; + let radius = half_thickness * f32::from_bits(0x3F66_6666); + let mut children = vec![DeckChild { + shape: DeckShape::RoundedBox { + half_extents: Vector3::new( + half_width - radius, + half_thickness - radius, + half_length - radius, + ), + radius, + }, + transform: RetailAffineTransform::IDENTITY, + collision_enabled: settings.enable_deck_volume_collisions, + }]; + + // 82E0B1E0: midpoint = first + (second-first)/2. These endpoints are + // parallel to local Z, so the orientation branch leaves identity. + let longitudinal_half_delta = (-half_length - half_length) * 0.5; + let capsule_half_length = vector_length_z(longitudinal_half_delta); + let capsule_center_z = half_length + longitudinal_half_delta; + let arc_width = half_width - half_thickness; + for x in [arc_width, half_thickness - half_width] { + children.push(DeckChild { + shape: DeckShape::Capsule { + radius: half_thickness, + half_length: capsule_half_length, + }, + transform: translated(Vector3::new(x, 0.0, capsule_center_z)), + collision_enabled: settings.enable_end_volume_collisions, + }); + } + // 82C0A3E0 retains the sphere constructor's enabled flag, independent + // of the two authored deck collision toggles. + for z in [f32::from_bits(0x3E70_A3D7), f32::from_bits(0xBE70_A3D7)] { + children.push(DeckChild { + shape: DeckShape::Sphere { radius: 0.035 }, + transform: translated(Vector3::new(0.0, f32::from_bits(0xBCA3_D70A), z)), + collision_enabled: true, + }); + } + + let radians = f32::from_bits(0x3C8E_FA35); + let back_angle = settings.back_end_angle_degrees * radians; + let front_angle = settings.front_end_angle_degrees * radians; + let back_slope = (trigonometry::sin(back_angle), cosine(back_angle)); + let front_slope = (trigonometry::sin(front_angle), cosine(front_angle)); + let end_length = settings.back_end_size - half_thickness; + let mut previous_back = (trigonometry::sin(0.0), cosine(0.0)); + let mut previous_front = previous_back; + for segment in 1..=settings.end_capsule_count { + let back_arc = + (segment as f32 * f32::from_bits(0xC049_0FDB)) / settings.end_capsule_count as f32; + let next_back = (trigonometry::sin(back_arc), cosine(back_arc)); + children.push(fan_triangle( + previous_back, + next_back, + back_slope, + arc_width, + end_length, + -half_length, + half_thickness, + false, + settings.enable_deck_volume_collisions, + )); + previous_back = next_back; + let front_arc = + (segment as f32 * f32::from_bits(0x4049_0FDB)) / settings.end_capsule_count as f32; + let next_front = (trigonometry::sin(front_arc), cosine(front_arc)); + children.push(fan_triangle( + previous_front, + next_front, + front_slope, + arc_width, + end_length, + half_length, + half_thickness, + true, + settings.enable_deck_volume_collisions, + )); + previous_front = next_front; + } + Self { children } + } + + pub fn mass_moments(&self) -> MassMoments { + let mut total = MassMoments::ZERO; + for child in &self.children { + total.add(child.mass_moments()); + } + // The aggregate volume's transform is identity (82AD7740). + total.transform(RetailAffineTransform::IDENTITY.basis, Vector3::ZERO); + total + } +} + +impl DeckChild { + pub fn mass_moments(&self) -> MassMoments { + let (shape, transform) = match self.shape { + DeckShape::Sphere { radius } => (MassShape::Sphere { radius }, self.transform), + DeckShape::Capsule { + radius, + half_length, + } => ( + MassShape::Capsule { + radius, + half_length, + }, + self.transform, + ), + DeckShape::RoundedBox { + half_extents, + radius, + } => ( + MassShape::RoundedBox { + half_extents, + radius, + }, + self.transform, + ), + DeckShape::Triangle { + vertices, fatness, .. + } => { + // TU3 TriangleVolume::GetBBox 82ADDC40, null transform branch: + // min(v0,min(v1,v2))-fatness, max(v0,max(v1,v2))+fatness. + // 82AE7058 deliberately uses this box for triangle mass; no + // triangle pose transform is applied again after this branch. + let axis = |get: fn(Vector3) -> f32| { + let a = get(vertices[0]); + let b = get(vertices[1]); + let c = get(vertices[2]); + let low = native_arithmetic::vector_min(a, native_arithmetic::vector_min(b, c)) + - fatness; + let high = + native_arithmetic::vector_max(a, native_arithmetic::vector_max(b, c)) + + fatness; + let half = (high - low) * 0.5; + (half, low + half) + }; + let x = axis(|v| v.x); + let y = axis(|v| v.y); + let z = axis(|v| v.z); + ( + MassShape::RoundedBox { + half_extents: Vector3::new(x.0, y.0, z.0), + radius: 0.0, + }, + translated(Vector3::new(x.1, y.1, z.1)), + ) + } + }; + let mut moments = MassMoments::from_primitive( + primitive_mass(shape).expect("every deck child follows a supported source mass branch"), + ); + moments.transform(transform.basis, transform.translation); + moments + } +} + +fn translated(translation: Vector3) -> RetailAffineTransform { + RetailAffineTransform { + translation, + ..RetailAffineTransform::IDENTITY + } +} + +#[allow(clippy::too_many_arguments)] +fn fan_triangle( + previous: (f32, f32), + next: (f32, f32), + slope: (f32, f32), + width: f32, + length: f32, + center_z: f32, + fatness: f32, + front: bool, + enabled: bool, +) -> DeckChild { + let point = |arc: (f32, f32)| { + let along_end = arc.0 * length; + let elevation = if front { along_end } else { -along_end }; + Vector3::new( + arc.1 * width, + slope.0 * elevation, + along_end.mul_add(slope.1, center_z), + ) + }; + DeckChild { + shape: DeckShape::Triangle { + vertices: [ + Vector3::new(0.0, 0.0, center_z), + point(previous), + point(next), + ], + fatness, + edge_cosines: [1.0, -1.0, 1.0], + volume_flags: 0x3E2, + }, + transform: RetailAffineTransform::IDENTITY, + collision_enabled: enabled, + } +} + +/// Length path in 82E0B1E0; the shared estimate retains its explicitly marked +/// Xenon arithmetic approximation boundary until hardware validation. +fn vector_length_z(z: f32) -> f32 { + let squared = native_arithmetic::dot3([0.0, 0.0, z, 0.0], [0.0, 0.0, z, 0.0]); + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + let residual = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(residual, inverse); + } + if squared == 0.0 { + 0.0 + } else { + squared * inverse + } +} + +/// Standalone cosine 82473930. Its even-power tree differs from SinCos. +fn cosine(angle: f32) -> f32 { + let turns = (angle * f32::from_bits(0x3E22_F983)).round_ties_even(); + let x = (-f32::from_bits(0x40C9_0FDB)).mul_add(turns, angle); + let x2 = x * x; + let x4 = x2 * x2; + let x6 = x4 * x2; + let x8 = x4 * x4; + let x10 = x6 * x4; + let x12 = x6 * x6; + let x14 = x8 * x6; + let x16 = x8 * x8; + let x18 = x10 * x8; + let x20 = x10 * x10; + let x22 = x12 * x10; + let mut result = (-0.5_f32).mul_add(x2, 1.0); + for (power, coefficient) in [ + (x4, 0x3D2A_AAAB), + (x6, 0xBAB6_0B61), + (x8, 0x37D0_0D01), + (x10, 0xB493_F27E), + (x12, 0x310F_76C8), + (x14, 0xAD49_CBA5), + (x16, 0x2957_3F9F), + (x18, 0xA534_13C3), + (x20, 0x20F2_A15D), + (x22, 0x9C86_71CB), + ] { + result = f32::from_bits(coefficient).mul_add(power, result); + } + result +} diff --git a/crates/skate-core/src/physics/construction/mass_moments.rs b/crates/skate-core/src/physics/construction/mass_moments.rs new file mode 100644 index 00000000..407d655c --- /dev/null +++ b/crates/skate-core/src/physics/construction/mass_moments.rs @@ -0,0 +1,127 @@ +//! Volume moments accumulated by TU3 82AE6CB8/82AE6D88/82AE6F88 and reduced +//! to a local principal frame by 82AE7508. This is the aggregate mass producer; +//! geometry construction and requested-mass scaling remain separate stages. + +use super::{PrimitiveMass, principal_axes::diagonalize, refined_reciprocal}; +use crate::math::{Basis3, Vector3}; +use crate::physics::rigid_body::RetailLocalMassFrame; + +/// Integrals of [x,y,z,1] outer products, in column order. The last column +/// holds first moments and volume. Keeping all lanes preserves native sums. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MassMoments { + pub columns: [[f32; 4]; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AggregateMassProperties { + pub volume: f32, + pub local_mass_frame: RetailLocalMassFrame, + pub moments_per_unit_mass: Vector3, +} + +impl MassMoments { + pub const ZERO: Self = Self { + columns: [[0.0; 4]; 4], + }; + + /// 82AE6CB8 converts primitive principal inertia into volume moments. + pub fn from_primitive(primitive: PrimitiveMass) -> Self { + let inertia = primitive.moments_per_unit_mass; + let volume = primitive.volume; + let x = (((inertia.z + inertia.y) - inertia.x) * volume) * 0.5; + let y = (((inertia.z + inertia.x) - inertia.y) * volume) * 0.5; + let z = (((inertia.y + inertia.x) - inertia.z) * volume) * 0.5; + Self { + columns: [ + [x, 0.0, 0.0, 0.0], + [0.0, y, 0.0, 0.0], + [0.0, 0.0, z, 0.0], + [0.0, 0.0, 0.0, volume], + ], + } + } + + /// 82AE6D88: H * moments * transpose(H). The source explicitly sets the + /// basis fourth lanes to zero and the translation fourth lane to one. + pub fn transform(&mut self, basis: Basis3, translation: Vector3) { + let mut affine = [[0.0; 4]; 4]; + for (column, axis) in affine[..3].iter_mut().zip(basis.columns) { + column[..3].copy_from_slice(&axis); + } + affine[3] = [translation.x, translation.y, translation.z, 1.0]; + let left = self.columns.map(|column| product(affine, column)); + self.columns = core::array::from_fn(|column| { + product(left, core::array::from_fn(|row| affine[row][column])) + }); + } + + /// 82AE6F88 adds successful children in volume order, without weighting or + /// normalizing them individually. Collision-enable flags are not read here. + pub fn add(&mut self, child: Self) { + for (output, value) in self.columns.iter_mut().zip(child.columns) { + for (output, value) in output.iter_mut().zip(value) { + *output += value; + } + } + } + + /// 82AE7508 mutates the aggregate into its center-of-mass frame before + /// diagonalizing its inertia. Preserve this mutation and native axis order. + pub fn principal_properties(&mut self) -> AggregateMassProperties { + let volume = self.columns[3][3]; + let inverse_volume = refined_reciprocal(volume); + let center = Vector3::new( + self.columns[3][0] * inverse_volume, + self.columns[3][1] * inverse_volume, + self.columns[3][2] * inverse_volume, + ); + self.transform( + RetailLocalMassFrame::IDENTITY.basis, + Vector3::new(-center.x, -center.y, -center.z), + ); + let m = self.columns; + let xy = (m[1][0] + m[0][1]) * -0.5; + let xz = (m[2][0] + m[0][2]) * -0.5; + let yz = (m[2][1] + m[1][2]) * -0.5; + let mut inertia = [ + [m[1][1] + m[2][2], xy, xz], + [xy, m[0][0] + m[2][2], yz], + [xz, yz, m[0][0] + m[1][1]], + ]; + let axes = diagonalize(&mut inertia); + let maximum_yz = + select_nonnegative(inertia[1][1] - inertia[2][2], inertia[1][1], inertia[2][2]); + let maximum = select_nonnegative(inertia[0][0] - maximum_yz, inertia[0][0], maximum_yz); + let floor = maximum * 0.0050000004; + let moments = core::array::from_fn::<_, 3, _>(|i| { + select_nonnegative(inertia[i][i] - floor, inertia[i][i], floor) * inverse_volume + }); + AggregateMassProperties { + volume, + local_mass_frame: RetailLocalMassFrame { + basis: Basis3 { + columns: core::array::from_fn(|i| core::array::from_fn(|j| axes[j][i])), + }, + translation: center, + }, + moments_per_unit_mass: Vector3::new(moments[0], moments[1], moments[2]), + } + } +} + +fn product(matrix: [[f32; 4]; 4], vector: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|i| { + matrix[3][i].mul_add( + vector[3], + matrix[2][i].mul_add( + vector[2], + matrix[1][i].mul_add(vector[1], matrix[0][i] * vector[0]), + ), + ) + }) +} + +fn select_nonnegative(selector: f32, positive: f32, negative: f32) -> f32 { + if selector >= 0.0 { positive } else { negative } +} diff --git a/crates/skate-core/src/physics/construction/primitive_mass.rs b/crates/skate-core/src/physics/construction/primitive_mass.rs new file mode 100644 index 00000000..35dd8814 --- /dev/null +++ b/crates/skate-core/src/physics/construction/primitive_mass.rs @@ -0,0 +1,142 @@ +//! Forward principal moments and volume from TU3 82AE7228/82AE6B78. +//! These quantities precede mass scaling and inverse-inertia construction. + +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MassShape { + Sphere { + radius: f32, + }, + /// The capsule's longitudinal principal axis is local Z. + Capsule { + radius: f32, + half_length: f32, + }, + RoundedBox { + half_extents: Vector3, + radius: f32, + }, + Cylinder { + radius: f32, + half_length: f32, + padding: f32, + }, + /// Triangle, aggregate, or another type rejected by 82AE7228. The caller + /// must use the aggregate calculation; no moments are produced here. + Unsupported, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrimitiveMass { + pub moments_per_unit_mass: Vector3, + pub volume: f32, +} + +const MIN_RADIUS: f32 = f32::from_bits(0x33d6_bf95); +const SPHERE_VOLUME: f32 = f32::from_bits(0x4086_0a92); +const FOUR_THIRDS: f32 = f32::from_bits(0x3faa_aaab); +const TWO_THIRDS: f32 = f32::from_bits(0x3f2a_aaab); +const ONE_THIRD: f32 = f32::from_bits(0x3eaa_aaab); +const ONE_TWELFTH: f32 = f32::from_bits(0x3daa_aaab); +const PI: f32 = f32::from_bits(0x4049_0fdb); +const TWO_PI: f32 = f32::from_bits(0x40c9_0fdb); + +// The native fsubs/fsel chooses the right operand for an unordered difference. +// Keep this ordered comparison, including its signed-zero operand selection. +fn greater_extent(left: f32, right: f32) -> f32 { + if left - right >= 0.0 { + left + } else { + right + } +} + +/// Complete shape dispatch in 82AE7228. Unsupported shapes return no result, +/// matching the native false return and absence of output writes. All supported +/// branches retain the native scalar operation order and fused multiply-adds. +pub fn primitive_mass(shape: MassShape) -> Option { + Some(match shape { + MassShape::Unsupported => return None, + MassShape::Sphere { radius } => { + let radius = greater_extent(radius, MIN_RADIUS); + let radius_squared = radius * radius; + let moment = radius_squared * 0.4; + PrimitiveMass { + moments_per_unit_mass: Vector3::new(moment, moment, moment), + volume: (radius_squared * radius) * SPHERE_VOLUME, + } + } + MassShape::Capsule { + radius, + half_length, + } => { + let length_squared = half_length * half_length; + let radius = greater_extent(greater_extent(radius, MIN_RADIUS), half_length * 0.05); + let numerator = radius + .mul_add(1.6, half_length * 0.75) + .mul_add(radius, length_squared * 4.0) + .mul_add(radius, length_squared * half_length); + let denominator = radius.mul_add(4.0, half_length * 3.0); + let transverse = numerator / denominator; + let axial = (radius * radius) * 0.4; + PrimitiveMass { + moments_per_unit_mass: Vector3::new(transverse, transverse, axial), + volume: ((radius.mul_add(FOUR_THIRDS, half_length * 2.0) * radius) * radius) * PI, + } + } + MassShape::RoundedBox { + half_extents, + radius, + } => rounded_box_mass(half_extents, radius), + MassShape::Cylinder { + radius, + half_length, + padding, + } => { + let radius = radius + padding; + let half_length = half_length + padding; + let radius_squared = radius * radius; + let transverse = + (half_length * half_length).mul_add(4.0, radius_squared * 3.0) * ONE_TWELFTH; + PrimitiveMass { + moments_per_unit_mass: Vector3::new(transverse, transverse, radius_squared * 0.5), + volume: ((half_length * radius) * radius) * TWO_PI, + } + } + }) +} + +/// Complete 82AE6B78: native rounded-box dimension floor, moments and volume. +fn rounded_box_mass(half_extents: Vector3, radius: f32) -> PrimitiveMass { + let largest = greater_extent( + half_extents.x, + greater_extent(half_extents.y, half_extents.z), + ); + let floor = greater_extent((largest + radius) * 0.05, MIN_RADIUS) - radius; + let x = greater_extent(half_extents.x, floor); + let y = greater_extent(half_extents.y, floor); + let z = greater_extent(half_extents.z, floor); + let radius_moment = (radius * radius) * 0.5; + let x_squared = x * x; + let y_squared = y * y; + let z_squared = z * z; + let xy_radius = (x + y) * radius; + let yz_radius = (y + z) * radius; + let xz_radius = (x + z) * radius; + let moment_x = + (yz_radius.mul_add(2.0, z_squared) + y_squared).mul_add(ONE_THIRD, radius_moment); + let moment_y = + (xz_radius.mul_add(2.0, x_squared) + z_squared).mul_add(ONE_THIRD, radius_moment); + let moment_z = + (xy_radius.mul_add(2.0, x_squared) + y_squared).mul_add(ONE_THIRD, radius_moment); + let rounded_edges = (((radius.mul_add(TWO_THIRDS, x) + y) + z) * radius) * TWO_PI; + let face_products = (y + z).mul_add(x, y * z); + let box_volume = ((x * y) * z) * 8.0; + PrimitiveMass { + moments_per_unit_mass: Vector3::new(moment_x, moment_y, moment_z), + volume: face_products + .mul_add(8.0, rounded_edges) + .mul_add(radius, box_volume), + } +} diff --git a/crates/skate-core/src/physics/construction/principal_axes.rs b/crates/skate-core/src/physics/construction/principal_axes.rs new file mode 100644 index 00000000..65812a81 --- /dev/null +++ b/crates/skate-core/src/physics/construction/principal_axes.rs @@ -0,0 +1,84 @@ +//! TU3 inertia diagonalization: 82AE8388 and its three rotation helpers. +//! Rows here are mathematical rows. The caller transposes the resulting axes +//! into RenderWare's Ri/Up/At storage. No eigenvalue sorting is performed. + +use super::refined_reciprocal; + +pub(super) type Matrix3 = [[f32; 3]; 3]; + +pub(super) fn diagonalize(inertia: &mut Matrix3) -> Matrix3 { + let mut axes = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + let mut remaining = 10; + loop { + // 82AE84A4..8680: squared lower-triangle residual, relative to the + // smallest squared diagonal. Preserve comparison and FMA ordering. + let diagonal = [inertia[0][0], inertia[1][1], inertia[2][2]].map(|x| x * x); + let smallest = if diagonal[1] > diagonal[0] { + if diagonal[2] > diagonal[0] { + diagonal[0] + } else { + diagonal[2] + } + } else if diagonal[2] > diagonal[1] { + diagonal[1] + } else { + diagonal[2] + }; + let residual = inertia[2][1].mul_add( + inertia[2][1], + inertia[1][0].mul_add(inertia[1][0], inertia[2][0] * inertia[2][0]), + ); + if !(residual > smallest * 1.0e-24) || remaining == 0 { + break; + } + for row in 1..3 { + for column in 0..row { + let (sine, cosine) = rotation(inertia, row, column); + rotate_columns(&mut axes, row, column, sine, cosine); + rotate_columns(inertia, row, column, sine, cosine); + rotate_rows(inertia, row, column, sine, cosine); + } + } + remaining -= 1; + } + axes +} + +/// 82AE7AD8. The zero off-diagonal path is exactly the identity rotation. +fn rotation(matrix: &Matrix3, row: usize, column: usize) -> (f32, f32) { + let off_diagonal = matrix[row][column]; + if off_diagonal == 0.0 { + return (0.0, 1.0); + } + let difference = matrix[column][column] - matrix[row][row]; + let theta = refined_reciprocal(off_diagonal) * (difference * 0.5); + let length = theta.mul_add(theta, 1.0).sqrt(); + let tangent = if theta > 0.0 { + 1.0 / (length + theta) + } else { + -1.0 / (length - theta) + }; + let cosine = 1.0 / tangent.mul_add(tangent, 1.0).sqrt(); + (cosine * tangent, cosine) +} + +/// 82AE7C30: independently rounded products for the subtraction and one FMA +/// for the addition. Combining the subtraction into an FMA changes the port. +fn rotate_columns(matrix: &mut Matrix3, a: usize, b: usize, sine: f32, cosine: f32) { + for row in matrix { + let left = row[a]; + let right = row[b]; + row[a] = left * cosine - right * sine; + row[b] = left.mul_add(sine, right * cosine); + } +} + +/// 82AE7FD0 applies the matching rotation across the two matrix rows. +fn rotate_rows(matrix: &mut Matrix3, a: usize, b: usize, sine: f32, cosine: f32) { + let left = matrix[a]; + let right = matrix[b]; + for column in 0..3 { + matrix[a][column] = left[column] * cosine - right[column] * sine; + matrix[b][column] = left[column].mul_add(sine, right[column] * cosine); + } +} diff --git a/crates/skate-core/src/physics/contact.rs b/crates/skate-core/src/physics/contact.rs new file mode 100644 index 00000000..bf5b3e3b --- /dev/null +++ b/crates/skate-core/src/physics/contact.rs @@ -0,0 +1,330 @@ +//! Skate 3 TU3's RenderWare contact-record construction. +//! +//! This is the scalar form of the VMX128 block in TU3's two recovered +//! add-contact paths. It builds the 256-byte logical `rw::physics::Contact` +//! payload consumed in place by `ContactBatchBuild`; it does not solve the +//! contact or move a body. + +use crate::math::Vector3; + +pub mod tu3 { + /// Game-side physical-material combine helper used before both recovered + /// collision-to-contact record writers. + pub const COMBINE_PHYSICAL_MATERIALS: u32 = 0x8276_3078; + /// First recovered collision-to-contact record block, end exclusive. + pub const GENERATE_CONTACT_A: u32 = 0x8277_A828; + pub const GENERATE_CONTACT_A_END: u32 = 0x8277_AA88; + /// Second field-for-field collision-to-contact record block, end exclusive. + pub const GENERATE_CONTACT_B: u32 = 0x8277_BFAC; + pub const GENERATE_CONTACT_B_END: u32 = 0x8277_C21C; + /// In-place contact Jacobian builder. + pub const CONTACT_BATCH_BUILD: u32 = 0x82AE_10C8; +} + +/// Exact TU3 constant used to reject a degenerate velocity-derived tangent. +/// +/// The Xbox data table at `0x830382B0` contains `0x00800000` in every lane. +/// This is the smallest positive normal binary32 value, not a tuned gameplay +/// threshold. +pub const CONTACT_TANGENT_MINIMUM_SQUARED: f32 = f32::from_bits(0x0080_0000); +pub const CONTACT_FALLBACK_HALF: f32 = f32::from_bits(0x3F00_0000); + +/// The five 16-byte body vectors copied into a contact's solver workspace. +/// +/// Original member names and offsets are independently present in preserved +/// Skate-era EA SDK DWARF and are confirmed by TU3's loads: +/// +/// - `mCom/mId` at body `+0x10`; +/// - `mIfull/mInvm` at `+0x70`; +/// - `mIsplt/mState` at `+0x80`; +/// - `mForce/mKine` at `+0x90`; +/// - `mTorque/mCool` at `+0xa0`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactBodyState { + /// ID written to the contact header from the collision queue's body-ref + /// entry. It is kept separate from `reaction_id` because TU3 loads them + /// from different records. + pub contact_body_id: u32, + pub center_of_mass: Vector3, + pub reaction_id: u32, + /// Retail packed world inverse-inertia vector + /// `mIfull = (Ixx, Ixy, Ixz)`. + pub inverse_inertia_full: Vector3, + pub inverse_mass: f32, + /// Retail packed world inverse-inertia vector + /// `mIsplt = (Izz, Iyy, Iyz)`. + pub inverse_inertia_split: Vector3, + pub state: u32, + pub force_acceleration: Vector3, + pub kinetic_energy: f32, + pub torque_acceleration: Vector3, + pub cool_down: u32, + pub linear_velocity: Vector3, + pub angular_velocity: Vector3, +} + +/// Collision result and already-combined material values consumed by TU3's +/// contact generator. +/// +/// The material-combine routine that produces the three scalars is outside the +/// recovered block and remains deliberately explicit at this boundary. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactInput { + pub position_on_a: Vector3, + pub position_on_b: Vector3, + /// Positive normal impulse acts along this vector on A and opposite it on + /// B. For wheel A over static ground B, this points upward. Collision query + /// adapters must preserve this solver convention when ordering the pair. + pub normal: Vector3, + pub restitution: f32, + pub static_friction: f32, + pub dynamic_friction: f32, + pub tag: u32, +} + +/// Three scalar fields in the game-side physical material record consumed by +/// TU3 `0x82763078`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactMaterial { + pub static_friction: f32, + pub dynamic_friction: f32, + pub restitution: f32, +} + +/// Exact game-side material combine used immediately before the recovered +/// contact writers. +/// +/// TU3 performs three independent ordered comparisons: +/// +/// - static friction is the greater operand; +/// - dynamic friction is the greater operand; +/// - restitution is the lesser operand. +/// +/// The comparisons are kept explicit instead of using `f32::max`/`min` +/// because the retail branch behavior for unordered inputs is operand-order +/// sensitive: equality and unordered comparisons select B for all three fields. +/// Scalar lfs/stfs also quiet a selected signalling NaN while preserving payload. +pub fn combine_contact_materials( + material_a: RetailContactMaterial, + material_b: RetailContactMaterial, +) -> RetailContactMaterial { + let static_friction = if material_a.static_friction > material_b.static_friction { + material_a.static_friction + } else { + material_b.static_friction + }; + let dynamic_friction = if material_a.dynamic_friction > material_b.dynamic_friction { + material_a.dynamic_friction + } else { + material_b.dynamic_friction + }; + let restitution = if material_a.restitution < material_b.restitution { + material_a.restitution + } else { + material_b.restitution + }; + RetailContactMaterial { + static_friction: material_scalar_store(static_friction), + dynamic_friction: material_scalar_store(dynamic_friction), + restitution: material_scalar_store(restitution), + } +} + +fn material_scalar_store(value: f32) -> f32 { + if value.is_nan() { + f32::from_bits(value.to_bits() | 0x0040_0000) + } else { + value + } +} + +/// Scalar representation of the complete logical 256-byte retail contact. +/// +/// `body_a_workspace` and `body_b_workspace` correspond to the ten alternating +/// 16-byte vectors at offsets `0x60..0xff`. Keeping those copied values in the +/// record is important: `ContactBatchBuild` reads them from the contact and +/// does not dereference the live bodies. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContact { + pub position_on_a: Vector3, + pub body_a_id: u32, + pub position_on_b: Vector3, + pub body_b_id: u32, + pub normal: Vector3, + pub restitution: f32, + pub tangent_0: Vector3, + pub static_friction: f32, + pub tangent_1: Vector3, + pub dynamic_friction: f32, + /// Velocity at B's contact point minus velocity at A's contact point. + pub relative_velocity: Vector3, + pub tag: u32, + pub body_a_workspace: RetailContactWorkspace, + pub body_b_workspace: RetailContactWorkspace, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactWorkspace { + pub center_of_mass: Vector3, + pub reaction_id: u32, + pub inverse_inertia_full: Vector3, + pub inverse_mass: f32, + pub inverse_inertia_split: Vector3, + pub state: u32, + pub force_acceleration: Vector3, + pub kinetic_energy: f32, + pub torque_acceleration: Vector3, + pub cool_down: u32, +} + +impl From for RetailContactWorkspace { + fn from(body: RetailContactBodyState) -> Self { + Self { + center_of_mass: body.center_of_mass, + reaction_id: body.reaction_id, + inverse_inertia_full: body.inverse_inertia_full, + inverse_mass: body.inverse_mass, + inverse_inertia_split: body.inverse_inertia_split, + state: body.state, + force_acceleration: body.force_acceleration, + kinetic_energy: body.kinetic_energy, + torque_acceleration: body.torque_acceleration, + cool_down: body.cool_down, + } + } +} + +/// Port of TU3 `0x8277A828..0x8277AA88` and its duplicate +/// `0x8277BFAC..0x8277C21C`. +/// +/// VMX128 operation order is retained at the scalar-expression level: +/// +/// 1. compute each contact arm from the copied body center of mass; +/// 2. compute point rates as `linear + angular cross arm`; +/// 3. derive tangent 0 from `relative_velocity cross normal`; +/// 4. if that axis is subnormal, use the exact X/Z projection fallback; +/// 5. derive tangent 1 as `normal cross tangent_0`; +/// 6. copy the ten body-state vectors used by `ContactBatchBuild`. +/// +/// The retail input normal is already unit length. This routine intentionally +/// does not renormalize it or repair malformed collision input. +pub fn generate_contact( + input: RetailContactInput, + body_a: RetailContactBodyState, + body_b: RetailContactBodyState, +) -> RetailContact { + let arm_a = sub(input.position_on_a, body_a.center_of_mass); + let arm_b = sub(input.position_on_b, body_b.center_of_mass); + let point_velocity_a = point_velocity(body_a.linear_velocity, body_a.angular_velocity, arm_a); + let point_velocity_b = point_velocity(body_b.linear_velocity, body_b.angular_velocity, arm_b); + let relative_velocity = sub(point_velocity_b, point_velocity_a); + + let velocity_tangent = cross(relative_velocity, input.normal); + let velocity_tangent_squared = length_squared(velocity_tangent); + let (unnormalized_tangent, tangent_squared) = + if velocity_tangent_squared >= CONTACT_TANGENT_MINIMUM_SQUARED { + (velocity_tangent, velocity_tangent_squared) + } else { + fallback_tangent(input.normal) + }; + + // TU3 issues one reciprocal-square-root estimate and applies it to both + // axes. `tangent_1` therefore assumes the collision normal is unit length. + let inverse_length = super::reciprocal_sqrt::estimate(tangent_squared); + let tangent_0 = scale(unnormalized_tangent, inverse_length); + // The second cross product rounds the positive product first, then uses + // vnmsubfp for the negative term; tangent_0 uses the opposite order. + let n = input.normal; + let t = unnormalized_tangent; + let tangent_1 = scale( + Vector3::new( + (-t.y).mul_add(n.z, t.z.mul_add(n.y, 0.0)), + (-t.z).mul_add(n.x, t.x.mul_add(n.z, 0.0)), + (-t.x).mul_add(n.y, t.y.mul_add(n.x, 0.0)), + ), + inverse_length, + ); + + RetailContact { + position_on_a: input.position_on_a, + body_a_id: body_a.contact_body_id, + position_on_b: input.position_on_b, + body_b_id: body_b.contact_body_id, + normal: input.normal, + restitution: input.restitution, + tangent_0, + static_friction: input.static_friction, + tangent_1, + dynamic_friction: input.dynamic_friction, + relative_velocity, + tag: input.tag, + body_a_workspace: body_a.into(), + body_b_workspace: body_b.into(), + } +} + +/// Exact normal-only fallback selected by the TU3 VMX block. +fn fallback_tangent(normal: Vector3) -> (Vector3, f32) { + let candidate_x = Vector3::new( + (-normal.x).mul_add(normal.x, 1.0), + (-normal.y).mul_add(normal.x, 0.0), + (-normal.z).mul_add(normal.x, 0.0), + ); + let candidate_z = Vector3::new( + (-normal.x).mul_add(normal.z, 0.0), + (-normal.y).mul_add(normal.z, 0.0), + (-normal.z).mul_add(normal.z, 1.0), + ); + if (-normal.x).mul_add(normal.x, CONTACT_FALLBACK_HALF) >= -0.0 { + (candidate_x, candidate_x.x) + } else { + ( + Vector3::new(-candidate_z.x, -candidate_z.y, -candidate_z.z), + candidate_z.z, + ) + } +} + +fn point_velocity(linear: Vector3, omega: Vector3, arm: Vector3) -> Vector3 { + // Native folds the linear velocity into two fused cross-product terms. + Vector3::new( + arm.z.mul_add(omega.y, (-arm.y).mul_add(omega.z, linear.x)), + arm.x.mul_add(omega.z, (-arm.z).mul_add(omega.x, linear.y)), + arm.y.mul_add(omega.x, (-arm.x).mul_add(omega.y, linear.z)), + ) +} + +fn sub(left: Vector3, right: Vector3) -> Vector3 { + Vector3::new(left.x - right.x, left.y - right.y, left.z - right.z) +} + +fn scale(vector: Vector3, scalar: f32) -> Vector3 { + Vector3::new( + vector.x.mul_add(scalar, 0.0), + vector.y.mul_add(scalar, 0.0), + vector.z.mul_add(scalar, 0.0), + ) +} + +fn cross(left: Vector3, right: Vector3) -> Vector3 { + Vector3::new( + left.y.mul_add(right.z, (-left.z).mul_add(right.y, 0.0)), + left.z.mul_add(right.x, (-left.x).mul_add(right.z, 0.0)), + left.x.mul_add(right.y, (-left.y).mul_add(right.x, 0.0)), + ) +} + +fn dot(left: Vector3, right: Vector3) -> f32 { + left.z.mul_add( + right.z, + left.y.mul_add(right.y, left.x.mul_add(right.x, 0.0)), + ) +} + +fn length_squared(vector: Vector3) -> f32 { + dot(vector, vector) +} + +#[cfg(test)] +#[path = "tests/contact.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/contact_feedback/board_reports.rs b/crates/skate-core/src/physics/contact_feedback/board_reports.rs new file mode 100644 index 00000000..15b29003 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/board_reports.rs @@ -0,0 +1,144 @@ +//! Solver observations consumed by the board's post-physics update. +//! +//! TU3 82AE1608 observes completed contact rows after integration; 827682B0 +//! then constructs at most sixteen reports per board, in contact order. +//! Rust owns the report storage and part identifiers. No guest pointers or +//! copied native map/locking infrastructure are needed for this single board. +use crate::math::Vector3; + +use super::super::{ + assembly::BodySnapshot, + board::{BODY_COUNT, BodyId}, + board_step::CollisionBody, + contact_solver::RetailContactJacobian, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardContactReport { + pub part: BodyId, + pub other: CollisionBody, + pub is_body_a: bool, + /// Oriented toward this board, as ContactInfo+16. + pub normal: Vector3, + pub position: Vector3, + /// Linear velocities after integration. Native does not add omega cross r. + pub relative_linear_velocity: Vector3, + pub other_surface: u16, + /// The underlying spy retains A's orientation even in a B-side report. + pub normal_force_on_a: Vector3, + pub friction_force_on_a: Vector3, + pub tangents: [Vector3; 2], +} + +/// Replace this frame's reports from the actual solved rows. All seven board +/// parts share one report budget and one owner; their self contacts are excluded +/// from reporting, independently of whether they participated in the solver. +pub(crate) fn collect( + output: &mut Vec, + contacts: &[RetailContactJacobian], + bodies: &[BodySnapshot; BODY_COUNT], + frequency: f32, +) { + output.clear(); + let frequency_squared = frequency * frequency; + for contact in contacts { + let words = contact.words(); + let impulse = contact.accumulated_impulse(); + // 82AE1668..168C: enabled spy and strictly positive normal response. + if words[11] & 8 == 0 || !(impulse[0] > 0.0) { + continue; + } + //Other assemblies share this solve. Their own output owners consume + //their spies; only board/world records are routed to this collector. + if !((words[31] < BODY_COUNT as u32 && words[43] == u32::MAX) + || (words[43] < BODY_COUNT as u32 && words[31] == u32::MAX)) { + continue; + } + let a = body_id(words[31]); + let b = body_id(words[43]); + let (part, other, is_body_a) = match (a, b) { + (CollisionBody::Board(part), CollisionBody::StaticWorld) => (part, b, true), + (CollisionBody::StaticWorld, CollisionBody::Board(part)) => (part, a, false), + // 82768418: same board/owner exclusion; no world report subscriber. + _ => continue, + }; + if output.len() == 16 { + break; + } + let normal = vector(words, 28); + let tangents = [vector(words, 40), vector(words, 52)]; + let a_position = add(center(a, bodies), vector(words, 0)); + let b_position = add(center(b, bodies), vector(words, 4)); + let a_weight = f32::from_bits(words[35]); + let b_weight = f32::from_bits(words[39]); + let inverse_weight = 1.0 / (b_weight + a_weight); + let position = combine(a_position, a_weight, scale(b_position, b_weight)); + let friction = combine(tangents[0], impulse[1], scale(tangents[1], impulse[2])); + let sign = if is_body_a { 1.0 } else { -1.0 }; + let this = CollisionBody::Board(part); + output.push(BoardContactReport { + part, + other, + is_body_a, + normal: scale(normal, sign), + position: scale(position, inverse_weight), + relative_linear_velocity: subtract(velocity(this, bodies), velocity(other, bodies)), + other_surface: if is_body_a { + words[55] as u16 + } else { + (words[55] >> 16) as u16 + }, + normal_force_on_a: scale(scale(normal, impulse[0]), frequency_squared), + friction_force_on_a: scale(friction, frequency_squared), + tangents, + }); + } +} + +fn body_id(value: u32) -> CollisionBody { + if value == u32::MAX { + CollisionBody::StaticWorld + } else { + CollisionBody::Board(BodyId::ORDER[value as usize]) + } +} + +fn center(body: CollisionBody, bodies: &[BodySnapshot; BODY_COUNT]) -> Vector3 { + match body { + CollisionBody::Board(id) => bodies[id.index()].rates.position, + CollisionBody::StaticWorld => Vector3::ZERO, + CollisionBody::Attached(_) => unreachable!("collector selects board/world contacts above"), + } +} + +fn velocity(body: CollisionBody, bodies: &[BodySnapshot; BODY_COUNT]) -> Vector3 { + match body { + CollisionBody::Board(id) => bodies[id.index()].rates.linear_velocity, + CollisionBody::StaticWorld => Vector3::ZERO, + CollisionBody::Attached(_) => unreachable!("collector selects board/world contacts above"), + } +} + +fn vector(words: &[u32; 64], offset: usize) -> Vector3 { + Vector3::new( + f32::from_bits(words[offset]), + f32::from_bits(words[offset + 1]), + f32::from_bits(words[offset + 2]), + ) +} +fn scale(v: Vector3, scale: f32) -> Vector3 { + Vector3::new(v.x * scale, v.y * scale, v.z * scale) +} +fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +fn subtract(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +fn combine(a: Vector3, weight: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(weight, b.x), + a.y.mul_add(weight, b.y), + a.z.mul_add(weight, b.z), + ) +} diff --git a/crates/skate-core/src/physics/contact_feedback/collision_info.rs b/crates/skate-core/src/physics/contact_feedback/collision_info.rs new file mode 100644 index 00000000..6fd9a583 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/collision_info.rs @@ -0,0 +1,73 @@ +use super::CollisionInfo; + +fn word(bytes: &[u8], offset: usize) -> u32 { + u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) +} + +fn put(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); +} + +fn up(bytes: &mut [u8], offset: usize) { + bytes[offset..offset + 16].fill(0); + put(bytes, offset + 4, 0x3f80_0000); +} + +/// Complete 82C00CA0 selective per-frame reset. Wheel surface/audio/object +/// slots and line-test state survive; this differs from startup 82C00D68. +pub fn reset_frame_collision_info(info: &mut CollisionInfo) { + up(info, 0); + for i in 0..7 { + up(info, 32 + i * 16); + for offset in [144, 368, 480] { + info[offset + i * 16..offset + i * 16 + 16].fill(0); + } + info[780 + i] = 0; + } + for i in 4..7 { + for offset in [692, 720, 748] { + put(info, offset + i * 4, 0); + } + } + info[592..608].fill(0); + info[688..692].fill(1); + for offset in [776, 788, 796, 800] { + put(info, offset, 0); + } + info[804] = 0; + info[805] = 0; + let flags = word(info, 808) & 0x01ff_ffff; + put(info, 808, flags); +} + +/// Complete 82C08818 surface vote. Noncontacting wheels still contribute one +/// vote; contacting wheels contribute four. Equal counts favor the lower ID. +/// IDs14/15 are accumulated but not considered by the native winner scan. +pub fn choose_physics_surface(info: &mut CollisionInfo) { + let surfaces = std::array::from_fn(|i| word(info, 692 + i * 4)); + let contacts = std::array::from_fn(|i| info[780 + i] != 0); + let result = choose_surface(surfaces, contacts, word(info, 808) & 0x0200_0000 != 0); + put(info, 776, result); +} + +/// Typed82C08818 boundary. Values are the retained wheel surface outputs; +/// query misses and contact replacements belong to their original producers. +pub fn choose_surface(surfaces: [u32; 4], contacts: [bool; 4], forced_twelve: bool) -> u32 { + let mut votes = [0u32; 16]; + for i in 0..4 { + let surface = surfaces[i] as usize; + if surface != 0 { + assert!(surface < 16, "native surface histogram index exceeded"); + votes[surface] += if contacts[i] { 4 } else { 1 }; + } + } + let mut winner = 1; + let mut maximum = 0; + for (surface, &count) in votes.iter().enumerate().take(14).skip(1) { + if count > maximum { + maximum = count; + winner = surface as u32; + } + } + if forced_twelve { 12 } else { winner } +} diff --git a/crates/skate-core/src/physics/contact_feedback/compression.rs b/crates/skate-core/src/physics/contact_feedback/compression.rs new file mode 100644 index 00000000..e30c9ba3 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/compression.rs @@ -0,0 +1,111 @@ +use crate::physics::board_pose::{PartPose, PoseMatrix}; + +type V = [f32; 4]; +type M = [V; 4]; + +fn matrix(words: PoseMatrix) -> M { + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(words[i * 4 + j]))) +} +fn madd(a: V, s: f32, b: V) -> V { + std::array::from_fn(|i| a[i].mul_add(s, b[i])) +} +fn scale(a: V, s: f32) -> V { + a.map(|v| v * s) +} +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]), + ] +} +fn dot(a: V, b: V) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} + +/// Complete 82585B58. Live bodies return their mass-frame basis/COM directly. +/// A body-less part with a local mass frame removes that frame from its cache. +/// This is distinct from 82585CB0's geometric-part transform getter. +pub fn part_mass_transform(part: &PartPose) -> PoseMatrix { + if let Some(body) = &part.body { + return std::array::from_fn(|i| body[[16, 20, 24, 4][i / 4] + i % 4]); + } + let Some(local) = part.local_mass_frame else { + return part.transform; + }; + let local = matrix(local); + let basis: [V; 3] = std::array::from_fn(|i| [local[0][i], local[1][i], local[2][i], 0.0]); + let neg = local[3].map(|v| 0.0 - v); + let position = madd( + basis[0], + neg[0], + madd(basis[1], neg[1], scale(basis[2], neg[2])), + ); + let cached = matrix(part.transform); + let result: M = std::array::from_fn(|i| { + let v = if i == 3 { position } else { basis[i] }; + let first = if i == 3 { + madd(cached[0], v[0], cached[3]) + } else { + scale(cached[0], v[0]) + }; + madd(cached[2], v[2], madd(cached[1], v[1], first)) + }); + std::array::from_fn(|i| result[i / 4][i % 4].to_bits()) +} + +/// Complete 82C08968: observe wheel COM positions in inverse deck mass space, +/// average native pairs0/1 and2/3, subtract board+8380, store+8372/+8376 only. +pub fn calculate_wheel_compressions(board: &mut [u8; 8400], parts: &[PartPose; 7]) { + let deck = matrix(part_mass_transform(&parts[6])); + let wheels = std::array::from_fn(|i| matrix(part_mass_transform(&parts[i]))[3]); + let distance = f32::from_bits(u32::from_be_bytes(board[8380..8384].try_into().unwrap())); + let heights = average_wheel_compressions(deck, wheels, distance); + for (offset, height) in [(8372, heights[0]), (8376, heights[1])] { + let word = if height.is_nan() { + height.to_bits() | 0x0040_0000 + } else { + height.to_bits() + }; + board[offset..offset + 4].copy_from_slice(&word.to_be_bytes()); + } +} + +///82C08968, with live mass-frame observations rather than a byte-buffer owner. +///Rest height is physicstrucks/default.TruckYPos, copied to8380 by82C08D18. +pub fn average_wheel_compressions(deck: M, wheel_positions: [V; 4], rest_height: f32) -> [f32; 2] { + let c0 = cross(deck[1], deck[2]); + let c1 = cross(deck[2], deck[0]); + let c2 = cross(deck[0], deck[1]); + let determinant = dot(deck[0], c0); + // Native reciprocal estimate and two refinements. + let mut inverse = reciprocal_estimate(determinant); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(determinant, 1.0), inverse); + } + let basis: [V; 3] = std::array::from_fn(|i| scale([c0[i], c1[i], c2[i], c1[i]], inverse)); + let neg = deck[3].map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)); + let position = madd( + basis[2], + neg[2], + madd(basis[1], neg[1], scale(basis[0], neg[0])), + ); + let heights: [f32; 4] = std::array::from_fn(|i| { + let p = wheel_positions[i]; + let local = madd( + basis[2], + p[2], + madd(basis[1], p[1], madd(basis[0], p[0], position)), + ); + local[1] + }); + [ + (heights[0] + heights[1]) * 0.5 - rest_height, + (heights[2] + heights[3]) * 0.5 - rest_height, + ] +} + +fn reciprocal_estimate(value: f32) -> f32 { + crate::physics::native_arithmetic::reciprocal_estimate(value) +} diff --git a/crates/skate-core/src/physics/contact_feedback/mod.rs b/crates/skate-core/src/physics/contact_feedback/mod.rs new file mode 100644 index 00000000..a64019f1 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/mod.rs @@ -0,0 +1,20 @@ +//! TU3 post-solver contact observations and their native record lifetimes. +pub(crate) mod board_reports; +mod collision_info; +mod compression; +mod reports; +mod spy; + +pub use board_reports::BoardContactReport; +pub use collision_info::{choose_physics_surface, choose_surface, reset_frame_collision_info}; +pub use compression::{ + average_wheel_compressions, calculate_wheel_compressions, part_mass_transform, +}; +pub use reports::{ + ContactReportBackend, ContactReportBuffer, ContactReportDispatchBackend, ContactTarget, + create_contact_reports, get_contact_reports, resolve_contact_reports, +}; +pub use spy::spy_contact_jacobians; + +/// SkateboardBody+64..+876, retained as guest big-endian bytes. +pub type CollisionInfo = [u8; 812]; diff --git a/crates/skate-core/src/physics/contact_feedback/reports.rs b/crates/skate-core/src/physics/contact_feedback/reports.rs new file mode 100644 index 00000000..21040190 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/reports.rs @@ -0,0 +1,130 @@ +/// Registered target for a rigid-body pointer (Island+80 lookup). +#[derive(Clone, Copy, Debug)] +pub struct ContactTarget { + pub body: u32, + pub owner: u32, + pub tag: u32, +} + +/// Supplied storage preserves every unwritten byte when records are reused. +pub struct ContactReportBuffer { + pub count: u32, + pub records: [[u8; 96]; 16], +} + +/// Container/locking boundary of 827682B0. The map operation inserts an absent +/// Body key; clearing resets existing vector ends without erasing keys/storage. +/// Allocated report slots and their padding are supplied by the backend. +pub trait ContactReportBackend { + fn lock(&mut self); + fn clear_report_counts(&mut self); + fn lookup_target(&mut self, rigid_body: u32) -> Option; + fn get_or_insert_reports(&mut self, body: u32) -> usize; + fn reports(&mut self, handle: usize) -> &mut ContactReportBuffer; + fn linear_velocity(&mut self, rigid_body: u32) -> [u32; 4]; + fn unlock(&mut self); +} + +/// Complete 82768250 lookup lifetime: lock, operator[] (possibly inserts), +/// unlock, return the live vector handle. No detached report copy is produced. +pub fn get_contact_reports(body: u32, backend: &mut impl ContactReportBackend) -> usize { + backend.lock(); + let handle = backend.get_or_insert_reports(body); + backend.unlock(); + handle +} + +/// Live tree traversal and Body virtual+8 dispatch boundary of 827685C8. +/// The backend retains native map order and resolves next after each callback. +pub trait ContactReportDispatchBackend { + fn first_report_entry(&mut self) -> Option; + fn next_report_entry(&mut self, handle: usize) -> Option; + fn report_count(&mut self, handle: usize) -> u32; + fn dispatch_reports(&mut self, handle: usize); +} + +/// Complete 827685C8. Visits every map entry, invokes only nonempty live vectors, +/// and performs no list clearing or local locking around callbacks. +pub fn resolve_contact_reports(backend: &mut impl ContactReportDispatchBackend) { + let mut entry = backend.first_report_entry(); + while let Some(handle) = entry { + if backend.report_count(handle) != 0 { + backend.dispatch_reports(handle); + } + entry = backend.next_report_entry(handle); + } +} + +/// Complete 827682B0 report construction/filter/order, with its native map and +/// lock operations supplied by the backend. `spy_base` retains native pointer +/// identity; reports borrow that simulation storage through the consuming phase. +pub fn create_contact_reports( + spies: &[[u32; 28]], + spy_base: u32, + backend: &mut impl ContactReportBackend, +) { + backend.lock(); + backend.clear_report_counts(); + for (index, spy) in spies.iter().enumerate() { + let a = backend.lookup_target(spy[24]); + let b = backend.lookup_target(spy[25]); + let a_body = a.map_or(0, |target| target.body); + let b_body = b.map_or(0, |target| target.body); + let a_owner = a.map_or(0, |target| target.owner); + let b_owner = b.map_or(0, |target| target.owner); + let same_body = a.is_some() && b.is_some() && a_body == b_body; + let excluded = same_body || (a_owner != 0 && a_owner == b_owner); + for (target, is_a) in [(a, true), (b, false)] { + let Some(target) = target else { + continue; + }; + // Native operator[] runs even for a subsequently excluded pair. + let handle = backend.get_or_insert_reports(target.body); + let count = backend.reports(handle).count; + if count >= 16 || excluded { + continue; + } + let other = if is_a { spy[25] } else { spy[24] }; + let this = if is_a { spy[24] } else { spy[25] }; + let other_velocity = backend.linear_velocity(other).map(f32::from_bits); + let this_velocity = backend.linear_velocity(this).map(f32::from_bits); + let normal: [u32; 4] = std::array::from_fn(|i| { + (f32::from_bits(spy[i]) * if is_a { 1.0 } else { -1.0 }).to_bits() + }); + let relative: [u32; 4] = + std::array::from_fn(|i| (this_velocity[i] - other_velocity[i]).to_bits()); + let buffer = backend.reports(handle); + let record = &mut buffer.records[count as usize]; + put( + record, + 0, + spy_base.wrapping_add((index as u32).wrapping_mul(112)), + ); + for i in 0..4 { + put(record, 16 + i * 4, normal[i]); + put(record, 32 + i * 4, spy[12 + i]); + put(record, 48 + i * 4, relative[i]); + } + put(record, 64, target.tag); + put( + record, + 68, + if is_a { + spy[26] & 0xffff + } else { + spy[26] >> 16 + }, + ); + put(record, 72, target.body); + put(record, 76, if is_a { b_body } else { a_body }); + record[80] = 0; + record[81] = u8::from(is_a); + buffer.count += 1; + } + } + backend.unlock(); +} + +fn put(record: &mut [u8; 96], offset: usize, value: u32) { + record[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); +} diff --git a/crates/skate-core/src/physics/contact_feedback/spy.rs b/crates/skate-core/src/physics/contact_feedback/spy.rs new file mode 100644 index 00000000..d3d75ab4 --- /dev/null +++ b/crates/skate-core/src/physics/contact_feedback/spy.rs @@ -0,0 +1,63 @@ +/// Complete TU3 82AE1608. Compacts eligible256-byte compiled contact rows into +///112-byte spies at the same buffer start. `spy_count` increments from its input +///value; it does not choose the write offset and is not reset by this routine. +/// `body_center` resolves the exact body pointer to its packed COM/ID vector. +pub fn spy_contact_jacobians( + storage: &mut [u32], + contact_count: u32, + spy_count: &mut u32, + frequency: f32, + mut body_center: impl FnMut(u32) -> [u32; 4], +) { + assert!(storage.len() >= contact_count as usize * 64); + let squared_frequency = frequency * frequency; + let mut write = 0; + for index in 0..contact_count as usize { + let start = index * 64; + if storage[start + 11] & 8 == 0 { + continue; + } + let impulse = vector(storage, start + 20); + if !(impulse[0] > 0.0) { + continue; + } + let a = storage[start + 31]; + let b = storage[start + 43]; + let b_center = body_center(b).map(f32::from_bits); + let a_center = body_center(a).map(f32::from_bits); + let a_position = vector(storage, start); + let b_position = vector(storage, start + 4); + let a_weight = f32::from_bits(storage[start + 35]); + let b_weight = f32::from_bits(storage[start + 39]); + let normal = vector(storage, start + 28); + let tangent0 = vector(storage, start + 40); + let tangent1 = vector(storage, start + 52); + let tangent: [f32; 4] = + std::array::from_fn(|i| tangent0[i].mul_add(impulse[1], tangent1[i] * impulse[2])); + let inverse = 1.0 / (b_weight + a_weight); + let position: [f32; 4] = std::array::from_fn(|i| { + (a_center[i] + a_position[i]) + .mul_add(a_weight, (b_center[i] + b_position[i]) * b_weight) + * inverse + }); + let tag = storage[start + 55]; + // All source vectors/scalars are loaded before the in-place stores. + storage[write + 24] = a; + storage[write + 25] = b; + storage[write + 26] = tag; + for i in 0..4 { + storage[write + 16 + i] = ((normal[i] * impulse[0]) * squared_frequency).to_bits(); + storage[write + 20 + i] = (tangent[i] * squared_frequency).to_bits(); + storage[write + i] = normal[i].to_bits(); + storage[write + 4 + i] = tangent0[i].to_bits(); + storage[write + 8 + i] = tangent1[i].to_bits(); + storage[write + 12 + i] = position[i].to_bits(); + } + *spy_count = spy_count.wrapping_add(1); + write += 28; + } +} + +fn vector(words: &[u32], offset: usize) -> [f32; 4] { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} diff --git a/crates/skate-core/src/physics/contact_layout.rs b/crates/skate-core/src/physics/contact_layout.rs new file mode 100644 index 00000000..e69a1fcf --- /dev/null +++ b/crates/skate-core/src/physics/contact_layout.rs @@ -0,0 +1,39 @@ +//! Serialization of the uncompiled TU3 256-byte contact, in guest word order. +use super::contact::{RetailContact, RetailContactWorkspace}; +use crate::math::Vector3; + +pub(super) fn encode(c: RetailContact) -> [u32; 64] { + let mut words = [0; 64]; + let header = [ + lanes(c.position_on_a, c.body_a_id), + lanes(c.position_on_b, c.body_b_id), + lanes(c.normal, c.restitution.to_bits()), + lanes(c.tangent_0, c.static_friction.to_bits()), + lanes(c.tangent_1, c.dynamic_friction.to_bits()), + lanes(c.relative_velocity, c.tag), + ]; + for (i, v) in header.into_iter().enumerate() { + words[i * 4..i * 4 + 4].copy_from_slice(&v); + } + for (i, (a, b)) in workspace(c.body_a_workspace) + .into_iter() + .zip(workspace(c.body_b_workspace)) + .enumerate() + { + words[24 + i * 8..28 + i * 8].copy_from_slice(&a); + words[28 + i * 8..32 + i * 8].copy_from_slice(&b); + } + words +} +fn lanes(v: Vector3, w: u32) -> [u32; 4] { + [v.x.to_bits(), v.y.to_bits(), v.z.to_bits(), w] +} +fn workspace(w: RetailContactWorkspace) -> [[u32; 4]; 5] { + [ + lanes(w.center_of_mass, w.reaction_id), + lanes(w.inverse_inertia_full, w.inverse_mass.to_bits()), + lanes(w.inverse_inertia_split, w.state), + lanes(w.force_acceleration, w.kinetic_energy.to_bits()), + lanes(w.torque_acceleration, w.cool_down), + ] +} diff --git a/crates/skate-core/src/physics/contact_solver.rs b/crates/skate-core/src/physics/contact_solver.rs new file mode 100644 index 00000000..dcf05cba --- /dev/null +++ b/crates/skate-core/src/physics/contact_solver.rs @@ -0,0 +1,87 @@ +//! TU3 ContactBatchBuild and the shared Horus contact solver. +//! Typed contacts are encoded only at the compiled-row boundary. Contact +//! iteration has a readable direct-source implementation; the native builder's +//! reciprocal-estimate dependency remains unavailable. +use super::{contact::RetailContact, rigid_body::RetailReactionCorrections}; +use crate::math::Vector3; + +pub mod tu3 { + pub const CONTACT_BATCH_BUILD: u32 = 0x82AE_10C8; + pub const ITERATIVE_CONSTRAINT_SOLVER: u32 = 0x82AE_27D0; +} +pub const ACTIVE_BODY: u32 = 4; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailContactJacobian { + pub(crate) words: [u32; 64], + pub reaction_index_a: usize, + pub reaction_index_b: usize, +} +impl RetailContactJacobian { + /// Native compiled layout, for inspection and independent comparisons. + pub fn words(&self) -> &[u32; 64] { + &self.words + } + pub fn accumulated_impulse(&self) -> [f32; 4] { + self.lanes(5) + } + /// Weighted velocity targets in xyz and position-only target in w. + pub fn target_impulse(&self) -> [f32; 4] { + self.lanes(6) + } + pub fn static_friction(&self) -> f32 { + f32::from_bits(self.words[15]) + } + pub fn dynamic_friction(&self) -> f32 { + f32::from_bits(self.words[19]) + } + pub fn angular_response_a(&self) -> [Vector3; 3] { + core::array::from_fn(|i| self.xyz(8 + i * 3)) + } + pub fn angular_response_b(&self) -> [Vector3; 3] { + core::array::from_fn(|i| self.xyz(9 + i * 3)) + } + fn lanes(&self, vector: usize) -> [f32; 4] { + core::array::from_fn(|i| f32::from_bits(self.words[vector * 4 + i])) + } + fn xyz(&self, vector: usize) -> Vector3 { + let v = self.lanes(vector); + Vector3::new(v[0], v[1], v[2]) + } +} + +/// Requests native contact construction. The unresolved reciprocal estimate +/// currently stops this call explicitly; callers must not treat compiled-row +/// solver tests as evidence that this producer is ready for gameplay. +/// Each input body workspace must include forces accumulated for this tick. +pub fn build_contact_jacobian(contact: RetailContact, time_step: f32) -> RetailContactJacobian { + assert!(time_step.is_finite() && time_step > 0.0); + let mut words = super::contact_layout::encode(contact); + super::solver::compile_contact(&mut words, time_step); + RetailContactJacobian { + words, + reaction_index_a: contact.body_a_workspace.reaction_id as usize, + reaction_index_b: contact.body_b_workspace.reaction_id as usize, + } +} + +/// Contact-only entry point uses the same kernel and reaction layout as the +/// complete contact -> joint -> drive iteration. Friction reads the preceding +/// normal impulse, before this pass publishes its new normal impulse. +pub fn solve_contact_jacobians( + contacts: &mut [RetailContactJacobian], + reactions: &mut [RetailReactionCorrections], + maximum_iterations: u32, +) { + super::solver::solve_constraints(contacts, &mut [], &mut [], reactions, maximum_iterations); +} +pub fn solve_contact_jacobian_iteration( + contacts: &mut [RetailContactJacobian], + reactions: &mut [RetailReactionCorrections], +) { + solve_contact_jacobians(contacts, reactions, 1); +} + +#[cfg(test)] +#[path = "tests/contact_solver.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/deck_angular_correction.rs b/crates/skate-core/src/physics/deck_angular_correction.rs new file mode 100644 index 00000000..1428f6ff --- /dev/null +++ b/crates/skate-core/src/physics/deck_angular_correction.rs @@ -0,0 +1,218 @@ +//! Deck angular corrections from TU3 82C07000 and 82C075B8. +//! These add angular acceleration before the shared solve. They never set yaw +//! or orientation directly. Both native fixed-step conversions are retained. +use super::{ + board_motion_output::{dot, inverse_length_squared}, + native_arithmetic::reciprocal_estimate, + rigid_body::RetailBodyRates, +}; +use crate::math::{Basis3, Vector3}; +const STEP: f32 = f32::from_bits(0x3c88_8889); +const NORMAL_MINIMUM: f32 = f32::from_bits(0x3586_37bd); //82F826F8 ->830BD350. + +/// 82C07000 first limits the requested angular displacement against the +/// displacement already supplied by angular velocity along that same axis. +pub fn apply_limited_displacement(body: &mut RetailBodyRates, requested: Vector3) { + let squared = dot(requested, requested); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + let direction = if length > NORMAL_MINIMUM { + scale(requested, inverse) + } else { + Vector3::ZERO + }; + let existing = scale( + direction, + dot(direction, scale(body.angular_velocity, STEP)), + ); + let remainder = subtract(requested, existing); + let remainder = if dot(remainder, requested) < 0.0 { + Vector3::ZERO + } else { + remainder + }; + let selected = if dot(existing, requested) < 0.0 { + requested + } else { + remainder + }; + apply_angular_displacement(body, selected); +} + +/// 82C075B8. The inverse of the current world inverse-inertia tensor converts +/// displacement/step to torque; another step division and the original tensor +/// convert it to angular acceleration. Do not algebraically cancel the tensor +/// pair: its rounded cofactors and ordered products are observable. +pub fn apply_angular_displacement(body: &mut RetailBodyRates, displacement: Vector3) { + let tensor = body.world_inverse_inertia; + let inverse = invert_symmetric(tensor); + let inverse_step = reciprocal_refined(STEP); + let target_rate = scale(displacement, inverse_step); + let momentum = multiply(inverse, target_rate); + let torque = scale(momentum, reciprocal_refined(STEP)); + let acceleration = multiply(tensor, torque); + body.torque_acceleration = Vector3::new( + body.torque_acceleration.x + acceleration.x, + body.torque_acceleration.y + acceleration.y, + body.torque_acceleration.z + acceleration.z, + ); + // Native accumulator +172 shares its vector with the cooldown word. + body.cool_down = 0; +} + +fn invert_symmetric(tensor: Basis3) -> Basis3 { + let [a, b, c] = tensor.columns.map(|[x, y, z]| Vector3::new(x, y, z)); + let first = cross(b, c); + let second = cross(c, a); + let third = cross(a, b); + let inverse_det = reciprocal_refined(dot(a, first)); + Basis3 { + columns: [ + [first.x, second.x, third.x].map(|x| x * inverse_det), + [first.y, second.y, third.y].map(|x| x * inverse_det), + [first.z, second.z, third.z].map(|x| x * inverse_det), + ], + } +} +fn reciprocal_refined(value: f32) -> f32 { + let estimate = reciprocal_estimate(value); + let first = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + first.mul_add((-first).mul_add(value, 1.0), first) +} +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +fn multiply(matrix: Basis3, v: Vector3) -> Vector3 { + let lane = |i: usize| { + matrix.columns[2][i].mul_add( + v.z, + matrix.columns[1][i].mul_add(v.y, matrix.columns[0][i] * v.x), + ) + }; + Vector3::new(lane(0), lane(1), lane(2)) +} +fn scale(v: Vector3, x: f32) -> Vector3 { + Vector3::new(v.x * x, v.y * x, v.z * x) +} +fn subtract(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physics::rigid_body::RetailQuaternion; + fn body() -> RetailBodyRates { + let identity = Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }; + RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: identity, + world_inverse_inertia: Basis3 { + columns: [[3.0, 0.2, 0.1], [0.2, 2.0, 0.3], [0.1, 0.3, 1.0]], + }, + position: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 3, + } + } + #[test] + fn correction_enters_accumulator_and_respects_existing_angular_motion() { + let request = Vector3::new(0.02, 0.01, -0.03); + let mut state = body(); + apply_angular_displacement(&mut state, request); + for (actual, want) in [ + (state.torque_acceleration.x, 72.0), + (state.torque_acceleration.y, 36.0), + (state.torque_acceleration.z, -108.0), + ] { + assert!((actual - want).abs() < 0.0001); + } + assert_eq!(state.orientation, RetailQuaternion::IDENTITY); + assert_eq!(state.cool_down, 0); + let mut overshoot = body(); + overshoot.angular_velocity = Vector3::new(0.0, 3.0, 0.0); + apply_limited_displacement(&mut overshoot, Vector3::new(0.0, 0.02, 0.0)); + assert_eq!(overshoot.torque_acceleration, Vector3::ZERO); + let mut against = body(); + against.angular_velocity = Vector3::new(0.0, -3.0, 0.0); + apply_limited_displacement(&mut against, Vector3::new(0.0, 0.02, 0.0)); + assert!((against.torque_acceleration.y - 72.0).abs() < 0.0001); + let mut ground = body(); + ground.basis = Basis3 { columns: [[0., -1., 0.], [1., 0., 0.], [0., 0., 1.]] }; + apply_ground_body_torque(&mut ground); + assert!((ground.torque_acceleration.x + 1.815).abs() < 0.00001); + assert!((ground.torque_acceleration.y + 0.121).abs() < 0.00001); + assert!((ground.torque_acceleration.z + 0.0605).abs() < 0.00001); + assert_eq!(ground.force_acceleration, Vector3::ZERO); + } +} + +/// Ground board update82D389DC..82D38C98. Skateboard+16 is the cached deck +/// rigid body (ctor82C01278); this torque therefore enters the same accumulator. +/// The inline pow calculation has fixed inputs10 and-1. Retain its two +/// coefficient trees and reciprocal refinements instead of replacing it by.1. +pub fn apply_ground_body_torque(body: &mut RetailBodyRates) { + let local = Vector3::new(0.0, ground_torque_scalar(), 0.0); + let world = multiply(body.basis, local); + let acceleration = multiply(body.world_inverse_inertia, world); + body.torque_acceleration = Vector3::new( + acceleration.x + body.torque_acceleration.x, + acceleration.y + body.torque_acceleration.y, + acceleration.z + body.torque_acceleration.z, + ); + body.cool_down = 0; +} +fn ground_torque_scalar() -> f32 { + // Positive fixed input10 has exponent3 and mantissa1.25. Thus the native + // vlog estimate followed by floor selects3; its sign/special-value masks + // cannot select another branch for these immutable instruction inputs. + let x = 0.25_f32; + let square = x * x; + let cube = x * square; + let fourth = square * square; + let log = [ + 0x3fb8aa0e, 0xbf389e52, 0x3ef5162d, 0xbeb1d204, 0x3e77adbd, 0xbe0cd4fb, 0x3d5541c6, + 0xbc188b0b, + ] + .map(f32::from_bits); + let low = cube.mul_add(log[3], square.mul_add(log[2], x.mul_add(log[1], log[0]))); + let high = cube.mul_add(log[7], square.mul_add(log[6], x.mul_add(log[5], log[4]))); + let exponent = (-x).mul_add(fourth.mul_add(high, low), -3.0); + let integral = exponent.floor(); + let fraction = exponent - integral; + let square = fraction * fraction; + let cube = fraction * square; + let fourth = square * square; + let exp = [ + 0x3f800000, 0xbf317218, 0x3e75fded, 0xbd6357ca, 0x3c1d8c54, 0xbaae1854, 0x391aa7d7, + 0xb7364261, + ] + .map(f32::from_bits); + let low = cube.mul_add( + exp[3], + square.mul_add(exp[2], fraction.mul_add(exp[1], exp[0])), + ); + let high = cube.mul_add( + exp[7], + square.mul_add(exp[6], fraction.mul_add(exp[5], exp[4])), + ); + let polynomial = fourth.mul_add(high, low); + //vexptefp sees an integral exponent here, so its power of two is exact. + let power_of_two = f32::from_bits(((127 + integral as i32) as u32) << 23); + (power_of_two * reciprocal_refined(polynomial)) * f32::from_bits(0xc0c1_999a) +} + diff --git a/crates/skate-core/src/physics/drive_frames.rs b/crates/skate-core/src/physics/drive_frames.rs new file mode 100644 index 00000000..66459b34 --- /dev/null +++ b/crates/skate-core/src/physics/drive_frames.rs @@ -0,0 +1,431 @@ +//! Scalar port of Skate 3 TU3's skateboard drive-frame construction. +//! +//! `SkateboardBody::SetDriveFrames2` (`0x82C0C088`) writes an identity matrix +//! to the child/second-body frame and writes the computed relative matrix to +//! the parent/first-body frame. The retail matrices are affine column-basis +//! matrices (`Ri`, `Up`, `At`, translation). This module keeps that convention +//! explicit and does not substitute a Bevy joint. + +use super::{ + board::*, + drive_parameters::{RetailDriveFrameRaw, RetailDriveFramesRaw}, + mass::retail_deck_mass_properties, + native_arithmetic, + rigid_body::RetailQuaternion, +}; +use crate::{ + math::{Basis3, Vector3}, + trigonometry, +}; + +#[path = "construction/authored_transforms.rs"] +mod authored; +pub use authored::{AuthoredTransformInputs, authored_body_pose_records, authored_body_transforms}; + +pub mod tu3 { + pub const CALCULATE_TRUCK_TRANSFORMS: u32 = 0x82C0_BC90; + pub const SET_DRIVE_FRAMES_2: u32 = 0x82C0_C088; + pub const MATRIX_TO_FRAME_A: u32 = 0x82BD_3A10; + pub const MATRIX_TO_FRAME_B: u32 = 0x82BD_3BD0; + pub const PART_SET_TRANSFORM: u32 = 0x82BD_4318; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailAffineTransform { + /// Column vectors in retail `Ri`, `Up`, `At` order. + pub basis: Basis3, + pub translation: Vector3, +} + +impl RetailAffineTransform { + pub const IDENTITY: Self = Self { + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + translation: Vector3::ZERO, + }; +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailDriveFrame { + pub orientation: RetailQuaternion, + pub translation: Vector3, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailDriveFrames { + /// Native child/second-body record written by `SetChildFrame` at +0/+16. + pub body_a: RetailDriveFrame, + /// Native parent/first-body record written by `SetParentFrame` at +32/+48. + pub body_b: RetailDriveFrame, +} + +/// Inputs read by TU3 `SkateboardBody::CalculateTruckTransforms`. +/// +/// The two longitudinal fields retain the decoded retail attribute names. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailTruckTransformInputs { + pub deck_mid_length: f32, + pub truck_z_position_front: f32, + pub truck_z_position_back: f32, + pub truck_y_position: f32, + pub truck_rotation_axis_angle_degrees: f32, +} + +pub const RETAIL_DEFAULT_TRUCK_TRANSFORM_INPUTS: RetailTruckTransformInputs = + RetailTruckTransformInputs { + deck_mid_length: RETAIL_DECK_MID_LENGTH, + truck_z_position_front: RETAIL_TRUCK_Z_POSITION_FRONT, + truck_z_position_back: RETAIL_TRUCK_Z_POSITION_BACK, + truck_y_position: RETAIL_TRUCK_Y_POSITION, + truck_rotation_axis_angle_degrees: RETAIL_TRUCK_ROTATION_AXIS_ANGLE_DEGREES, + }; + +/// TU3 `SkateboardBody::CalculateTruckTransforms` (`0x82C0BC90`) evaluated +/// from the stock skater-collection inputs. +/// +/// The returned order is the native `+7712`, `+7776` storage order. The names +/// of the front/back Z inputs are retained even though the native slots do not +/// follow an intuitive front/back naming order. +pub fn default_truck_transforms() -> [RetailAffineTransform; 2] { + calculate_truck_transforms(RETAIL_DEFAULT_TRUCK_TRANSFORM_INPUTS) +} + +/// Authored part matrices from `82C0ADF0`, evaluated from stock collection inputs. +/// These are the inputs to Part::SetTransform, not settled or live COM poses. +pub fn default_body_transforms() -> [RetailAffineTransform; BODY_COUNT] { + authored_body_transforms(AuthoredTransformInputs::STOCK) +} + +/// Live center-of-mass transforms emitted by TU3 `Part::SetTransform` +/// (`0x82BD4318`) from the seven authored part transforms. +pub fn default_live_body_transforms() -> [RetailAffineTransform; BODY_COUNT] { + let mut transforms = default_body_transforms(); + let deck_mass_frame = retail_deck_mass_properties().local_mass_frame; + transforms[BodyId::Deck.index()] = compose_affine( + transforms[BodyId::Deck.index()], + inverse_affine(RetailAffineTransform { + basis: deck_mass_frame.basis, + translation: deck_mass_frame.translation, + }), + ); + transforms +} + +/// Quaternions written beside the live center-of-mass transforms by +/// `Part::SetTransform`. +pub fn default_live_body_orientations() -> [RetailQuaternion; BODY_COUNT] { + default_live_body_transforms().map(|transform| retail_quaternion_from_basis(transform.basis)) +} + +/// Deck-center height that places the initialized wheel spheres exactly on a +/// level Y=0 riding surface. +/// +/// This is a geometry-derived fixture placement, not a tuned suspension or +/// contact constant. +pub const RETAIL_LEVEL_GROUND_DECK_CENTER_HEIGHT: f32 = + RETAIL_WHEEL_RADIUS - RETAIL_TRUCK_Y_POSITION; + +/// Zero-steering truck/deck frames in native drive order. TU3 consumes the +/// `+7776` transform before the `+7712` transform. +pub fn default_truck_drive_frames() -> [RetailDriveFrames; 2] { + let base = default_truck_transforms(); + [ + set_drive_frames_2(RetailAffineTransform::IDENTITY, base[1]), + set_drive_frames_2(RetailAffineTransform::IDENTITY, base[0]), + ] +} + +/// Wheel/truck drive frames emitted by the four `CreateWheelDrives` calls. +pub fn default_wheel_drive_frames() -> [RetailDriveFrames; 4] { + let positive = wheel_drive_frame(AuthoredTransformInputs::STOCK.wheel_x_distance); + let negative = wheel_drive_frame(-AuthoredTransformInputs::STOCK.wheel_x_distance); + [positive, negative, positive, negative] +} + +/// Matrix stage of `SkateboardBody::SetDriveFrames2`. +/// +/// The first input is the parent/first body's world transform and the second +/// input is the child/second body's world transform. TU3 writes: +/// +/// - identity to the child frame through `SetChildFrame` (`0x82BD3A10`); +/// - `transpose(parent.basis) * child.basis` and the corresponding relative +/// translation to the parent frame through `SetParentFrame` (`0x82BD3BD0`). +/// +pub fn set_drive_frames_2( + parent_world: RetailAffineTransform, + child_world: RetailAffineTransform, +) -> RetailDriveFrames { + let parent_frame = relative_parent_transform(parent_world, child_world); + let child_frame = RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::ZERO, + }; + + RetailDriveFrames { + body_a: child_frame, + body_b: RetailDriveFrame { + orientation: retail_quaternion_from_basis(parent_frame.basis), + translation: parent_frame.translation, + }, + } +} + +/// Dominant-component matrix-to-quaternion calculation in TU3 child/parent +/// frame setters `0x82BD3A10` and `0x82BD3BD0`. +/// +/// The function evaluates the same four component candidates as the vector +/// implementation, selects trace/X/Y/Z with its strict comparisons, and uses +/// the accepted Xenon reciprocal-square-root estimate with two recovered fused +/// refinements. Matrix storage remains column-major `Ri`, `Up`, `At`. +pub fn retail_quaternion_from_basis(basis: Basis3) -> RetailQuaternion { + let m00 = basis.columns[0][0]; + let m01 = basis.columns[1][0]; + let m02 = basis.columns[2][0]; + let m10 = basis.columns[0][1]; + let m11 = basis.columns[1][1]; + let m12 = basis.columns[2][1]; + let m20 = basis.columns[0][2]; + let m21 = basis.columns[1][2]; + let m22 = basis.columns[2][2]; + + let trace = (m00 + m11) + m22; + if trace > 0.0 { + quaternion_candidate( + (m00 + m11) + (m22 + 1.0), + [m21 - m12, m02 - m20, m10 - m01, 0.0], + 3, + ) + } else if m00 > m11 && m00 > m22 { + quaternion_candidate( + (m00 - m11) + ((0.0 - m22) + 1.0), + [0.0, m01 + m10, m02 + m20, m21 - m12], + 0, + ) + } else if m11 > m22 { + quaternion_candidate( + ((0.0 - m00) + m11) + ((0.0 - m22) + 1.0), + [m01 + m10, 0.0, m12 + m21, m02 - m20], + 1, + ) + } else { + quaternion_candidate( + ((0.0 - m00) - m11) + (m22 + 1.0), + [m02 + m20, m12 + m21, 0.0, m10 - m01], + 2, + ) + } +} + +fn refined_reciprocal_square_root(value: f32) -> f32 { + let mut estimate = native_arithmetic::reciprocal_square_root_estimate(value); + for _ in 0..2 { + let square = estimate * estimate; + let half_estimate = estimate * 0.5; + let residual = (-value).mul_add(square, 1.0); + estimate = half_estimate.mul_add(residual, estimate); + } + estimate +} + +fn wheel_drive_frame(z_offset: f32) -> RetailDriveFrames { + RetailDriveFrames { + body_a: RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::ZERO, + }, + body_b: RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::new(0.0, 0.0, z_offset), + }, + } +} + +fn inverse_affine(transform: RetailAffineTransform) -> RetailAffineTransform { + let columns = transform.basis.columns; + let inverse_basis = Basis3 { + columns: [ + [columns[0][0], columns[1][0], columns[2][0]], + [columns[0][1], columns[1][1], columns[2][1]], + [columns[0][2], columns[1][2], columns[2][2]], + ], + }; + let translated = multiply_basis_vector(inverse_basis, transform.translation); + RetailAffineTransform { + basis: inverse_basis, + translation: Vector3::new(0.0 - translated.x, 0.0 - translated.y, 0.0 - translated.z), + } +} + +fn compose_affine( + parent: RetailAffineTransform, + local: RetailAffineTransform, +) -> RetailAffineTransform { + let translated = multiply_basis_vector(parent.basis, local.translation); + RetailAffineTransform { + basis: multiply_basis(parent.basis, local.basis), + translation: Vector3::new( + translated.x + parent.translation.x, + translated.y + parent.translation.y, + translated.z + parent.translation.z, + ), + } +} + +fn multiply_basis_vector(basis: Basis3, vector: Vector3) -> Vector3 { + let component = |lane| { + let first = vector.x * basis.columns[0][lane]; + let second = vector.y.mul_add(basis.columns[1][lane], first); + vector.z.mul_add(basis.columns[2][lane], second) + }; + Vector3::new(component(0), component(1), component(2)) +} + +fn quaternion_candidate(sum: f32, companion: [f32; 4], dominant: usize) -> RetailQuaternion { + let inverse_root = refined_reciprocal_square_root(sum); + let half_inverse_root = inverse_root * 0.5; + let half_root = (sum * inverse_root) * 0.5; + let mut lanes = companion.map(|value| value * half_inverse_root); + lanes[dominant] = half_root; + RetailQuaternion { + x: lanes[0], + y: lanes[1], + z: lanes[2], + w: lanes[3], + } +} + +impl From for RetailDriveFramesRaw { + fn from(frames: RetailDriveFrames) -> Self { + Self { + body_a: raw_frame(frames.body_a), + body_b: raw_frame(frames.body_b), + } + } +} + +fn raw_frame(frame: RetailDriveFrame) -> RetailDriveFrameRaw { + RetailDriveFrameRaw { + quaternion_lanes: [ + frame.orientation.x.to_bits(), + frame.orientation.y.to_bits(), + frame.orientation.z.to_bits(), + frame.orientation.w.to_bits(), + ], + translation_lanes: [ + frame.translation.x.to_bits(), + frame.translation.y.to_bits(), + frame.translation.z.to_bits(), + 0, + ], + } +} + +pub fn calculate_truck_transforms(inputs: RetailTruckTransformInputs) -> [RetailAffineTransform; 2] { + const DEGREES_TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); + const HALF: f32 = f32::from_bits(0x3F00_0000); + const NEGATIVE_HALF_PI: f32 = f32::from_bits(0xBFC9_0FDB); + const POSITIVE_HALF_PI: f32 = f32::from_bits(0x3FC9_0FDB); + + let alpha = inputs.truck_rotation_axis_angle_degrees * DEGREES_TO_RADIANS; + let front_z = inputs + .deck_mid_length + .mul_add(HALF, inputs.truck_z_position_front); + let back_z = -inputs + .deck_mid_length + .mul_add(HALF, inputs.truck_z_position_back); + + [ + RetailAffineTransform { + basis: multiply_basis(rotation_x(alpha), rotation_y(POSITIVE_HALF_PI)), + translation: Vector3::new(0.0, inputs.truck_y_position, back_z), + }, + RetailAffineTransform { + basis: multiply_basis(rotation_x(-alpha), rotation_y(NEGATIVE_HALF_PI)), + translation: Vector3::new(0.0, inputs.truck_y_position, front_z), + }, + ] +} + +fn rotation_x(angle: f32) -> Basis3 { + let (sin, cos) = trigonometry::sin_cos(angle); + Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]], + } +} + +fn rotation_y(angle: f32) -> Basis3 { + let (sin, cos) = trigonometry::sin_cos(angle); + Basis3 { + columns: [[cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]], + } +} + +fn multiply_basis(left: Basis3, right: Basis3) -> Basis3 { + Basis3 { + columns: right.columns.map(|column| { + core::array::from_fn(|lane| { + let first = column[0] * left.columns[0][lane]; + let second = column[1].mul_add(left.columns[1][lane], first); + column[2].mul_add(left.columns[2][lane], second) + }) + }), + } +} + +fn relative_parent_transform( + parent_world: RetailAffineTransform, + child_world: RetailAffineTransform, +) -> RetailAffineTransform { + RetailAffineTransform { + basis: transpose_multiply_basis(parent_world.basis, child_world.basis), + translation: transpose_relative_translation( + parent_world.basis, + parent_world.translation, + child_world.translation, + ), + } +} + +fn transpose_multiply_basis(parent: Basis3, child: Basis3) -> Basis3 { + Basis3 { + columns: core::array::from_fn(|column| { + let child_column = child.columns[column]; + core::array::from_fn(|lane| { + let parent_column = parent.columns[lane]; + let first = child_column[0] * parent_column[0]; + let second = child_column[1].mul_add(parent_column[1], first); + child_column[2].mul_add(parent_column[2], second) + }) + }), + } +} + +fn transpose_relative_translation( + parent_basis: Basis3, + parent_translation: Vector3, + child_translation: Vector3, +) -> Vector3 { + let neg_parent = Vector3::new( + 0.0 - parent_translation.x, + 0.0 - parent_translation.y, + 0.0 - parent_translation.z, + ); + let component = |parent_column: [f32; 3]| { + let result = neg_parent.z * parent_column[2]; + let result = neg_parent.y.mul_add(parent_column[1], result); + let result = neg_parent.x.mul_add(parent_column[0], result); + let result = child_translation.x.mul_add(parent_column[0], result); + let result = child_translation.y.mul_add(parent_column[1], result); + child_translation.z.mul_add(parent_column[2], result) + }; + Vector3::new( + component(parent_basis.columns[0]), + component(parent_basis.columns[1]), + component(parent_basis.columns[2]), + ) +} + +#[cfg(test)] +#[path = "tests/drive_frames.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/drive_parameters.rs b/crates/skate-core/src/physics/drive_parameters.rs new file mode 100644 index 00000000..6448928f --- /dev/null +++ b/crates/skate-core/src/physics/drive_parameters.rs @@ -0,0 +1,189 @@ +//! Recovered drive parameter layout and TU3 truck/wheel construction branches. +const fn retail_f32(bits: u32) -> f32 { + f32::from_bits(bits) +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] +pub enum RetailDriveType { + NoDrive = 0, + SoftDrive = 1, + HardDrive = 2, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct RetailDriveParams { + pub spring_or_max_velocity: f32, + pub damping: f32, + pub max_strength: f32, + pub drive_type: RetailDriveType, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct RetailDriveDynamics { + pub linear: RetailDriveParams, + pub angular: RetailDriveParams, +} + +/// Separate collections read by TU3 CreateDrives and SetTruckDriveDynamics. +/// `physicstrucks_drives.UseSoftDrives` is not read by this TU3 path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailTruckDriveSettings { + /// physicstrucks layout +0; gates only the linear configuration. + pub use_linear_drives: bool, + /// physicsdeck full attribute 103FDC19514D4246; read only when enabled. + pub use_hard_linear_drives: bool, + /// physicstrucks_drives layout +24, +28 and +20 respectively. + pub angular_displacement: f32, + pub angular_damping: f32, + pub angular_strength: f32, +} + +impl RetailTruckDriveSettings { + /// Decoded default collections, not truck geometry/twist-limit fields. + pub const STOCK: Self = Self { + use_linear_drives: false, + use_hard_linear_drives: true, + angular_displacement: 10.0, + angular_damping: 0.0, + angular_strength: 11.0, + }; +} + +impl RetailDriveParams { + pub const DISABLED: Self = Self { + spring_or_max_velocity: 0.0, + damping: 0.0, + max_strength: 0.0, + drive_type: RetailDriveType::NoDrive, + }; +} + +/// Constructor 82C06298 zeros both halves at +7152/+7168, then CreateDrives +/// 82C0B770 creates the truck pair and applies these settings. Postphysics only +/// refreshes frames in TU3; it does not rerun this parameter setup every tick. +pub fn retail_truck_drive_dynamics(settings: RetailTruckDriveSettings) -> RetailDriveDynamics { + let linear = if !settings.use_linear_drives { + RetailDriveParams::DISABLED + } else if settings.use_hard_linear_drives { + RetailDriveParams { + spring_or_max_velocity: retail_f32(0x45BB_7FFF), + damping: 0.0, + max_strength: retail_f32(0x48AF_C7FF), + drive_type: RetailDriveType::HardDrive, + } + } else { + RetailDriveParams { + spring_or_max_velocity: retail_f32(0x47C3_5000), + // 82C0B858..B8EC: native sqrt(100000) refinement, then *2. + damping: soft_linear_damping(), + max_strength: retail_f32(0x48AF_C7FF), + drive_type: RetailDriveType::SoftDrive, + } + }; + RetailDriveDynamics { + linear, + angular: RetailDriveParams { + spring_or_max_velocity: settings.angular_displacement * retail_f32(0x426F_FFFF), + damping: settings.angular_damping, + max_strength: settings.angular_strength * retail_f32(0x4560_FFFE), + drive_type: RetailDriveType::HardDrive, + }, + } +} + +pub const fn retail_wheel_drive_dynamics(use_hard_drives: bool) -> RetailDriveDynamics { + if use_hard_drives { + RetailDriveDynamics { + linear: RetailDriveParams { + spring_or_max_velocity: retail_f32(0x45BB_7FFF), + damping: 0.0, + max_strength: retail_f32(0x48AF_C7FF), + drive_type: RetailDriveType::HardDrive, + }, + angular: RetailDriveParams { + spring_or_max_velocity: 0.0, + damping: 0.0, + max_strength: 0.0, + drive_type: RetailDriveType::NoDrive, + }, + } + } else { + RetailDriveDynamics { + linear: RetailDriveParams { + spring_or_max_velocity: retail_f32(0x4561_0000), + damping: retail_f32(0x4270_0000), + max_strength: retail_f32(0x470C_9FFF), + drive_type: RetailDriveType::SoftDrive, + }, + angular: RetailDriveParams { + spring_or_max_velocity: 0.0, + damping: 0.0, + max_strength: 0.0, + drive_type: RetailDriveType::NoDrive, + }, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct RetailDriveFrameRaw { + /// Retail quaternion in observed `(x, y, z, w)` lane order. + pub quaternion_lanes: [u32; 4], + /// Affine translation vector copied from matrix offset `0x30`. + pub translation_lanes: [u32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct RetailDriveFramesRaw { + /// Frame emitted by helper `0x82BD3A10`. + pub body_a: RetailDriveFrameRaw, + /// Frame emitted by helper `0x82BD3BD0`. + pub body_b: RetailDriveFrameRaw, +} + +/// Original TU3 82C0B8A8..B8EC: two reciprocal-square-root refinements, +/// multiply by the spring, select zero for zero spring, then multiply by two. +/// The estimate uses our independent Rust arithmetic; hardware parity is unverified. +fn soft_linear_damping() -> f32 { + let spring = retail_f32(0x47C3_5000); // 822F91F0 + let mut reciprocal_root = super::native_arithmetic::reciprocal_square_root_estimate(spring); + for _ in 0..2 { + let squared = reciprocal_root * reciprocal_root; + let half = reciprocal_root * 0.5; + let residual = (-spring).mul_add(squared, 1.0); + reciprocal_root = half.mul_add(residual, reciprocal_root); + } + let root = if spring == 0.0 { + 0.0 + } else { + spring * reciprocal_root + }; + root * retail_f32(0x4000_0000) // 82060C50 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enabled_soft_linear_trucks_have_critical_damping() { + let settings = RetailTruckDriveSettings { + use_linear_drives: true, + use_hard_linear_drives: false, + ..RetailTruckDriveSettings::STOCK + }; + let dynamics = retail_truck_drive_dynamics(settings); + assert_eq!(dynamics.linear.drive_type, RetailDriveType::SoftDrive); + assert_eq!(dynamics.linear.spring_or_max_velocity, 100_000.0); + assert!((dynamics.linear.damping - 632.455_5).abs() < 0.000_1); + assert_eq!(dynamics.linear.max_strength.to_bits(), 0x48AF_C7FF); + assert_eq!( + dynamics.angular, + retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK).angular + ); + } +} diff --git a/crates/skate-core/src/physics/drive_preparation/mod.rs b/crates/skate-core/src/physics/drive_preparation/mod.rs new file mode 100644 index 00000000..206d33a5 --- /dev/null +++ b/crates/skate-core/src/physics/drive_preparation/mod.rs @@ -0,0 +1,38 @@ +//! Drive-frame preparation in TU3 Island::Step_Solver2 82763B08. +//! The active-drive list is visited before thaw/partition and island-table +//! construction. Every island mode runs this loop, regardless of body flags. + +/// Normalize each nonnull frame record in native active-list order. Entries +/// are frame-record indices, not drive/body indices; duplicate references are +/// processed repeatedly just as live pointers are in 82763BB4..82763C50. +/// Null frames are skipped. Translations and all their packed lanes survive. +pub fn normalize_active_drive_frames( + frames: &mut [[u32; 16]], + active_frame_indices: &[Option], +) { + for index in active_frame_indices.iter().flatten() { + normalize_drive_frames(&mut frames[*index]); + } +} + +/// The two quaternion operations in the same stage. No zero-length or finite +/// fallback is supplied here. +pub fn normalize_drive_frames(frames: &mut [u32; 16]) { + for offset in [0, 8] { + let q: [f32; 4] = core::array::from_fn(|i| f32::from_bits(frames[offset + i])); + let d = quaternion_squared_length(q); + let mut e = super::reciprocal_sqrt::estimate(d); + for _ in 0..2 { + let squared = e * e; + let half = e * 0.5; + e = half.mul_add((-d).mul_add(squared, 1.0), e); + } + for i in 0..4 { + frames[offset + i] = (q[i] * e).to_bits(); + } + } +} + +fn quaternion_squared_length(q: [f32; 4]) -> f32 { + super::native_arithmetic::dot4(q, q) +} diff --git a/crates/skate-core/src/physics/drive_solver.rs b/crates/skate-core/src/physics/drive_solver.rs new file mode 100644 index 00000000..e453fb62 --- /dev/null +++ b/crates/skate-core/src/physics/drive_solver.rs @@ -0,0 +1,128 @@ +//! Scalar port of Skate 3 TU3's RenderWare drive Jacobian and solve path. +//! +//! A retail drive is not a generic six-degree-of-freedom Bevy joint. TU3 +//! builds three linear and three quaternion-component constraint rows into a +//! 384-byte workspace, then solves each drive sequentially. This module keeps +//! those rows in named scalar fields and preserves the observed soft/hard +//! coefficient and impulse-limit equations. + +use super::{ + drive_frames::{RetailDriveFrame, RetailDriveFrames}, + drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}, + rigid_body::{RetailPackedWorldInverseInertia, RetailQuaternion, RetailReactionCorrections}, +}; +use crate::math::Vector3; +mod builder; +pub use builder::build_drive_rows; + +pub mod tu3 { + pub const DRIVE_JACOBIAN_BUILD: u32 = 0x82AE_1AE8; + pub const ITERATIVE_CONSTRAINT_SOLVER: u32 = 0x82AE_27D0; +} + +pub const ACTIVE_BODY: u32 = 4; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailDriveBodyState { + pub reaction_index: usize, + pub state: u32, + pub orientation: RetailQuaternion, + /// Native Body+64/+80/+96, independently read for the anchor transform. + pub basis: crate::math::Basis3, + pub center_of_mass: Vector3, + pub linear_velocity: Vector3, + pub angular_velocity: Vector3, + pub force_acceleration: Vector3, + pub torque_acceleration: Vector3, + pub inverse_mass: f32, + pub world_inverse_inertia: RetailPackedWorldInverseInertia, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailDriveRows { + /// Body paired with `DriveFrames::body_a`. + pub frame_a_body: RetailDriveBodyState, + /// Body paired with `DriveFrames::body_b`. + pub frame_b_body: RetailDriveBodyState, + pub arm_a: Vector3, + pub arm_b: Vector3, + /// World-space columns of the frame-B basis. + pub linear_axes: [Vector3; 3], + /// Normalized world-space quaternion-component Jacobian axes. + /// + /// Unlike an ordinary rotation basis, these three axes are generally not + /// orthogonal. + pub angular_axes: [Vector3; 3], + pub linear_inverse_effective_mass: [f32; 3], + pub angular_inverse_effective_mass: [f32; 3], + pub linear_softness: f32, + pub angular_softness: f32, + /// Preconditioned target impulses in constraint-row order. + pub linear_target_impulse: [f32; 3], + pub angular_target_impulse: [f32; 3], + pub linear_maximum_impulse: [f32; 3], + pub angular_maximum_impulse: [f32; 3], + pub accumulated_linear_impulse: [f32; 3], + pub accumulated_angular_impulse: [f32; 3], +} + +/// Runs TU3's drive portion of the sequential iterative constraint solver. +pub fn solve_drive_rows( + drives: &mut [RetailDriveRows], + reactions: &mut [RetailReactionCorrections], + maximum_iterations: u32, +) { + super::solver::solve_constraints(&mut [], &mut [], drives, reactions, maximum_iterations); +} + +/// One drive pass through the same native kernel used by the shared solver. +/// Both linear and angular candidates read the initial reactions of this +/// drive, before either candidate is applied (82AE2ED0..82AE2FB0). +pub fn solve_drive_iteration( + drives: &mut [RetailDriveRows], + reactions: &mut [RetailReactionCorrections], +) { + solve_drive_rows(drives, reactions, 1); +} + +fn is_active(body: RetailDriveBodyState) -> bool { + body.state & ACTIVE_BODY == ACTIVE_BODY +} + +fn basis_columns(basis: crate::math::Basis3) -> [Vector3; 3] { + basis + .columns + .map(|column| Vector3::new(column[0], column[1], column[2])) +} + +fn project(vector: Vector3, axes: [Vector3; 3]) -> [f32; 3] { + axes.map(|axis| dot(vector, axis)) +} + +fn point_rate(linear: Vector3, angular: Vector3, arm: Vector3) -> Vector3 { + super::constraint_frames::point_rate(linear, angular, arm) +} + +const fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} + +const fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} + +const fn scale(value: Vector3, factor: f32) -> Vector3 { + Vector3::new(value.x * factor, value.y * factor, value.z * factor) +} + +fn dot(a: Vector3, b: Vector3) -> f32 { + super::native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]) +} + +fn cross(a: Vector3, b: Vector3) -> Vector3 { + super::constraint_frames::cross(a, b) +} + +#[cfg(test)] +#[path = "tests/drive_solver.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/drive_solver/builder.rs b/crates/skate-core/src/physics/drive_solver/builder.rs new file mode 100644 index 00000000..5a2e8592 --- /dev/null +++ b/crates/skate-core/src/physics/drive_solver/builder.rs @@ -0,0 +1,296 @@ +//! TU3 drive Jacobian construction, separate from iterative solving. +use super::*; +use crate::physics::{constraint_frames, native_arithmetic}; +/// Scalar translation of TU3 `DriveJacobian::Build`. +/// +/// RenderWare pairs frame A with internal `Drive::m_bodyA` at `Drive+0x10` +/// and frame B with `Drive::m_bodyB` at `Drive+0x14`. `Simulation::AddDrive` +/// stores its second body argument as internal A and its first as internal B. +/// The linear error is frame-B minus frame-A; the solver therefore applies +/// positive impulse to frame A and negative impulse to frame B. +pub fn build_drive_rows( + frame_a_body: RetailDriveBodyState, + frame_b_body: RetailDriveBodyState, + frames: RetailDriveFrames, + dynamics: RetailDriveDynamics, + time_step: f32, +) -> RetailDriveRows { + debug_assert!(time_step.is_finite() && time_step > 0.0); + + let frame_a_world = world_frame(frame_a_body, frames.body_a); + let frame_b_world = world_frame(frame_b_body, frames.body_b); + let arm_a = frame_a_world.arm; + let arm_b = frame_b_world.arm; + let linear_axes = basis_columns(constraint_frames::basis(frame_b_world.orientation)); + + let raw_angular = + constraint_frames::quaternion_rows(frame_a_world.orientation, frame_b_world.orientation); + let components = [ + raw_angular.relative.x, + raw_angular.relative.y, + raw_angular.relative.z, + ]; + let squared = components + .map(|c| native_arithmetic::vector_min(native_arithmetic::vector_max(c * c, 0.0), 1.0)); + // 830BDB40 selects bytes [0,4,8,8] in every word: any saturated + // quaternion component replaces ALL three axes with frame B's basis. + let singular = squared.iter().any(|&c| c == 1.0); + let inverse_lengths = if singular { + [1.0; 3] + } else { + squared.map(|c| constraint_frames::reciprocal_sqrt(1.0 - c)) + }; + let angular_axes = core::array::from_fn(|i| { + scale( + if singular { + linear_axes[i] + } else { + raw_angular.axes[i] + }, + inverse_lengths[i], + ) + }); + let angular_position_error = + core::array::from_fn(|i| (components[i] * inverse_lengths[i]) * 2.0); + + let linear_position_error = sub(frame_b_world.position, frame_a_world.position); + let linear_velocity_displacement = sub( + scale( + point_rate( + frame_b_body.linear_velocity, + frame_b_body.angular_velocity, + arm_b, + ), + time_step, + ), + scale( + point_rate( + frame_a_body.linear_velocity, + frame_a_body.angular_velocity, + arm_a, + ), + time_step, + ), + ); + let linear_acceleration_displacement = sub( + scale( + scale( + point_rate( + frame_b_body.force_acceleration, + frame_b_body.torque_acceleration, + arm_b, + ), + time_step, + ), + time_step, + ), + scale( + scale( + point_rate( + frame_a_body.force_acceleration, + frame_a_body.torque_acceleration, + arm_a, + ), + time_step, + ), + time_step, + ), + ); + let angular_velocity_displacement = scale( + sub(frame_b_body.angular_velocity, frame_a_body.angular_velocity), + time_step, + ); + let angular_acceleration_displacement = scale( + scale( + sub( + frame_b_body.torque_acceleration, + frame_a_body.torque_acceleration, + ), + time_step, + ), + time_step, + ); + let linear_inverse_effective_mass = core::array::from_fn(|index| { + constraint_frames::reciprocal(linear_effective_mass( + frame_a_body, + frame_b_body, + arm_a, + arm_b, + linear_axes[index], + )) + }); + let angular_inverse_effective_mass = core::array::from_fn(|index| { + constraint_frames::reciprocal(angular_effective_mass( + frame_a_body, + frame_b_body, + angular_axes[index], + )) + }); + + let linear_coefficients = build_coefficients( + dynamics.linear, + components_of(linear_position_error), + components_of(linear_velocity_displacement), + components_of(linear_acceleration_displacement), + time_step, + ); + // Linear weights combine in world space before the projection (20A8..20D0). + let displacement = linear_coefficients.target_displacement; + let linear_target = project( + Vector3::new(displacement[0], displacement[1], displacement[2]), + linear_axes, + ); + let angular_coefficients = build_coefficients( + dynamics.angular, + angular_position_error, + project(angular_velocity_displacement, angular_axes), + project(angular_acceleration_displacement, angular_axes), + time_step, + ); + RetailDriveRows { + frame_a_body, + frame_b_body, + arm_a, + arm_b, + linear_axes, + angular_axes, + linear_inverse_effective_mass, + angular_inverse_effective_mass, + linear_softness: linear_coefficients.softness, + angular_softness: angular_coefficients.softness, + linear_target_impulse: core::array::from_fn(|i| { + linear_target[i] * linear_inverse_effective_mass[i] + }), + angular_target_impulse: core::array::from_fn(|i| { + angular_coefficients.target_displacement[i] * angular_inverse_effective_mass[i] + }), + linear_maximum_impulse: linear_inverse_effective_mass + .map(|v| v * linear_coefficients.maximum_strength), + angular_maximum_impulse: angular_inverse_effective_mass + .map(|v| v * angular_coefficients.maximum_strength), + accumulated_linear_impulse: [0.0; 3], + accumulated_angular_impulse: [0.0; 3], + } +} + +#[derive(Clone, Copy)] +struct WorldFrame { + orientation: RetailQuaternion, + arm: Vector3, + position: Vector3, +} + +fn world_frame(body: RetailDriveBodyState, frame: RetailDriveFrame) -> WorldFrame { + let arm = constraint_frames::transform_direction(body.basis, frame.translation); + WorldFrame { + orientation: constraint_frames::compose(body.orientation, frame.orientation), + arm, + position: add(body.center_of_mass, arm), + } +} + +#[derive(Clone, Copy)] +struct DriveCoefficients { + softness: f32, + target_displacement: [f32; 3], + maximum_strength: f32, +} + +fn components_of(v: Vector3) -> [f32; 3] { + [v.x, v.y, v.z] +} + +/// 82AE1E68..20B8 and the matching angular block219C..237C. +/// Inputs are already scaled in their source order; the hard cap affects only +/// position error, before the rate and acceleration displacements are added. +fn build_coefficients( + params: RetailDriveParams, + mut position_error: [f32; 3], + velocity_displacement: [f32; 3], + acceleration_displacement: [f32; 3], + time_step: f32, +) -> DriveCoefficients { + let damping = time_step * params.damping; + let (position_weight, velocity_weight, acceleration_weight) = + if params.drive_type == RetailDriveType::SoftDrive { + let spring = (time_step * params.spring_or_max_velocity) * time_step; + let inverse = constraint_frames::reciprocal((1.0 + spring) + damping); + ( + inverse * spring, + inverse * damping, + inverse * (spring + damping), + ) + } else { + (constraint_frames::reciprocal(1.0 + damping) * 1.0, 1.0, 1.0) + }; + if params.drive_type == RetailDriveType::HardDrive { + let squared = native_arithmetic::dot3( + [position_error[0], position_error[1], position_error[2], 0.0], + [position_error[0], position_error[1], position_error[2], 0.0], + ); + let maximum = time_step * params.spring_or_max_velocity; + if squared > maximum * maximum { + let factor = maximum * constraint_frames::reciprocal_sqrt(squared); + position_error = position_error.map(|v| v * factor); + } + } + DriveCoefficients { + softness: acceleration_weight, + target_displacement: core::array::from_fn(|i| { + let rate = velocity_displacement[i] * velocity_weight; + let position_and_rate = position_error[i].mul_add(position_weight, rate); + acceleration_displacement[i].mul_add(acceleration_weight, position_and_rate) + }), + // Type zero follows the same native strength load; its authored params + // normally provide zero strength rather than the builder inventing it. + maximum_strength: (time_step * params.max_strength) * time_step, + } +} + +fn linear_effective_mass( + frame_a: RetailDriveBodyState, + frame_b: RetailDriveBodyState, + arm_a: Vector3, + arm_b: Vector3, + axis: Vector3, +) -> f32 { + let mut denominator = 0.0; + if is_active(frame_a) { + let angular = cross(arm_a, axis); + denominator += frame_a.inverse_mass + + dot( + angular, + constraint_frames::multiply_inertia(frame_a.world_inverse_inertia, angular), + ); + } + if is_active(frame_b) { + let angular = cross(arm_b, axis); + denominator += frame_b.inverse_mass + + dot( + angular, + constraint_frames::multiply_inertia(frame_b.world_inverse_inertia, angular), + ); + } + denominator +} + +fn angular_effective_mass( + frame_a: RetailDriveBodyState, + frame_b: RetailDriveBodyState, + axis: Vector3, +) -> f32 { + let mut denominator = 0.0; + if is_active(frame_a) { + denominator += dot( + axis, + constraint_frames::multiply_inertia(frame_a.world_inverse_inertia, axis), + ); + } + if is_active(frame_b) { + denominator += dot( + axis, + constraint_frames::multiply_inertia(frame_b.world_inverse_inertia, axis), + ); + } + denominator +} diff --git a/crates/skate-core/src/physics/filtered_state.rs b/crates/skate-core/src/physics/filtered_state.rs new file mode 100644 index 00000000..cacf0984 --- /dev/null +++ b/crates/skate-core/src/physics/filtered_state.rs @@ -0,0 +1,164 @@ +//! Complete filtered physical-state update82DE5BA0, with reset82DE5588. +//! This conditions the actual selected physics state; collision alone does not +//! select that state. Skate2 named82E2D800 identifies fields; TU3 governs branches. +use crate::animation::{output::attributes::AttributeName, skeleton_input::name::encode}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u32)] +pub enum FilteredCategory { + #[default] + Invalid = 0, + Ground = 1, + Air = 2, + Grind = 3, + Wipeout = 4, + Teleport = 5, + Offboard = 6, + OffboardAir = 7, +} + +/// PhysOut.Grinds fields copied when the selected state is400..405. Names are +/// native five-word strings, never addresses or collection hashes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GrindState { + pub kind: i32, + pub scorable_id: i32, + pub name: AttributeName, + pub scoring_name: AttributeName, + pub on_front: bool, + pub crouch: f32, + pub pathed_guid: u64, + pub local_guid: u64, +} +impl Default for GrindState { + fn default() -> Self { + Self { + kind: -1, scorable_id: -1, + // Initializer82F8AF08 encodes NUL text82060799 to830C0D60. + name: encode(b""), scoring_name: encode(b""), + on_front: false, crouch: 0.0, + pathed_guid: u64::MAX, local_guid: u64::MAX, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct FilteredStateInput { + /// Selected PhysOut.State category12 and state16, not filtered categories. + pub physics_category: i32, + pub physics_state: i32, + /// PhysOut.Collision3477 and16, after actual collision publication. + pub anything_in_contact: bool, + pub physics_surface_type: i32, + /// PhysOut.Ground316, owned by ground-state wall-ride exit calculations. + pub wall_ride_exit: bool, + /// PhysOut.Air443, owned by the trajectory/grind target decision. + pub targeting_grind: bool, + /// PhysOut.OffBoard320 and315 respectively. + pub offboard_has_landed: bool, + pub offboard_on_deck: bool, + pub grind: GrindState, + /// EmpiricalMeasurements82DE5858 updates conditioner196 before this call. + pub last_grind_distance: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FilteredStateOutput { + pub category: FilteredCategory, + pub previous_category: FilteredCategory, + pub grinding: bool, + pub grind: GrindState, + pub last_grind_distance: f32, +} + +#[derive(Clone, Debug)] +pub struct FilteredState { + pub category: FilteredCategory, + pub previous_category: FilteredCategory, + pub previous_physics_state: i32, + pub air_count: i32, + pub nonspecific_count: i32, + pub nonspecific_collision_free_count: i32, + pub nonspecific_collision_count: i32, + pub frames_since_ground_stairs: i32, + pub must_change: bool, + // Nongrind publication clears the output metadata, not these cached fields. + cached_grind: GrindState, +} +impl Default for FilteredState { + fn default() -> Self { + Self { + category: FilteredCategory::Invalid, + previous_category: FilteredCategory::Invalid, + previous_physics_state: 0, + air_count: 0, nonspecific_count: 0, + nonspecific_collision_free_count: 0, nonspecific_collision_count: 0, + frames_since_ground_stairs: 0, must_change: true, + cached_grind: GrindState::default(), + } + } +} +impl FilteredState { + pub fn reset(&mut self) { *self = Self::default(); } + + pub fn update(&mut self, input: FilteredStateInput) -> FilteredStateOutput { + use FilteredCategory::*; + let old = self.category; + self.previous_category = old; + self.must_change = old == Wipeout || old == Invalid + || self.previous_physics_state == 702 && input.physics_state != 702; + self.air_count = count(self.air_count, input.physics_category == 200); + self.nonspecific_count = count(self.nonspecific_count, input.physics_state == 701); + self.nonspecific_collision_free_count = count(self.nonspecific_collision_free_count, + input.physics_state == 701 && !input.anything_in_contact); + self.nonspecific_collision_count = count(self.nonspecific_collision_count, + input.physics_state == 701 && input.anything_in_contact); + self.frames_since_ground_stairs = count(self.frames_since_ground_stairs, + !(input.physics_category == 100 && input.physics_surface_type == 8)); + self.category = match input.physics_state { + 100 => if input.wall_ride_exit { Air } else { Ground }, + 101..=105 | 602 => Ground, + 200 => { + let air_delay_passed = self.air_count > 3; + if old == Air { + // TU3 adds contact return to ground, absent in the named S2 body. + if air_delay_passed && input.anything_in_contact || self.must_change { + Ground + } else { Air } + } else { + let may_enter_air = air_delay_passed + && (old != Ground || self.frames_since_ground_stairs > 9); + if may_enter_air || self.must_change { Air } else { old } + } + } + 201 => if input.anything_in_contact && !input.targeting_grind && self.air_count > 5 { + Ground + } else { Air }, + 202 | 600 | 601 => Air, + 300 => Wipeout, + 400..=405 => { self.cached_grind = input.grind; Grind } + 500 | 502 => Offboard, + 501 => if input.offboard_has_landed { Offboard } else { OffboardAir }, + 503 => if input.offboard_on_deck { Ground } else { Offboard }, + 702 => Teleport, + // Native701 and unrecognized states retain the prior category. + _ => old, + }; + self.previous_physics_state = input.physics_state; + let grinding = self.category == Grind; + FilteredStateOutput { + category: self.category, + previous_category: self.previous_category, + grinding, + grind: if grinding { self.cached_grind } else { GrindState::default() }, + last_grind_distance: input.last_grind_distance, + } + } +} +fn count(previous: i32, active: bool) -> i32 { + if active { previous.wrapping_add(1) } else { 0 } +} + +#[cfg(test)] +#[path = "tests/filtered_state.rs"] +mod tests; \ No newline at end of file diff --git a/crates/skate-core/src/physics/foot_physical_output.rs b/crates/skate-core/src/physics/foot_physical_output.rs new file mode 100644 index 00000000..ac73f402 --- /dev/null +++ b/crates/skate-core/src/physics/foot_physical_output.rs @@ -0,0 +1,136 @@ +//! Full original SkeletonIK::FillPhysOut_FeetDeckInteraction82BF22A0. +//! Physical toe positions and derivatives are observed in the physical board's +//! frame. They are distinct from animation targets and contact-query validity. +use super::{ + native_arithmetic::reciprocal_estimate, + skeleton_animation_record::AnimationPartTransform as Transform, + skeleton_body::SkeletonPhysicalRecord, +}; + +#[derive(Clone, Copy, Debug)] +pub struct FootPhysicalSettings { + pub deck_half_width: f32, + pub deck_total_half_length: f32, + /// Original physics_skeletonik layout64 FootOnDeckPadding. + pub padding: [f32; 4], +} +#[derive(Clone, Debug, Default)] +pub struct FootPhysicalState { + /// IK3136/3152. Reset82BEDB5C/60 writes both vectors to zero. + pub previous_local_toes: [[f32; 4]; 2], +} +#[derive(Clone, Copy, Debug, Default)] +pub struct FootPhysicalOutput { + /// PhysOutSkeleton192/208: change in local physical toe positions / dt. + pub local_velocity: [[f32; 4]; 2], + /// PhysOutSkeleton224/240: physical record velocities15/19, copied intact. + pub world_velocity: [[f32; 4]; 2], + /// PhysOutSkeleton600/601. Strict XYZ containment, with authored padding. + pub within_deck_box: [bool; 2], +} +impl FootPhysicalState { + pub fn reset(&mut self) { + *self = Self::default(); + } + pub fn update( + &mut self, + record: &SkeletonPhysicalRecord, + dt: f32, + settings: FootPhysicalSettings, + ) -> FootPhysicalOutput { + let inverse = inverse_physical_board(&record.pose[0]); + let positions = [15usize, 19].map(|part| transform_point(&inverse, record.pose[part][3])); + let mut inverse_dt = reciprocal_estimate(dt); + for _ in 0..2 { + inverse_dt = inverse_dt.mul_add((-inverse_dt).mul_add(dt, 1.0), inverse_dt); + } + let local_velocity = core::array::from_fn(|foot| { + core::array::from_fn(|lane| { + (positions[foot][lane] - self.previous_local_toes[foot][lane]) * inverse_dt + }) + }); + self.previous_local_toes = positions; + //82165A10 is zero: only authored padding supplies the box's Y extent. + let dimensions = [ + settings.deck_half_width, + 0.0, + settings.deck_total_half_length, + 0.0, + ]; + let bounds: [f32; 4] = + core::array::from_fn(|lane| dimensions[lane] + settings.padding[lane]); + FootPhysicalOutput { + local_velocity, + world_velocity: [record.velocities[15], record.velocities[19]], + within_deck_box: positions.map(|p| { + p[0].abs() < bounds[0] && p[1].abs() < bounds[1] && p[2].abs() < bounds[2] + }), + } + } +} + +fn inverse_physical_board(board: &Transform) -> Transform { + let mut inverse: Transform = [[0.0; 4]; 4]; + for axis in 0..3 { + for lane in 0..3 { + inverse[axis][lane] = board[lane][axis]; + } + } + //82BF2398..241C builds -P.z * inverseZ, then Y and X in source order. + for lane in 0..4 { + let z = -board[3][2] * inverse[2][lane]; + let yz = (-board[3][1]).mul_add(inverse[1][lane], z); + inverse[3][lane] = (-board[3][0]).mul_add(inverse[0][lane], yz); + } + inverse +} +fn transform_point(frame: &Transform, p: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|lane| { + let x = frame[0][lane].mul_add(p[0], frame[3][lane]); + let xy = frame[1][lane].mul_add(p[1], x); + frame[2][lane].mul_add(p[2], xy) + }) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn moving_board_frame_separates_world_rates_from_relative_foot_motion() { + let mut record = SkeletonPhysicalRecord::default(); + record.pose[0][3] = [10.0, 2.0, 3.0, 0.0]; + record.pose[15][3] = [10.2, 2.0, 3.3, 0.0]; + record.pose[19][3] = [9.8, 2.0, 2.7, 0.0]; + record.velocities[15] = [4.0, 0.0, 0.0, 0.0]; + record.velocities[19] = [5.0, 0.0, 0.0, 0.0]; + let settings = FootPhysicalSettings { + deck_half_width: 0.3, + deck_total_half_length: 0.5, + padding: [0.0, 0.1, 0.0, 0.0], + }; + let mut state = FootPhysicalState::default(); + let initial = state.update(&record, 0.25, settings); + assert_eq!(initial.within_deck_box, [true, true]); + assert_eq!( + initial.world_velocity, + [record.velocities[15], record.velocities[19]] + ); + for part in [0, 15, 19] { + record.pose[part][3][0] += 1.0; + } + let moved = state.update(&record, 0.25, settings); + assert!( + moved + .local_velocity + .iter() + .flatten() + .all(|value| value.abs() < 0.00001) + ); + let mut boundaries = SkeletonPhysicalRecord::default(); + boundaries.pose[15][3] = [0.3, 0.0, 0.0, 0.0]; + boundaries.pose[19][3] = [0.0, 0.1, 0.0, 0.0]; + assert_eq!( + state.update(&boundaries, 0.25, settings).within_deck_box, + [false, false] + ); + } +} diff --git a/crates/skate-core/src/physics/force_queue.rs b/crates/skate-core/src/physics/force_queue.rs new file mode 100644 index 00000000..b0838ca1 --- /dev/null +++ b/crates/skate-core/src/physics/force_queue.rs @@ -0,0 +1,100 @@ +//! Board force delivery: TU3 append 0x82C03EF0 and consumer 0x82C03718. +use crate::math::{Basis3, Vector3}; + +use super::point_force::{RetailForceAccumulator, accumulate_point_force}; + +pub const FORCE_CAPACITY: usize = 21; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct QueuedPointForce { + /// Native tag is retained for attribution; it does not select a body. + pub tag: u32, + pub force_world: Vector3, + pub point_body: Vector3, +} + +#[derive(Clone, Debug)] +pub struct BoardForceQueue { + entries: [QueuedPointForce; FORCE_CAPACITY], + count: usize, +} + +impl Default for BoardForceQueue { + fn default() -> Self { + Self { + entries: [QueuedPointForce::default(); FORCE_CAPACITY], + count: 0, + } + } +} + +impl BoardForceQueue { + /// TU3 drops the new force when 21 entries are already present. Repeated + /// tags remain separate entries, in append order; they are not summed here. + pub fn append(&mut self, entry: QueuedPointForce) -> bool { + if self.count == FORCE_CAPACITY { + return false; + } + self.entries[self.count] = entry; + self.count += 1; + true + } + + pub fn entries(&self) -> &[QueuedPointForce] { + &self.entries[..self.count] + } + + /// Every record acts on the deck. The caller resolves the native collection + /// force-point Y offset; the native null-collection fallback is zero. + /// Consumption does not clear the records in Skate 3. + pub fn apply_to_deck( + &self, + mut accumulator: RetailForceAccumulator, + deck_basis: Basis3, + inverse_mass: f32, + world_inverse_inertia: Basis3, + force_point_y_offset: f32, + ) -> RetailForceAccumulator { + for entry in self.entries() { + let point = Vector3::new( + entry.point_body.x + 0.0, + entry.point_body.y + force_point_y_offset, + entry.point_body.z + 0.0, + ); + accumulator = accumulate_point_force( + accumulator, + entry.force_world, + point, + deck_basis, + inverse_mass, + world_inverse_inertia, + ); + } + accumulator + } + + /// Separate lifecycle operation, corresponding to the queue reset at + /// 0x82C02360..0x82C02368 (also reset when the board is repositioned). + pub fn clear(&mut self) { + self.count = 0; + } +} + +/// TU3 0x82C06F58: accumulate alternating body masses, then combine. The +/// grouped binary32 additions differ from an ordinary left-to-right sum. +/// Native input is live-body inverse mass at +124, in definition order. +pub fn total_body_mass(inverse_masses: &[f32]) -> f32 { + let mut even = 0.0_f32; + let mut odd = 0.0_f32; + let mut pairs = inverse_masses.chunks_exact(2); + for pair in &mut pairs { + even = 1.0 / pair[0] + even; + odd = 1.0 / pair[1] + odd; + } + let paired = odd + even; + paired + + pairs + .remainder() + .first() + .map_or(0.0, |inverse| 1.0 / inverse) +} diff --git a/crates/skate-core/src/physics/ground_hang_geometry.rs b/crates/skate-core/src/physics/ground_hang_geometry.rs new file mode 100644 index 00000000..49bba161 --- /dev/null +++ b/crates/skate-core/src/physics/ground_hang_geometry.rs @@ -0,0 +1,189 @@ +//! Geometry actually observed by Ground::ManageHangUps82D39510. +//! The caller reads only query completion, geometry kind104 and flag29 from +//! 82C20C08; unrelated grind surface classifications are not used in this path. +use super::{ + board_motion_output::{dot, inverse_length_squared}, + board_world::BoardWorld, +}; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug)] +pub struct HangGeometryInput { + /// Processed1312,1328 and1232, respectively. + pub edge_start: Vector3, + pub edge_end: Vector3, + pub reference_point: Vector3, +} +#[derive(Clone, Copy, Debug)] +pub struct HangLine { + pub start: Vector3, + pub end: Vector3, + pub radius: f32, +} + +/// Full six-line branch82C20728 used by82D397A0. Input's optional seventh +/// line byte is zeroed by82C20530 and never set by this ground caller. +pub fn hang_lines(input: HangGeometryInput, deck_center_to_truck: f32) -> Option<[HangLine; 6]> { + let delta = sub(input.edge_end, input.edge_start); + let first = cross(Vector3::new(0., 1., 0.), delta); + let raw_up = cross(delta, first); + let (up, length) = normalize_or_retain(raw_up); + let (direction, _) = normalize_or_retain(delta); + if length < f32::from_bits(0x3727_c5ac) { + return None; + } + let on_edge = madd( + direction, + dot(sub(input.reference_point, input.edge_start), direction), + input.edge_start, + ); + // Preserve the native pair of subtractions rather than canceling them. + let center = sub(input.reference_point, sub(input.reference_point, on_edge)); + let side = cross(up, direction); + let short_up = scale(up, 0.04); + let near_side = scale(side, 0.09); + let far_side = scale(side, deck_center_to_truck); + let far_up = scale(up, deck_center_to_truck * f32::from_bits(0x3f87_ae14)); + let last_up = scale(up, 0.09); + let line = |center: Vector3, up: Vector3, radius| HangLine { + start: add(center, up), + end: sub(center, up), + radius, + }; + Some([ + line(add(center, near_side), short_up, 0.), + line(sub(center, near_side), short_up, 0.), + line(add(center, far_side), far_up, 0.), + line(sub(center, far_side), far_up, 0.), + line(center, short_up, 0.), + line(add(center, last_up), near_side, 0.001), + ]) +} + +/// 82C20ED4..82C20F84 and Ground's82D397EC..82D39810. We execute all six +/// native queries, then use the exact two classification fields read by Ground. +pub fn detect_hung_geometry( + world: &BoardWorld, + input: HangGeometryInput, + deck_center_to_truck: f32, +) -> Result { + let Some(lines) = hang_lines(input, deck_center_to_truck) else { + return Ok(false); + }; + let mut fractions = [None; 6]; + for (i, line) in lines.into_iter().enumerate() { + fractions[i] = world + .query_swept_line(line.start, line.end, line.radius)? + .map(|hit| hit.geometry.fraction); + } + Ok(hung_classification(fractions)) +} +fn hung_classification(hits: [Option; 6]) -> bool { + if (hits[0].is_some() && hits[1].is_some()) || hits[4].is_some() { + return false; + } + let kind = if hits[0].is_some() { + if hits[2].is_some_and(|fraction| fraction < 0.65) { + 2 + } else { + 1 + } + } else if hits[1].is_some() { + if hits[3].is_some_and(|fraction| fraction < 0.65) { + 2 + } else { + 1 + } + } else { + 0 + }; + kind != 2 +} +fn normalize_or_retain(v: Vector3) -> (Vector3, f32) { + let square = dot(v, v); + let inverse = inverse_length_squared(square, 2); + let length = if square == 0. { 0. } else { square * inverse }; + ( + if length > f32::from_bits(0x3586_37bd) { + scale(v, inverse) + } else { + v + }, + length, + ) +} +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +fn scale(a: Vector3, k: f32) -> Vector3 { + Vector3::new(a.x * k, a.y * k, a.z * k) +} +fn madd(a: Vector3, k: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(k, b.x), + a.y.mul_add(k, b.y), + a.z.mul_add(k, b.z), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn hanging_geometry_distinguishes_rail_side_and_blocked_deck() { + let input = HangGeometryInput { + edge_start: Vector3::ZERO, + edge_end: Vector3::new(0., 0., 2.), + reference_point: Vector3::new(4., 5., 1.), + }; + let lines = hang_lines(input, 0.243).unwrap(); + assert_eq!(lines[0].start, Vector3::new(0.09, 0.04, 1.)); + assert_eq!(lines[0].end, Vector3::new(0.09, -0.04, 1.)); + assert_eq!(lines[1].start.x, -0.09); + assert_eq!(lines[5].radius, 0.001); + assert_eq!(lines[5].start, Vector3::new(0.09, 0.09, 1.)); + assert_eq!(lines[5].end, Vector3::new(-0.09, 0.09, 1.)); + assert!(hung_classification([ + Some(0.5), + None, + None, + None, + None, + None + ])); + assert!(!hung_classification([ + Some(0.5), + None, + Some(0.64), + None, + None, + None + ])); + assert!(!hung_classification([ + Some(0.5), + Some(0.5), + None, + None, + None, + None + ])); + assert!(!hung_classification([ + None, + None, + None, + None, + Some(0.5), + None + ])); + } +} diff --git a/crates/skate-core/src/physics/hook_drive/frames.rs b/crates/skate-core/src/physics/hook_drive/frames.rs new file mode 100644 index 00000000..e11c8b0b --- /dev/null +++ b/crates/skate-core/src/physics/hook_drive/frames.rs @@ -0,0 +1,71 @@ +//! Full 82C00940/82C00AF0 matrix33 conversion. Unlike Part::SetTransform, +//! these functions do not normalize the selected quaternion afterward. +type V = [f32; 4]; +fn add(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] + b[i]) +} +fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +fn mul(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] * b[i]) +} +pub(super) fn rsqrt(v: f32) -> f32 { + let mut e = super::super::reciprocal_sqrt::estimate(v); + for _ in 0..2 { + let s = e * e; + let h = e * 0.5; + e = h.mul_add((-v).mul_add(s, 1.0), e); + } + e +} +/// 82C00940 preserves both translations and the parent quaternion. +pub fn set_child_angular_frame(frames: &mut [u32; 16], basis: [u32; 12]) { + frames[..4].copy_from_slice(&convert(basis)); +} +/// 82C00AF0 preserves both translations and the child quaternion. +pub fn set_parent_angular_frame(frames: &mut [u32; 16], basis: [u32; 12]) { + frames[8..12].copy_from_slice(&convert(basis)); +} +fn convert(basis: [u32; 12]) -> [u32; 4] { + quaternion(core::array::from_fn(|i| { + core::array::from_fn(|j| f32::from_bits(basis[i * 4 + j])) + })) + .map(f32::to_bits) +} +fn quaternion(m: [V; 3]) -> V { + let [ri, up, at] = m; + // Sign masks are initialized by TU3 82F83360/90/C0, not image defaults. + let rx = [ri[0], ri[0], -ri[0], -ri[0]]; + let uy = [up[1], -up[1], up[1], -up[1]]; + let az = [at[2], -at[2], -at[2], at[2]]; + let first = add(rx, uy); + let d = add(first, add(az, [1.0; 4])); + let inv = d.map(rsqrt); + let roots = mul(d, inv); + let halves = mul([0.5; 4], inv); + let a = [up[2], at[0], ri[1], 0.5]; + let b = [at[1], ri[2], up[0], 0.0]; + let minus = sub(a, b); + let plus = add(a, b); + let y = mul( + [plus[2], minus[3], plus[0], minus[1]], + [halves[2], roots[2], halves[2], halves[2]], + ); + let z = mul( + [plus[1], plus[0], minus[3], minus[2]], + [halves[3], halves[3], roots[3], halves[3]], + ); + let x = mul( + [minus[3], plus[2], plus[1], minus[0]], + [roots[1], halves[1], halves[1], halves[1]], + ); + let w = mul(minus, [halves[0], halves[0], halves[0], roots[0]]); + let yz = if up[1] > at[2] { y } else { z }; + let xyz = if ri[0] > up[1] && ri[0] > at[2] { + x + } else { + yz + }; + if first[0] + at[2] > 0.0 { w } else { xyz } +} diff --git a/crates/skate-core/src/physics/hook_drive/mod.rs b/crates/skate-core/src/physics/hook_drive/mod.rs new file mode 100644 index 00000000..dfbfc8d4 --- /dev/null +++ b/crates/skate-core/src/physics/hook_drive/mod.rs @@ -0,0 +1,92 @@ +//! TU3 hook-drive state. The hook is a separate target part, child of deck 6. +//! Registration, target publication and the caller's state transition remain +//! explicit; changing these live dynamics does not recreate the drive. + +mod frames; +pub use frames::{set_child_angular_frame, set_parent_angular_frame}; + +use super::drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HookDriveState { + /// JointFrames at SkateboardBody+896: child quaternion/translation, parent + /// quaternion/translation, four words each in guest lane order. + pub frames: [u32; 16], + /// Live linear then angular parameters at SkateboardBody+960. + pub dynamics: [u32; 8], +} + +impl HookDriveState { + /// Frame/dynamics initialization in 82C0D330. This does not manufacture the + /// separate hook assembly, allocate a drive, or choose a gameplay pose. + pub fn initial() -> Self { + let mut state = Self { + frames: [0; 16], + dynamics: [0; 8], + }; + let identity = + [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0].map(f32::to_bits); + set_child_angular_frame(&mut state.frames, identity); + set_parent_angular_frame(&mut state.frames, identity); + state.disable_linear(); + state.disable_angular(); + state + } + + /// 82C04708, including its inline linear enable and 82C0D5C0 call. + /// TU3 reads no caller strength multiplier here. + pub fn enable_animation_soft(&mut self, animated: &mut u8) { + *animated = 1; + self.dynamics[..4].copy_from_slice(&soft(5000.0, f32::from_bits(0x44e0_fffe))); + self.enable_angular_soft(); + } + + /// Complete 82C0D5C0. Other fields and the caller's animated flag survive. + pub fn enable_angular_soft(&mut self) { + self.dynamics[4..].copy_from_slice(&soft(20000.0, f32::from_bits(0x470c_9fff))); + } + + /// Complete 82C05658: animated flag, angular soft, then linear hard/zero. + pub fn enable_angular_only(&mut self, animated: &mut u8) { + *animated = 1; + self.enable_angular_soft(); + self.disable_linear(); + } + + /// Inline Ground Enter82D375D0..5FC and LetGo82D75450..98 operation. + /// The blend flag and stored board quaternion are not inputs or outputs. + pub fn disable_animation(&mut self, animated: &mut u8) { + *animated = 0; + self.disable_linear(); + self.disable_angular(); + } + + pub fn disable_linear(&mut self) { + self.dynamics[..4].copy_from_slice(&[0, 0, 0, 2]); + } + pub fn disable_angular(&mut self) { + self.dynamics[4..].copy_from_slice(&[0, 0, 0, 2]); + } + + pub fn solver_dynamics(&self) -> RetailDriveDynamics { + let params = |i| RetailDriveParams { + spring_or_max_velocity: f32::from_bits(self.dynamics[i]), + damping: f32::from_bits(self.dynamics[i + 1]), + max_strength: f32::from_bits(self.dynamics[i + 2]), + drive_type: match self.dynamics[i + 3] { + 1 => RetailDriveType::SoftDrive, + 2 => RetailDriveType::HardDrive, + _ => RetailDriveType::NoDrive, + }, + }; + RetailDriveDynamics { + linear: params(0), + angular: params(4), + } + } +} + +fn soft(spring: f32, strength: f32) -> [u32; 4] { + let damping = (spring * frames::rsqrt(spring)) * 2.0; + [spring.to_bits(), damping.to_bits(), strength.to_bits(), 1] +} diff --git a/crates/skate-core/src/physics/joint_builder.rs b/crates/skate-core/src/physics/joint_builder.rs new file mode 100644 index 00000000..dbb1f371 --- /dev/null +++ b/crates/skate-core/src/physics/joint_builder.rs @@ -0,0 +1,447 @@ +//! Semantic port of TU3 `rw::physics::JointJacobian::Build`. +//! +//! This module is intentionally separate from generic engine joints. It +//! accepts the exact 64-byte parameter and 80-byte frame records emitted by +//! `SkateboardBody::CreateJoints`, plus the rigid-body fields read by +//! `0x82AE3BC8`, and emits the 384-byte workspace consumed by the joint branch +//! of `0x82AE27D0`. +//! +//! Evidence labels used below: +//! - **Observed**: direct field loads, stores, constants, or branches in TU3. +//! - **Derived**: scalar algebra preserving the observed VMX data flow. +//! - **Inferred**: source-level naming where the executable has no type name. + +use super::{ + constraint_frames, + joint_records::{RetailJointFramesRaw, RetailJointParametersRaw}, + joint_solver::RetailJointJacobian, + native_arithmetic, + rigid_body::{RetailPackedWorldInverseInertia, RetailQuaternion}, +}; +use crate::math::{Basis3, Vector3}; +mod arithmetic; +use arithmetic::*; + +pub mod tu3 { + pub const JOINT_BATCH_BUILD: u32 = 0x82AE_39D0; + pub const JOINT_JACOBIAN_BUILD: u32 = 0x82AE_3BC8; + pub const JACOBIAN_RQD_CREATE: u32 = 0x82AE_0DB0; + pub const ITERATIVE_CONSTRAINT_SOLVER: u32 = 0x82AE_27D0; + + pub const ACTIVE_BODY: u32 = 4; + pub const JACOBIAN_BYTES: usize = 0x180; + pub const JACOBIAN_WORDS: usize = JACOBIAN_BYTES / 4; + + /// Observed TU3 vector constant used as the finite "unbounded" value. + pub const FINITE_INFINITY: f32 = f32::from_bits(0x7F7F_FFFF); + /// Observed singularity guard initialized at `0x830BDE50`. + pub const NEAR_PARALLEL: f32 = f32::from_bits(0x3F7F_FF58); +} + +/// Explicit body snapshot consumed by `JointJacobian::Build`. +/// +/// All fields are **Observed** inputs. `basis` is kept separate from +/// `orientation` because TU3 reads both rather than rebuilding one from the +/// other. Likewise the reaction address is metadata copied into the raw +/// Jacobian; this port never dereferences it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailJointBodyInput { + pub reaction_guest_address: u32, + pub state: u32, + pub orientation: RetailQuaternion, + pub center_of_mass: Vector3, + pub basis: Basis3, + pub linear_velocity: Vector3, + pub angular_velocity: Vector3, + pub force_acceleration: Vector3, + pub torque_acceleration: Vector3, + pub inverse_mass: f32, + pub world_inverse_inertia: RetailPackedWorldInverseInertia, +} + +/// Complete explicit call state for TU3 `JointJacobian::Build`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailJointBuildInput { + /// **Observed** 64-byte record at `Joint+4`. + pub parameters: RetailJointParametersRaw, + /// **Observed** 80-byte record at `Joint+0`. + pub frames: RetailJointFramesRaw, + /// **Observed** body at `Joint+0x10`. + pub body_a: RetailJointBodyInput, + /// **Observed** body at `Joint+0x14`. + pub body_b: RetailJointBodyInput, + /// **Observed** `Simulation+0xA0`. + pub time_step: f32, + /// **Observed** source `Joint*`, retained in vector 12's fourth word. + pub joint_guest_address: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct JointParameters { + linear_position_allowance: Vector3, + linear_velocity_allowance: Vector3, + twist_velocity_allowance: f32, + swing_velocity_allowance: f32, + swing_threshold: f32, + twist_threshold: f32, + swing_mode: u32, + twist_mode: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct JointFrames { + orientation_a: RetailQuaternion, + anchor_a: Vector3, + orientation_b: RetailQuaternion, + anchor_b: Vector3, + linear_orientation_b: RetailQuaternion, +} + +#[derive(Clone, Copy, Debug)] +struct Rqd { + relative: RetailQuaternion, + axes: [Vector3; 3], +} + +/// Builds TU3's fixed six-lane, 384-byte joint workspace. +/// +/// The three linear and three angular accumulators begin at zero because the +/// retail builder clears all three 128-byte cache lines on every batch build. +pub fn build_retail_joint_jacobian(input: RetailJointBuildInput) -> RetailJointJacobian { + debug_assert!(input.time_step.is_finite() && input.time_step > 0.0); + + let parameters = decode_parameters(input.parameters); + let frames = decode_frames(input.frames); + let active_a = input.body_a.state & tu3::ACTIVE_BODY != 0; + let active_b = input.body_b.state & tu3::ACTIVE_BODY != 0; + + // Observed composition order: body orientation followed by local frame. + let world_orientation_a = quaternion_multiply(input.body_a.orientation, frames.orientation_a); + let world_orientation_b = quaternion_multiply(input.body_b.orientation, frames.orientation_b); + let world_linear_orientation = + quaternion_multiply(input.body_b.orientation, frames.linear_orientation_b); + let angular_basis_a = basis_from_quaternion_retail(world_orientation_a); + let angular_basis_b = basis_from_quaternion_retail(world_orientation_b); + let linear_basis = basis_from_quaternion_retail(world_linear_orientation); + + let arm_a = multiply_basis(input.body_a.basis, frames.anchor_a); + let arm_b = multiply_basis(input.body_b.basis, frames.anchor_b); + let linear_axes = basis_columns(linear_basis); + // Retained matrix product: the original gathers an RQD quaternion before + // constructing this matrix. The VA extension of 82AE3D98 (10242A35) is + // not independently decoded, so its rounding path remains unverified. + let relative_matrix = relative_basis(angular_basis_a, angular_basis_b); + let rqd = create_rqd(world_orientation_a, world_orientation_b); + let (angular_axes, angular_raw_low, angular_raw_high) = angular_rows( + parameters, + angular_basis_a, + angular_basis_b, + relative_matrix, + rqd, + ); + + let inverse_mass_a = if active_a { + input.body_a.inverse_mass + } else { + 0.0 + }; + let inverse_mass_b = if active_b { + input.body_b.inverse_mass + } else { + 0.0 + }; + let inertia_a = if active_a { + input.body_a.world_inverse_inertia + } else { + zero_inertia() + }; + let inertia_b = if active_b { + input.body_b.world_inverse_inertia + } else { + zero_inertia() + }; + + let linear_inverse_effective_mass = core::array::from_fn(|index| { + let a = cross(arm_a, linear_axes[index]); + let b = cross(arm_b, linear_axes[index]); + let ia = constraint_frames::multiply_inertia_from_zero(inertia_a, a); + let ib = constraint_frames::multiply_inertia_from_zero(inertia_b, b); + // 82AE4814..4990 combines the two bodies component-wise, then uses + // (Z + Y) + (X + inverse_mass_A + inverse_mass_B), not two dot sums. + let x = b.x.mul_add(ib.x, a.x.mul_add(ia.x, 0.0)); + let y = b.y.mul_add(ib.y, a.y.mul_add(ia.y, 0.0)); + let z = b.z.mul_add(ib.z, a.z.mul_add(ia.z, 0.0)); + let denominator = (z + y) + (x + (inverse_mass_a + inverse_mass_b)); + native_arithmetic::reciprocal_estimate(denominator) + }); + let angular_inverse_effective_mass = core::array::from_fn(|index| { + let axis = angular_axes[index]; + let response = add( + constraint_frames::multiply_inertia_from_zero(inertia_a, axis), + constraint_frames::multiply_inertia_from_zero(inertia_b, axis), + ); + let x = axis.x.mul_add(response.x, 0.0); + let y = axis.y.mul_add(response.y, 0.0); + let z = axis.z.mul_add(response.z, 0.0); + // 82AE496C..49A0: Z + (X + Y), one estimate, then the RQD factor 1/2. + 0.5 * native_arithmetic::reciprocal_estimate(z + (x + y)) + }); + + let point_velocity_a = point_rate( + input.body_a.linear_velocity, + input.body_a.angular_velocity, + arm_a, + ); + let point_velocity_b = point_rate( + input.body_b.linear_velocity, + input.body_b.angular_velocity, + arm_b, + ); + // Native prediction is computed before the active-body inertia mask. + // An inactive body can still supply the source-owned kinematic rates. + let point_acceleration_a = point_rate( + input.body_a.force_acceleration, + input.body_a.torque_acceleration, + arm_a, + ); + let point_acceleration_b = point_rate( + input.body_b.force_acceleration, + input.body_b.torque_acceleration, + arm_b, + ); + let displacement_a = scale( + super::constraint_frames::multiply_add( + point_acceleration_a, + input.time_step, + point_velocity_a, + ), + input.time_step, + ); + let displacement_b = scale( + super::constraint_frames::multiply_add( + point_acceleration_b, + input.time_step, + point_velocity_b, + ), + input.time_step, + ); + let relative_displacement = sub(displacement_b, displacement_a); + let predicted_separation = sub( + add(add(input.body_b.center_of_mass, arm_b), displacement_b), + add(add(input.body_a.center_of_mass, arm_a), displacement_a), + ); + let local_rate_displacement = project(relative_displacement, linear_axes); + let local_separation = project(predicted_separation, linear_axes); + + let mut linear_low = [0.0; 3]; + let mut linear_high = [0.0; 3]; + let position_allowance = vector_to_array(parameters.linear_position_allowance); + let velocity_allowance = vector_to_array(parameters.linear_velocity_allowance); + for lane in 0..3 { + let displacement_low = local_separation[lane] - position_allowance[lane]; + let displacement_high = local_separation[lane] + position_allowance[lane]; + let velocity_low = + local_rate_displacement[lane] - velocity_allowance[lane] * input.time_step; + let velocity_high = + local_rate_displacement[lane] + velocity_allowance[lane] * input.time_step; + let raw_low = vmx_max(displacement_low, vmx_min(velocity_low, displacement_high)); + let raw_high = vmx_min(displacement_high, vmx_max(velocity_high, displacement_low)); + linear_low[lane] = raw_low * linear_inverse_effective_mass[lane]; + linear_high[lane] = raw_high * linear_inverse_effective_mass[lane]; + } + + let relative_angular_rate = sub( + super::constraint_frames::multiply_add( + input.body_b.torque_acceleration, + input.time_step, + sub(input.body_b.angular_velocity, input.body_a.angular_velocity), + ), + scale(input.body_a.torque_acceleration, input.time_step), + ); + let local_angular_displacement = + project(scale(relative_angular_rate, input.time_step), angular_axes); + let angular_velocity_allowance = [ + parameters.twist_velocity_allowance, + parameters.swing_velocity_allowance, + parameters.swing_velocity_allowance, + ]; + let mut angular_low = [0.0; 3]; + let mut angular_high = [0.0; 3]; + for lane in 0..3 { + let predicted_low = angular_raw_low[lane] + local_angular_displacement[lane]; + let predicted_high = angular_raw_high[lane] + local_angular_displacement[lane]; + let velocity_low = + local_angular_displacement[lane] - angular_velocity_allowance[lane] * input.time_step; + let velocity_high = angular_velocity_allowance[lane] + .mul_add(input.time_step, local_angular_displacement[lane]); + let raw_low = vmx_max(predicted_low, vmx_min(velocity_low, predicted_high)); + let raw_high = vmx_min(predicted_high, vmx_max(velocity_high, predicted_low)); + angular_low[lane] = raw_low * angular_inverse_effective_mass[lane]; + angular_high[lane] = raw_high * angular_inverse_effective_mass[lane]; + } + + let linear_projection = scaled_projection_columns(linear_axes, linear_inverse_effective_mass); + let angular_projection = + scaled_projection_columns(angular_axes, angular_inverse_effective_mass); + let inertia_columns_a = packed_inertia_columns(inertia_a, inverse_mass_a); + let inertia_columns_b = packed_inertia_columns(inertia_b, inverse_mass_b); + + let mut output = RetailJointJacobian { + words: [0; tu3::JACOBIAN_WORDS], + }; + write_vector( + &mut output, + 0, + [ + arm_a.x, + arm_a.y, + arm_a.z, + f32::from_bits(input.body_a.reaction_guest_address), + ], + ); + write_vector( + &mut output, + 1, + [ + arm_b.x, + arm_b.y, + arm_b.z, + f32::from_bits(input.body_b.reaction_guest_address), + ], + ); + // Vectors 2 and 3 are the cleared linear/angular accumulators. + write_vector( + &mut output, + 4, + [ + linear_projection[0][0], + linear_projection[0][1], + linear_projection[0][2], + 0.0, + ], + ); + write_vector( + &mut output, + 5, + [ + angular_projection[0][0], + angular_projection[0][1], + angular_projection[0][2], + 0.0, + ], + ); + write_vector( + &mut output, + 6, + [ + linear_projection[1][0], + linear_projection[1][1], + linear_projection[1][2], + angular_low[0], + ], + ); + write_vector( + &mut output, + 7, + [ + angular_projection[1][0], + angular_projection[1][1], + angular_projection[1][2], + angular_low[1], + ], + ); + write_vector( + &mut output, + 8, + [ + linear_projection[2][0], + linear_projection[2][1], + linear_projection[2][2], + angular_high[0], + ], + ); + write_vector( + &mut output, + 9, + [ + angular_projection[2][0], + angular_projection[2][1], + angular_projection[2][2], + angular_high[1], + ], + ); + write_vector( + &mut output, + 10, + [linear_low[0], linear_low[1], linear_low[2], angular_low[2]], + ); + write_vector( + &mut output, + 11, + [ + linear_high[0], + linear_high[1], + linear_high[2], + angular_high[2], + ], + ); + write_vector( + &mut output, + 12, + [ + linear_axes[0].x, + linear_axes[0].y, + linear_axes[0].z, + f32::from_bits(input.joint_guest_address), + ], + ); + // Packed carry lanes remain unverified pending direct TU3 instruction review. + write_vector( + &mut output, + 13, + [ + linear_axes[1].x, + linear_axes[1].y, + linear_axes[1].z, + linear_axes[1].x, + ], + ); + write_vector( + &mut output, + 14, + [ + linear_axes[2].x, + linear_axes[2].y, + linear_axes[2].z, + linear_axes[0].y, + ], + ); + for lane in 0..3 { + write_vector( + &mut output, + 15 + lane, + [ + angular_axes[lane].x, + angular_axes[lane].y, + angular_axes[lane].z, + angular_axes[lane].x, + ], + ); + } + for lane in 0..3 { + write_vector(&mut output, 18 + lane, inertia_columns_a[lane]); + write_vector(&mut output, 21 + lane, inertia_columns_b[lane]); + } + output +} + +fn write_vector(output: &mut RetailJointJacobian, vector: usize, value: [f32; 4]) { + let start = vector * 4; + for lane in 0..4 { + output.words[start + lane] = value[lane].to_bits(); + } +} + +#[cfg(test)] +#[path = "tests/joint_builder.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/joint_builder/arithmetic.rs b/crates/skate-core/src/physics/joint_builder/arithmetic.rs new file mode 100644 index 00000000..876a1afa --- /dev/null +++ b/crates/skate-core/src/physics/joint_builder/arithmetic.rs @@ -0,0 +1,259 @@ +//! Original TU3 joint angular limits and shared constraint-frame arithmetic. +use super::*; +use crate::physics::constraint_frames; +pub(super) fn decode_parameters(raw: RetailJointParametersRaw) -> JointParameters { + JointParameters { + linear_position_allowance: vector_from_words(&raw.words, 0), + linear_velocity_allowance: vector_from_words(&raw.words, 4), + twist_velocity_allowance: f32::from_bits(raw.words[8]), + swing_velocity_allowance: f32::from_bits(raw.words[9]), + swing_threshold: f32::from_bits(raw.words[12]), + twist_threshold: f32::from_bits(raw.words[13]), + swing_mode: raw.words[14], + twist_mode: raw.words[15], + } +} + +pub(super) fn decode_frames(raw: RetailJointFramesRaw) -> JointFrames { + JointFrames { + orientation_a: quaternion_from_words(&raw.words, 0), + anchor_a: vector_from_words(&raw.words, 4), + orientation_b: quaternion_from_words(&raw.words, 8), + anchor_b: vector_from_words(&raw.words, 12), + linear_orientation_b: quaternion_from_words(&raw.words, 16), + } +} + +pub(super) fn angular_rows( + parameters: JointParameters, + basis_a: Basis3, + basis_b: Basis3, + relative: [[f32; 3]; 3], + rqd: Rqd, +) -> ([Vector3; 3], [f32; 3], [f32; 3]) { + let finite_infinity = tu3::FINITE_INFINITY; + // Native defaults are all three frame-B axes, including skipped limits. + let mut axes = basis_columns(basis_b); + let mut low = [-finite_infinity; 3]; + let mut high = [finite_infinity; 3]; + + match parameters.swing_mode { + 0 => { + axes[1] = rqd.axes[1]; + axes[2] = rqd.axes[2]; + low[1] = 2.0 * rqd.relative.y; + high[1] = low[1]; + low[2] = 2.0 * rqd.relative.z; + high[2] = low[2]; + } + 1 => { + // 82AE4178..4188 skips the cone calculation when all splatted + // cosine lanes compare >= the original 0.99999 guard. + if !(relative[0][0] >= tu3::NEAR_PARALLEL) { + let first_a = basis_column(basis_a, 0); + let first_b = basis_column(basis_b, 0); + let perpendicular_z = relative[0][2]; + let perpendicular_y = relative[0][1]; + let squared = + perpendicular_y.mul_add(perpendicular_y, perpendicular_z * perpendicular_z); + let root = squared * constraint_frames::reciprocal_sqrt(squared); + let root = if squared == 0.0 { 0.0 } else { root }; + let inverse_length = constraint_frames::reciprocal(root) * 1.0; + axes[1] = scale(cross(first_a, first_b), inverse_length); + axes[2] = cross(axes[1], first_a); + // The bound is written even while inside the cone, allowing + // the predicted rate to reach the limit during this step. + low[1] = (parameters.swing_threshold - relative[0][0]) * inverse_length; + } + } + 2 | 3 => { + axes[1] = basis_column(basis_b, 1); + if parameters.twist_mode == 0 { + axes[2] = rqd.axes[2]; + low[2] = 2.0 * rqd.relative.z; + high[2] = low[2]; + } else { + axes[2] = cross(basis_column(basis_a, 0), axes[1]); + low[2] = -relative[0][1]; + high[2] = low[2]; + } + if parameters.swing_mode == 2 { + let denominator = relative[0][2]; + // 82AE42F4..4304: equality skips the reciprocal and retains + // both unbounded values. NaN is not equal to zero. + if denominator != 0.0 { + let correction = constraint_frames::reciprocal(denominator) + * (parameters.swing_threshold - relative[0][0]); + if denominator < 0.0 { + high[1] = correction; + } else { + low[1] = correction; + } + } + } + } + _ => { + axes[1] = basis_column(basis_b, 1); + axes[2] = basis_column(basis_b, 2); + } + } + + match parameters.twist_mode { + 0 => { + axes[0] = rqd.axes[0]; + low[0] = 2.0 * rqd.relative.x; + high[0] = low[0]; + } + 1 => { + axes[0] = basis_column(basis_a, 0); + // Derived matrix-index form matching the emitted TU3 subtraction. + let denominator = relative[2][1] - relative[1][2]; + // 82AE4434..4444 also retains the unbounded interval at zero. + if denominator != 0.0 { + let numerator = (1.0 + relative[0][0]) * parameters.twist_threshold + - (relative[1][1] + relative[2][2]); + let correction = constraint_frames::reciprocal(denominator) * numerator; + if denominator < 0.0 { + high[0] = correction; + } else { + low[0] = correction; + } + } + } + _ => { + axes[0] = basis_column(basis_b, 0); + } + } + + (axes, low, high) +} + +pub(super) fn create_rqd(world_a: RetailQuaternion, world_b: RetailQuaternion) -> Rqd { + let rows = constraint_frames::quaternion_rows(world_a, world_b); + Rqd { + relative: rows.relative, + axes: rows.axes, + } +} + +pub(super) fn relative_basis(a: Basis3, b: Basis3) -> [[f32; 3]; 3] { + core::array::from_fn(|row| { + core::array::from_fn(|column| dot3(basis_column(a, row), basis_column(b, column))) + }) +} + +pub(super) fn scaled_projection_columns( + axes: [Vector3; 3], + inverse_effective_mass: [f32; 3], +) -> [[f32; 3]; 3] { + core::array::from_fn(|component| { + core::array::from_fn(|row| { + vector_to_array(axes[row])[component] * inverse_effective_mass[row] + }) + }) +} + +pub(super) fn packed_inertia_columns( + packed: RetailPackedWorldInverseInertia, + inverse_mass: f32, +) -> [[f32; 4]; 3] { + [ + [packed.full.x, packed.full.y, packed.full.z, inverse_mass], + [packed.full.y, packed.split.y, packed.split.z, inverse_mass], + [packed.full.z, packed.split.z, packed.split.x, inverse_mass], + ] +} + +pub(super) fn zero_inertia() -> RetailPackedWorldInverseInertia { + RetailPackedWorldInverseInertia { + full: Vector3::ZERO, + split: Vector3::ZERO, + } +} + +pub(super) fn basis_from_quaternion_retail(q: RetailQuaternion) -> Basis3 { + constraint_frames::basis(q) +} + +pub(super) fn quaternion_multiply(a: RetailQuaternion, b: RetailQuaternion) -> RetailQuaternion { + constraint_frames::compose(a, b) +} + +pub(super) fn point_rate(linear: Vector3, angular: Vector3, arm: Vector3) -> Vector3 { + constraint_frames::point_rate(linear, angular, arm) +} + +pub(super) fn project(vector: Vector3, axes: [Vector3; 3]) -> [f32; 3] { + axes.map(|axis| dot3(vector, axis)) +} + +pub(super) fn multiply_basis(basis: Basis3, vector: Vector3) -> Vector3 { + constraint_frames::transform_direction(basis, vector) +} + +pub(super) fn basis_columns(basis: Basis3) -> [Vector3; 3] { + core::array::from_fn(|index| basis_column(basis, index)) +} + +pub(super) fn basis_column(basis: Basis3, index: usize) -> Vector3 { + Vector3::new( + basis.columns[index][0], + basis.columns[index][1], + basis.columns[index][2], + ) +} + +pub(super) fn vector_from_words(words: &[u32; N], offset: usize) -> Vector3 { + Vector3::new( + f32::from_bits(words[offset]), + f32::from_bits(words[offset + 1]), + f32::from_bits(words[offset + 2]), + ) +} + +pub(super) fn quaternion_from_words( + words: &[u32; N], + offset: usize, +) -> RetailQuaternion { + RetailQuaternion { + x: f32::from_bits(words[offset]), + y: f32::from_bits(words[offset + 1]), + z: f32::from_bits(words[offset + 2]), + w: f32::from_bits(words[offset + 3]), + } +} + +pub(super) fn vector_to_array(value: Vector3) -> [f32; 3] { + [value.x, value.y, value.z] +} + +pub(super) fn cross(left: Vector3, right: Vector3) -> Vector3 { + constraint_frames::cross(left, right) +} + +pub(super) fn dot3(left: Vector3, right: Vector3) -> f32 { + crate::physics::native_arithmetic::dot3( + [left.x, left.y, left.z, 0.0], + [right.x, right.y, right.z, 0.0], + ) +} + +pub(super) fn add(left: Vector3, right: Vector3) -> Vector3 { + Vector3::new(left.x + right.x, left.y + right.y, left.z + right.z) +} + +pub(super) fn sub(left: Vector3, right: Vector3) -> Vector3 { + Vector3::new(left.x - right.x, left.y - right.y, left.z - right.z) +} + +pub(super) fn scale(value: Vector3, scalar: f32) -> Vector3 { + Vector3::new(value.x * scalar, value.y * scalar, value.z * scalar) +} + +pub(super) fn vmx_min(left: f32, right: f32) -> f32 { + crate::physics::native_arithmetic::vector_min(left, right) +} + +pub(super) fn vmx_max(left: f32, right: f32) -> f32 { + crate::physics::native_arithmetic::vector_max(left, right) +} diff --git a/crates/skate-core/src/physics/joint_records.rs b/crates/skate-core/src/physics/joint_records.rs new file mode 100644 index 00000000..fa703ac4 --- /dev/null +++ b/crates/skate-core/src/physics/joint_records.rs @@ -0,0 +1,241 @@ +//! TU3 skateboard joint definitions reconstructed from stock collection data. +//! +//! `SkateboardBody::CreateJoints` (`0x82C0C268`) builds two deck/truck joints +//! followed by four truck/wheel joints. This module emits the native parameter +//! and frame payloads that `Assembly::Initialize` consumes; it does not replace +//! them with a third-party constraint type. + +use super::{ + board::BodyId, + drive_frames::{ + default_truck_drive_frames, retail_quaternion_from_basis, AuthoredTransformInputs, + RetailDriveFrame, RetailDriveFrames, + }, + rigid_body::RetailQuaternion, +}; +use crate::{ + math::{Basis3, Vector3}, + trigonometry, +}; + +pub mod tu3 { + pub const CREATE_JOINTS: u32 = 0x82C0_C268; + pub const ASSEMBLY_INITIALIZE: u32 = 0x82AD_FAF8; + pub const ASSEMBLY_POPULATE: u32 = 0x82AD_FBB8; + pub const FRAME_PACK_0: u32 = 0x82C0_0940; + pub const FRAME_PACK_1: u32 = 0x82C0_0AF0; + pub const FRAME_PACK_2: u32 = 0x82BD_3858; +} + +pub const JOINT_COUNT: usize = 6; + +const DEGREES_TO_RADIANS: f32 = f32::from_bits(0x3C8E_FA35); +const SIMULATION_FREQUENCY: f32 = f32::from_bits(0x426F_FFFF); +const HALF_PI: f32 = f32::from_bits(0x3FC9_0FDB); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailJointSettings { + pub truck_twist_limit_degrees: f32, + pub truck_twist_angle_degrees: f32, + pub wheel_swing_limit_degrees: f32, +} + +impl RetailJointSettings { + /// Values decoded from the stock `physicstrucks` and `physicswheels` + /// collection XMLs read by `CreateJoints`. + pub const STOCK: Self = Self { + truck_twist_limit_degrees: f32::from_bits(0x42AC_3333), + truck_twist_angle_degrees: f32::from_bits(0x4160_0000), + wheel_swing_limit_degrees: f32::from_bits(0x4974_2400), + }; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(C)] +pub struct RetailJointParametersRaw { + pub words: [u32; 16], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(C)] +pub struct RetailJointFramesRaw { + pub words: [u32; 20], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RetailJointRecord { + pub definition_body_0: BodyId, + pub definition_body_1: BodyId, + pub parameters: RetailJointParametersRaw, + pub frames: RetailJointFramesRaw, +} + +impl RetailJointRecord { + /// Body written to live `Joint+0x10` by assembly activation. + pub const fn live_body_a(self) -> BodyId { + self.definition_body_1 + } + + /// Body written to live `Joint+0x14` by assembly activation. + pub const fn live_body_b(self) -> BodyId { + self.definition_body_0 + } +} + +/// Complete stock record set in the unrolled write order of TU3 +/// `SkateboardBody::CreateJoints`. +pub fn default_joint_records() -> [RetailJointRecord; JOINT_COUNT] { + joint_records(RetailJointSettings::STOCK) +} + +pub fn joint_records(settings: RetailJointSettings) -> [RetailJointRecord; JOINT_COUNT] { + let truck_parameters = truck_parameters(settings); + let wheel_parameters = wheel_parameters(settings); + let truck_frames = default_truck_drive_frames().map(truck_joint_frames); + let wheel_orientation = wheel_joint_orientation(); + let wheel_offset = AuthoredTransformInputs::STOCK.wheel_x_distance; + let positive_wheel_frames = wheel_joint_frames(wheel_orientation, wheel_offset); + let negative_wheel_frames = wheel_joint_frames(wheel_orientation, -wheel_offset); + + [ + joint( + BodyId::Deck, + BodyId::FrontTruck, + truck_parameters, + truck_frames[0], + ), + joint( + BodyId::Deck, + BodyId::BackTruck, + truck_parameters, + truck_frames[1], + ), + joint( + BodyId::FrontTruck, + BodyId::RightFrontWheel, + wheel_parameters, + positive_wheel_frames, + ), + joint( + BodyId::FrontTruck, + BodyId::LeftFrontWheel, + wheel_parameters, + negative_wheel_frames, + ), + joint( + BodyId::BackTruck, + BodyId::RightBackWheel, + wheel_parameters, + positive_wheel_frames, + ), + joint( + BodyId::BackTruck, + BodyId::LeftBackWheel, + wheel_parameters, + negative_wheel_frames, + ), + ] +} + +fn truck_parameters(settings: RetailJointSettings) -> RetailJointParametersRaw { + let angle_radians = settings.truck_twist_angle_degrees * DEGREES_TO_RADIANS; + let (_, angle_cosine) = trigonometry::sin_cos(angle_radians); + let twist_velocity = + (settings.truck_twist_limit_degrees * DEGREES_TO_RADIANS) * SIMULATION_FREQUENCY; + let mut words = [0; 16]; + words[8] = twist_velocity.to_bits(); + // The native parameter block retains the angular boundary in its spare + // fourth lane while the solver consumes cos(angle) from word 13. + words[11] = angle_radians.to_bits(); + words[12] = 1.0_f32.to_bits(); + words[13] = angle_cosine.to_bits(); + words[14] = 0; + words[15] = 1; + RetailJointParametersRaw { words } +} + +fn wheel_parameters(settings: RetailJointSettings) -> RetailJointParametersRaw { + let swing_velocity = + (settings.wheel_swing_limit_degrees * DEGREES_TO_RADIANS) * SIMULATION_FREQUENCY; + let mut words = [0; 16]; + words[9] = swing_velocity.to_bits(); + words[12] = 1.0_f32.to_bits(); + words[13] = 1.0_f32.to_bits(); + words[14] = 3; + words[15] = 0; + RetailJointParametersRaw { words } +} + +fn truck_joint_frames(frames: RetailDriveFrames) -> RetailJointFramesRaw { + pack_frames(frames.body_a, frames.body_b, frames.body_b.orientation) +} + +fn wheel_joint_orientation() -> RetailQuaternion { + let (sin, cos) = trigonometry::sin_cos(HALF_PI); + retail_quaternion_from_basis(Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]], + }) +} + +fn wheel_joint_frames(orientation: RetailQuaternion, z_offset: f32) -> RetailJointFramesRaw { + pack_frames( + RetailDriveFrame { + orientation, + translation: Vector3::ZERO, + }, + RetailDriveFrame { + orientation, + translation: Vector3::new(0.0, 0.0, z_offset), + }, + orientation, + ) +} + +fn pack_frames( + body_a: RetailDriveFrame, + body_b: RetailDriveFrame, + linear_orientation_b: RetailQuaternion, +) -> RetailJointFramesRaw { + let mut words = [0; 20]; + write_quaternion(&mut words, 0, body_a.orientation); + write_vector(&mut words, 4, body_a.translation); + write_quaternion(&mut words, 8, body_b.orientation); + write_vector(&mut words, 12, body_b.translation); + write_quaternion(&mut words, 16, linear_orientation_b); + RetailJointFramesRaw { words } +} + +fn write_quaternion(words: &mut [u32; 20], offset: usize, value: RetailQuaternion) { + words[offset..offset + 4].copy_from_slice(&[ + value.x.to_bits(), + value.y.to_bits(), + value.z.to_bits(), + value.w.to_bits(), + ]); +} + +fn write_vector(words: &mut [u32; 20], offset: usize, value: Vector3) { + words[offset..offset + 3].copy_from_slice(&[ + value.x.to_bits(), + value.y.to_bits(), + value.z.to_bits(), + ]); +} + +const fn joint( + definition_body_0: BodyId, + definition_body_1: BodyId, + parameters: RetailJointParametersRaw, + frames: RetailJointFramesRaw, +) -> RetailJointRecord { + RetailJointRecord { + definition_body_0, + definition_body_1, + parameters, + frames, + } +} + +#[cfg(test)] +#[path = "tests/joint_records.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/joint_solver.rs b/crates/skate-core/src/physics/joint_solver.rs new file mode 100644 index 00000000..e3bc6805 --- /dev/null +++ b/crates/skate-core/src/physics/joint_solver.rs @@ -0,0 +1,82 @@ +//! Semantic port of Skate 3 TU3's six-degree joint iteration. +//! +//! Skate 3 builds one 384-byte `JointJacobian` for each of the six skateboard +//! assembly joints. `rw::physics::Simulation::Solve` visits contacts, joints, +//! and drives in that order on each configured outer iteration. The joint loop +//! starts near `0x82AE2BC8`; the loop near `0x82AE2E78` is the drive family. +//! +//! Generated-code validation is withdrawn. All arithmetic and packed-lane +//! semantics require independent review against TU3 IDA before parity claims. + +use crate::physics::rigid_body::RetailReactionCorrections; + +pub mod tu3 { + pub const SIMULATION_SOLVE: u32 = 0x82AE_27D0; + pub const JOINT_SOLVE_LOOP: u32 = 0x82AE_2BC8; + pub const DRIVE_SOLVE_LOOP: u32 = 0x82AE_2E78; + pub const JOINT_BATCH_BUILD: u32 = 0x82AE_39D0; + pub const JOINT_JACOBIAN_BUILD: u32 = 0x82AE_3BC8; + pub const JACOBIAN_BYTES: usize = 384; + pub const REACTION_BYTES: usize = 64; +} + +const JACOBIAN_VECTOR_COUNT: usize = tu3::JACOBIAN_BYTES / 16; +const REACTION_VECTOR_COUNT: usize = tu3::REACTION_BYTES / 16; + +/// Raw guest word order of TU3's 384-byte `JointJacobian`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(C)] +pub struct RetailJointJacobian { + pub words: [u32; JACOBIAN_VECTOR_COUNT * 4], +} + +/// Four raw vectors indexed by a rigid body's solver id. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(C)] +pub struct RetailJointReactionBlock { + pub words: [u32; REACTION_VECTOR_COUNT * 4], +} + +impl RetailJointReactionBlock { + /// Packs the two reaction vectors consumed by the joint-family branch. + /// + /// TU3 leaves reaction vectors one and three to the contact family's + /// position/orientation correction. + pub fn from_reactions(reactions: RetailReactionCorrections) -> Self { + let mut words = [0; REACTION_VECTOR_COUNT * 4]; + words[0] = reactions.linear_displacement.x.to_bits(); + words[1] = reactions.linear_displacement.y.to_bits(); + words[2] = reactions.linear_displacement.z.to_bits(); + words[8] = reactions.angular_displacement.x.to_bits(); + words[9] = reactions.angular_displacement.y.to_bits(); + words[10] = reactions.angular_displacement.z.to_bits(); + Self { words } + } + + /// Writes back only the two joint-owned velocity correction vectors. + pub fn write_velocity_reactions(self, reactions: &mut RetailReactionCorrections) { + reactions.linear_displacement.x = f32::from_bits(self.words[0]); + reactions.linear_displacement.y = f32::from_bits(self.words[1]); + reactions.linear_displacement.z = f32::from_bits(self.words[2]); + reactions.angular_displacement.x = f32::from_bits(self.words[8]); + reactions.angular_displacement.y = f32::from_bits(self.words[9]); + reactions.angular_displacement.z = f32::from_bits(self.words[10]); + } +} + +/// One joint pass using TU3's fused vector operations and projector. +pub fn solve_joint_iteration( + jacobian: &mut RetailJointJacobian, + reaction_a: &mut RetailJointReactionBlock, + reaction_b: &mut RetailJointReactionBlock, +) { + super::solver::solve_joint_record( + &mut jacobian.words, + &mut reaction_a.words, + &mut reaction_b.words, + ); +} + +#[cfg(test)] +#[path = "tests/joint_solver.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/manual.rs b/crates/skate-core/src/physics/manual.rs new file mode 100644 index 00000000..551a24da --- /dev/null +++ b/crates/skate-core/src/physics/manual.rs @@ -0,0 +1,7 @@ +//! Source-grounded manual-balance controller units (TU3 82C047F0). +//! Complete gameplay integration and numerical dependencies are tracked in +//! docs/agents/overnight/gameplay.md. +pub mod angle; +pub mod controller; +pub mod settings; +pub mod state; diff --git a/crates/skate-core/src/physics/manual/angle.rs b/crates/skate-core/src/physics/manual/angle.rs new file mode 100644 index 00000000..dcc84ac4 --- /dev/null +++ b/crates/skate-core/src/physics/manual/angle.rs @@ -0,0 +1,30 @@ +//! Finite scalar Angle::Normalize 8258DB98, from direct TU3 disassembly. +//! Kept local pending coordinator's shared angle-operation audit. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AngleError { + /// Native fctiwz result for nonfinite/overflow input is not reconstructed. + IntegerConversionUnavailable, +} + +pub fn normalize(angle: f32) -> Result { + let pi = f32::from_bits(0x4049_0fdb); + if angle >= -pi && angle < pi { + return Ok(angle); + } + let turns = angle * f32::from_bits(0x3e22_f983); + if !turns.is_finite() || turns < i32::MIN as f32 || turns as f64 > i32::MAX as f64 { + return Err(AngleError::IntegerConversionUnavailable); + } + let whole_turns = turns as i32; + let tau = f32::from_bits(0x40c9_0fdb); + // fnmsubs performs fused -(count*tau - angle), then binary32 rounding. + let reduced = -((whole_turns as f32).mul_add(tau, -angle)); + Ok(if reduced >= pi { + reduced - tau + } else if reduced < -pi { + reduced + tau + } else { + reduced + }) +} diff --git a/crates/skate-core/src/physics/manual/controller.rs b/crates/skate-core/src/physics/manual/controller.rs new file mode 100644 index 00000000..04b96d53 --- /dev/null +++ b/crates/skate-core/src/physics/manual/controller.rs @@ -0,0 +1,213 @@ +//! CalcManualEffect 82C047F0 control/scalar response and output construction. +//! The native angle-between-vectors measurement remains a required dependency. +use super::{ + angle, + settings::{ManualMode, ManualSettings}, + state::ManualState, +}; + +#[derive(Clone, Copy, Debug)] +pub struct ManualInput { + /// ProcessedPhysIn+2720 and +2620. + pub balance: f32, + pub flipped_controls: f32, + /// +2664, +2616 and +2768. Noise time is independent of manual elapsed. + pub procedural_noise_time: f32, + pub absolute_speed: f32, + pub animation_noise: f32, + /// +2604; this controller does not replace it with a fixed 1/60. + pub timestep: f32, + /// The explicit CalcManualEffect argument; Ground callers pass false. + pub powersliding: bool, + /// Processed2468 bit29 selects BrakeTiltAngle and correction-force gates. + pub braking: bool, + /// Processed2472 bits27 and26, respectively. Physical axle identities are + /// not assumed here; the signed balance gates are preserved directly. + pub positive_balance_contact: bool, + pub negative_balance_contact: bool, + /// Processed2468 bit20 reverses the point-selection balance sign. + pub reversed_point_selection: bool, + /// Reference frame from board+292, matrix+752: first and third columns. + pub reference_x: [f32; 4], + pub reference_z: [f32; 4], + /// Deck part6 transform's z-axis from 82585CB0, not an animation pose. + pub deck_z: [f32; 4], + /// Processed transform third column+224 and angular velocity+416. + pub velocity_frame_z: [f32; 4], + pub angular_velocity_world: [f32; 4], + /// Native body caches. Producers/original names remain unresolved; these + /// layout identities stay at the input boundary instead of guessed levers. + pub correction_point_7888: [f32; 4], + pub correction_point_7952: [f32; 4], +} + +/// Exact 8296EC98 dependency: degeneracy gates, normalization, acos and signed +/// cross-product selection. There is deliberately no host atan2/acos fallback. +pub trait ManualAngleMeasurement { + type Error; + fn angle_between( + &mut self, + deck_z: [f32; 4], + reference_z: [f32; 4], + reference_x: [f32; 4], + ) -> Result; +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ManualError { + Angle(angle::AngleError), + Measurement(E), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManualEffect { + /// Native result+0, applied with ApplyAngularDisplacementToDeck. + pub angular_displacement: [f32; 4], + /// Result+16/+32 are explicitly zeroed by this function. + pub force_world: [f32; 4], + pub force_point_body: [f32; 4], + /// Result+48/+64, plus result+80/+81 status bytes. + pub corrective_force_world: [f32; 4], + pub corrective_point_body: [f32; 4], + pub correction_active: bool, + pub opposing_motion_without_correction: bool, +} + +/// An unavailable measurement/conversion aborts the tick. Already executed +/// scalar-state writes remain visible, but no effect is returned for publication. +pub fn calculate( + state: &mut ManualState, + settings: &ManualSettings, + mode: ManualMode, + input: &ManualInput, + geometry: &mut G, +) -> Result> { + let mut effect = ManualEffect { + angular_displacement: [0.0; 4], + force_world: [0.0; 4], + force_point_body: [0.0; 4], + corrective_force_world: [0.0; 4], + corrective_point_body: [0.0; 4], + correction_active: false, + opposing_motion_without_correction: false, + }; + let mut output_scale = 1.0; + if input.balance == 0.0 { + state.reset(); + } else { + let norm = |value| angle::normalize(value).map_err(ManualError::Angle); + let gains = if input.powersliding { + settings.powerslide + } else { + settings.manual + }; + let scaled_balance = input.balance * input.flipped_controls; + let sign = if scaled_balance > 0.0 { 1.0 } else { -1.0 }; + let signed_target = scaled_balance.abs().mul_add(0.75, 0.25) * sign; + let started = if state.elapsed == 0.0 { 0.0 } else { 1.0 }; + let degrees_to_radians = f32::from_bits(0x3c8e_fa35); + let base_angle = + norm((settings.maximum_tilt_degrees * signed_target) * degrees_to_radians)?; + let target = if input.braking { + norm((settings.brake_tilt_degrees * signed_target) * degrees_to_radians)? + } else { + let phase = (input.procedural_noise_time * settings.procedural_noise_frequency) + * f32::from_bits(0x40c9_0fdb); + let noise = crate::trigonometry::sin(phase) * settings.procedural_noise_scale; + let speed_scale = settings.noise_vs_speed.evaluate(input.absolute_speed); + let animated = input.animation_noise * settings.animation_noise_scale; + let disturbance = animated.mul_add(input.flipped_controls, noise); + let noise_angle = norm(speed_scale * disturbance)?; + norm(noise_angle + base_angle)? + }; + if state.elapsed == 0.0 { + state.angular_correction = norm(settings.start_torque_scale * target)?; + } + state.target_angle = target; + let measured = geometry + .angle_between(input.deck_z, input.reference_z, input.reference_x) + .map_err(ManualError::Measurement)?; + let measured = norm(measured)?; + let angle_change = measured - state.measured_angle; + state.measured_angle = measured; + let error = clamp_symmetric(norm(target - measured)?, settings.maximum_angle_error); + let weighted_error = error * f32::from_bits(0x3d4c_cccd); + state.filtered_angle_error = state + .filtered_angle_error + .mul_add(f32::from_bits(0x3f73_3333), weighted_error); + let derivative = clamp_symmetric( + (angle_change * started) * gains.derivative, + settings.derivative_limit, + ); + let wheel_scale = if input.positive_balance_contact != input.negative_balance_contact { + 0.5 + } else { + 1.0 + }; + let wrong_contact = (!input.positive_balance_contact && input.balance > 0.0) + || (!input.negative_balance_contact && input.balance < 0.0); + if wrong_contact { + state.angular_correction = + (settings.torque_bleed_without_contact * state.angular_correction) * wheel_scale; + output_scale = settings.torque_scale_without_contact * wheel_scale; + } else { + state.angular_correction = ((state.angular_correction + derivative) + + state.filtered_angle_error * gains.integral) + + error * gains.proportional; + } + apply_correction(&mut effect, input, mode, target, measured); + state.elapsed = input.timestep + state.elapsed; + } + let correction = state.angular_correction * output_scale; + effect.angular_displacement = input.reference_x.map(|component| (-component) * correction); + Ok(effect) +} + +fn apply_correction( + effect: &mut ManualEffect, + input: &ManualInput, + mode: ManualMode, + target: f32, + measured: f32, +) { + let frame = input.velocity_frame_z; + let omega = input.angular_velocity_world; + let local_z = frame[2].mul_add(omega[2], frame[1].mul_add(omega[1], frame[0] * omega[0])); + if !input.braking || !(0.0 > local_z * input.balance) { + return; + } + if !mode.corrective_force_enabled { + effect.opposing_motion_without_correction = true; + return; + } + if !(local_z.abs() > mode.correction_angular_speed_threshold) { + return; + } + let radians_to_degrees = f32::from_bits(0x4265_2ee1); + let error_degrees = (target * radians_to_degrees) - (measured * radians_to_degrees); + if !(error_degrees.abs() < 1.0) { + return; + } + let alternate = if input.reversed_point_selection { + input.balance < 0.0 + } else { + input.balance > 0.0 + }; + effect.corrective_point_body = if alternate { + input.correction_point_7888 + } else { + input.correction_point_7952 + }; + effect.corrective_force_world = omega.map(|component| (component * -1.0) * 400.0); + effect.correction_active = true; +} + +fn clamp_symmetric(value: f32, bound: f32) -> f32 { + let lower = -bound; + let lower_clamped = if lower - value >= 0.0 { lower } else { value }; + if bound - lower_clamped >= 0.0 { + lower_clamped + } else { + bound + } +} diff --git a/crates/skate-core/src/physics/manual/settings.rs b/crates/skate-core/src/physics/manual/settings.rs new file mode 100644 index 00000000..a733b7b2 --- /dev/null +++ b/crates/skate-core/src/physics/manual/settings.rs @@ -0,0 +1,42 @@ +//! physics_manual stock layout at global830CFDA4+200, wrapper+4. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct ManualGains { + pub proportional: f32, + pub integral: f32, + pub derivative: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct ManualSettings { + /// NoiseVsSpeed has a 16-byte header; X/Y arrays start at +96/+128. + pub noise_vs_speed: PointGraph<8>, + /// +160 TorqueScalarWithNoContact; applies to emitted displacement, not state. + pub torque_scale_without_contact: f32, + /// +164 TorqueBleedOffWithNoContact, +172 StartTorqueScalar. + pub torque_bleed_without_contact: f32, + pub start_torque_scale: f32, + /// +184 ProceduralNoiseScalar, +188 ProceduralNoiseFreq. + pub procedural_noise_scale: f32, + pub procedural_noise_frequency: f32, + /// +200/204/208 PowerScalar_P/I/D; +232/236/240 ManualScalar_P/I/D. + pub powerslide: ManualGains, + pub manual: ManualGains, + /// +216 MaxTiltAngle (degrees); +224 MaxAngleDiff (native radians). + pub maximum_tilt_degrees: f32, + pub maximum_angle_error: f32, + /// +244 D_Scalar_Limit, +248 BrakeTiltAngle (degrees), +252 AnimationNoiseScalar. + pub derivative_limit: f32, + pub brake_tilt_degrees: f32, + pub animation_noise_scale: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct ManualMode { + /// physics_mode+32 Hash_84CB2F459F3FC811, compared to abs(local angular Z). + /// Original field name remains unresolved; this name describes its use. + pub correction_angular_speed_threshold: f32, + /// physics_mode+36 Hash_F8CBC0F5FEF2240E, read as a byte. + pub corrective_force_enabled: bool, +} diff --git a/crates/skate-core/src/physics/manual/state.rs b/crates/skate-core/src/physics/manual/state.rs new file mode 100644 index 00000000..c9ab3ebc --- /dev/null +++ b/crates/skate-core/src/physics/manual/state.rs @@ -0,0 +1,60 @@ +//! The five board-owned persistent fields used by CalcManualEffect. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ManualState { + /// Board+264: 0.95/0.05 filtered clamped angle error. + pub filtered_angle_error: f32, + /// Board+268: normalized requested angle for this tick. + pub target_angle: f32, + /// Board+272: normalized measured deck angle, retained for the next derivative. + pub measured_angle: f32, + /// Board+276: accumulated controller output, scaled to angular displacement. + pub angular_correction: f32, + /// Board+280: zero also identifies the first active controller tick. + pub elapsed: f32, +} + +impl ManualState { + /// 82C04EEC..4EFC when ProcessedPhysIn+2720 equals either signed zero. + /// This resets only these five controller fields, not heading state+284. + pub fn reset(&mut self) { + self.filtered_angle_error = 0.0; + self.target_angle = 0.0; + self.measured_angle = 0.0; + self.elapsed = 0.0; + self.angular_correction = 0.0; + } + + /// Manual-state slice of Ground Enter 82D376F4..7750. The caller still + /// owns all other entry work, including the returned velocity operation. + /// `previous_category` is ProcessedPhysIn+2516; 100 is the ground category. + pub fn enter_ground( + &mut self, + previous_category: u32, + powerslide_exit_scale: f32, + ) -> ManualEntryContinuation { + if previous_category == 100 { + // +268 target, +272 measurement and +280 timer are preserved. + self.angular_correction *= powerslide_exit_scale; + self.filtered_angle_error *= powerslide_exit_scale; + ManualEntryContinuation::Continue + } else { + // Ground's inline reset82D3770C..771C writes +276 before +280. + self.filtered_angle_error = 0.0; + self.target_angle = 0.0; + self.measured_angle = 0.0; + self.angular_correction = 0.0; + self.elapsed = 0.0; + ManualEntryContinuation::RemoveVelocityIntoGround + } + } +} + +/// Explicit dependency returned by the entry-state slice, never a no-op +/// replacement for Ground's call to RemoveVelIntoGroundForManual 82D37960. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use] +pub enum ManualEntryContinuation { + Continue, + RemoveVelocityIntoGround, +} diff --git a/crates/skate-core/src/physics/mass.rs b/crates/skate-core/src/physics/mass.rs new file mode 100644 index 00000000..5ae5c78a --- /dev/null +++ b/crates/skate-core/src/physics/mass.rs @@ -0,0 +1,354 @@ +//! Recovered board-part mass properties, separate from integration. +use super::{ + native_arithmetic, + rigid_body::{RetailBodyMassProperties, RetailInertiaDynamics, RetailLocalMassFrame}, +}; +use crate::math::{Basis3, Vector3}; + +#[path = "construction/primitive_mass.rs"] +mod primitive_mass; +pub use primitive_mass::{MassShape, PrimitiveMass, primitive_mass}; +#[path = "construction/board_parts.rs"] +mod board_parts; +pub use board_parts::{ + ForwardMassProperties, PartMassInput, TruckMassSettings, WheelMassSettings, + forward_mass_properties, truck_mass_input, wheel_mass_input, +}; + +#[path = "construction/mass_moments.rs"] +mod mass_moments; +#[path = "construction/principal_axes.rs"] +mod principal_axes; +pub use mass_moments::{AggregateMassProperties, MassMoments}; +#[path = "construction/deck_geometry.rs"] +mod deck_geometry; +pub use deck_geometry::{DeckChild, DeckGeometry, DeckGeometrySettings, DeckShape}; + +/// TU3 ComputeMassProperties82AE7770 for one centered primitive. Skeleton +/// parts use this same finalizer as board parts, including volume fallback +/// when the requested mass is strictly below MIN_POSITIVE; NaN is preserved. +pub fn primitive_mass_properties( + input: PartMassInput, + maximum_angular_velocity: f32, + angular_drag: f32, +) -> Option { + let primitive = primitive_mass(input.shape)?; + let mass = if input.requested_mass < f32::MIN_POSITIVE { + primitive.volume + } else { + input.requested_mass + }; + let inertia = primitive.moments_per_unit_mass; + Some(finalize_principal_mass( + RetailLocalMassFrame::IDENTITY, + mass, + Vector3::new(inertia.x * mass, inertia.y * mass, inertia.z * mass), + maximum_angular_velocity, + angular_drag, + )) +} + +/// TU3 ComputeMassProperties82AE7770 after compound-volume accumulation. +/// The caller may apply a source-owned part-frame override after this result. +pub fn aggregate_mass_properties( + mut moments: MassMoments, + requested_mass: f32, + maximum_angular_velocity: f32, + angular_drag: f32, +) -> RetailBodyMassProperties { + let properties = moments.principal_properties(); + // 82AE79EC BO4/BI24 skips fallback when CR6.LT is clear, including NaN. + let mass = if requested_mass < f32::MIN_POSITIVE { + properties.volume + } else { + requested_mass + }; + let inertia = properties.moments_per_unit_mass; + finalize_principal_mass( + inverse_mass_frame(properties.local_mass_frame, inertia), + mass, + Vector3::new(inertia.x * mass, inertia.y * mass, inertia.z * mass), + maximum_angular_velocity, + angular_drag, + ) +} + +/// 82AE78BC..79DC: omit negligible local-frame corrections, otherwise store +/// inverseBodyLTM. The inverse translation starts with Z, then adds Y and X +/// through fused operations, as in the native matrix transpose/inverse path. +fn inverse_mass_frame(forward: RetailLocalMassFrame, inertia: Vector3) -> RetailLocalMassFrame { + let center = forward.translation; + let center_squared = native_arithmetic::dot3( + [center.x, center.y, center.z, 0.0], + [center.x, center.y, center.z, 0.0], + ); + let minimum_offset_squared = + ((inertia.y + inertia.z) + inertia.x) * f32::from_bits(0x3586_37BE); + let translated = center_squared >= minimum_offset_squared; + let rotated = forward + .basis + .columns + .iter() + .enumerate() + .any(|(column, axis)| { + axis.iter().enumerate().any(|(row, value)| { + let identity = if column == row { 1.0 } else { 0.0 }; + (*value - identity).abs() > f32::from_bits(0x3A83_126F) + }) + }); + if !translated && !rotated { + return RetailLocalMassFrame::IDENTITY; + } + let columns = core::array::from_fn::<_, 3, _>(|column| { + core::array::from_fn(|row| forward.basis.columns[row][column]) + }); + let negative_center = [0.0 - center.x, 0.0 - center.y, 0.0 - center.z]; + let translation = core::array::from_fn::<_, 3, _>(|row| { + negative_center[0].mul_add( + columns[0][row], + negative_center[1].mul_add(columns[1][row], negative_center[2] * columns[2][row]), + ) + }); + RetailLocalMassFrame { + basis: Basis3 { columns }, + translation: Vector3::new(translation[0], translation[1], translation[2]), + } +} + +#[cfg(test)] +#[path = "tests/deck_geometry.rs"] +mod deck_geometry_tests; +#[cfg(test)] +#[path = "tests/mass_moments.rs"] +mod mass_moment_tests; + +pub const RETAIL_UNBOUNDED_VELOCITY: f32 = f32::from_bits(0x7F7F_FFFF); +pub const RETAIL_WHEEL_MAXIMUM_ANGULAR_VELOCITY: f32 = f32::from_bits(0x476A_5FFF); + +/// Stock wheel properties produced by TU3 `0x82C0AA78 -> 0x82AE7770`. +pub fn retail_wheel_mass_properties() -> RetailBodyMassProperties { + wheel_mass_properties(WheelMassSettings::STOCK) +} + +pub fn wheel_mass_properties(settings: WheelMassSettings) -> RetailBodyMassProperties { + let forward = forward_mass_properties(wheel_mass_input(settings)) + .expect("the stock wheel is a supported sphere"); + finalize_principal_mass( + RetailLocalMassFrame::IDENTITY, + forward.mass, + forward.principal_moments, + RETAIL_WHEEL_MAXIMUM_ANGULAR_VELOCITY, + 0.0, + ) +} + +/// Stock truck properties produced by TU3 `0x82C0A6F8 -> 0x82AE7770`. +pub fn retail_truck_mass_properties() -> RetailBodyMassProperties { + truck_mass_properties(TruckMassSettings::STOCK) +} + +pub fn truck_mass_properties(settings: TruckMassSettings) -> RetailBodyMassProperties { + let forward = forward_mass_properties(truck_mass_input(settings)) + .expect("the stock truck is a supported capsule"); + finalize_principal_mass( + RetailLocalMassFrame::IDENTITY, + forward.mass, + forward.principal_moments, + RETAIL_UNBOUNDED_VELOCITY, + 0.0, + ) +} + +/// TU3 deck construction82C09290 -> mass accumulation82AE7058 -> +/// ComputeMassProperties82AE7770. Every authored child contributes to mass. +pub fn deck_mass_properties( + geometry: &DeckGeometry, + mass: f32, + angular_drag: f32, +) -> RetailBodyMassProperties { + let mut properties = aggregate_mass_properties( + geometry.mass_moments(), + mass, + RETAIL_UNBOUNDED_VELOCITY, + angular_drag, + ); + // 82C0A264..278 overrides the deck inverse-body local transform after + // calculating its principal inertia. Keep that inertia, reset the frame. + properties.local_mass_frame = RetailLocalMassFrame::IDENTITY; + properties +} + +/// Stock convenience constructor; the game supplies its loaded XML settings. +pub fn retail_deck_mass_properties() -> RetailBodyMassProperties { + let geometry = DeckGeometry::new(DeckGeometrySettings::STOCK); + // physicsdeck.DeckMass * physics_world.SkateboardMassFactor, + // DeckAngularDrag * the refined fixed-step Simulation::frequency. + deck_mass_properties( + &geometry, + 6.0, + f32::from_bits(0x3EE6_6666) * f32::from_bits(0x426F_FFFF), + ) +} + +/// Body order used by `SkateboardBody`: wheels 0..3, trucks 4..5, deck 6. +pub fn default_skateboard_mass_properties() -> [RetailBodyMassProperties; 7] { + [ + retail_wheel_mass_properties(), + retail_wheel_mass_properties(), + retail_wheel_mass_properties(), + retail_wheel_mass_properties(), + retail_truck_mass_properties(), + retail_truck_mass_properties(), + retail_deck_mass_properties(), + ] +} + +/// Final finite path in TU3 `ComputeMassProperties` (`0x82AE7770`). The Xenon +/// reciprocal estimate is followed by two fused Newton refinements for each +/// principal moment. Inverse mass and the spherical energy term use scalar +/// division in the recovered function. +fn finalize_principal_mass( + local_mass_frame: RetailLocalMassFrame, + mass: f32, + principal_moments: Vector3, + maximum_angular_velocity: f32, + angular_drag: f32, +) -> RetailBodyMassProperties { + let inverse_tensor = Vector3::new( + refined_reciprocal(principal_moments.x), + refined_reciprocal(principal_moments.y), + refined_reciprocal(principal_moments.z), + ); + let smallest_inverse = native_arithmetic::vector_min( + native_arithmetic::vector_min(inverse_tensor.x, inverse_tensor.y), + inverse_tensor.z, + ); + RetailBodyMassProperties { + local_mass_frame, + dynamics: RetailInertiaDynamics { + inverse_tensor, + inverse_mass: 1.0 / mass, + spherical: 1.0 / smallest_inverse, + maximum_linear_velocity: RETAIL_UNBOUNDED_VELOCITY, + maximum_angular_velocity, + linear_drag: 0.0, + angular_drag, + }, + } +} + +fn refined_reciprocal(value: f32) -> f32 { + let mut estimate = native_arithmetic::reciprocal_estimate(value); + for _ in 0..2 { + let residual = (-estimate).mul_add(value, 1.0); + estimate = estimate.mul_add(residual, estimate); + } + estimate +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_mass_finalizers_preserve_nan_and_use_volume_only_below_threshold() { + // A unit cube has volume one. Both original shape paths converge at + // 82AE79E8/79EC: fallback only when CR6.LT is set by requested mass. + let shape = MassShape::RoundedBox { + half_extents: Vector3::new(0.5, 0.5, 0.5), + radius: 0.0, + }; + let moments = MassMoments::from_primitive(primitive_mass(shape).unwrap()); + for (requested_mass, expected_inverse) in [(f32::NAN, f32::NAN), (0.0, 1.0), (2.0, 0.5)] { + let primitive = primitive_mass_properties( + PartMassInput { + shape, + requested_mass, + }, + RETAIL_UNBOUNDED_VELOCITY, + 0.0, + ) + .unwrap(); + let aggregate = + aggregate_mass_properties(moments, requested_mass, RETAIL_UNBOUNDED_VELOCITY, 0.0); + for body in [primitive, aggregate] { + if expected_inverse.is_nan() { + assert!(body.dynamics.inverse_mass.is_nan()); + } else { + assert_eq!(body.dynamics.inverse_mass, expected_inverse); + } + } + } + } + + #[test] + fn stock_simple_parts_reach_recovered_finalizer_words() { + let wheel = retail_wheel_mass_properties().dynamics; + assert_eq!( + [ + wheel.inverse_tensor.x.to_bits(), + wheel.inverse_tensor.y.to_bits(), + wheel.inverse_tensor.z.to_bits(), + wheel.inverse_mass.to_bits(), + wheel.spherical.to_bits(), + ], + [ + 0x45C3_E491, + 0x45C3_E491, + 0x45C3_E491, + 0x401A_3785, + 0x3927_466F, + ] + ); + + let truck = retail_truck_mass_properties().dynamics; + assert_eq!( + [ + truck.inverse_tensor.x.to_bits(), + truck.inverse_tensor.y.to_bits(), + truck.inverse_tensor.z.to_bits(), + truck.inverse_mass.to_bits(), + truck.spherical.to_bits(), + ], + [ + 0x438E_E68E, + 0x438E_E68E, + 0x4690_4D8B, + 0x3F03_4835, + 0x3B65_4E66, + ] + ); + } + + #[test] + fn deck_frame_override_retains_geometry_driven_inertia() { + let stock = DeckGeometry::new(DeckGeometrySettings::STOCK); + let deck = deck_mass_properties(&stock, 6.0, 27.0); + assert_eq!(deck.local_mass_frame, RetailLocalMassFrame::IDENTITY); + assert_eq!(deck.dynamics.inverse_mass, 1.0 / 6.0); + let doubled_mass = deck_mass_properties(&stock, 12.0, 27.0); + for (one, two) in [ + ( + deck.dynamics.inverse_tensor.x, + doubled_mass.dynamics.inverse_tensor.x, + ), + ( + deck.dynamics.inverse_tensor.y, + doubled_mass.dynamics.inverse_tensor.y, + ), + ( + deck.dynamics.inverse_tensor.z, + doubled_mass.dynamics.inverse_tensor.z, + ), + ] { + assert!((one - 2.0 * two).abs() < 1e-5); + } + let mut wider = DeckGeometrySettings::STOCK; + wider.width *= 1.5; + let changed = deck_mass_properties(&DeckGeometry::new(wider), 6.0, 27.0); + assert_ne!( + deck.dynamics.inverse_tensor, + changed.dynamics.inverse_tensor + ); + } +} diff --git a/crates/skate-core/src/physics/mod.rs b/crates/skate-core/src/physics/mod.rs new file mode 100644 index 00000000..13deab92 --- /dev/null +++ b/crates/skate-core/src/physics/mod.rs @@ -0,0 +1,70 @@ +//! Rigid-body kernels and connected-board simulation, separate from collision +//! queries and player behavior. +pub mod assembly; +pub mod board; +pub mod board_animation; +pub mod board_ground; +pub mod board_dynamic_normal; +pub mod board_input_output; +pub mod board_motion_output; +pub mod board_pose; +pub mod board_runtime; +pub mod board_step; +pub mod board_toolkit; +pub mod board_world; +pub mod collision; +pub mod centre_of_mass_filter; +pub mod constraint_batch; +mod constraint_frames; +pub mod contact; +pub mod contact_feedback; +mod contact_layout; +pub mod contact_solver; +pub mod drive_frames; +pub mod drive_parameters; +pub mod drive_preparation; +pub mod drive_solver; +pub mod force_queue; +pub mod foot_physical_output; +pub mod hook_drive; +pub mod joint_builder; +pub mod joint_records; +pub mod joint_solver; +pub mod manual; +pub mod mass; +pub(crate) mod native_arithmetic; +pub mod point_force; +pub(crate) mod reciprocal_sqrt; +pub mod rigid_body; +pub mod solver; +pub mod startup; +mod triangle_closest; +pub mod triangle_query; +mod triangle_sweep; +mod triangle_sweep_roots; +mod triangle_sweep_walk; +pub mod truck_frames; +pub mod world_contact; + +#[cfg(test)] +#[path = "tests/world_dispatch.rs"] +mod world_dispatch_tests; + +pub mod board_probes; +pub mod deck_angular_correction; +pub mod filtered_state; +pub mod ground_hang_geometry; +#[cfg(test)] +#[path = "tests/primitive_query.rs"] +mod primitive_query_tests; +pub mod skeleton_animation_record; +pub mod skeleton_air_frames; +pub mod skeleton_board_frames; +pub mod skeleton_output; +pub mod skeleton_board_offset; +pub mod skeleton_body; +pub mod skeleton_landing; +pub mod skeleton_motion; +pub mod skeleton_root; +pub mod skeleton_general; +pub mod skateboard_controller; diff --git a/crates/skate-core/src/physics/native_arithmetic.rs b/crates/skate-core/src/physics/native_arithmetic.rs new file mode 100644 index 00000000..da9f3288 --- /dev/null +++ b/crates/skate-core/src/physics/native_arithmetic.rs @@ -0,0 +1,33 @@ +//! Independent host math for the operations used by recovered TU3 callers. +//! +//! Uses Rust floating-point operations without external estimate tables or +//! translator-specific special cases. Callers retain their recovered refinement +//! steps. This is working PC math, not bit-exact Xenon instruction emulation. + +pub(crate) fn reciprocal_estimate(value: f32) -> f32 { + value.recip() +} + +pub(crate) fn reciprocal_square_root_estimate(value: f32) -> f32 { + value.sqrt().recip() +} + +pub(crate) fn dot3(left: [f32; 4], right: [f32; 4]) -> f32 { + left[0] * right[0] + left[1] * right[1] + left[2] * right[2] +} + +pub(crate) fn dot4(left: [f32; 4], right: [f32; 4]) -> f32 { + dot3(left, right) + left[3] * right[3] +} + +pub(crate) fn vector_min(left: f32, right: f32) -> f32 { + left.min(right) +} + +pub(crate) fn vector_max(left: f32, right: f32) -> f32 { + left.max(right) +} + +#[cfg(test)] +#[path = "tests/native_arithmetic.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/point_force.rs b/crates/skate-core/src/physics/point_force.rs new file mode 100644 index 00000000..b52a7c33 --- /dev/null +++ b/crates/skate-core/src/physics/point_force.rs @@ -0,0 +1,51 @@ +//! TU3 queued point-force application, 0x82C037DC..0x82C03908. +use crate::math::{Basis3, Vector3}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailForceAccumulator { + pub force_acceleration: Vector3, + pub torque_acceleration: Vector3, + pub cool_down: u32, +} + +/// Apply one queued force using the native multiply / fused-add order. +/// `application_point_body` already includes the collection's force-point Y +/// offset (added by the queue consumer at 0x82C037EC). +pub fn accumulate_point_force( + mut accumulator: RetailForceAccumulator, + force_world: Vector3, + application_point_body: Vector3, + deck_basis: Basis3, + inverse_mass: f32, + world_inverse_inertia: Basis3, +) -> RetailForceAccumulator { + // 0x82C03850: vmulfp; 0x82C03878..0x82C03894: scalar adds. + accumulator.force_acceleration.x += force_world.x * inverse_mass; + accumulator.force_acceleration.y += force_world.y * inverse_mass; + accumulator.force_acceleration.z += force_world.z * inverse_mass; + let arm = multiply_basis(deck_basis, application_point_body); + // 0x82C0387C / 0x82C038B0 / 0x82C038B4: multiply, fused negative + // multiply-add, permutation. Fusing the positive term instead differs. + let torque = Vector3::new( + (-arm.z).mul_add(force_world.y, arm.y * force_world.z), + (-arm.x).mul_add(force_world.z, arm.z * force_world.x), + (-arm.y).mul_add(force_world.x, arm.x * force_world.y), + ); + let angular = multiply_basis(world_inverse_inertia, torque); + accumulator.torque_acceleration.x += angular.x; + accumulator.torque_acceleration.y += angular.y; + accumulator.torque_acceleration.z += angular.z; + accumulator.cool_down = 0; + accumulator +} + +fn multiply_basis(basis: Basis3, v: Vector3) -> Vector3 { + // Native multiplies column 0, then fuses column 1, then column 2. + let lane = |i: usize| { + basis.columns[2][i].mul_add( + v.z, + basis.columns[1][i].mul_add(v.y, basis.columns[0][i] * v.x), + ) + }; + Vector3::new(lane(0), lane(1), lane(2)) +} diff --git a/crates/skate-core/src/physics/reciprocal_sqrt.rs b/crates/skate-core/src/physics/reciprocal_sqrt.rs new file mode 100644 index 00000000..0616380a --- /dev/null +++ b/crates/skate-core/src/physics/reciprocal_sqrt.rs @@ -0,0 +1,5 @@ +//! Compatibility entry point for recovered callers that use `vrsqrtefp`. + +pub(crate) fn estimate(value: f32) -> f32 { + super::native_arithmetic::reciprocal_square_root_estimate(value) +} diff --git a/crates/skate-core/src/physics/rigid_body.rs b/crates/skate-core/src/physics/rigid_body.rs new file mode 100644 index 00000000..0be86006 --- /dev/null +++ b/crates/skate-core/src/physics/rigid_body.rs @@ -0,0 +1,431 @@ +//! TU3 RenderWare rigid-body accumulation and integration primitives. +//! +//! These routines are ports of the arithmetic in the Skate 3 TU3 executable, +//! not a wrapper around Bevy or a generic rigid-body engine. The caller supplies +//! solver corrections; this module does not own collision or solver scheduling. + +pub use super::point_force::{RetailForceAccumulator, accumulate_point_force}; +use crate::math::{Basis3, Vector3}; +mod dynamic_update; +pub use dynamic_update::{DynamicUpdateResult, dynamic_update_packed}; + +pub mod tu3 { + /// Per-active-body integration routine called by + /// `rw::physics::Simulation::BatchIntegrator`. + pub const BATCH_INTEGRATOR_BODY: u32 = 0x82AE_6590; + /// `Sk8::Physics::Skateboard::ApplyQueuedSkateboardForces`. + pub const APPLY_QUEUED_SKATEBOARD_FORCES: u32 = 0x82C0_3718; +} + +/// Exact scalar fields read from TU3's `rw::physics::Simulation` by +/// `0x82AE6590`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailSimulationStep { + pub time_step: f32, + pub frequency: f32, + pub cool_down: u32, + pub minimum_energy: f32, + pub gravity_acceleration: Vector3, +} + +impl RetailSimulationStep { + pub fn fixed_60_hz(cool_down: u32, minimum_energy: f32, gravity_acceleration: Vector3) -> Self { + let time_step = f32::from_bits(0x3C88_8889); + Self { + time_step, + frequency: time_step.recip(), + cool_down, + minimum_energy, + gravity_acceleration, + } + } +} + +/// Exact scalar layout of `rw::physics::Inertia` after its inverse-tensor +/// vector. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailInertiaDynamics { + pub inverse_tensor: Vector3, + pub inverse_mass: f32, + pub spherical: f32, + pub maximum_linear_velocity: f32, + pub maximum_angular_velocity: f32, + pub linear_drag: f32, + pub angular_drag: f32, +} + +/// Inverse local principal-axis frame stored by TU3 `ComputeMassProperties` +/// (`0x82AE7770`) at `PartDefinition + 64` (`inverseBodyLTM`). +/// +/// The stock wheel and truck retain identity. Deck construction `0x82C09290` +/// also explicitly resets this frame to identity after computing aggregate +/// principal inertia; that override does not discard the calculated inertia. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailLocalMassFrame { + /// Column vectors in RenderWare `Ri`, `Up`, `At` order. + pub basis: Basis3, + pub translation: Vector3, +} + +impl RetailLocalMassFrame { + pub const IDENTITY: Self = Self { + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + translation: Vector3::ZERO, + }; +} + +/// Complete per-part mass data needed by the TU3 rigid-body integrator. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailBodyMassProperties { + pub local_mass_frame: RetailLocalMassFrame, + pub dynamics: RetailInertiaDynamics, +} + +/// The four 16-byte correction vectors indexed by `RigidBody::mId`. +/// +/// The first and third vectors participate in the velocity-producing frame +/// displacement. The second and fourth vectors correct position and +/// orientation only. This separation is directly visible in `0x82AE6590`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RetailReactionCorrections { + pub linear_displacement: Vector3, + pub position_displacement: Vector3, + pub angular_displacement: Vector3, + pub orientation_displacement: Vector3, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailBodyRates { + pub orientation: RetailQuaternion, + pub basis: Basis3, + pub world_inverse_inertia: Basis3, + pub position: Vector3, + pub linear_velocity: Vector3, + pub angular_velocity: Vector3, + /// Acceleration accumulator. `RigidBody::AddForce` has already multiplied + /// physical force by inverse mass before this field reaches the integrator. + pub force_acceleration: Vector3, + /// Angular-acceleration accumulator. Point-force torque has already passed + /// through the body's world inverse inertia. + pub torque_acceleration: Vector3, + pub kinetic_energy: f32, + pub cool_down: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailBodyRateStep { + pub state: RetailBodyRates, + /// Rotation increment consumed by the quaternion branch in the same retail + /// function. Keeping this explicit prevents a presentation system from + /// replacing it with a surface-normal snap. + pub orientation_displacement: Vector3, + pub linear_speed_squared: f32, + pub angular_speed_squared: f32, +} + +/// Skate-era RenderWare's six-value packed symmetric world inverse-inertia +/// tensor. +/// +/// The lane mapping is: +/// +/// - `mIfull = (Ixx, Ixy, Ixz)`; +/// - `mIsplt = (Izz, Iyy, Iyz)`. +/// +/// This retained lane mapping requires independent TU3 instruction review. +/// Earlier generated-code validation has been withdrawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailPackedWorldInverseInertia { + pub full: Vector3, + pub split: Vector3, +} + +/// RenderWare quaternion lane order is `(x, y, z, w)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RetailQuaternion { + pub x: f32, + pub y: f32, + pub z: f32, + pub w: f32, +} + +impl RetailQuaternion { + pub const IDENTITY: Self = Self { + x: 0.0, + y: 0.0, + z: 0.0, + w: 1.0, + }; +} + +/// Typed adapter to the complete TU3 `RigidBody::DynamicUpdate` (`0x82AE6590`). +/// The packed entry point additionally preserves guest metadata and clears its +/// mutable reaction record. This value-taking adapter returns the updated body. +/// +/// The order matters: +/// +/// 1. acceleration is integrated to a candidate velocity; +/// 2. candidate velocity is converted to a frame displacement; +/// 3. solver correction displacements are added; +/// 4. position is advanced; +/// 5. velocity is reconstructed from displacement with retail drag; +/// 6. linear and angular speed caps are applied; +/// 7. energy/cool-down is updated; +/// 8. force/torque accumulators are reset for the next step. +/// +/// `BoardStep` supplies all four correction vectors after the shared contact, +/// joint and drive solve. Player-state scheduling remains a separate owner. +pub fn integrate_body_rates( + mut body: RetailBodyRates, + inertia: RetailInertiaDynamics, + simulation: RetailSimulationStep, + reactions: RetailReactionCorrections, +) -> RetailBodyRateStep { + let mut words = [0_u32; 44]; + words[..4].copy_from_slice(&[ + body.orientation.x.to_bits(), + body.orientation.y.to_bits(), + body.orientation.z.to_bits(), + body.orientation.w.to_bits(), + ]); + for (offset, value) in [ + (4, body.position), + (8, body.linear_velocity), + (12, body.angular_velocity), + (36, body.force_acceleration), + (40, body.torque_acceleration), + ] { + words[offset..offset + 3].copy_from_slice(&[ + value.x.to_bits(), + value.y.to_bits(), + value.z.to_bits(), + ]); + } + words[31] = inertia.inverse_mass.to_bits(); + words[39] = body.kinetic_energy.to_bits(); + words[43] = body.cool_down; + let native_inertia = [ + inertia.inverse_tensor.x, + inertia.inverse_tensor.y, + inertia.inverse_tensor.z, + 0.0, + inertia.inverse_mass, + inertia.spherical, + inertia.maximum_linear_velocity, + inertia.maximum_angular_velocity, + inertia.linear_drag, + inertia.angular_drag, + ] + .map(f32::to_bits); + let mut correction_words = [0; 16]; + for (index, value) in [ + reactions.linear_displacement, + reactions.position_displacement, + reactions.angular_displacement, + reactions.orientation_displacement, + ] + .into_iter() + .enumerate() + { + correction_words[index * 4..index * 4 + 3].copy_from_slice(&[ + value.x.to_bits(), + value.y.to_bits(), + value.z.to_bits(), + ]); + } + let result = dynamic_update_packed( + &mut words, + &native_inertia, + simulation, + &mut correction_words, + ); + let vector = |offset| { + Vector3::new( + f32::from_bits(words[offset]), + f32::from_bits(words[offset + 1]), + f32::from_bits(words[offset + 2]), + ) + }; + body.orientation = RetailQuaternion { + x: f32::from_bits(words[0]), + y: f32::from_bits(words[1]), + z: f32::from_bits(words[2]), + w: f32::from_bits(words[3]), + }; + body.position = vector(4); + body.linear_velocity = vector(8); + body.angular_velocity = vector(12); + body.force_acceleration = vector(36); + body.torque_acceleration = vector(40); + body.kinetic_energy = f32::from_bits(words[39]); + body.cool_down = words[43]; + body.basis = Basis3 { + columns: core::array::from_fn(|i| { + core::array::from_fn(|j| f32::from_bits(words[16 + i * 4 + j])) + }), + }; + body.world_inverse_inertia = unpack_world_inverse_inertia(vector(28), vector(32)); + RetailBodyRateStep { + state: body, + orientation_displacement: Vector3::new( + result.orientation_displacement[0], + result.orientation_displacement[1], + result.orientation_displacement[2], + ), + linear_speed_squared: result.linear_speed_squared, + angular_speed_squared: result.angular_speed_squared, + } +} + +/// Quaternion branch from TU3 `0x82AE66A8..0x82AE67B8`. +/// +/// Mapping the VMX128 word permutations back to RenderWare's public +/// `(x,y,z,w)` lane order gives: +/// +/// `q += 0.5 * Quaternion(angular_displacement, 0) * q` +/// +/// followed by normalization. This pre-multiplication order is important: +/// swapping it changes the board's world-space angular response. +pub fn integrate_orientation( + orientation: RetailQuaternion, + angular_displacement: Vector3, +) -> RetailQuaternion { + let q = dynamic_update::orientation( + [orientation.x, orientation.y, orientation.z, orientation.w], + [ + angular_displacement.x, + angular_displacement.y, + angular_displacement.z, + 0.0, + ], + ); + RetailQuaternion { + x: q[0], + y: q[1], + z: q[2], + w: q[3], + } +} + +/// `Ri`, `Up`, and `At` columns rebuilt immediately after TU3 normalizes the +/// quaternion in `0x82AE6590`. +pub fn basis_from_quaternion(q: RetailQuaternion) -> Basis3 { + let columns = dynamic_update::quaternion_basis([q.x, q.y, q.z, q.w]); + Basis3 { + columns: columns.map(|column| [column[0], column[1], column[2]]), + } +} + +/// Native packed inverse-inertia rebuild in 82AE6590, with its fused order. +pub fn world_inverse_inertia(basis: Basis3, inverse_tensor: Vector3) -> Basis3 { + let native_basis = basis.columns.map(|v| [v[0], v[1], v[2], 0.0]); + let tensor = [inverse_tensor.x, inverse_tensor.y, inverse_tensor.z].map(f32::to_bits); + let (full, split) = dynamic_update::inverse_inertia(native_basis, tensor); + unpack_world_inverse_inertia( + Vector3::new(full[0], full[1], full[2]), + Vector3::new(split[0], split[1], split[2]), + ) +} + +fn unpack_world_inverse_inertia(full: Vector3, split: Vector3) -> Basis3 { + Basis3 { + columns: [ + [full.x, full.y, full.z], + [full.y, split.y, split.z], + [full.z, split.z, split.x], + ], + } +} + +/// Packs a symmetric world inverse-inertia tensor in RenderWare's `mIfull` / +/// `mIsplt` lane order. +pub fn pack_world_inverse_inertia(tensor: Basis3) -> RetailPackedWorldInverseInertia { + RetailPackedWorldInverseInertia { + full: Vector3::new( + tensor.columns[0][0], + tensor.columns[1][0], + tensor.columns[2][0], + ), + split: Vector3::new( + tensor.columns[2][2], + tensor.columns[1][1], + tensor.columns[2][1], + ), + } +} + +/// Multiplies a vector by the exact symmetric tensor represented by retail +/// `mIfull` and `mIsplt`. +pub fn multiply_packed_world_inverse_inertia( + tensor: RetailPackedWorldInverseInertia, + vector: Vector3, +) -> Vector3 { + Vector3::new( + tensor.full.x * vector.x + tensor.full.y * vector.y + tensor.full.z * vector.z, + tensor.full.y * vector.x + tensor.split.y * vector.y + tensor.split.z * vector.z, + tensor.full.z * vector.x + tensor.split.z * vector.y + tensor.split.x * vector.z, + ) +} + +#[cfg(test)] +fn multiply_basis(basis: Basis3, vector: Vector3) -> Vector3 { + Vector3::new( + basis.columns[0][0] * vector.x + + basis.columns[1][0] * vector.y + + basis.columns[2][0] * vector.z, + basis.columns[0][1] * vector.x + + basis.columns[1][1] * vector.y + + basis.columns[2][1] * vector.z, + basis.columns[0][2] * vector.x + + basis.columns[1][2] * vector.y + + basis.columns[2][2] * vector.z, + ) +} + +#[cfg(test)] +#[path = "tests/rigid_body.rs"] +mod tests; + +// Existing equation-level unit checks retain their independent scalar helpers. +#[cfg(test)] +fn column(basis: Basis3, index: usize) -> Vector3 { + let c = basis.columns[index]; + Vector3::new(c[0], c[1], c[2]) +} +#[cfg(test)] +fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +#[cfg(test)] +fn scale(v: Vector3, s: f32) -> Vector3 { + Vector3::new(v.x * s, v.y * s, v.z * s) +} +#[cfg(test)] +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x, + ) +} +#[cfg(test)] +fn dot(a: Vector3, b: Vector3) -> f32 { + a.x * b.x + a.y * b.y + a.z * b.z +} +#[cfg(test)] +fn length_squared(v: Vector3) -> f32 { + dot(v, v) +} +#[cfg(test)] +fn normalize_quaternion(q: RetailQuaternion) -> RetailQuaternion { + let inverse = (q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w) + .sqrt() + .recip(); + RetailQuaternion { + x: q.x * inverse, + y: q.y * inverse, + z: q.z * inverse, + w: q.w * inverse, + } +} diff --git a/crates/skate-core/src/physics/rigid_body/dynamic_update.rs b/crates/skate-core/src/physics/rigid_body/dynamic_update.rs new file mode 100644 index 00000000..ea4a23ef --- /dev/null +++ b/crates/skate-core/src/physics/rigid_body/dynamic_update.rs @@ -0,0 +1,192 @@ +//! RigidBody::DynamicUpdate82AE6590 reconstruction candidate. +//! Guest pointers are opaque metadata; the caller supplies their referenced data. +use super::RetailSimulationStep; +type V = [f32; 4]; + +#[derive(Clone, Copy, Debug)] +pub struct DynamicUpdateResult { + pub orientation_displacement: [f32; 3], + /// Native cap-selected squared speeds used by the sleep metric. + pub linear_speed_squared: f32, + pub angular_speed_squared: f32, +} + +/// Update every native body field and clear all 64 reaction bytes. `body` is the +/// 176-byte RigidBody prefix; `inertia` is its 40-byte Inertia record. Metadata +/// lanes, including pointers and IDs, are preserved. No activation is performed. +pub fn dynamic_update_packed( + body: &mut [u32; 44], + inertia: &[u32; 10], + simulation: RetailSimulationStep, + reactions: &mut [u32; 16], +) -> DynamicUpdateResult { + let dt = [simulation.time_step; 4]; + let linear = madd( + madd(load(body, 36), dt, load(body, 8)), + dt, + load(reactions, 0), + ); + let angular = madd( + madd(load(body, 40), dt, load(body, 12)), + dt, + load(reactions, 8), + ); + let position = add(add(load(body, 4), load(reactions, 4)), linear); + store_xyz(body, 4, position); + let rotation = add(angular, load(reactions, 12)); + let q = orientation(load(body, 0), rotation); + body[..4].copy_from_slice(&q.map(f32::to_bits)); + let basis = quaternion_basis(q); + for (offset, column) in [16, 20, 24].into_iter().zip(basis) { + store_xyz(body, offset, column); + } + let tensor = inertia[..3].try_into().unwrap(); + let (full, split) = inverse_inertia(basis, tensor); + store_xyz(body, 28, full); + store_xyz(body, 32, split); + let rate = |drag: u32| { + let difference = simulation.frequency - f32::from_bits(drag); + if difference > 0.0 { difference } else { 0.0 } + }; + let (omega, angular_squared) = cap( + mul(angular, [rate(inertia[9]); 4]), + f32::from_bits(inertia[7]), + ); + let (velocity, linear_squared) = cap( + mul(linear, [rate(inertia[8]); 4]), + f32::from_bits(inertia[6]), + ); + // Scalar fmuls followed by fmadds; cap branches publish the squared cap, + // rather than recalculating squared speed from the rounded scaled vector. + let factor = f32::from_bits(inertia[5]) * f32::from_bits(body[31]); + let energy = factor.mul_add(angular_squared, linear_squared); + body[43] = if !(energy < simulation.minimum_energy) { + 0 + } else { + let cooldown = if !(energy > f32::from_bits(body[39])) { + body[43].wrapping_add(1) + } else { + body[43] + }; + cooldown.min(simulation.cool_down) + }; + body[39] = energy.to_bits(); + store_xyz(body, 8, velocity); + store_xyz(body, 12, omega); + reactions.fill(0); + let gravity = simulation.gravity_acceleration; + store_xyz(body, 36, [gravity.x, gravity.y, gravity.z, 0.0]); + store_xyz(body, 40, [0.0; 4]); + DynamicUpdateResult { + orientation_displacement: rotation[..3].try_into().unwrap(), + linear_speed_squared: linear_squared, + angular_speed_squared: angular_squared, + } +} + +pub(super) fn orientation(q: V, rotation: V) -> V { + // 82AE6680..67B8: preserve the native cross-product and normalization order. + let cross = perm( + nmsub( + perm(rotation, [1, 2, 0, 3]), + q, + mul(rotation, perm(q, [1, 2, 0, 3])), + ), + [1, 2, 0, 3], + ); + let mut increment = mul(madd(rotation, [q[3]; 4], cross), [0.5; 4]); + increment[3] = dot(rotation, q, false) * -0.5; + let candidate = add(q, increment); + mul( + candidate, + [refined_rsqrt(dot(candidate, candidate, true)); 4], + ) +} + +pub(super) fn quaternion_basis(q: V) -> [V; 3] { + // 82139A00 contains sqrt(2) rounded to binary32. Replacing these products + // with 2*q_i*q_j changes the native basis words. + let s = mul(q, [f32::from_bits(0x3FB5_04F3); 4]); + let d = nmsub(s, s, [0.5; 4]); + let diagonal = add(d, perm(d, [1, 2, 0, 0])); + let products = mul(s, perm(s, [1, 2, 0, 0])); + let w_products = mul([s[3]; 4], perm(s, [2, 0, 1, 0])); + let plus = add(products, w_products); + let minus = sub(products, w_products); + [ + [diagonal[1], plus[0], minus[2], 0.0], + [minus[0], diagonal[2], plus[1], 0.0], + [plus[2], minus[1], diagonal[0], 0.0], + ] +} + +pub(super) fn inverse_inertia(basis: [V; 3], tensor: [u32; 3]) -> (V, V) { + let [ri, up, at] = basis; + let r = mul(ri, [f32::from_bits(tensor[0]); 4]); + let u = mul(up, [f32::from_bits(tensor[1]); 4]); + let a = mul(at, [f32::from_bits(tensor[2]); 4]); + let full = madd(a, [at[0]; 4], madd(r, [ri[0]; 4], mul(u, [up[0]; 4]))); + let p = |v| perm(v, [2, 1, 1, 3]); + let s = |v| perm(v, [2, 1, 2, 3]); + let split = madd(p(at), s(a), madd(p(ri), s(r), mul(p(up), s(u)))); + (full, split) +} + +fn cap(value: V, maximum: f32) -> (V, f32) { + let squared = dot(value, value, false); + let maximum_squared = maximum * maximum; + if squared > maximum_squared { + let ratio = maximum_squared / squared; + let root = ratio * refined_rsqrt(ratio); + let scale = if ratio == 0.0 { 0.0 } else { root }; + (mul(value, [scale; 4]), maximum_squared) + } else { + (value, squared) + } +} + +fn refined_rsqrt(value: f32) -> f32 { + let mut estimate = super::super::reciprocal_sqrt::estimate(value); + for _ in 0..2 { + let square = estimate * estimate; + let half_estimate = estimate * 0.5; + let residual = (-value).mul_add(square, 1.0); + estimate = half_estimate.mul_add(residual, estimate); + } + estimate +} + +fn dot(a: V, b: V, four: bool) -> f32 { + if four { + crate::physics::native_arithmetic::dot4(a, b) + } else { + crate::physics::native_arithmetic::dot3(a, b) + } +} + +fn load(words: &[u32], offset: usize) -> V { + core::array::from_fn(|i| f32::from_bits(words[offset + i])) +} +fn store_xyz(words: &mut [u32], offset: usize, value: V) { + for i in 0..3 { + words[offset + i] = value[i].to_bits(); + } +} +fn perm(value: V, indices: [usize; 4]) -> V { + indices.map(|i| value[i]) +} +fn mul(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] * b[i]) +} +fn add(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] + b[i]) +} +fn sub(a: V, b: V) -> V { + core::array::from_fn(|i| a[i] - b[i]) +} +fn madd(a: V, b: V, c: V) -> V { + core::array::from_fn(|i| a[i].mul_add(b[i], c[i])) +} +fn nmsub(a: V, b: V, c: V) -> V { + core::array::from_fn(|i| (-a[i]).mul_add(b[i], c[i])) +} diff --git a/crates/skate-core/src/physics/skateboard_controller.rs b/crates/skate-core/src/physics/skateboard_controller.rs new file mode 100644 index 00000000..a76d3433 --- /dev/null +++ b/crates/skate-core/src/physics/skateboard_controller.rs @@ -0,0 +1,47 @@ +//! Original SkateboardController's lifecycle, TU3 82D74DD8/82D75EA0. +//! This is the hand-held/retrieval controller. Normal riding stops it. +use crate::player::lifecycle::{SkateboardControllerActions, SkateboardControllerFields}; + +pub struct SkateboardController { + /// The same mutable fields consumed by PhysicalPlayer state changes. + pub fields: SkateboardControllerFields, +} +impl Default for SkateboardController { + fn default() -> Self { + Self::new() + } +} +impl SkateboardController { + pub const fn new() -> Self { + //82D74EFC/4F00/4F04, after the retrieval-progress initializer. + Self { + fields: SkateboardControllerFields { + word_444: 0, + state_448: 0, + system_on_452: false, + }, + } + } + + ///82D75EA0. PhysicalPlayer invokes this for requested states outside + ///500..502. The active-held release remains the real LetGoOfSkateboard + ///action; do not replace it with a transform reset or an empty callback. + pub fn stop(&mut self, actions: &mut impl SkateboardControllerActions) { + if !self.fields.system_on_452 { + return; + } + let previous = self.fields.state_448; + self.fields.word_444 = 0; + if previous != 0 { + actions.let_go_of_skateboard(); + self.fields.state_448 = 0; + } + self.fields.system_on_452 = false; + } + + ///The player passes this to Skeleton::UpdatePostPhysics. Native868 + ///is the count of seven board parts in contact, not wheel count869. + pub fn request_partial_ragdoll(&self, part_contact_count: u8) -> bool { + self.fields.state_448 == 1 && part_contact_count != 0 + } +} diff --git a/crates/skate-core/src/physics/skeleton_air_frames.rs b/crates/skate-core/src/physics/skeleton_air_frames.rs new file mode 100644 index 00000000..a422dac1 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_air_frames.rs @@ -0,0 +1,105 @@ +//! Original TU3 animated-board and COM-controlled air frame calculations. +//! 82BDDA10, 82BDE600 and 82BDE7B8. Persistent frames remain in their owners. +use super::{ + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY, compose_affine}, + skeleton_board_frames::SkeletonBoardFrames, + skeleton_root::{SkeletonRootFrames, inverse_rigid, orthonormalize}, +}; +use crate::trigonometry; + +/// 82B985E8 publishes the one-frame request and retained frame count; +/// 82BDE920 loads that count with lwz before unsigned-to-floating conversion. +#[derive(Clone, Copy, Debug)] +pub struct AirDismountRevert { + pub requested: bool, + pub frames: u32, + pub goofy: bool, +} + +/// 82BDDA5C..DBBC, after the common UpdateRootTransforms82BE0318. +/// The skate root uses the unblended animation target in this path. +pub fn prepare_animated( + roots: &SkeletonRootFrames, + board: &mut SkeletonBoardFrames, + mapped_board: &Transform, + flags: &mut u32, +) -> Transform { + let local = compose_affine(&roots.animation_to_board, mapped_board); + let mut placement = IDENTITY; + placement[3] = roots.predicted_board_position; + let target = compose_affine(&placement, &local); + *flags |= 1 << 19; + board.animation_target = target; + board.skate_root = target; + board.update_com_lift(&roots.animation_to_world, board.centre_of_mass, 0.0); + target +} + +/// 82BDE7B8: preserve prior heading on entry, apply an authored dismount +/// revert, then place the current animation COM at the integrated air target. +/// It does not update animation_to_board/inverse_board or consume prediction. +pub fn update_known_air_roots( + roots: &mut SkeletonRootFrames, + reckoning: &Transform, + target_com: [f32; 4], + animation_com: [f32; 4], + revert: AirDismountRevert, +) { + if roots.initialize_heading { + roots.heading_alignment = + compose_affine(&inverse_rigid(reckoning), &roots.animation_to_world); + //82BDE8EC overwrites the entire affine translation with zero. + roots.heading_alignment[3] = [0.0; 4]; + roots.initialize_heading = false; + } + if revert.requested { + let mut angle = f32::from_bits(0x4049_0fdb) / revert.frames as f32; + if !revert.goofy { + angle = -angle; + } + let (sin, cos) = trigonometry::sin_cos(angle); + let rotation = [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [0.0; 4], + ]; + roots.heading_alignment = + orthonormalize(compose_affine(&roots.heading_alignment, &rotation)); + } + let mut world = compose_affine(reckoning, &roots.heading_alignment); + //82BDEB98..BB8: rotate COM without adding the prior translation. + world[3] = std::array::from_fn(|lane| { + let x = world[0][lane] * animation_com[0]; + let y = world[1][lane].mul_add(animation_com[1], x); + target_com[lane] - world[2][lane].mul_add(animation_com[2], y) + }); + roots.animation_to_world = orthonormalize(world); + roots.world_to_animation = inverse_rigid(&roots.animation_to_world); +} + +///82BDE634..6DC. ApplyBoardAnimation follows before the completion below. +pub fn prepare_known_air( + roots: &SkeletonRootFrames, + board: &mut SkeletonBoardFrames, + mapped_board: &Transform, + flags: &mut u32, +) -> Transform { + let target = compose_affine(&roots.animation_to_world, mapped_board); + *flags |= 1 << 19; + board.animation_target = target; + target +} + +///82BDE748..7A4, after applying the target and the board velocity update. +pub fn finish_known_air( + roots: &mut SkeletonRootFrames, + board: &mut SkeletonBoardFrames, + effective_board: Transform, +) { + board.physical_board = effective_board; + board.skate_root = effective_board; + board.update_com_lift(&roots.animation_to_world, board.centre_of_mass, 0.0); + roots.predicted_board_position = effective_board[3]; + roots.supplied_prediction = Some(effective_board[3]); +} diff --git a/crates/skate-core/src/physics/skeleton_animation_record.rs b/crates/skate-core/src/physics/skeleton_animation_record.rs new file mode 100644 index 00000000..b1b6d9b3 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_animation_record.rs @@ -0,0 +1,205 @@ +//! Animation-record COM producer82BEBF88 and mass normalization82BEBAA8. +//! Skeleton embeds this source state at6464, so com_to_deck_world4544 is the +//! ProcessData82BD8918 input published from Skeleton11008 to Processed752. +use super::rigid_body::{RetailQuaternion, basis_from_quaternion}; +use crate::math::Vector3; + +/// Native affine columns X,Y,Z,translation. Keep all four stored lanes; +/// UpdateAnimRecord copies24 complete transforms, not just the positions. +pub type AnimationPartTransform = [[f32; 4]; 4]; +pub const IDENTITY: AnimationPartTransform = [ + [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, 0.0, 0.0, 0.0], +]; + +/// Forward PhysicsParamBoneData frame from SkeletonData::Init82BD6E24..6E98. +/// The same sqrt(2), fused negative product and lane permutations are used by +/// RigidBody::DynamicUpdate. Keep the otherwise unused fourth rotation lanes: +/// native permute masks822FB8A0/B0/C0 duplicate Z.z, Y.x and X.y respectively. +/// This is the forward frame, not the inverse stored by the later init slice. +pub fn physics_bone_frame(quaternion: [f32; 4], translation: [f32; 4]) -> AnimationPartTransform { + let [x, y, z, w] = quaternion; + let [ri, up, at] = basis_from_quaternion(RetailQuaternion { x, y, z, w }).columns; + [ + [ri[0], ri[1], ri[2], at[2]], + [up[0], up[1], up[2], up[0]], + [at[0], at[1], at[2], ri[1]], + translation, + ] +} + +/// ProcessData82BD8BB4..8CAC: gather each named global animation bone, then +/// right-multiply its forward physics frame. Native maps names to hierarchy +/// indices in82BD6CB4..6CE4; the host loader provides those verified indices. +/// The result is the input to the following skeleton IK and adjustment stages, +/// which must run before UpdateAnimRecord for the final physical pose. +pub fn map_animation_parts( + global_bones: &[AnimationPartTransform], + bone_indices: &[usize; 24], + physics_frames: &[AnimationPartTransform; 24], +) -> Result<[AnimationPartTransform; 24], &'static str> { + if bone_indices.iter().any(|i| *i >= global_bones.len()) { + return Err("physics skeleton bone index is outside the animation hierarchy"); + } + Ok(std::array::from_fn(|part| { + let animation = &global_bones[bone_indices[part]]; + let physics = &physics_frames[part]; + std::array::from_fn(|column| { + if column == 3 { + transform_point(animation, physics[3]) + } else { + std::array::from_fn(|lane| { + let x = physics[column][0] * animation[0][lane]; + let y = physics[column][1].mul_add(animation[1][lane], x); + physics[column][2].mul_add(animation[2][lane], y) + }) + } + }) + })) +} + +#[derive(Clone, Debug)] +pub struct SkeletonAnimationMasses { + /// Native misleadingly calls these part masses. They are bone-box volumes + /// before density and animated/ragdoll physical mass scaling. + pub part_weights: [f32; 24], + pub total: f32, + pub fractional: [f32; 24], +} +impl SkeletonAnimationMasses { + /// Full weight assignment slice82BE4900/82BE5428. Board part0 is weight0; + /// other parts use original PhysicsParamBoneData.size32, not collider size. + /// Shapes0/1/2 all use the same box product. Native unrecognized shapes>=3 + /// retain the iteration's initialized0. A hat on head1 takes its own branch + /// and still uses the original head box product, excluding the hat volume. + pub fn from_bone_data( + sizes: [Vector3; 24], + collision_shapes: [u32; 24], + head_has_hat: bool, + ) -> Self { + let weights = std::array::from_fn(|i| { + if i == 0 || collision_shapes[i] >= 3 && !(i == 1 && head_has_hat) { + 0.0 + } else { + (sizes[i].x * sizes[i].y) * sizes[i].z + } + }); + Self::normalize(weights) + } + + ///82BEBAA8: ordered24 scalar additions, one scalar single-precision divide, + /// then24 multiplies. No clamping, epsilon, density or re-normalization. + pub fn normalize(part_weights: [f32; 24]) -> Self { + let mut total = 0.0f32; + for (i, weight) in part_weights.iter().enumerate() { + // Preserve native operand order, including signed zero/NaN cases. + total = if i % 8 == 0 || i % 8 == 7 { + *weight + total + } else { + total + *weight + }; + } + let reciprocal = 1.0f32 / total; + Self { + part_weights, + total, + fractional: part_weights.map(|v| v * reciprocal), + } + } +} + +#[derive(Clone, Debug)] +pub struct SkeletonAnimationRecord { + pub pose: [AnimationPartTransform; 24], + pub centre_of_mass: [f32; 4], + pub centre_of_mass_delta: [f32; 4], + pub com_to_deck_world: [f32; 4], + pub com_to_deck_world_delta: [f32; 4], + pub reset_scalar: f32, +} +impl Default for SkeletonAnimationRecord { + fn default() -> Self { + // Constructor82BEB8C8 initializes24 animation transforms via82BF30C0, + // then all four vectors and resetScalar5224 to0. + Self { + pose: [IDENTITY; 24], + centre_of_mass: [0.0; 4], + centre_of_mass_delta: [0.0; 4], + com_to_deck_world: [0.0; 4], + com_to_deck_world_delta: [0.0; 4], + reset_scalar: 0.0, + } + } +} +impl SkeletonAnimationRecord { + /// Animation fields of SkeletonState::Reset82BEBBB0. The function leaves + /// animation transforms, fractional masses and resetScalar unchanged. + pub fn reset_history(&mut self) { + self.centre_of_mass = [0.0; 4]; + self.centre_of_mass_delta = [0.0; 4]; + self.com_to_deck_world = [0.0; 4]; + self.com_to_deck_world_delta = [0.0; 4]; + } + + pub fn update( + &mut self, + pose: &[AnimationPartTransform; 24], + animation_to_board: &AnimationPartTransform, + masses: &SkeletonAnimationMasses, + ) { + self.pose = *pose; + let mut com = [0.0f32; 4]; + for (part, weight) in self.pose.iter().zip(masses.fractional) { + for (lane, value) in com.iter_mut().enumerate() { + *value = part[3][lane].mul_add(weight, *value); + } + } + self.centre_of_mass_delta = std::array::from_fn(|i| com[i] - self.centre_of_mass[i]); + self.centre_of_mass = com; + // Native transforms both points separately then subtracts, retaining + // each affine FMA's rounding; do not rotate their difference instead. + let transformed_com = transform_point(animation_to_board, com); + let transformed_deck = transform_point(animation_to_board, self.pose[0][3]); + let relative = std::array::from_fn(|i| transformed_com[i] - transformed_deck[i]); + self.com_to_deck_world_delta = + std::array::from_fn(|i| (relative[i] - self.com_to_deck_world[i]) * self.reset_scalar); + self.com_to_deck_world = relative; + self.reset_scalar = 1.0; + } + + pub fn com_to_deck(&self) -> Vector3 { + let v = self.com_to_deck_world; + Vector3::new(v[0], v[1], v[2]) + } +} +pub fn transform_point(transform: &AnimationPartTransform, point: [f32; 4]) -> [f32; 4] { + std::array::from_fn(|i| { + let x = transform[0][i].mul_add(point[0], transform[3][i]); + let y = transform[1][i].mul_add(point[1], x); + transform[2][i].mul_add(point[2], y) + }) +} + +#[cfg(test)] +#[path = "tests/skeleton_animation_record.rs"] +mod tests; + +/// Native affine product used by ProcessData and board-offset application. +pub fn compose_affine( + parent: &AnimationPartTransform, + child: &AnimationPartTransform, +) -> AnimationPartTransform { + std::array::from_fn(|column| { + if column == 3 { + transform_point(parent, child[3]) + } else { + std::array::from_fn(|lane| { + let x = child[column][0] * parent[0][lane]; + let y = child[column][1].mul_add(parent[1][lane], x); + child[column][2].mul_add(parent[2][lane], y) + }) + } + }) +} diff --git a/crates/skate-core/src/physics/skeleton_board_frames.rs b/crates/skate-core/src/physics/skeleton_board_frames.rs new file mode 100644 index 00000000..141db27f --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_board_frames.rs @@ -0,0 +1,152 @@ +//! Ground skeleton frame publication82BDF530 and COM lift82BDE310. +//! The authored board11728, world target15952 and physical board12496 have +//! different producers. Keep them separate through the animation/physics tick. +use super::{ + native_arithmetic::reciprocal_estimate, + skeleton_animation_record::{ + AnimationPartTransform as Transform, IDENTITY, compose_affine, transform_point, + }, + skeleton_root::{SkeletonRootFrames, orthonormalize}, +}; + +pub struct SkeletonBoardFrames { + pub physical_board: Transform, + pub skate_root: Transform, + pub animation_target: Transform, + pub com_frame: Transform, + pub lifted_com_frame: Transform, + pub centre_of_mass: [f32; 4], + pub previous_centre_of_mass: [f32; 4], + pub com_velocity: [f32; 4], + pub local_centre_of_mass: [f32; 4], + pub local_board_position: [f32; 4], + ///Skeleton16412, a per-update height limiter, not a timestep spring. + pub lift_height: f32, +} +impl Default for SkeletonBoardFrames { + fn default() -> Self { + Self { + physical_board: IDENTITY, + skate_root: IDENTITY, + animation_target: IDENTITY, + com_frame: IDENTITY, + lifted_com_frame: IDENTITY, + centre_of_mass: [0.; 4], + previous_centre_of_mass: [0.; 4], + com_velocity: [0.; 4], + local_centre_of_mass: [0.; 4], + local_board_position: [0.; 4], + lift_height: 0., + } + } +} +impl SkeletonBoardFrames { + ///Reset82BD9990: the retained COM seed is the supplied spawn position; + ///the two COM drive frames initially share spawn plus one world-Y unit. + ///The following physical observations replace the seed with weighted COM. + pub fn reset(&mut self, spawn: Transform) { + self.com_frame = spawn; + self.com_frame[3][1] += 1.0; + self.lifted_com_frame = self.com_frame; + self.centre_of_mass = spawn[3]; + self.local_centre_of_mass = [0.; 4]; + } + ///82BD9EA8, after the physical record observation: inverse-transform + ///the retained physical COM and actual board position independently. + pub fn publish_local_observations( + &mut self, + roots: &SkeletonRootFrames, + physical_board: &Transform, + ) { + self.local_centre_of_mass = transform_point(&roots.world_to_animation, self.centre_of_mass); + self.local_board_position = transform_point(&roots.world_to_animation, physical_board[3]); + } + + ///82BD9FB0..82BDA060 after animation/physics errors are updated. This is + ///physical weighted COM4464, not physical hips23 or authored hips height. + pub fn publish_centre_of_mass(&mut self, com: [f32; 4], dt: f32, flags_2472: u32) { + self.previous_centre_of_mass = self.centre_of_mass; + self.centre_of_mass = com; + let mut inverse = reciprocal_estimate(dt); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(dt, 1.0), inverse); + } + self.com_velocity = + std::array::from_fn(|i| (com[i] - self.previous_centre_of_mass[i]) * inverse); + if flags_2472 & 0x400 != 0 { + //Native updates previous COM after calculating velocity. + self.previous_centre_of_mass = com; + } + } + + ///82BDF55C..F774, following UpdateRootTransforms and preceding GeneralUpdate. + ///Returns the complete frame published to ProcessedPhysIn0..48. + pub fn prepare_ground( + &mut self, + roots: &SkeletonRootFrames, + mapped_board: &Transform, + actual_board: Transform, + flags_2468: &mut u32, + ) -> Transform { + let local_target = compose_affine(&roots.animation_to_board, mapped_board); + let mut world_position = IDENTITY; + world_position[3] = roots.predicted_board_position; + self.animation_target = compose_affine(&world_position, &local_target); + *flags_2468 &= !0x80000; + self.physical_board = actual_board; + self.skate_root = actual_board; + self.update_com_lift(&roots.animation_to_world, self.centre_of_mass, 0.0); + self.animation_target + } + + ///82BDF7C0. Teleport shares the authored target composition, but its COM + ///drive frame starts one world-Y unit above the physical deck position. + pub fn prepare_teleport( + &mut self, + roots: &SkeletonRootFrames, + mapped_board: &Transform, + actual_board: Transform, + flags_2468: &mut u32, + ) -> Transform { + let local_target = compose_affine(&roots.animation_to_board, mapped_board); + let mut world_position = IDENTITY; + world_position[3] = roots.predicted_board_position; + self.animation_target = compose_affine(&world_position, &local_target); + *flags_2468 &= !0x80000; + self.physical_board = actual_board; + self.skate_root = actual_board; + let mut position = actual_board[3]; + position[1] += 1.0; //82BDFA20..30 replaces Y only. + self.update_com_lift(&roots.animation_to_world, position, 0.0); + self.animation_target + } + + ///82BDE310. Preserve both fsel comparisons and the later vector FMA. + pub fn update_com_lift( + &mut self, + animation_to_world: &Transform, + position: [f32; 4], + requested_height: f32, + ) { + let step = f32::from_bits(0x3D23_D70A); //82216FEC .04 + let low = self.lift_height - step; + let high = self.lift_height + step; + let above_low = if low - requested_height >= 0.0 { + low + } else { + requested_height + }; + self.lift_height = if high - above_low >= 0.0 { + above_low + } else { + high + }; + let mut frame = *animation_to_world; + frame[3] = position; + self.com_frame = orthonormalize(frame); + self.lifted_com_frame = self.com_frame; + let height = self.lift_height + f32::from_bits(0x3E99_999A); //820D06C0 .3 + self.lifted_com_frame[3] = + std::array::from_fn(|i| self.com_frame[1][i].mul_add(height, self.com_frame[3][i])); + } +} diff --git a/crates/skate-core/src/physics/skeleton_board_offset.rs b/crates/skate-core/src/physics/skeleton_board_offset.rs new file mode 100644 index 00000000..a2be5228 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_board_offset.rs @@ -0,0 +1,107 @@ +//! Complete Skeleton::UpdateSkateboardOffsetTransform82BDD630 and +//! SkeletonIK::AddOffsetTransform82BF1C68. Counters are animation updates. +use super::{ + board_motion_output::inverse_length_squared, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, +}; + +#[derive(Clone, Debug)] +pub struct SkateboardOffset { + pub transform: AnimationPartTransform, + pub orientation_frames: f32, + pub height_frames: f32, + pub orientation_refreshed: bool, + pub height_refreshed: bool, +} +impl Default for SkateboardOffset { + fn default() -> Self { + // Skeleton constructor82BD76E8..7708 and82BD79A0..79BC. + Self { + transform: IDENTITY, + orientation_frames: 0.0, + height_frames: 0.0, + orientation_refreshed: false, + height_refreshed: false, + } + } +} +impl SkateboardOffset { + /// The offboard, landing-on-board and grind-air producers refresh both + /// channels for15 updates after writing their independently calculated frame. + pub fn refresh_transform(&mut self, transform: AnimationPartTransform) { + self.transform = transform; + self.orientation_frames = 15.0; + self.height_frames = 15.0; + self.orientation_refreshed = true; + self.height_refreshed = true; + } + + /// LandingAdjust writes only Y and its own duration, preserving X/Z/basis. + pub fn refresh_height(&mut self, height: f32, frames: f32) { + self.transform[3][1] = height; + self.height_frames = frames; + self.height_refreshed = true; + } + + /// Update decay first, then apply the resulting offset to the board and + /// four IK targets. Positive counters select application before decrement. + pub fn update( + &mut self, + board_pose: &mut AnimationPartTransform, + targets: &mut [AnimationPartTransform; 4], + ) { + let mut apply = false; + if self.orientation_frames > 0.0 { + apply = true; + if !self.orientation_refreshed { + let ratio = (self.orientation_frames - 1.0) / self.orientation_frames; + let weight = ratio * ratio; + let identity_weight = 1.0 - weight; + for (axis, identity) in IDENTITY.iter().enumerate().take(3) { + for (lane, value) in self.transform[axis].iter_mut().enumerate() { + *value = value.mul_add(weight, identity[lane] * identity_weight); + } + } + // vrlimi mask4 retains Y. X/Z/W belong to orientation decay. + for lane in [0, 2, 3] { + self.transform[3][lane] *= weight; + } + let unnormalized = self.transform; + // Native Gram-Schmidt traverses At,Up,Ri in that order. + for axis in (0..3).rev() { + let mut vector = unnormalized[axis]; + for prior in ((axis + 1)..3).rev() { + let projection = dot3(self.transform[prior], unnormalized[axis]); + for (lane, value) in vector.iter_mut().enumerate() { + *value -= self.transform[prior][lane] * projection; + } + } + let reciprocal = inverse_length_squared(dot3(vector, vector), 2); + self.transform[axis] = vector.map(|v| v * reciprocal); + } + self.orientation_frames -= 1.0; + } + } + if self.height_frames > 0.0 { + apply = true; + if !self.height_refreshed { + let ratio = (self.height_frames - 1.0) / self.height_frames; + self.transform[3][1] *= ratio * ratio; + self.height_frames -= 1.0; + } + } + if apply { + *board_pose = compose_affine(&self.transform, board_pose); + for target in targets { + *target = compose_affine(&self.transform, target); + } + } + self.orientation_refreshed = false; + self.height_refreshed = false; + } +} + +#[cfg(test)] +#[path = "tests/skeleton_board_offset.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/skeleton_body/collision_feedback.rs b/crates/skate-core/src/physics/skeleton_body/collision_feedback.rs new file mode 100644 index 00000000..202c5df2 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_feedback.rs @@ -0,0 +1,223 @@ +//! SkeletonCollision observations from original TU3 82BD4A30/82BD5DA0. +//! Reports must come from the completed solver, in native report order. +use super::{SkeletonCollisionSettings, SkeletonPhysicalRecord}; +use crate::physics::skeleton_animation_record::AnimationPartTransform; + +pub(super) type V = [f32; 4]; + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonFeedbackSettings { + pub body: SkeletonCollisionSettings, + pub small_object_mass: f32, + pub ground_plane_max_distance: f32, + pub ground_plane_max_angle: f32, + pub skater_scalar: f32, + pub ai_scalar: f32, + pub groin_offset: V, + pub face_offset: V, + pub groin_radius: f32, + pub face_radius: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonContactBody { + pub state_flags: u32, + pub inverse_mass: f32, + pub linear_velocity: V, +} + +/// The useful fields of original 96-byte ContactInfo and its solved spy. +/// `normal` already has the report-side sign; `solved_vector` is spy+80. +#[derive(Clone, Copy, Debug)] +pub struct SkeletonContactReport { + pub part: usize, + pub normal: V, + pub point: V, + pub tag: u32, + pub other_group: u32, + pub other_entity: Option, + pub body_a: SkeletonContactBody, + pub body_b: SkeletonContactBody, + pub side_a: bool, + pub solved_vector: V, +} + +/// Original UpdatePostPhysics82BD80D8 builds these values before Update. +pub struct SkeletonCollisionInput<'a> { + pub dt: f32, + pub plane_point: V, + pub plane_normal: V, + pub reference_velocity: V, + pub com_velocity: V, + pub ragdoll: bool, + /// Processed2472 bit0x8000, which suppresses normal ground filtering. + pub disable_ground_filter: bool, + /// Processed2472 bit0x10000000 selects AISkeletonScalar. + pub ai_collision_scalar: bool, + /// Category500, excluding states501 and503. + pub offboard: bool, + pub entering_offboard: bool, + pub category_600: bool, + pub request_partial_ragdoll: bool, + pub physical: &'a SkeletonPhysicalRecord, + pub part_weights: &'a [f32; 24], + /// Actual GetPartTransform frames used by CheckConflictingContacts. + pub body_frames: &'a [AnimationPartTransform; 26], +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct BoneContact { + pub normal: V, + pub specific_normal: V, + pub tangent: V, + pub specific_tangent: V, + pub point: V, + pub force: f32, + pub specific_force: f32, + pub tag: u32, + pub specific_tag: u32, + pub groups: [bool; 4], +} +#[derive(Clone, Copy, Debug)] +pub struct ContactRegion { + pub force: f32, + pub weighted_force: f32, + pub tangent_speed: f32, + pub material_flags: u32, + pub normal: V, + pub part: Option, +} +impl Default for ContactRegion { + fn default() -> Self { + Self { + force: 0.0, + weighted_force: 0.0, + tangent_speed: 0.0, + material_flags: 0, + normal: [0.0; 4], + part: None, + } + } +} +#[derive(Clone, Copy, Debug, Default)] +pub struct ContactPlane { + pub normal: V, + pub part: usize, +} +#[derive(Clone, Copy, Debug)] +pub struct SpecificContact { + pub part: usize, + pub local_point: V, + pub world_point: V, + pub radius_squared: f32, + pub current: bool, + pub recent: bool, +} +#[derive(Clone, Copy, Debug, Default)] +pub struct SkeletonContactFlags { + pub material_6: bool, + pub noncompliant: bool, + /// Native4070. + pub compliant: bool, + pub recovering: bool, + pub group_8: bool, + pub nonboard: bool, + pub ragdoll: bool, + pub conflicting: bool, + pub impaled: bool, + /// Native4077. This does not itself contain the pose-error vector16272. + pub has_impulse: bool, + pub foot_board: bool, + pub material_10: bool, + pub material_11: bool, + pub material_12: bool, + pub any: bool, +} + +pub struct SkeletonCollisionFeedback { + pub settings: SkeletonFeedbackSettings, + pub contact_age: [f32; 24], + pub priority: [f32; 24], + pub compliant: [bool; 24], + pub current: [bool; 24], + pub bones: [BoneContact; 24], + pub regions: [ContactRegion; 8], + pub planes: Vec, + pub specific: [SpecificContact; 2], + pub highest_normal: V, + pub foot_normal: V, + pub material_normals: [V; 2], + pub timer: f32, + /// Native4028. Reset is zero; Update recovers toward one. + pub drive_weight: f32, + pub maximum_priority: f32, + pub wipeout_times: [f32; 3], + pub maximum_skater_force: f32, + pub other_skater: i32, + pub maximum_group_8_force: f32, + pub maximum_group_11_force: f32, + pub material_12_height: f32, + pub flags: SkeletonContactFlags, +} + +impl SkeletonCollisionFeedback { + /// Constructor82BD5F48/6038 plus the final Body constructor Reset. Static + /// compliant settings survive Reset, while the priority array is cleared. + pub fn new(settings: SkeletonFeedbackSettings) -> Self { + let specific = [ + (23, settings.groin_offset, settings.groin_radius), + (1, settings.face_offset, settings.face_radius), + ] + .map(|(part, local_point, radius)| SpecificContact { + part, + local_point, + world_point: [0.0; 4], + radius_squared: radius * radius, + current: false, + recent: false, + }); + Self { + settings, + contact_age: [0.0; 24], + priority: [0.0; 24], + compliant: settings.body.compliant, + current: [false; 24], + bones: [BoneContact::default(); 24], + regions: [ContactRegion::default(); 8], + planes: Vec::with_capacity(20), + specific, + highest_normal: [0.0, 1.0, 0.0, 0.0], + foot_normal: [0.0, 1.0, 0.0, 0.0], + material_normals: [[0.0; 4]; 2], + timer: 0.0, + drive_weight: 0.0, + maximum_priority: 0.0, + wipeout_times: [0.0; 3], + maximum_skater_force: 0.0, + other_skater: -1, + maximum_group_8_force: 0.0, + maximum_group_11_force: 0.0, + material_12_height: 0.0, + flags: SkeletonContactFlags::default(), + } + } + + ///82BD5DA0 preserves compliant settings, specific-point histories and4082. + pub fn reset(&mut self) { + let specific = self.specific; + let compliant = self.compliant; + let any = self.flags.any; + *self = Self::new(self.settings); + self.specific = specific; + self.compliant = compliant; + self.flags.any = any; + } + + /// SetUpNormal82BE7280 calls Reset before reinstalling authored priority + /// and compliant settings. A subsequent constructor Reset clears priority. + pub fn set_up_normal(&mut self) { + self.reset(); + self.priority = self.settings.body.priority; + self.compliant = self.settings.body.compliant; + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_filter.rs b/crates/skate-core/src/physics/skeleton_body/collision_filter.rs new file mode 100644 index 00000000..863b9d53 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_filter.rs @@ -0,0 +1,71 @@ +//! Original82BD5A00: select the angular interval of active contact planes. +use super::{SkeletonCollisionFeedback, collision_feedback::V, collision_vector::*}; +use crate::{ + math::Vector3, + riding::collision_response::signed_angle, + trigonometry::{cos, sin}, +}; + +impl SkeletonCollisionFeedback { + pub fn filter_error(&self, error: V, axis: V) -> V { + let projected = sub(error, scale(axis, dot(error, axis))); + let direction = normalize(projected); + if dot(direction, direction) < 0.1 { + return projected; + } + let half_pi = f32::from_bits(0x3fc9_0fdb); + let mut minimum = half_pi; + let mut maximum = -half_pi; + let mut found = false; + for plane in &self.planes { + let normal = normalize(sub(plane.normal, scale(axis, dot(plane.normal, axis)))); + if dot(normal, normal) < 0.1 { + continue; + } + let angle = signed_angle(vector(direction), vector(normal), vector(axis)); + let turns = angle * f32::from_bits(0x3e22_f983); + let fractional = turns - turns.floor(); + let centered = fractional - if fractional > 0.5 { 1.0 } else { 0.0 }; + let wrapped = centered * f32::from_bits(0x40c9_0fdb); + if wrapped > -half_pi && wrapped < half_pi { + if wrapped < minimum { + minimum = wrapped; + } + if wrapped > maximum { + maximum = wrapped; + } + found = true; + } + } + let angle = (maximum + minimum) * 0.5; + let magnitude = if found { cos(angle) } else { 0.0 }; + //82BD35B8 rotates identity around the supplied axis before applying + //the projected error. Preserve its product/FMA and standalone trig. + let c = cos(angle); + let s = sin(angle); + let [x, y, z, _] = axis; + let sx = s * x; + let sy = s * y; + let sz = s * z; + let t = 1.0 - c; + let tx = t * x; + let ty = t * y; + let tz = t * z; + let rotation = [ + [x.mul_add(tx, c), tx.mul_add(y, sz), tx * z - sy], + [ty * x - sz, ty.mul_add(y, c), ty.mul_add(z, sx)], + [tz.mul_add(x, sy), tz * y - sx, z.mul_add(tz, c)], + ]; + let mut result = [0.0; 4]; + for i in 0..3 { + result[i] = rotation[2][i].mul_add( + projected[2], + rotation[1][i].mul_add(projected[1], rotation[0][i] * projected[0]), + ) * magnitude; + } + result + } +} +fn vector(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_mode.rs b/crates/skate-core/src/physics/skeleton_body/collision_mode.rs new file mode 100644 index 00000000..93ae440c --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_mode.rs @@ -0,0 +1,342 @@ +//! Original normal body setup82BE7280 and driven selector82D913C0 modes1/5/6. +//! Collision volume groups and part metadata are distinct from body state bits. +use super::{ANIMATION_PART_COUNT, PART_COUNT, SkeletonBody, SkeletonCollisionFeedback}; +use crate::physics::{contact::RetailContactMaterial, rigid_body::world_inverse_inertia}; + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonCollisionSettings { + pub enabled: bool, + pub normal_material: RetailContactMaterial, + /// Original collision classification settings copied by82BE745C/7468. + pub compliant: [bool; ANIMATION_PART_COUNT], + pub priority: [f32; ANIMATION_PART_COUNT], + pub effect_time: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonPartCollision { + /// Volume+92 bit0. Other volume flags are owned by geometry construction. + pub enabled: bool, + /// Volume+84; group4 is written by DisableCollisionWithWorld. + pub volume_group: u32, + /// Original Part+92, distinct from its volume group. + pub part_group: u32, + pub material: RetailContactMaterial, +} + +pub struct SkeletonCollisionMode { + pub parts: [SkeletonPartCollision; PART_COUNT], + pub disable_count: [u32; ANIMATION_PART_COUNT], + /// Original Body8224 gates postphysics countdown/reenabling. + pub pending_reenable: bool, + pub assembly_group: u32, + pub partial_ragdoll: bool, + pub is_ragdoll: bool, + pub settings: SkeletonCollisionSettings, + ///82BE82C0 table: true suppresses this pair before geometric collision. + pub self_culling: [[bool; PART_COUNT]; PART_COUNT], + normal_self_culling: [[bool; PART_COUNT]; PART_COUNT], +} + +impl SkeletonCollisionMode { + ///82BE7078, used by the real teleport path82BE3508. Collision volume + ///enabled flags and materials survive this reset. + pub fn reset_body_state(&mut self, feedback: &mut SkeletonCollisionFeedback) { + self.is_ragdoll = false; + feedback.reset(); + self.disable_count = [0; ANIMATION_PART_COUNT]; + self.pending_reenable = false; + } + + /// Full normal constructor state after82BE4290 and SetUpNormal. The final + /// argument is the actual constructor byte8227, not a guessed AI identity. + pub fn new_normal(settings: SkeletonCollisionSettings, cull_all_self_pairs: bool) -> Self { + //82BE5130..5164 assigns this zero material to parts0,24,25. + let zero = RetailContactMaterial { + static_friction: 0.0, + dynamic_friction: 0.0, + restitution: 0.0, + }; + let mut result = Self { + parts: std::array::from_fn(|part| SkeletonPartCollision { + enabled: false, + volume_group: if part == 0 { 4 } else { 0 }, + part_group: 5, + material: if (1..24).contains(&part) { + settings.normal_material + } else { + zero + }, + }), + disable_count: [0; ANIMATION_PART_COUNT], + pending_reenable: false, //82BE4660, following the24 counter clears. + assembly_group: 5, + partial_ragdoll: false, + is_ragdoll: false, + settings, + self_culling: [[true; PART_COUNT]; PART_COUNT], + normal_self_culling: [[true; PART_COUNT]; PART_COUNT], + }; + if !cull_all_self_pairs { + for (a, b) in [(14, 4), (14, 8), (23, 4), (23, 8), (4, 18), (8, 22)] { + result.self_culling[a][b] = false; + result.self_culling[b][a] = false; + } + } + if settings.enabled { + result.normal_collision(); + } else { + result.disable_all(true); + } + result.normal_self_culling = result.self_culling; + result + } + + ///Normal riding and ascending-Air collision. Mode6 calls82BE3700, whose + ///root/extra disabling, group5 and eligible-bone writes match mode5's result. + pub fn select_driven(&mut self, mode: u32) -> Result<(), &'static str> { + match mode { + 3 => { + self.select_biped(); + return Ok(()); + } + 5 | 6 => self.normal_collision(), + 1 => { + //82BE3808 enables eligible bones before switching the group. + self.enable_eligible_bones(); + self.set_group(3); + } + _ => return Err("Skeleton collision selector requires its non-driven physical setup"), + } + self.partial_ragdoll = false; + self.disable_root(); + self.parts[24].enabled = false; + self.parts[25].enabled = false; + Ok(()) + } + + ///82BE70D8. Does not consult BoneHasCollision, clear positive disable + ///counts, alter materials or disable the two extra volumes. + pub fn normal_collision(&mut self) { + self.set_group(5); + self.disable_root(); + self.enable_eligible_bones(); + } + + ///82BE6FF8. Bone volume group values survive this operation. + pub fn disable_all(&mut self, clear_counts: bool) { + self.disable_root(); + self.parts[24].enabled = false; + self.parts[25].enabled = false; + for part in 1..24 { + self.parts[part].enabled = false; + if clear_counts { + self.disable_count[part] = 0; + } + } + } + + ///82BE7190. This operation tests the counter but does not decrement it. + pub fn enable_bone(&mut self, part: usize) { + if self.disable_count[part] == 0 { + self.parts[part].enabled = true; + self.parts[part].volume_group = 0; + } + self.parts[part].part_group = 5; + } + + ///82BD8364..83BC runs after collision observations and before82BD9FB0. + /// It only changes the enabled bit, preserving each volume's group. + pub fn finish_contact_frame(&mut self) { + if !self.pending_reenable { + return; + } + let mut remaining = false; + for part in 0..ANIMATION_PART_COUNT { + if (self.disable_count[part] as i32) > 0 { + self.disable_count[part] -= 1; + let enabled = self.disable_count[part] == 0; + self.parts[part].enabled = enabled; + remaining |= !enabled; + } + } + self.pending_reenable = remaining; + } + + ///82BE71F0, unlike the whole-body normal path, uses BoneHasCollision. + pub fn normal_bone(&mut self, part: usize, has_collision: bool) { + if has_collision { + if self.disable_count[part] == 0 { + self.parts[part].enabled = true; + self.parts[part].volume_group = 0; + } + } else { + self.parts[part].enabled = false; + self.parts[part].volume_group = 4; + self.disable_count[part] = 0; + } + } + + /// Physical-property portion of82BE7280. The coordinator separately resets + /// the real SkeletonCollision observations and reinstalls normal joint + /// limits in that method's original order. This method does not fake either. + pub fn restore_normal_properties(&mut self, skeleton: &mut SkeletonBody) { + self.self_culling = self.normal_self_culling; + self.set_group(5); + for part in 0..PART_COUNT { + let animated = skeleton.definition.parts[part]; + let body = &mut skeleton.bodies_mut()[part]; + body.inertia.linear_drag = 0.0; + body.inertia.angular_drag = 0.0; + if (1..24).contains(&part) { + body.inertia.inverse_mass = animated.inverse_mass_animated; + body.inertia.inverse_tensor = animated.animated.dynamics.inverse_tensor; + let tensor = body.inertia.inverse_tensor; + let mut minimum = tensor.x; + if minimum >= tensor.y { + minimum = tensor.y; + } + if minimum >= tensor.z { + minimum = tensor.z; + } + body.inertia.spherical = 1.0 / minimum; + body.rates.world_inverse_inertia = world_inverse_inertia(body.rates.basis, tensor); + self.parts[part].material = self.settings.normal_material; + } + } + if self.settings.enabled { + self.normal_collision(); + } else { + self.disable_all(true); + } + self.is_ragdoll = false; + } + + ///Physical properties of82BE6D60; joint limits are set first by its caller. + pub fn apply_ragdoll_properties( + &mut self, + skeleton: &mut SkeletonBody, + inverse_mass: bool, + inverse_inertia: bool, + drag: [f32; 2], + materials: [RetailContactMaterial; 2], + ) { + //Exact raw82BE8900..A664 stores, not the asymmetric decompiler output. + const CULLED: [u32; PART_COUNT] = [ + 0x03FFFFFF, 0x03FFF447, 0x03FFFC47, 0x039B8479, 0x03BB8479, 0x039F8479, 0x03FFFFFF, + 0x03B987C1, 0x03BB87C1, 0x03F987C1, 0x03FFFFFF, 0x03FFFC45, 0x03FFFC47, 0x03FFFC47, + 0x03FFFC47, 0x03FFFFFF, 0x038FFFFF, 0x038FFD7F, 0x03CFFC67, 0x03FFFFFF, 0x03F8FFFF, + 0x03F8FFD7, 0x03FCFE47, 0x03FFFFFF, 0x03FFFFFF, 0x03FFFFFF, + ]; + //Constructor byte8227 selects the all-culled table for both modes. + if self + .normal_self_culling + .iter() + .flatten() + .all(|culled| *culled) + { + self.self_culling = [[true; PART_COUNT]; PART_COUNT]; + } else { + self.self_culling = + std::array::from_fn(|a| std::array::from_fn(|b| CULLED[a] & (1 << b) != 0)); + } + self.set_group(6); + for body in skeleton.bodies_mut() { + body.inertia.linear_drag = drag[0]; + body.inertia.angular_drag = drag[1]; + } + for part in 1..24 { + let authored = skeleton.definition.parts[part]; + let body = &mut skeleton.bodies_mut()[part]; + self.disable_count[part] = 0; + self.parts[part].enabled = true; + self.pending_reenable = false; + self.parts[part].material = materials[usize::from(matches!(part, 1 | 3 | 7))]; + if inverse_mass { + body.inertia.inverse_mass = authored.inverse_mass_ragdoll; + } + if inverse_inertia { + let tensor = authored.ragdoll.dynamics.inverse_tensor; + body.inertia.inverse_tensor = tensor; + let mut minimum = tensor.x; + if minimum >= tensor.y { + minimum = tensor.y; + } + if minimum >= tensor.z { + minimum = tensor.z; + } + body.inertia.spherical = 1.0 / minimum; + } + body.rates.world_inverse_inertia = + world_inverse_inertia(body.rates.basis, body.inertia.inverse_tensor); + } + self.is_ragdoll = true; + } + + ///The post-body portions of original selector modes7..10. + pub fn finish_ragdoll_request(&mut self, mode: u32) { + match mode { + 7 | 10 => { + //Skeleton82BE3350 clears partial mode, root and extra volumes. + self.partial_ragdoll = false; + self.disable_root(); + self.parts[24].enabled = false; + self.parts[25].enabled = false; + } + 8 => { + //82D915A0..C4 uses constructor material3076 for all bones. + for part in 1..24 { + self.parts[part].material = RetailContactMaterial { + static_friction: 0.5, + dynamic_friction: 0.3, + restitution: 0.4, + }; + } + } + 9 => self.set_group(7), + _ => unreachable!("Only original Wipeout requests8..11 enter this adapter"), + } + } + + fn set_group(&mut self, group: u32) { + self.assembly_group = group; + for part in &mut self.parts { + part.part_group = group; + } + } + ///Request4 branch82D914B0 and Body82BE5248. Existing physical masses, + ///joint limits, material and self-pair table survive this policy change. + fn select_biped(&mut self) { + self.set_group(6); + self.disable_all(false); + for part in [17, 21, 15, 19, 16, 20] { + self.enable_bone(part); + self.parts[part].part_group = 17; + } + for part in [5, 4, 3, 9, 8, 7, 2, 1] { + self.enable_bone(part); + } + for part in [6, 10, 11, 12, 13, 14, 23] { + self.enable_bone(part); + self.parts[part].part_group = 18; + } + self.partial_ragdoll = true; + for part in [24, 25] { + self.parts[part].part_group = 20; + self.parts[part].enabled = true; + } + } + fn disable_root(&mut self) { + self.parts[0].volume_group = 4; + self.parts[0].enabled = false; + self.disable_count[0] = 0; + } + fn enable_eligible_bones(&mut self) { + for part in 1..24 { + if self.disable_count[part] == 0 { + self.parts[part].enabled = true; + self.parts[part].volume_group = 0; + } + } + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_update.rs b/crates/skate-core/src/physics/skeleton_body/collision_update.rs new file mode 100644 index 00000000..4b528f7a --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_update.rs @@ -0,0 +1,356 @@ +//! Complete contact traversal and classification of TU3 82BD4A30. +use super::collision_feedback::*; +use super::collision_vector::{ + add, clamp_length, cross, dot, length, normalize, scale, sub, transform, +}; + +// Original read-only table820CFCB0. Part0 is not a region participant. +const REGIONS: [usize; 24] = [ + usize::MAX, + 0, + 1, + 2, + 2, + 2, + 1, + 3, + 3, + 3, + 1, + 1, + 1, + 1, + 1, + 6, + 6, + 4, + 4, + 7, + 7, + 5, + 5, + 1, +]; + +impl SkeletonCollisionFeedback { + pub fn update( + &mut self, + input: &SkeletonCollisionInput<'_>, + reports: &[SkeletonContactReport], + ) { + self.begin_frame(input); + let reference_speed = length(input.reference_velocity); + let direction = normalize(input.reference_velocity); + for report in reports { + // Original constructor enables reports for the24 animated parts. + // A different owner must not silently index this state as part24/25. + assert!( + report.part < 24, + "SkeletonCollision report part exceeds native24-part array" + ); + let part = report.part; + let material = (report.tag >> 7) & 31; + self.flags.any = true; + if report.other_group == 4 && [15, 16, 19, 20].contains(&part) { + self.flags.foot_board = true; + } + if material == 6 { + self.flags.material_6 = true; + } + if material == 12 { + self.flags.material_12 = true; + self.material_12_height = report.point[1]; + } + if self.ignore_ground(input, report) { + continue; + } + self.observe_normal(input.ragdoll, report, material); + let mut specific = false; + for point in &mut self.specific { + let delta = sub(report.point, point.world_point); + if part == point.part && dot(delta, delta) < point.radius_squared { + point.current = true; + point.recent = true; + specific = true; + } + } + // This gate precedes priority replacement and all force recording. + if self.planes.len() >= 20 { + continue; + } + self.contact_age[part] = 0.0; + self.current[part] = true; + if self.priority[part] > self.maximum_priority { + self.maximum_priority = self.priority[part]; + self.planes.clear(); + } + if self.priority[part] == self.maximum_priority { + self.planes.push(ContactPlane { + normal: report.normal, + part, + }); + } + + let mass_a = mass(report.body_a); + let mass_b = mass(report.body_b); + let other_mass = if report.side_a { mass_b } else { mass_a }; + let small = other_mass > 0.0001 && other_mass < self.settings.small_object_mass; + let factor = ((mass_b + mass_a) / (mass_b * mass_a)) * f32::from_bits(0x3991_A2B5); + let weighted_force = + length(scale(report.solved_vector, factor)) * input.part_weights[part]; + let part_velocity = input.physical.velocities[part]; + let own_velocity = if input.ragdoll { + scale(add(input.com_velocity, part_velocity), 0.5) + } else if input.offboard || input.entering_offboard { + let mut velocity = input.com_velocity; + velocity[1] = 0.0; + velocity + } else { + let projected = scale(direction, dot(part_velocity, direction)); + clamp_length(projected, reference_speed) + }; + let other = if report.side_a { + report.body_b + } else { + report.body_a + }; + let relative = sub(other.linear_velocity, own_velocity); + let mut force = dot(relative, report.normal).abs(); + if small { + force *= 0.5; + } + match report.other_group { + 5 => { + force *= if input.ai_collision_scalar { + self.settings.ai_scalar + } else { + self.settings.skater_scalar + }; + self.maximum_skater_force = maximum(self.maximum_skater_force, force); + if let Some(entity) = report.other_entity { + self.other_skater = entity; + } + self.flags.nonboard = true; + } + 8 => { + self.flags.group_8 = true; + self.maximum_group_8_force = maximum(self.maximum_group_8_force, force); + self.flags.nonboard = true; + } + 11 => { + self.maximum_group_11_force = maximum(self.maximum_group_11_force, force); + self.flags.nonboard = true; + } + 4 => (), + _ => self.flags.nonboard = true, + } + if part > 0 { + self.record_force(report, specific, relative, force, weighted_force); + self.timer = self.settings.body.effect_time; + } + if !self.compliant[part] || small { + self.flags.noncompliant = true; + } else { + self.flags.compliant = true; + } + self.flags.has_impulse = self.flags.compliant || input.offboard; + } + if input.ragdoll { + self.flags.impaled = self.check_impaled(); + } else { + self.flags.conflicting = self.check_conflicting(input); + } + let duration = self.settings.body.effect_time; + self.drive_weight = if duration == 0.0 { + 1.0 + } else { + let ratio = self.timer / duration; + let low = if -ratio >= 0.0 { 0.0 } else { ratio }; + 1.0 - if 1.0 - low >= 0.0 { low } else { 1.0 } + }; + self.flags.recovering = self.drive_weight < 1.0; + } + + fn begin_frame(&mut self, input: &SkeletonCollisionInput<'_>) { + self.timer -= input.dt; + self.wipeout_times[2] += input.dt; + self.flags = SkeletonContactFlags { + ragdoll: input.ragdoll, + ..Default::default() + }; + self.planes.clear(); + self.maximum_priority = 0.0; + self.maximum_skater_force = 0.0; + self.other_skater = -1; + self.maximum_group_8_force = 0.0; + self.maximum_group_11_force = 0.0; + self.material_12_height = 0.0; + self.highest_normal = [0.0, -1.0, 0.0, 0.0]; + self.foot_normal = [0.0; 4]; + self.material_normals = [[0.0, -1.0, 0.0, 0.0]; 2]; + for part in 0..24 { + self.contact_age[part] -= input.dt; + self.current[part] = false; + self.bones[part] = BoneContact::default(); + } + for region in &mut self.regions { + // Unlike Reset, Update leaves the material flag untouched. + *region = ContactRegion { + material_flags: region.material_flags, + ..Default::default() + }; + } + if !input.ragdoll { + self.wipeout_times = [0.0; 3]; + } + for point in &mut self.specific { + point.recent &= input.ragdoll; + point.current = false; + point.world_point = transform(input.physical.pose[point.part], point.local_point); + } + if input.request_partial_ragdoll { + self.timer = self.settings.body.effect_time; + } + } + + fn ignore_ground( + &self, + input: &SkeletonCollisionInput<'_>, + report: &SkeletonContactReport, + ) -> bool { + if input.ragdoll || (input.disable_ground_filter && !input.category_600) { + return false; + } + let distance = dot(input.plane_normal, sub(report.point, input.plane_point)); + let threshold = if input.offboard { + 0.3 + } else { + self.settings.ground_plane_max_distance + }; + distance < threshold + && (input.offboard + || dot(report.normal, input.plane_normal).abs() + > self.settings.ground_plane_max_angle) + } + fn observe_normal(&mut self, ragdoll: bool, report: &SkeletonContactReport, material: u32) { + let normal = report.normal; + if normal[1] > self.highest_normal[1] { + self.highest_normal = normal; + } + if ragdoll { + if material == 10 && normal[1] > self.material_normals[0][1] { + self.material_normals[0] = normal; + self.flags.material_10 = true; + } else if material == 11 && normal[1] > self.material_normals[1][1] { + self.material_normals[1] = normal; + self.flags.material_11 = true; + } + } else if (report.part == 15 || report.part == 19) && normal[1] > self.foot_normal[1] { + self.foot_normal = normal; + } + } + fn record_force( + &mut self, + report: &SkeletonContactReport, + specific: bool, + relative: V, + force: f32, + weighted_force: f32, + ) { + let bone = &mut self.bones[report.part]; + let region = &mut self.regions[REGIONS[report.part]]; + let previous = if specific { + bone.specific_force + } else { + bone.force + }; + let tangent = if force > previous || force > region.force { + cross(relative, report.normal) + } else { + [0.0; 4] + }; + if force > previous { + if specific { + bone.specific_force = force; + bone.specific_normal = report.normal; + bone.specific_tangent = tangent; + bone.specific_tag = report.tag; + } else { + bone.force = force; + bone.normal = report.normal; + bone.tangent = tangent; + bone.point = report.point; + bone.tag = report.tag; + } + for (i, group) in [8, 11, 5].into_iter().enumerate() { + bone.groups[i] |= report.other_group == group; + } + bone.groups[3] |= ![8, 11, 5].contains(&report.other_group); + } + if force > region.force { + *region = ContactRegion { + force, + weighted_force, + tangent_speed: length(tangent), + material_flags: report.tag & 0x7f, + normal: report.normal, + part: Some(report.part), + }; + } + } + /// Original82BD5770 calls GetPartTransform, not animation-pose lookup. + fn check_conflicting(&self, input: &SkeletonCollisionInput<'_>) -> bool { + for (i, a) in self.regions.iter().enumerate() { + let Some(part_a) = a.part else { + continue; + }; + if !(a.force > 0.0) || !self.compliant[part_a] { + continue; + } + for (j, b) in self.regions.iter().enumerate() { + let Some(part_b) = b.part else { + continue; + }; + if i == j || !(b.force > 0.0) || !self.compliant[part_b] { + continue; + } + if dot(a.normal, b.normal) < -0.7 + && dot( + sub(input.body_frames[part_b][3], input.body_frames[part_a][3]), + a.normal, + ) < 0.0 + { + return true; + } + } + } + false + } + fn check_impaled(&self) -> bool { + for i in 1..23 { + let a = self.bones[i]; + if !(a.force > 0.0) { + continue; + } + for b in &self.bones[i + 1..24] { + if b.force > 0.0 + && dot(a.normal, b.normal) < -0.99 + && dot(a.normal, sub(a.point, b.point)).abs() < 0.03 + { + return true; + } + } + } + false + } +} +fn mass(body: SkeletonContactBody) -> f32 { + 1.0 / if body.state_flags == 1 { + 0.001 + } else { + body.inverse_mass + } +} +fn maximum(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { a } else { b } +} diff --git a/crates/skate-core/src/physics/skeleton_body/collision_vector.rs b/crates/skate-core/src/physics/skeleton_body/collision_vector.rs new file mode 100644 index 00000000..b94e8964 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/collision_vector.rs @@ -0,0 +1,66 @@ +//! Original vector arithmetic used by82BD4A30 and82BD5A00. +use super::collision_feedback::V; +use crate::physics::{ + native_arithmetic::dot3, reciprocal_sqrt::estimate, + skeleton_animation_record::AnimationPartTransform, +}; +pub(super) fn dot(a: V, b: V) -> f32 { + dot3(a, b) +} +pub(super) fn scale(v: V, s: f32) -> V { + v.map(|v| v * s) +} +pub(super) fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn add(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn inverse_length(square: f32) -> f32 { + let mut inverse = estimate(square); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-square).mul_add(inverse * inverse, 1.0), inverse); + } + inverse +} +pub(super) fn length(v: V) -> f32 { + let square = dot(v, v); + let result = square * inverse_length(square); + if square == 0.0 { 0.0 } else { result } +} +pub(super) fn normalize(v: V) -> V { + let square = dot(v, v); + let inverse = inverse_length(square); + let size = if square == 0.0 { 0.0 } else { square * inverse }; + if size > 1.0e-6 { + scale(v, inverse) + } else { + [0.0; 4] + } +} +///82BD3D90 keeps all stored lanes, including the unused fourth velocity lane. +pub(super) fn clamp_length(v: V, maximum: f32) -> V { + let size = length(v); + if size < f32::from_bits(0x3780_0000) { + return v; + } + let bounded = if maximum - size >= 0.0 { size } else { maximum }; + let mut inverse = crate::physics::native_arithmetic::reciprocal_estimate(size); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(size, 1.0), inverse); + } + scale(scale(v, bounded), inverse) +} +pub(super) fn transform(m: AnimationPartTransform, v: V) -> V { + std::array::from_fn(|i| { + m[2][i].mul_add(v[2], m[1][i].mul_add(v[1], m[0][i].mul_add(v[0], m[3][i]))) + }) +} +pub(super) 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]), + ] +} diff --git a/crates/skate-core/src/physics/skeleton_body/construction.rs b/crates/skate-core/src/physics/skeleton_body/construction.rs new file mode 100644 index 00000000..a58477c0 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/construction.rs @@ -0,0 +1,181 @@ +//! SkeletonBody::Init82BE4900, head82BE5428 and extra capsules82BE5180. +use super::{ANIMATION_PART_COUNT, PART_COUNT}; +use crate::{math::{Basis3, Vector3}, physics::{ + mass::{MassMoments, MassShape, PartMassInput, RETAIL_UNBOUNDED_VELOCITY, + aggregate_mass_properties, primitive_mass, primitive_mass_properties}, + native_arithmetic, + rigid_body::{RetailBodyMassProperties, RetailLocalMassFrame}, + skeleton_animation_record::SkeletonAnimationMasses, +}}; + +#[derive(Clone, Copy, Debug)] +pub struct BoneSettings { + pub mass_factor: f32, + pub ragdoll_mass_factor: f32, + pub has_collision: bool, + pub use_root_drive: bool, + pub volume_type: u32, + pub volume_scalar: f32, + pub num_parents: u32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonBodySettings { + /// Native MassOfSkeleton multiplies each original bone-box volume. It is + /// not a requested total body mass and must not normalize the part masses. + pub density: f32, + pub root_radius: f32, + pub root_half_length: f32, + pub capsule_radius_scalar: f32, + pub capsule_length_scalar: f32, + pub ragdoll_inverse_mass_factor: f32, + pub inertia_multiply_type: u32, + pub inertia_factor: f32, +} + +/// Authored cylinder and transform from physics_hat, before head aggregation. +#[derive(Clone, Copy, Debug)] +pub struct HatGeometry { + pub radius: f32, + pub half_length: f32, + pub basis: Basis3, + pub translation: Vector3, +} +impl HatGeometry { + ///82BE55A0..569C: authored Euler angles feed XMVectorSinCos directly. + pub fn from_offsets(radius: f32, thickness: f32, angles: Vector3, translation: Vector3) -> Self { + let (sx,cx) = crate::trigonometry::sin_cos(angles.x); + let (sy,cy) = crate::trigonometry::sin_cos(angles.y); + let (sz,cz) = crate::trigonometry::sin_cos(angles.z); + let cxsz = cx*sz; + let sxsz = sx*sz; + let sxcz = sx*cz; + let cxcz = cx*cz; + Self { radius, half_length: thickness*0.5, translation, + basis: Basis3 { columns: [ + [cy*cz, cy*sz, -sy], + [sy*sxcz-cxsz, sy.mul_add(sxsz,cxcz), cy*sx], + [sy.mul_add(cxcz,sxsz), sy*cxsz-sxcz, cy*cx], + ]} } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonPart { + /// Part1 can additionally contain the separately described hat cylinder. + pub shape: MassShape, + pub hat: Option, + pub animated: RetailBodyMassProperties, + pub ragdoll: RetailBodyMassProperties, + pub inverse_mass_animated: f32, + pub inverse_mass_ragdoll: f32, +} + +pub struct SkeletonBodyDefinition { + pub parts: [SkeletonPart; PART_COUNT], + pub bones: [BoneSettings; ANIMATION_PART_COUNT], + pub animation_masses: SkeletonAnimationMasses, +} +impl SkeletonBodyDefinition { + pub fn new(sizes: [Vector3; ANIMATION_PART_COUNT], + bones: [BoneSettings; ANIMATION_PART_COUNT], settings: SkeletonBodySettings, + hat: Option) -> Result { + let mut parts = Vec::with_capacity(PART_COUNT); + for i in 0..ANIMATION_PART_COUNT { + let b = bones[i]; + let size = sizes[i]; + let shape = if i == 0 { + MassShape::Capsule { radius: settings.root_radius, + half_length: settings.root_half_length } + } else if i == 1 && hat.is_some() || b.volume_type == 1 { + let x = size.x * 0.5; + let y = size.y * 0.5; + MassShape::Sphere { radius: (if x-y >= 0.0 {x} else {y}) * b.volume_scalar } + } else { match b.volume_type { + 0 => { + let scalar = b.volume_scalar * 0.5; + MassShape::RoundedBox { half_extents: Vector3::new( + size.x*scalar, size.y*scalar, size.z*scalar), radius: 0.0 } + } + 2 => { + let radius = (size.x * settings.capsule_radius_scalar) * 0.5; + let length = size.z * 0.5 - radius; + MassShape::Capsule { radius: b.volume_scalar * radius, + half_length: ((if -length >= 0.0 {0.0} else {length}) + * settings.capsule_length_scalar) * b.volume_scalar } + } + _ => return Err("Unresolved non-root skeleton volume type"), + }}; + let base_mass = if i == 0 { f32::from_bits(0x3A83_126F) } + else { settings.density * ((size.x * size.y) * size.z) }; + let head_hat = if i == 1 {hat} else {None}; + let animated_mass = b.mass_factor * base_mass; + let ragdoll_mass = b.ragdoll_mass_factor * base_mass; + let mut animated = mass_properties(shape, head_hat, animated_mass)?; + let ragdoll = mass_properties(shape, head_hat, ragdoll_mass)?; + adjust_inertia(&mut animated, settings.inertia_multiply_type, settings.inertia_factor); + parts.push(SkeletonPart { shape, hat: head_hat, animated, ragdoll, + inverse_mass_animated: 1.0 / animated_mass, + inverse_mass_ragdoll: settings.ragdoll_inverse_mass_factor / ragdoll_mass }); + } + //82BE4F80..4FC4 passes these independent radius/half-length/mass words. + for (radius, half_length, mass) in [ + (0x3E6B_851F, 0x3DCC_CCCD, 0x3DCC_CCCD), + (0x3EB8_51EC, 0x3E85_1EB8, 0x3C23_D70A), + ] { + let mass = f32::from_bits(mass); + let shape = MassShape::Capsule { radius: f32::from_bits(radius), + half_length: f32::from_bits(half_length) }; + let mut properties = mass_properties(shape, None, mass)?; + properties.dynamics.maximum_linear_velocity = f32::from_bits(0x41EF_FFFF); + properties.dynamics.maximum_angular_velocity = f32::from_bits(0x41EF_FFFF); + adjust_inertia(&mut properties, 1, 5.0); + parts.push(SkeletonPart { shape, hat: None, animated: properties, + ragdoll: properties, inverse_mass_animated: 1.0/mass, + inverse_mass_ragdoll: 1.0/mass }); + } + Ok(Self { parts: parts.try_into().map_err(|_| "Skeleton part count")?, + animation_masses: SkeletonAnimationMasses::from_bone_data( + sizes, bones.map(|b| b.volume_type), hat.is_some()), bones }) + } +} + +fn mass_properties(shape: MassShape, hat: Option, mass: f32) + -> Result { + if let Some(hat) = hat { + // Init82BE5428 uses cylinder first, then the head sphere. Preserve + // accumulation order and the later identity mass-frame override. + let mut moments = MassMoments::from_primitive(primitive_mass(MassShape::Cylinder { + radius: hat.radius, half_length: hat.half_length, padding: 0.0 + }).ok_or("Invalid skeleton hat primitive")?); + moments.transform(hat.basis, hat.translation); + moments.add(MassMoments::from_primitive(primitive_mass(shape).ok_or("Invalid head primitive")?)); + let mut properties = aggregate_mass_properties(moments, mass, RETAIL_UNBOUNDED_VELOCITY, 0.0); + properties.local_mass_frame = RetailLocalMassFrame::IDENTITY; + Ok(properties) + } else { + primitive_mass_properties(PartMassInput { shape, requested_mass: mass }, + RETAIL_UNBOUNDED_VELOCITY, 0.0).ok_or("Invalid skeleton primitive") + } +} + +/// DebugMultiplyInertiaTensor82E0B070 / SetSphericalInertiaTensor82E0B110. +fn adjust_inertia(properties: &mut RetailBodyMassProperties, kind: u32, factor: f32) { + let d = &mut properties.dynamics; + if kind == 1 { + let mut reciprocal = native_arithmetic::reciprocal_estimate(factor); + for _ in 0..2 { + reciprocal = reciprocal.mul_add((-reciprocal).mul_add(factor, 1.0), reciprocal); + } + d.inverse_tensor = Vector3::new(d.inverse_tensor.x*reciprocal, + d.inverse_tensor.y*reciprocal, d.inverse_tensor.z*reciprocal); + let xy = if d.inverse_tensor.x < d.inverse_tensor.y {d.inverse_tensor.x} else {d.inverse_tensor.y}; + let xyz = if xy < d.inverse_tensor.z {xy} else {d.inverse_tensor.z}; + d.spherical = 1.0 / xyz; + } else if kind == 2 { + let reciprocal = 1.0 / (factor * 3.0); + let value = ((d.inverse_tensor.x + d.inverse_tensor.y) + d.inverse_tensor.z) * reciprocal; + d.inverse_tensor = Vector3::new(value,value,value); + d.spherical = 1.0 / value; + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/drive_dynamics.rs b/crates/skate-core/src/physics/skeleton_body/drive_dynamics.rs new file mode 100644 index 00000000..8dc62f07 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/drive_dynamics.rs @@ -0,0 +1,134 @@ +//! BoneDrives::EnableDrive82BECCE0 and mode-five interpolation82BECFB8. +//! A bone shares its interpolation counter between the LOCAL and LOCAL_ROOT +//! channels. Dispatch order is therefore observable even within one tick. +use crate::physics::drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}; + +const FREQUENCY: f32 = f32::from_bits(0x426F_FFFF); +const FREQUENCY_SQUARED: f32 = f32::from_bits(0x4560_FFFE); +const HARD_VELOCITY: f32 = f32::from_bits(0x4415_FFFF); +const HARD_STRENGTH: f32 = f32::from_bits(0x470C_9FFF); + +#[derive(Clone, Copy, Debug)] +pub struct AnimationDriveSettings { + pub linear_strength: f32, + pub linear_displacement: f32, + pub angular_strength: f32, + pub angular_displacement: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct DriveInterpolation { + pub spring: [f32; 2], + pub strength: [f32; 2], + pub damping: [f32; 2], +} + +#[derive(Clone, Copy, Debug)] +pub struct BoneDriveSettings { + pub animation: [AnimationDriveSettings; 2], + pub collision_soft_displacement: f32, + pub collision_soft_strength: f32, + pub ragdoll_soft_displacement: f32, + pub ragdoll_soft_strength: f32, + pub transition_linear: DriveInterpolation, + pub transition_angular: DriveInterpolation, + pub transition_calls: f32, +} + +#[derive(Clone, Debug)] +pub struct BoneDriveDynamics { + pub channels: [RetailDriveDynamics; 2], + pub mode: u32, + pub strengths: [f32; 2], + pub transition_counter: f32, + pub transition_active: bool, +} +impl Default for BoneDriveDynamics { + fn default() -> Self { + Self { + channels: [RetailDriveDynamics { + linear: RetailDriveParams::DISABLED, + angular: RetailDriveParams::DISABLED, + }; 2], + mode: 0, strengths: [0.0; 2], transition_counter: 0.0, + transition_active: false, + } + } +} +impl BoneDriveDynamics { + pub fn enable(&mut self, channel: usize, strength: f32, settings: BoneDriveSettings) { + assert!(channel < 2); + if self.mode > 5 { return; } + if self.mode != 5 { self.transition_active = false; } + let animation = settings.animation[channel]; + let dynamics = match self.mode { + 0 => RetailDriveDynamics { + linear: hard(animation.linear_displacement * FREQUENCY, + animation.linear_strength * FREQUENCY_SQUARED), + angular: hard(animation.angular_displacement * FREQUENCY, + animation.angular_strength * FREQUENCY_SQUARED), + }, + 1 => RetailDriveDynamics { + linear: hard(HARD_VELOCITY, HARD_STRENGTH), + angular: hard(HARD_VELOCITY, HARD_STRENGTH), + }, + 2 => RetailDriveDynamics { + linear: hard(0.0, 0.0), + angular: hard(animation.angular_displacement * FREQUENCY, + animation.angular_strength * FREQUENCY_SQUARED), + }, + 3 => RetailDriveDynamics { + linear: soft(0.0, 0.0, 0.0), + angular: soft(settings.collision_soft_displacement * strength, 200.0, + (settings.collision_soft_strength * strength) * FREQUENCY_SQUARED), + }, + 4 => RetailDriveDynamics { + linear: if strength <= 0.5 { soft(0.0, 0.0, 0.0) } else { + soft((settings.ragdoll_soft_displacement * strength) * 0.5, 200.0, + (strength * settings.ragdoll_soft_strength) * FREQUENCY_SQUARED) + }, + angular: soft(settings.ragdoll_soft_displacement * strength, 200.0, + (strength * settings.ragdoll_soft_strength) * FREQUENCY_SQUARED), + }, + 5 => { + if !self.transition_active { + self.transition_active = true; + self.transition_counter = 1.0; + } else if self.transition_counter < settings.transition_calls { + self.transition_counter += 1.0; + } + let progress = self.transition_counter / settings.transition_calls; + RetailDriveDynamics { + linear: interpolate(settings.transition_linear, strength, progress), + angular: interpolate(settings.transition_angular, strength, progress), + } + }, + _ => unreachable!(), + }; + self.channels[channel] = dynamics; + } +} + +fn hard(velocity: f32, strength: f32) -> RetailDriveParams { + RetailDriveParams { spring_or_max_velocity: velocity, damping: 0.0, + max_strength: strength, drive_type: RetailDriveType::HardDrive } +} +fn soft(spring: f32, damping: f32, strength: f32) -> RetailDriveParams { + RetailDriveParams { spring_or_max_velocity: spring, damping, + max_strength: strength, drive_type: RetailDriveType::SoftDrive } +} +fn interpolate(settings: DriveInterpolation, strength: f32, progress: f32) -> RetailDriveParams { + // Original82BED100..114 are scalar fmadds (primary59,extended29). + // The retained older pseudocode expands them to multiply+add expressions. + let spring0 = settings.spring[0] * strength; + let strength0 = settings.strength[0] * strength; + let spring = (settings.spring[1] * strength - spring0).mul_add(progress, spring0); + let force = (settings.strength[1] * strength - strength0).mul_add(progress, strength0); + let damping = (settings.damping[1] - settings.damping[0]).mul_add(progress, settings.damping[0]); + let nonnegative = |value| if value >= 0.0 { value } else { 0.0 }; + RetailDriveParams { + spring_or_max_velocity: nonnegative(spring) * FREQUENCY, + max_strength: nonnegative(force) * FREQUENCY_SQUARED, + damping: nonnegative(damping), drive_type: RetailDriveType::HardDrive, + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/drive_frames.rs b/crates/skate-core/src/physics/skeleton_body/drive_frames.rs new file mode 100644 index 00000000..6b52c06f --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/drive_frames.rs @@ -0,0 +1,42 @@ +//! BoneDrives::SetDriveFrames82BD6FD0. A common animation-space frame is +//! expressed in the child and parent coordinates before quaternion packing. +use crate::{math::{Basis3, Vector3}, physics::{ + drive_frames::{RetailDriveFrame, RetailDriveFrames, retail_quaternion_from_basis}, + drive_preparation::normalize_drive_frames, + rigid_body::RetailQuaternion, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, +}}; + +pub fn bone_drive_frames(child_animation: &AnimationPartTransform, + inverse_child: &AnimationPartTransform, inverse_parent: &AnimationPartTransform, +) -> RetailDriveFrames { + let mut common = IDENTITY; + common[3] = child_animation[3]; + let child = compose_affine(inverse_child, &common); + let parent = compose_affine(inverse_parent, &common); + RetailDriveFrames { body_a: pack(child), body_b: pack(parent) } +} + +/// Step_Solver2 normalizes each active pair before building the drive rows. +pub fn prepare_bone_drive_frames(frames: RetailDriveFrames) -> RetailDriveFrames { + let mut words = [0;16]; + for (i, frame) in [frames.body_a, frames.body_b].into_iter().enumerate() { + let q = frame.orientation; + let p = frame.translation; + words[i*8..i*8+8].copy_from_slice(&[q.x,q.y,q.z,q.w,p.x,p.y,p.z,0.0].map(f32::to_bits)); + } + normalize_drive_frames(&mut words); + let frame = |offset: usize| { + let f = |i: usize| f32::from_bits(words[offset+i]); + RetailDriveFrame { orientation: RetailQuaternion { x:f(0),y:f(1),z:f(2),w:f(3) }, + translation: Vector3::new(f(4),f(5),f(6)) } + }; + RetailDriveFrames { body_a:frame(0), body_b:frame(8) } +} +fn pack(frame: AnimationPartTransform) -> RetailDriveFrame { + RetailDriveFrame { + orientation: retail_quaternion_from_basis(Basis3 { + columns: std::array::from_fn(|i| [frame[i][0],frame[i][1],frame[i][2]]) }), + translation: Vector3::new(frame[3][0],frame[3][1],frame[3][2]), + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/drives.rs b/crates/skate-core/src/physics/skeleton_body/drives.rs new file mode 100644 index 00000000..d17b47f5 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/drives.rs @@ -0,0 +1,226 @@ +//! Original SkeletonDrives82BEA670/82BEB488: two channels per physical bone +//! and four independent static targets, all solved in the same island. +use super::{ + ANIMATION_PART_COUNT, BoneDriveDynamics, BoneDriveSettings, PART_COUNT, SkeletonTargets, + TARGET_COUNT, bone_drive_frames, prepare_bone_drive_frames, + targets::{HARD_STRENGTH, HARD_VELOCITY, TARGET_PARTS, hard}, +}; +use crate::physics::{ + assembly::BodySnapshot, + board_pose::orthonormalize_part_basis, + drive_frames::RetailDriveFrames, + drive_solver::{RetailDriveBodyState, RetailDriveRows, build_drive_rows}, + rigid_body::{RetailSimulationStep, pack_world_inverse_inertia}, + skeleton_animation_record::{AnimationPartTransform, compose_affine}, + skeleton_root::inverse_rigid, +}; + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonDriveSettings { + pub bone: BoneDriveSettings, + pub enabled: bool, + pub strength: [f32; 2], + /// InitAttribData82BE7860 applies CollisionDriveScalar to each part's + /// LOCAL and LOCAL_ROOT collision strengths before use in Update. + pub collision_strength: [[f32; 2]; ANIMATION_PART_COUNT], +} +pub struct BoneDrives { + pub parent: [usize; 2], + pub active: [bool; 2], + pub frames: [RetailDriveFrames; 2], + pub dynamics: BoneDriveDynamics, +} +pub struct SkeletonDrives { + pub targets: SkeletonTargets, + pub bones: [Option; ANIMATION_PART_COUNT], + pub settings: SkeletonDriveSettings, +} +/// Identity stays parallel to eligible rows. Native m_spy requests force +/// observation; it does not disable a drive or remove it from the solve. +#[derive(Clone, Copy, Debug)] +pub enum SkeletonDriveIdentity { + Target(usize), + Bone { part: usize, channel: usize }, +} +pub struct SkeletonDriveBatch { + pub rows: Vec, + pub identities: Vec, + pub spy: Vec, +} +impl SkeletonDrives { + pub fn new( + initial_bones: &[AnimationPartTransform; ANIMATION_PART_COUNT], + initial_mapped: &[AnimationPartTransform; ANIMATION_PART_COUNT], + parents: &[Option; ANIMATION_PART_COUNT], + animation_to_world: AnimationPartTransform, + spawn: AnimationPartTransform, + simulation: RetailSimulationStep, + settings: SkeletonDriveSettings, + ) -> Result { + if parents.iter().flatten().any(|p| *p >= ANIMATION_PART_COUNT) + || parents[0].is_some() + || parents[23].is_some() + || parents[1..23].iter().any(Option::is_none) + { + return Err("Skeleton drives require22 bone pairs and two roots"); + } + let inverses = initial_bones.map(|m| inverse_rigid(&m)); + let bones = std::array::from_fn(|part| { + parents[part].map(|parent| { + let frames = [parent, 23].map(|p| { + bone_drive_frames(&initial_bones[part], &inverses[part], &inverses[p]) + }); + let mut dynamics = BoneDriveDynamics::default(); + // Init82BED180: LOCAL mode2 followed by LOCAL_ROOT mode0. + dynamics.mode = 2; + dynamics.enable(0, 1.0, settings.bone); + dynamics.mode = 0; + dynamics.enable(1, 1.0, settings.bone); + BoneDrives { + parent: [parent, 23], + active: [true, true], + frames, + dynamics, + } + }) + }); + let mut targets = SkeletonTargets::new(simulation); + let frame = initial_mapped[23]; + let normalized = + orthonormalize_part_basis(std::array::from_fn(|i| frame[i / 4][i % 4].to_bits())); + let hips = + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(normalized[i * 4 + j]))); + targets.reset(compose_affine(&animation_to_world, &hips), spawn); + Ok(Self { + targets, + bones, + settings, + }) + } + + ///82BEB488 consumes the24 IK-adjusted frames in animation coordinates. + /// Collision weight comes from the actual SkeletonCollision state owner. + pub fn update( + &mut self, + pose: &[AnimationPartTransform; ANIMATION_PART_COUNT], + partial: bool, + collision_weight: f32, + ) { + let inverses = pose.map(|frame| inverse_rigid(&frame)); + let settings = self.settings; + let base = if settings.enabled { + settings.strength + } else { + [0.0; 2] + }; + self.targets.dynamics[0].linear = hard(HARD_VELOCITY, HARD_STRENGTH); + for part in 1..23 { + let Some(bone) = self.bones[part].as_mut() else { + continue; + }; + let selected = !partial || matches!(part,1..=5|7..=9); + let strengths = if !(collision_weight >= 1.0) && selected { + bone.dynamics.mode = 3; + std::array::from_fn(|channel| { + let c = settings.collision_strength[part][channel]; + //82BEB6F0/F4 are fmadds, followed by scalar fmuls. + (1.0 - c).mul_add(collision_weight, c) * settings.strength[channel] + }) + } else { + bone.dynamics.mode = 0; + base + }; + bone.dynamics.strengths = strengths; + for channel in 0..2 { + if !bone.active[channel] { + continue; + } + bone.frames[channel] = bone_drive_frames( + &pose[part], + &inverses[part], + &inverses[bone.parent[channel]], + ); + bone.dynamics + .enable(channel, strengths[channel], settings.bone); + } + } + } + + /// Native order: four targets, then LOCAL and LOCAL_ROOT for parts1..22. + /// The caller appends rows to the board/contact/joint solve before any + /// integration and borrows targets.bodies directly into that same step. + pub fn build( + &mut self, + bodies: &[BodySnapshot; PART_COUNT], + reaction_base: usize, + target_reaction_base: usize, + time_step: f32, + ) -> SkeletonDriveBatch { + let mut batch = SkeletonDriveBatch { + rows: Vec::with_capacity(48), + identities: Vec::with_capacity(48), + spy: Vec::with_capacity(48), + }; + for target in 0..TARGET_COUNT { + self.targets.frames[target] = prepare_bone_drive_frames(self.targets.frames[target]); + let part = TARGET_PARTS[target]; + let a = bodies[part]; + let b = self.targets.bodies[target]; + if (a.state_flags | b.state_flags) & 4 != 0 { + batch.rows.push(build_drive_rows( + drive_body(a, reaction_base + part), + drive_body(b, target_reaction_base + target), + self.targets.frames[target], + self.targets.dynamics[target], + time_step, + )); + batch.identities.push(SkeletonDriveIdentity::Target(target)); + batch.spy.push(target >= 2); + } + } + for part in 1..23 { + let Some(bone) = self.bones[part].as_mut() else { + continue; + }; + for channel in 0..2 { + if !bone.active[channel] { + continue; + } + bone.frames[channel] = prepare_bone_drive_frames(bone.frames[channel]); + let parent = bone.parent[channel]; + let a = bodies[part]; + let b = bodies[parent]; + if (a.state_flags | b.state_flags) & 4 != 0 { + batch.rows.push(build_drive_rows( + drive_body(a, reaction_base + part), + drive_body(b, reaction_base + parent), + bone.frames[channel], + bone.dynamics.channels[channel], + time_step, + )); + batch + .identities + .push(SkeletonDriveIdentity::Bone { part, channel }); + batch.spy.push(true); + } + } + } + batch + } +} +fn drive_body(body: BodySnapshot, reaction_index: usize) -> RetailDriveBodyState { + let rates = body.rates; + RetailDriveBodyState { + reaction_index, + state: body.state_flags, + orientation: rates.orientation, + basis: rates.basis, + center_of_mass: rates.position, + linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, + force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, + inverse_mass: body.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(rates.world_inverse_inertia), + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/errors.rs b/crates/skate-core/src/physics/skeleton_body/errors.rs new file mode 100644 index 00000000..1d205389 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/errors.rs @@ -0,0 +1,97 @@ +//! Original physical pose errors82BEBEA8 and normal collision response82BE2438. +use super::{ + ExtraTargetPositions, SkeletonCollisionFeedback, SkeletonPhysicalRecord, collision_feedback::V, + collision_vector::*, +}; +use crate::physics::skeleton_animation_record::AnimationPartTransform; + +#[derive(Clone, Debug, Default)] +pub struct SkeletonPoseErrors { + /// SkeletonState4752, one vector per animated part. + pub parts: [V; 24], + /// SkeletonState5136/5152: the two additional physical target errors. + pub extra: [V; 2], + /// SkeletonState5168/5184/5200, written by UpdateExtraTargets. + pub targets: [V; 3], +} +#[derive(Clone, Copy, Debug)] +pub struct SkeletonNormalError { + /// Skeleton16272: physical error constrained by actual contact planes. + pub impulse: V, + /// Normal82BE2438 clears Skeleton16288 and16304. + pub extra: [V; 2], + /// Skeleton16384, length before contact-plane filtering. + pub maximum_error: f32, +} +impl SkeletonPoseErrors { + ///82BEBBB0 clears only target histories. The next82BEBEA8 replaces errors. + pub fn reset_history(&mut self) { + self.targets = [[0.0; 4]; 3]; + } + pub fn set_targets(&mut self, positions: ExtraTargetPositions) { + self.targets = [positions.com, positions.lifted_com, positions.following_com]; + } + ///82BEBEA8. These are actual part-frame errors, not COM or force proxies. + pub fn update( + &mut self, + physical: &SkeletonPhysicalRecord, + animation_to_world: AnimationPartTransform, + drive_frames: &[AnimationPartTransform; 24], + ) { + self.parts[0] = [0.0; 4]; + for part in 1..24 { + self.parts[part] = sub( + physical.pose[part][3], + transform(animation_to_world, drive_frames[part][3]), + ); + } + for i in 0..2 { + self.extra[i] = sub(physical.pose[24 + i][3], self.targets[1 + i]); + } + } + ///82BE2438. Strict comparison and the first raw-pair tie behavior differ + /// from the filtered-pair tie; preserve both selection sequences. + pub fn normal_response( + &self, + collision: &SkeletonCollisionFeedback, + support_normal: V, + ) -> SkeletonNormalError { + let candidates = [23, 17, 18, 16, 21, 22, 20, 1].map(|part| self.parts[part]); + let filtered = + candidates.map(|candidate| collision.filter_error(candidate, support_normal)); + let mut impulse = filtered[0]; + for candidate in &filtered[1..] { + if dot(*candidate, *candidate) > dot(impulse, impulse) { + impulse = *candidate; + } + } + SkeletonNormalError { + impulse, + extra: [[0.0; 4]; 2], + maximum_error: self.maximum_error(), + } + } + ///82BD9FB0 partial-ragdoll branch copies the two extra errors directly, + /// then82BE2310 computes the same raw eight-part maximum length. + pub fn partial_response(&self) -> SkeletonNormalError { + SkeletonNormalError { + impulse: self.extra[0], + extra: self.extra, + maximum_error: self.maximum_error(), + } + } + fn maximum_error(&self) -> f32 { + let candidates = [23, 17, 18, 16, 21, 22, 20, 1].map(|part| self.parts[part]); + let mut maximum = if dot(candidates[0], candidates[0]) > dot(candidates[1], candidates[1]) { + candidates[0] + } else { + candidates[1] + }; + for candidate in &candidates[2..] { + if dot(*candidate, *candidate) > dot(maximum, maximum) { + maximum = *candidate; + } + } + length(maximum) + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/joints.rs b/crates/skate-core/src/physics/skeleton_body/joints.rs new file mode 100644 index 00000000..8d2c5699 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/joints.rs @@ -0,0 +1,156 @@ +//! SkeletonBody::InitJoints82BE5B30 and SetUpNormalJointLimits82BE58A8. +//! Frames attach the child to its nearest physical ancestor; the initial +//! animation bone poses and authored volume frames are distinct inputs. +use super::{ANIMATION_PART_COUNT, JOINT_COUNT, PART_COUNT}; +use crate::{math::Basis3, trigonometry, physics::{ + assembly::BodySnapshot, + constraint_batch::compile_active, + drive_frames::retail_quaternion_from_basis, + joint_builder::{RetailJointBodyInput, RetailJointBuildInput, build_retail_joint_jacobian}, + joint_records::{RetailJointFramesRaw, RetailJointParametersRaw}, + native_arithmetic, + rigid_body::pack_world_inverse_inertia, + skeleton_animation_record::{AnimationPartTransform, compose_affine, physics_bone_frame}, + skeleton_root::inverse_rigid, + solver::JointConstraint, +}}; + +#[derive(Clone, Copy, Debug)] +pub struct JointBone { + pub parent_orientation: [f32; 4], + pub joint_orientation: [f32; 4], + pub volume_frame: AnimationPartTransform, + pub swing_limit: f32, + pub twist_limit: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct JointSettings { + pub ball_joint: bool, + pub swing_angle: f32, + pub twist_angle: f32, + pub swing_ragdoll: f32, + pub twist_ragdoll: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonJointSettings { + pub displacement_limit: [f32; 4], + pub twist_displacement_limit: f32, + pub swing_displacement_limit: f32, + pub enforce_swing_free: bool, + pub enforce_twist_free: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct SkeletonJoint { + pub parent: usize, + pub child: usize, + pub parameters: RetailJointParametersRaw, + pub frames: RetailJointFramesRaw, +} + +pub struct SkeletonJoints { + pub records: [SkeletonJoint; JOINT_COUNT], +} +impl SkeletonJoints { + pub fn new( + initial_bones: &[AnimationPartTransform; ANIMATION_PART_COUNT], + parents: &[Option; ANIMATION_PART_COUNT], + bones: &[JointBone; ANIMATION_PART_COUNT], + settings: &[JointSettings; JOINT_COUNT], + global: SkeletonJointSettings, + ) -> Result { + let mut records = Vec::with_capacity(JOINT_COUNT); + for child in 0..ANIMATION_PART_COUNT { + let Some(parent) = parents[child] else { continue }; + if parent >= ANIMATION_PART_COUNT || parent == child { + return Err("Invalid physical skeleton joint parent"); + } + let setting = *settings.get(records.len()).ok_or("Too many physical skeleton joints")?; + let bone = bones[child]; + let inverse_child_volume = inverse_rigid(&bone.volume_frame); + let inverse_parent_volume = inverse_rigid(&bones[parent].volume_frame); + let parent_orientation = physics_bone_frame(bone.parent_orientation, [0.0; 4]); + let joint_orientation = physics_bone_frame(bone.joint_orientation, [0.0; 4]); + let parent_joint = compose_affine(&inverse_parent_volume, + &inverse_rigid(&parent_orientation)); + let relative_bones = compose_affine(&inverse_rigid(&initial_bones[parent]), + &initial_bones[child]); + let parent_anchor = compose_affine(&inverse_parent_volume, &relative_bones)[3]; + let child_frame = compose_affine(&inverse_child_volume, &joint_orientation); + let parent_frame = compose_affine(&parent_joint, &joint_orientation); + let mut frames = RetailJointFramesRaw { words: [0; 20] }; + pack_frame(&mut frames.words[..8], child_frame, inverse_child_volume[3]); + pack_frame(&mut frames.words[8..16], parent_frame, parent_anchor); + // InitJoints passes the identity matrix to SetLinearFrame82BD3858. + frames.words[19] = 1.0f32.to_bits(); + + let mut parameters = RetailJointParametersRaw { words: [0; 16] }; + let frequency = inverse_native_step(); + parameters.words[4..8].copy_from_slice( + &global.displacement_limit.map(|v| (v * frequency).to_bits())); + let scalar_frequency = f32::from_bits(0x426F_FFFF); + parameters.words[8] = (global.twist_displacement_limit * scalar_frequency).to_bits(); + parameters.words[9] = (global.swing_displacement_limit * scalar_frequency).to_bits(); + parameters.words[14] = if setting.ball_joint || global.enforce_swing_free { 4 } else { 1 }; + parameters.words[15] = if setting.ball_joint || global.enforce_twist_free { 2 } else { 1 }; + // SetUpNormal runs after InitJoints and writes limits even for a + // free/ball joint. It does not replace the mode selectors. + let swing = minimum_angle(bone.swing_limit * setting.swing_angle); + let twist = minimum_angle(bone.twist_limit * setting.twist_angle); + parameters.words[10..14].copy_from_slice( + &[swing, twist, trigonometry::cos(swing), trigonometry::cos(twist)].map(f32::to_bits)); + records.push(SkeletonJoint { parent, child, parameters, frames }); + } + Ok(Self { records: records.try_into().map_err(|_| "Physical skeleton requires22 joints")? }) + } + + /// Append these to the same constraint pass as board contacts/joints/drives. + /// Reaction indices are supplied by the unified simulation owner. + pub fn build(&self, bodies: &[BodySnapshot; PART_COUNT], + reaction_base: usize, time_step: f32) -> Vec { + compile_active(&self.records, + |record| [bodies[record.child].state_flags, bodies[record.parent].state_flags], + |record| JointConstraint { + jacobian: build_retail_joint_jacobian(RetailJointBuildInput { + parameters: record.parameters, frames: record.frames, + body_a: joint_body(bodies[record.child]), + body_b: joint_body(bodies[record.parent]), + time_step, joint_guest_address: 0, + }), + reaction_a: reaction_base + record.child, + reaction_b: reaction_base + record.parent, + }) + } +} + +fn inverse_native_step() -> f32 { + let step = f32::from_bits(0x3C88_8889); //820849C8 + let mut reciprocal = native_arithmetic::reciprocal_estimate(step); + for _ in 0..2 { + reciprocal = reciprocal.mul_add((-reciprocal).mul_add(step, 1.0), reciprocal); + } + reciprocal +} +fn minimum_angle(value: f32) -> f32 { + let minimum = f32::from_bits(0x3C23_D70A); + if value - minimum >= 0.0 { value } else { minimum } +} +fn pack_frame(words: &mut [u32], frame: AnimationPartTransform, anchor: [f32; 4]) { + let q = retail_quaternion_from_basis(Basis3 { + columns: std::array::from_fn(|i| [frame[i][0],frame[i][1],frame[i][2]]) }); + words[..4].copy_from_slice(&[q.x,q.y,q.z,q.w].map(f32::to_bits)); + words[4..8].copy_from_slice(&anchor.map(f32::to_bits)); +} +fn joint_body(body: BodySnapshot) -> RetailJointBodyInput { + let rates = body.rates; + RetailJointBodyInput { + reaction_guest_address: 0, state: body.state_flags, + orientation: rates.orientation, center_of_mass: rates.position, + basis: rates.basis, linear_velocity: rates.linear_velocity, + angular_velocity: rates.angular_velocity, force_acceleration: rates.force_acceleration, + torque_acceleration: rates.torque_acceleration, inverse_mass: body.inertia.inverse_mass, + world_inverse_inertia: pack_world_inverse_inertia(rates.world_inverse_inertia), + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/mod.rs b/crates/skate-core/src/physics/skeleton_body/mod.rs new file mode 100644 index 00000000..dec178af --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/mod.rs @@ -0,0 +1,38 @@ +//! Physical skater assembly and its postphysics observations. +//! The body has 26 parts; animation and COM weighting use its first 24. +mod record; +pub use record::SkeletonPhysicalRecord; +mod construction; +pub use construction::{BoneSettings, HatGeometry, SkeletonBodyDefinition, SkeletonBodySettings, SkeletonPart}; +mod runtime; +pub use runtime::SkeletonBody; +mod joints; +pub use joints::{JointBone, JointSettings, SkeletonJointSettings, SkeletonJoint, SkeletonJoints}; +mod drive_dynamics; +pub use drive_dynamics::{AnimationDriveSettings, DriveInterpolation, BoneDriveSettings, BoneDriveDynamics}; +mod drive_frames; +pub use drive_frames::{bone_drive_frames, prepare_bone_drive_frames}; +mod targets; +pub use targets::{SkeletonTargets, TARGET_COUNT}; +mod target_update; +pub use target_update::{SkeletonTargetInput, SkeletonTargetUpdate, ExtraTargetPositions}; +mod drives; +pub use drives::{SkeletonDriveSettings, BoneDrives, SkeletonDrives, SkeletonDriveIdentity, SkeletonDriveBatch}; +mod collision_mode; +pub use collision_mode::{SkeletonCollisionSettings, SkeletonPartCollision, SkeletonCollisionMode}; +mod collision_feedback; +mod collision_update; +mod collision_vector; +mod collision_filter; +pub use collision_feedback::{SkeletonFeedbackSettings, SkeletonContactBody, + SkeletonContactReport, SkeletonCollisionInput, BoneContact, ContactRegion, + ContactPlane, SpecificContact, SkeletonContactFlags, SkeletonCollisionFeedback}; +mod errors; +pub use errors::{SkeletonPoseErrors, SkeletonNormalError}; + +pub const PART_COUNT: usize = 26; +pub const ANIMATION_PART_COUNT: usize = 24; +pub const JOINT_COUNT: usize = 22; +#[cfg(test)] +#[path = "tests/collision_feedback.rs"] +mod collision_feedback_tests; diff --git a/crates/skate-core/src/physics/skeleton_body/record.rs b/crates/skate-core/src/physics/skeleton_body/record.rs new file mode 100644 index 00000000..4df18454 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/record.rs @@ -0,0 +1,68 @@ +//! SkeletonState's physical record: TU3 82BEBD28 and 82BEC1E8. +use super::{ANIMATION_PART_COUNT, PART_COUNT}; +use crate::physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY}; + +#[derive(Clone, Debug)] +pub struct SkeletonPhysicalRecord { + /// SkeletonState1552: actual part frames, including the mass-frame inverse. + pub pose: [AnimationPartTransform; PART_COUNT], + /// SkeletonState3216: previous positions used by the fixed-step derivative. + pub positions: [[f32; 4]; PART_COUNT], + /// SkeletonState3632: pose-derived velocities, not rigid body rates. + pub velocities: [[f32; 4]; PART_COUNT], + /// SkeletonState4048: difference from the preceding derived velocity. + pub velocity_changes: [[f32; 4]; PART_COUNT], + /// SkeletonState4464/4480; only the first 24 weighted parts contribute. + pub centre_of_mass: [f32; 4], + pub centre_of_mass_velocity: [f32; 4], + pub timestep: f32, +} +impl Default for SkeletonPhysicalRecord { + fn default() -> Self { + // Constructor82BEB8C8 initializes all26 physical transforms and history. + Self { pose: [IDENTITY; PART_COUNT], positions: [[0.0; 4]; PART_COUNT], + velocities: [[0.0; 4]; PART_COUNT], velocity_changes: [[0.0; 4]; PART_COUNT], + centre_of_mass: [0.0; 4], centre_of_mass_velocity: [0.0; 4], timestep: 0.0 } + } +} +impl SkeletonPhysicalRecord { + /// Physical subset of Reset82BEBBB0. The native reset preserves physical + /// COM, velocity changes and timestep; other animation/error owners reset + /// their own fields at this same phase. + pub fn reset(&mut self, parts: &[AnimationPartTransform; PART_COUNT]) { + self.pose = *parts; + for (i, part) in parts.iter().enumerate() { + self.positions[i] = part[3]; + self.velocities[i] = [0.0; 4]; + } + } + + /// The caller supplies the actual GetPartTransform results. Native part0 + /// uses the explicit board-frame argument; all other parts come from the + /// skeleton assembly. Keep this distinction when the board drives a proxy. + pub fn update(&mut self, parts: &[AnimationPartTransform; PART_COUNT], + board_frame: AnimationPartTransform, fractional: &[f32; ANIMATION_PART_COUNT]) { + self.timestep = f32::from_bits(0x3C88_8889); + for (i, part) in parts.iter().enumerate() { + let transform = if i == 0 { board_frame } else { *part }; + self.pose[i] = transform; + for lane in 0..4 { + let velocity = (transform[3][lane] - self.positions[i][lane]) + * f32::from_bits(0x426F_FFFF); + self.velocity_changes[i][lane] = velocity - self.velocities[i][lane]; + self.velocities[i][lane] = velocity; + } + self.positions[i] = transform[3]; + } + let mut position = [0.0f32; 4]; + let mut velocity = [0.0f32; 4]; + for (i, weight) in fractional.iter().enumerate() { + for lane in 0..4 { + position[lane] = self.pose[i][3][lane].mul_add(*weight, position[lane]); + velocity[lane] = self.velocities[i][lane].mul_add(*weight, velocity[lane]); + } + } + self.centre_of_mass = position; + self.centre_of_mass_velocity = velocity; + } +} diff --git a/crates/skate-core/src/physics/skeleton_body/runtime.rs b/crates/skate-core/src/physics/skeleton_body/runtime.rs new file mode 100644 index 00000000..bc212f6e --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/runtime.rs @@ -0,0 +1,142 @@ +//! Actual 26-part ownership and Reset82BD9990 / physical-record publication. +use super::{SkeletonBodyDefinition, SkeletonPhysicalRecord, PART_COUNT, ANIMATION_PART_COUNT}; +use crate::physics::{ + assembly::BodySnapshot, + board_pose::{PartPose, PoseMatrix, part_transform, set_part_transform, orthonormalize_part_basis}, + board_runtime::{BoardMotion, initialized_body}, + rigid_body::{RetailBodyRates, RetailInertiaDynamics, RetailLocalMassFrame, RetailSimulationStep}, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, + skeleton_root::inverse_rigid, +}; + +pub struct SkeletonBody { + pub definition: SkeletonBodyDefinition, + pub record: SkeletonPhysicalRecord, + pub animation_to_world: AnimationPartTransform, + bodies: [BodySnapshot; PART_COUNT], +} +impl SkeletonBody { + /// `authored` is SkeletonData's initialization pose after forward physical + /// bone-frame mapping. The actor's native initialization pose producer must + /// supply it; the current graph's first frame is a separate publication. + pub fn new(definition: SkeletonBodyDefinition, + authored: &[AnimationPartTransform; ANIMATION_PART_COUNT], + spawn: AnimationPartTransform, simulation: RetailSimulationStep) -> Self { + // GetPartFrame82BD9878 normalizes the mapped animation frame before + // Reset composes the world alignment. PartSetTransform normalizes + // again after applying the local mass frame; the two stages differ. + let authored = authored.map(|frame| floats(orthonormalize_part_basis(words(frame)))); + let animation_to_world = compose_affine(&spawn, &inverse_rigid(&authored[0])); + let bodies = std::array::from_fn(|i| { + let desired = if i < ANIMATION_PART_COUNT { + compose_affine(&animation_to_world, &authored[i]) + } else { spawn }; + let mass = definition.parts[i].animated; + let mut inertia = mass.dynamics; + // SetUpNormal82BE7280 loads the independently cached inverse mass + // for the 23 skater bones. Root/extra bodies retain their setup. + if (1..ANIMATION_PART_COUNT).contains(&i) { + inertia.inverse_mass = definition.parts[i].inverse_mass_animated; + } + let mut part = PartPose { transform: words(desired), + local_mass_frame: Some(mass_words(mass.local_mass_frame)), + body: Some([0;44]), inertia: Some(inertia_words(inertia)) }; + set_part_transform(&mut part, words(desired)); + initialized_body(&part, inertia, simulation, BoardMotion::Active) + }); + let mut result = Self { definition, bodies, animation_to_world, + record: SkeletonPhysicalRecord::default() }; + // SkeletonInit82BD8010..804C makes two real physical observations. + // The second eliminates the constructor's zero-position derivative. + let pose = result.part_transforms(); + result.record.update(&pose, pose[0], &result.definition.animation_masses.fractional); + result.record.update(&pose, pose[0], &result.definition.animation_masses.fractional); + result + } + pub fn bodies(&self) -> &[BodySnapshot; PART_COUNT] { &self.bodies } + /// The shared solver owns these same states; do not maintain a second + /// kinematic pose array beside the states receiving its corrections. + pub fn bodies_mut(&mut self) -> &mut [BodySnapshot; PART_COUNT] { &mut self.bodies } + /// Skeleton::SetPartTransform82BE2A38 updates the live part through + ///82BD4318 and then retains the ORIGINAL requested physical-record frame. + ///Only pose and its inertia cache change; solved velocities survive. + pub fn set_part_transform(&mut self, part: usize, frame: AnimationPartTransform) { + use crate::{math::{Basis3, Vector3}, physics::rigid_body::RetailQuaternion}; + let body = &mut self.bodies[part]; + let mut pose = PartPose { + transform: words(frame), + local_mass_frame: Some(mass_words(self.definition.parts[part].animated.local_mass_frame)), + body: Some(body_words(body.rates)), + inertia: Some(inertia_words(body.inertia)), + }; + set_part_transform(&mut pose, words(frame)); + let packed = pose.body.unwrap(); + let f = |i| f32::from_bits(packed[i]); + body.rates.orientation = RetailQuaternion { x:f(0), y:f(1), z:f(2), w:f(3) }; + body.rates.position = Vector3::new(f(4),f(5),f(6)); + body.rates.basis = Basis3 { columns: [ + [f(16),f(17),f(18)], [f(20),f(21),f(22)], [f(24),f(25),f(26)]] }; + body.rates.world_inverse_inertia = Basis3 { columns: [ + [f(28),f(29),f(30)], [f(29),f(33),f(34)], [f(30),f(34),f(32)]] }; + self.record.pose[part] = frame; + } + pub fn part_transforms(&self) -> [AnimationPartTransform; PART_COUNT] { + std::array::from_fn(|i| { + let part = PartPose { transform: words(IDENTITY), + local_mass_frame: Some(mass_words(self.definition.parts[i].animated.local_mass_frame)), + body: Some(body_words(self.bodies[i].rates)), inertia: None }; + floats(part_transform(&part)) + }) + } + pub fn publish_physical_record(&mut self, board: AnimationPartTransform) { + let pose = self.part_transforms(); + self.record.update(&pose, board, &self.definition.animation_masses.fractional); + } + /// ApplyDisplacementToPart82BE7788. Despite its name this writes the + /// rigid body's force accumulator and clears cooldown; it is not a pose + /// adjustment. GeneralUpdate calls it for all24 animation parts. + pub fn apply_part_displacement(&mut self, part: usize, displacement: [f32;4]) { + use crate::physics::native_arithmetic; + let step = f32::from_bits(0x3C88_8889); + let mut inverse_step = native_arithmetic::reciprocal_estimate(step); + for _ in 0..2 { + inverse_step = inverse_step.mul_add((-inverse_step).mul_add(step, 1.0), inverse_step); + } + let body = &mut self.bodies[part]; + let value = displacement.map(|d| (d * inverse_step) * body.inertia.inverse_mass); + body.rates.force_acceleration.x += value[0]; + body.rates.force_acceleration.y += value[1]; + body.rates.force_acceleration.z += value[2]; + body.rates.cool_down = 0; + } +} + +fn words(frame: AnimationPartTransform) -> PoseMatrix { + std::array::from_fn(|i| frame[i/4][i%4].to_bits()) +} +fn floats(frame: PoseMatrix) -> AnimationPartTransform { + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(frame[i*4+j]))) +} +fn mass_words(frame: RetailLocalMassFrame) -> PoseMatrix { + let mut m = IDENTITY; + for (column, axis) in frame.basis.columns.iter().enumerate() { + m[column][..3].copy_from_slice(axis); + } + m[3] = [frame.translation.x,frame.translation.y,frame.translation.z,0.0]; + words(m) +} +fn inertia_words(d: RetailInertiaDynamics) -> [u32;10] { + [d.inverse_tensor.x,d.inverse_tensor.y,d.inverse_tensor.z,0.0,d.inverse_mass, + d.spherical,d.maximum_linear_velocity,d.maximum_angular_velocity, + d.linear_drag,d.angular_drag].map(f32::to_bits) +} +fn body_words(rates: RetailBodyRates) -> [u32;44] { + let mut w = [0;44]; + let q = rates.orientation; + w[..4].copy_from_slice(&[q.x,q.y,q.z,q.w].map(f32::to_bits)); + w[4..7].copy_from_slice(&[rates.position.x,rates.position.y,rates.position.z].map(f32::to_bits)); + for (i,axis) in rates.basis.columns.iter().enumerate() { + w[16+i*4..19+i*4].copy_from_slice(&axis.map(f32::to_bits)); + } + w +} diff --git a/crates/skate-core/src/physics/skeleton_body/target_update.rs b/crates/skate-core/src/physics/skeleton_body/target_update.rs new file mode 100644 index 00000000..48cbf87b --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/target_update.rs @@ -0,0 +1,105 @@ +//! Original Skeleton::UpdateHookPositions82BE1618 and extra followers82BE18B8. +//! Their target changes precede the returned teleport/large-displacement gate. +use super::{SkeletonBody, SkeletonTargets}; +use crate::{ + math::Vector3, + physics::{ + board_pose::orthonormalize_rotation, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, + }, + riding::ground_orientation::clamp_length, +}; + +pub struct SkeletonTargetInput<'a> { + ///Skeleton11792, mapped physical animation hips, not physical COM. + pub animation_hips: &'a Transform, + ///Skeleton11728, mapped physical animation board. + pub animation_board: &'a Transform, + ///Skeleton11920. + pub animation_to_world: &'a Transform, + ///Skeleton12368, inverse of the actual physical board. + pub inverse_board: &'a Transform, + ///Skeleton15760, published by the current movement branch. + pub skate_root: &'a Transform, + ///Skeleton15824 and15888 from the actual COM/lift producer. + pub com_frame: &'a Transform, + pub lifted_com_frame: &'a Transform, + ///Skeleton16508. + pub teleporting: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct ExtraTargetPositions { + ///Skeleton11632,11648,11664 respectively. + pub com: [f32; 4], + pub lifted_com: [f32; 4], + pub following_com: [f32; 4], +} +pub struct SkeletonTargetUpdate { + ///Skeleton12240, inverse physical board * animated board in world space. + pub animation_board_to_physics: Transform, + pub positions: ExtraTargetPositions, + ///False triggers GeneralUpdate's real teleport/reset behavior after all + ///target and velocity writes, not a guard suppressing those writes. + pub continuous: bool, +} + +impl SkeletonTargets { + pub fn update_positions( + &mut self, + input: SkeletonTargetInput<'_>, + skeleton: &mut SkeletonBody, + ) -> SkeletonTargetUpdate { + let hips = compose_affine(input.animation_to_world, input.animation_hips); + let board = compose_affine(input.animation_to_world, input.animation_board); + let animation_board_to_physics = compose_affine(input.inverse_board, &board); + let previous = self.transform(0)[3]; + let change = std::array::from_fn(|i| previous[i] - hips[3][i]); + self.set_transform(0, orthonormalize(hips)); + self.set_transform(1, orthonormalize(*input.skate_root)); + let positions = + self.update_extra_targets(skeleton, input.com_frame, input.lifted_com_frame); + //82BE18A0 checks strictly greater; unordered distance alone does not + //force this returnfalse. Preserve the independent teleport byte test. + let continuous = !input.teleporting && !(dot3(change, change) > 1.0); + SkeletonTargetUpdate { + animation_board_to_physics, + positions, + continuous, + } + } + + ///Complete82BE18B8, also called by Reset82BD9990 after constructing its + ///COM frames. Replaces only physical extra-body velocities, never forces. + pub fn update_extra_targets( + &mut self, + skeleton: &mut SkeletonBody, + com_frame: &Transform, + lifted_com_frame: &Transform, + ) -> ExtraTargetPositions { + self.set_transform(2, *lifted_com_frame); + self.set_transform(3, *com_frame); + let positions = ExtraTargetPositions { + com: com_frame[3], + lifted_com: lifted_com_frame[3], + following_com: com_frame[3], + }; + //GetPartTransform includes the actual local mass frame. Keep that + //source even though these centered extra capsules have identity frames. + let parts = skeleton.part_transforms(); + for (part, target) in [(24, lifted_com_frame[3]), (25, com_frame[3])] { + let current = parts[part][3]; + let velocity = Vector3::new( + (target[0] - current[0]) * 60.0, + (target[1] - current[1]) * 60.0, + (target[2] - current[2]) * 60.0, + ); + skeleton.bodies_mut()[part].rates.linear_velocity = clamp_length(velocity, 40.0); + } + positions + } +} +fn orthonormalize(frame: Transform) -> Transform { + let words = orthonormalize_rotation(std::array::from_fn(|i| frame[i / 4][i % 4].to_bits())); + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(words[i * 4 + j]))) +} diff --git a/crates/skate-core/src/physics/skeleton_body/targets.rs b/crates/skate-core/src/physics/skeleton_body/targets.rs new file mode 100644 index 00000000..f1683132 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/targets.rs @@ -0,0 +1,154 @@ +//! Original TU3 SkeletonDrives::InitHooks82BEAB70 creates four static targets. +//! These physical constraint endpoints contain no runtime interception code. +use crate::{ + math::{Basis3, Vector3}, + physics::{ + assembly::BodySnapshot, + board_pose::{PartPose, orthonormalize_rotation, set_part_transform}, + board_runtime::{BoardMotion, initialized_body}, + drive_frames::{RetailDriveFrame, RetailDriveFrames, retail_quaternion_from_basis}, + drive_parameters::{RetailDriveDynamics, RetailDriveParams, RetailDriveType}, + rigid_body::{RetailInertiaDynamics, RetailQuaternion, RetailSimulationStep}, + skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + }, +}; + +pub const TARGET_COUNT: usize = 4; +pub const TARGET_PARTS: [usize; TARGET_COUNT] = [23, 0, 24, 25]; +pub(super) const HARD_VELOCITY: f32 = f32::from_bits(0x4415_FFFF); +pub(super) const HARD_STRENGTH: f32 = f32::from_bits(0x470C_9FFF); + +pub struct SkeletonTargets { + pub bodies: [BodySnapshot; TARGET_COUNT], + pub frames: [RetailDriveFrames; TARGET_COUNT], + pub dynamics: [RetailDriveDynamics; TARGET_COUNT], +} +impl SkeletonTargets { + pub fn new(simulation: RetailSimulationStep) -> Self { + let bodies = std::array::from_fn(|_| { + let mut part = PartPose { + transform: words(IDENTITY), + local_mass_frame: Some(words(IDENTITY)), + body: Some([0; 44]), + inertia: None, + }; + set_part_transform(&mut part, words(IDENTITY)); + // Original82BEAED4 inserts STATIC_BODY(1), giving zero inverse + // mass/tensor/rates. The centered.1m box's authored100kg and its + // disabled collision volume never participate in that response. + initialized_body(&part, STATIC_INERTIA, simulation, BoardMotion::Static) + }); + let identity = RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::ZERO, + }; + //82BEAD34..ADC8: X=(0,1,0),Y=(0,0,1),Z=(1,0,0). + let extra = RetailDriveFrame { + orientation: retail_quaternion_from_basis(Basis3 { + columns: [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]], + }), + ..identity + }; + let frames = std::array::from_fn(|i| RetailDriveFrames { + body_a: if i >= 2 { extra } else { identity }, + body_b: identity, + }); + let strong = hard(HARD_VELOCITY, HARD_STRENGTH); + let root = hard(f32::from_bits(0x426F_FFFF), HARD_STRENGTH); + Self { + bodies, + frames, + dynamics: [ + RetailDriveDynamics { + linear: strong, + angular: strong, + }, + RetailDriveDynamics { + linear: root, + angular: root, + }, + RetailDriveDynamics { + linear: hard(0.0, 0.0), + angular: strong, + }, + RetailDriveDynamics { + linear: hard(0.0, 0.0), + angular: strong, + }, + ], + } + } + /// Reset82BD9DB8/9DE0 normalizes these two desired matrices before + /// PartSetTransform. The other two target parts retain their pose. + pub fn reset(&mut self, hips: AnimationPartTransform, spawn: AnimationPartTransform) { + for (index, frame) in [hips, spawn].into_iter().enumerate() { + self.set_transform(index, floats(orthonormalize_rotation(words(frame)))); + } + } + /// Native PartSetTransform preserves velocity and force state while + /// changing the body transform. Its local mass frame is identity here. + pub fn set_transform(&mut self, index: usize, frame: AnimationPartTransform) { + let body = &mut self.bodies[index]; + let mut part = PartPose { + transform: words(frame), + local_mass_frame: Some(words(IDENTITY)), + body: Some([0; 44]), + inertia: None, + }; + set_part_transform(&mut part, words(frame)); + let w = part.body.expect("Target has an actual rigid body"); + let f = |i| f32::from_bits(w[i]); + body.rates.orientation = RetailQuaternion { + x: f(0), + y: f(1), + z: f(2), + w: f(3), + }; + body.rates.position = Vector3::new(f(4), f(5), f(6)); + body.rates.basis = Basis3 { + columns: std::array::from_fn(|i| [f(16 + i * 4), f(17 + i * 4), f(18 + i * 4)]), + }; + } + pub fn transform(&self, index: usize) -> AnimationPartTransform { + let body = self.bodies[index]; + let mut m = IDENTITY; + for i in 0..3 { + m[i][..3].copy_from_slice(&body.rates.basis.columns[i]); + } + let p = body.rates.position; + m[3] = [p.x, p.y, p.z, 0.0]; + m + } + /// Ground::PredictFutureDeck82D38630 moves target0, not physical hips. + pub fn apply_future_deck_displacement(&mut self, displacement: Vector3) { + let mut frame = self.transform(0); + frame[3][0] += displacement.x; + frame[3][1] += displacement.y; + frame[3][2] += displacement.z; + self.set_transform(0, frame); + } +} + +const STATIC_INERTIA: RetailInertiaDynamics = RetailInertiaDynamics { + inverse_tensor: Vector3::ZERO, + inverse_mass: 0.0, + spherical: 0.0, + maximum_linear_velocity: 0.0, + maximum_angular_velocity: 0.0, + linear_drag: 0.0, + angular_drag: 0.0, +}; +pub(super) fn hard(velocity: f32, strength: f32) -> RetailDriveParams { + RetailDriveParams { + spring_or_max_velocity: velocity, + damping: 0.0, + max_strength: strength, + drive_type: RetailDriveType::HardDrive, + } +} +fn words(frame: AnimationPartTransform) -> [u32; 16] { + std::array::from_fn(|i| frame[i / 4][i % 4].to_bits()) +} +fn floats(frame: [u32; 16]) -> AnimationPartTransform { + std::array::from_fn(|i| std::array::from_fn(|j| f32::from_bits(frame[i * 4 + j]))) +} diff --git a/crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs b/crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs new file mode 100644 index 00000000..6631a1d7 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_body/tests/collision_feedback.rs @@ -0,0 +1,134 @@ +use super::*; +use crate::physics::{contact::RetailContactMaterial, skeleton_animation_record::IDENTITY}; + +fn settings() -> SkeletonFeedbackSettings { + SkeletonFeedbackSettings { + body: SkeletonCollisionSettings { + enabled: true, + normal_material: RetailContactMaterial { + static_friction: 0.0, + dynamic_friction: 0.0, + restitution: 0.0, + }, + compliant: [true; 24], + priority: [0.0; 24], + effect_time: 0.25, + }, + small_object_mass: 5.5, + ground_plane_max_distance: 0.3, + ground_plane_max_angle: 0.8, + skater_scalar: 0.5, + ai_scalar: 1.0, + groin_offset: [0.0; 4], + face_offset: [0.0; 4], + groin_radius: 0.0, + face_radius: 0.0, + } +} +fn input<'a>( + physical: &'a SkeletonPhysicalRecord, + frames: &'a [[[f32; 4]; 4]; 26], + weights: &'a [f32; 24], +) -> SkeletonCollisionInput<'a> { + SkeletonCollisionInput { + dt: 0.125, + plane_point: [0.0; 4], + plane_normal: [0.0, 1.0, 0.0, 0.0], + reference_velocity: [2.0, 0.0, 0.0, 0.0], + com_velocity: [0.0; 4], + ragdoll: false, + disable_ground_filter: false, + ai_collision_scalar: false, + offboard: false, + entering_offboard: false, + category_600: false, + request_partial_ragdoll: false, + physical, + part_weights: weights, + body_frames: frames, + } +} +fn report(normal: [f32; 4]) -> SkeletonContactReport { + SkeletonContactReport { + part: 2, + normal, + point: [0.0; 4], + tag: 0, + other_group: 0, + other_entity: None, + body_a: SkeletonContactBody { + state_flags: 4, + inverse_mass: 0.5, + linear_velocity: [2.0, 0.0, 0.0, 0.0], + }, + body_b: SkeletonContactBody { + state_flags: 1, + inverse_mass: 0.0, + linear_velocity: [0.0; 4], + }, + side_a: true, + solved_vector: [100.0, 0.0, 0.0, 0.0], + } +} + +#[test] +fn support_contact_is_filtered_but_wall_contact_changes_drives_and_recovers() { + let mut physical = SkeletonPhysicalRecord::default(); + physical.velocities[2] = [2.0, 0.0, 0.0, 0.0]; + let frames = [IDENTITY; 26]; + let weights = [1.0; 24]; + let input = input(&physical, &frames, &weights); + let mut collision = SkeletonCollisionFeedback::new(settings()); + collision.update(&input, &[report([0.0, 1.0, 0.0, 0.0])]); + assert!(collision.flags.any); + assert!(!collision.flags.compliant); + assert_eq!(collision.drive_weight, 1.0); + collision.update(&input, &[report([-1.0, 0.0, 0.0, 0.0])]); + assert!(collision.flags.compliant && collision.flags.has_impulse); + assert_eq!(collision.bones[2].force, 2.0); + assert_eq!(collision.drive_weight, 0.0); + assert!(collision.regions[1].weighted_force.is_finite()); + collision.update(&input, &[]); + assert_eq!(collision.drive_weight, 0.5); + collision.update(&input, &[]); + assert_eq!(collision.drive_weight, 1.0); +} + +#[test] +fn tiny_dynamic_opponent_is_noncompliant_even_for_compliant_bone() { + let mut physical = SkeletonPhysicalRecord::default(); + physical.velocities[2] = [2.0, 0.0, 0.0, 0.0]; + let frames = [IDENTITY; 26]; + let weights = [1.0; 24]; + let input = input(&physical, &frames, &weights); + let mut contact = report([-1.0, 0.0, 0.0, 0.0]); + contact.body_b.state_flags = 4; + contact.body_b.inverse_mass = 1.0; + let mut collision = SkeletonCollisionFeedback::new(settings()); + collision.update(&input, &[contact]); + assert_eq!(collision.bones[2].force, 1.0); + assert!(collision.flags.noncompliant); + assert!(!collision.flags.compliant && !collision.flags.has_impulse); +} + +#[test] +fn contact_planes_constrain_physical_pose_error_without_inventing_impulse() { + let mut collision = SkeletonCollisionFeedback::new(settings()); + let axis = [0.0, 1.0, 0.0, 0.0]; + let error = [2.0, 3.0, 0.0, 0.0]; + assert_eq!(collision.filter_error(error, axis), [0.0; 4]); + collision.planes.push(ContactPlane { + normal: [1.0, 0.0, 0.0, 0.0], + part: 2, + }); + let filtered = collision.filter_error(error, axis); + assert!((filtered[0] - 2.0).abs() < 1e-5); + assert_eq!(filtered[1], 0.0); + let diagonal = 0.5_f32.sqrt(); + collision.planes[0].normal = [diagonal, 0.0, diagonal, 0.0]; + let projected = collision.filter_error(error, axis); + assert!((projected[0] - 1.0).abs() < 1e-5); + assert!((projected[2] - 1.0).abs() < 1e-5); + collision.planes[0].normal = [-1.0, 0.0, 0.0, 0.0]; + assert_eq!(collision.filter_error(error, axis), [0.0; 4]); +} diff --git a/crates/skate-core/src/physics/skeleton_general.rs b/crates/skate-core/src/physics/skeleton_general.rs new file mode 100644 index 00000000..95a197bb --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_general.rs @@ -0,0 +1,87 @@ +//! Original Skeleton::GeneralUpdate82BDCA38 and discontinuity reset82BE2798. +//! This operates on the same physical bodies later borrowed by the solver. +use super::{ + native_arithmetic::reciprocal_estimate, + rigid_body::RetailSimulationStep, + skeleton_animation_record::{ + AnimationPartTransform as Transform, SkeletonAnimationRecord, compose_affine, + transform_point, + }, + skeleton_body::SkeletonBody, +}; +use crate::math::Vector3; + +///82BDCA50..CAB8: root-position derivative, two reciprocal refinements, +///then history replacement. It is distinct from animation trajectory velocity. +pub fn root_velocity(previous: &mut [f32; 4], current: [f32; 4], dt: f32) -> [f32; 4] { + let difference: [f32; 4] = std::array::from_fn(|i| current[i] - previous[i]); + let mut inverse = reciprocal_estimate(dt); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(dt, 1.0), inverse); + } + let velocity = difference.map(|v| v * inverse); + *previous = current; + velocity +} + +///82BDCB50..CC18 uses its fixed-step literal, not the caller timestep. +///The resulting displacement enters ApplyDisplacementToPart82BE7788, which +///updates force accumulators and cooldown, rather than body translations. +pub fn apply_gravity_displacement(body: &mut SkeletonBody, processed_gravity: f32) { + let step = f32::from_bits(0x3C88_8889); + let displacement = + [0.0, -processed_gravity, 0.0, 0.0].map(|value| ((value * 0.5) * step) * step); + for part in 0..24 { + body.apply_part_displacement(part, displacement); + } +} + +pub struct ResetObservation { + ///Skeleton16208, current adjusted animation hips translation. + pub local_hips: [f32; 4], + ///Skeleton16144 and16160 receive this value;16176 is zeroed. + pub world_hips: [f32; 4], +} + +///82BE2798: reset physical pose to the current mapped animation after a +///target discontinuity. Reset rates exactly here; ordinary postphysics pose +///correction must preserve rates and uses SkeletonBody::set_part_transform. +pub fn reset_to_animation( + body: &mut SkeletonBody, + animation: &mut SkeletonAnimationRecord, + drive_pose: &[Transform; 24], + animation_to_world: &Transform, + com_frame: Transform, + simulation: RetailSimulationStep, +) -> ResetObservation { + for part in 0..24 { + let world = compose_affine(animation_to_world, &drive_pose[part]); + body.set_part_transform(part, world); + reset_rates(body, part, simulation); + } + for part in [24, 25] { + body.set_part_transform(part, com_frame); + reset_rates(body, part, simulation); + } + let local_hips = drive_pose[23][3]; + let world_hips = transform_point(animation_to_world, local_hips); + //82BEBBB0 obtains all26 current GetPartTransform results before clearing + //their position/velocity histories and the four animation COM histories. + let current = body.part_transforms(); + body.record.reset(¤t); + animation.reset_history(); + ResetObservation { + local_hips, + world_hips, + } +} + +fn reset_rates(body: &mut SkeletonBody, part: usize, simulation: RetailSimulationStep) { + let rates = &mut body.bodies_mut()[part].rates; + rates.linear_velocity = Vector3::ZERO; + rates.angular_velocity = Vector3::ZERO; + //Original Reset copies the Simulation+144 gravity vector into forceXYZ; + //it does not clear force to zero or reset energy/cooldown. + rates.force_acceleration = simulation.gravity_acceleration; + rates.torque_acceleration = Vector3::ZERO; +} diff --git a/crates/skate-core/src/physics/skeleton_landing.rs b/crates/skate-core/src/physics/skeleton_landing.rs new file mode 100644 index 00000000..2eb1e292 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_landing.rs @@ -0,0 +1,250 @@ +//! Complete TU3 Skeleton::UpdateLandingAdjust82BD9028. This owns the four +//! landing modes and history; it does not synthesize physical COM observations. +use super::skeleton_board_offset::SkateboardOffset; +use crate::point_graph::PointGraph; +const STEP: f32 = f32::from_bits(0x3C88_8889); + +#[derive(Clone, Debug)] +pub struct LandingSettings { + pub manual_blend: PointGraph<4>, + pub grind_blend: PointGraph<4>, + pub coffin_height: PointGraph<4>, + pub minimum_height: f32, + pub maximum_velocity: f32, + pub manual_damping: f32, + pub manual_spring: f32, + pub ground_minimum_compression_time: f32, + pub ground_damping: f32, + pub ground_spring: f32, + pub grind_animation_target_time: f32, + pub grind_damping: f32, + pub grind_spring: f32, + pub grind_target_delta: f32, + pub desired_com_height: f32, + pub coffin_time: f32, + pub coffin_maximum_velocity: f32, + pub coffin_blend_frames: f32, + pub coffin_base_height: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct LandingInput { + pub filtered_state: u32, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2476: u32, + pub balance: f32, + /// dot(Processed608 physical COM velocity, Processed544 ground up). + pub physical_com_velocity_along_up: f32, + /// dot(Skeleton10928 physical COM - Skeleton9680 physical board, up). + pub physical_com_height: f32, + /// Previous animation COM Skeleton10960.y minus current board pose12672.y. + pub animation_com_height: f32, +} + +#[derive(Clone, Debug)] +pub struct LandingAdjustment { + pub active: bool, + pub kind: u32, + pub time: f32, + pub position: f32, + pub velocity: f32, + pub previous_com_velocity: f32, + pub previous_animation_height: f32, + pub previous_filtered_state: u32, + pub desired_grind_com: f32, +} +impl Default for LandingAdjustment { + fn default() -> Self { + // Skeleton ctor82BD7A9C..7ABC. + Self { + active: false, + kind: 0, + time: 0.0, + position: 0.0, + velocity: 0.0, + previous_com_velocity: 0.0, + previous_animation_height: f32::MAX, + previous_filtered_state: 0, + desired_grind_com: 0.0, + } + } +} +impl LandingAdjustment { + pub fn update( + &mut self, + input: LandingInput, + settings: &LandingSettings, + offset: &mut SkateboardOffset, + ) { + let from_offboard = self.previous_filtered_state == 6 && input.filtered_state == 1; + let from_air = self.previous_filtered_state == 2 && input.filtered_state == 1; + let ground = (from_offboard || from_air) && input.flags_2476 & 0x1000_0000 != 0; + let manual = from_air && input.balance != 0.0; + let grind = self.previous_filtered_state == 2 + && input.filtered_state == 3 + && input.flags_2468 & 0x20 == 0; + let coffin_flag = input.flags_2472 & 1 != 0; + let coffin = coffin_flag && (grind || from_air); + let cancel = input.flags_2476 & 0x4000_0000 != 0 && !coffin_flag; + if cancel { + self.active = false; + } + let mut first_ground_update = false; + if !cancel && !self.active && (ground || coffin || manual || grind) { + self.time = 0.0; + self.active = true; + self.kind = if coffin { + 3 + } else if manual { + 2 + } else if ground { + 0 + } else { + 1 + }; + first_ground_update = self.kind == 0; + let velocity = if grind || coffin { + self.previous_com_velocity + } else { + input.physical_com_velocity_along_up + }; + let lower = select( + -settings.maximum_velocity - velocity, + -settings.maximum_velocity, + velocity, + ); + self.velocity = select(-lower, lower, 0.0); + self.previous_animation_height = f32::MAX; + self.position = input.physical_com_height; + } + if self.active { + let mut spring = 0.0; + let mut damping = 0.0; + let mut blend = 0.0; + let mut release = false; + let mut allow_upwards = true; + match self.kind { + 0 => { + spring = settings.ground_spring; + damping = settings.ground_damping; + allow_upwards = false; + blend = 1.0; + release = input.filtered_state != 1 || input.flags_2476 & 0x1000_0000 == 0; + } + 1 => { + spring = settings.grind_spring; + damping = settings.grind_damping; + blend = settings.grind_blend.evaluate(self.time); + release = input.filtered_state != 3; + } + 2 => { + spring = settings.manual_spring; + damping = settings.manual_damping; + blend = settings.manual_blend.evaluate(self.time); + release = input.filtered_state != 1; + } + 3 => { + blend = 1.0; + release = input.filtered_state != 1 + || self.time > settings.coffin_time + || !coffin_flag; + } + _ => {} + } + let animation_height = input.animation_com_height; + if self.kind == 3 { + let ratio = self.velocity.abs() / settings.coffin_maximum_velocity; + let positive_ratio = select(-ratio, 0.0, ratio); + let scale = select(1.0 - positive_ratio, positive_ratio, 1.0); + let height = settings + .coffin_height + .evaluate(self.time / settings.coffin_time); + self.position = height.mul_add(scale, settings.coffin_base_height); + } else { + let mut target = settings.desired_com_height; + if self.kind == 1 { + if self.time > settings.grind_animation_target_time { + let minimum = self.desired_grind_com - settings.grind_target_delta; + let maximum = self.desired_grind_com + settings.grind_target_delta; + let lower = select(minimum - animation_height, minimum, animation_height); + target = select(maximum - lower, lower, maximum); + } + self.desired_grind_com = target; + } + let displacement = self.position - target; + let previous_velocity = self.velocity; + // fnmsubs of damping*velocity and negative spring displacement. + let spring_force = -(displacement * spring); + let acceleration = -(previous_velocity.mul_add(damping, -spring_force)); + let candidate = acceleration.mul_add(STEP, previous_velocity); + let lower = select( + -settings.maximum_velocity - candidate, + -settings.maximum_velocity, + candidate, + ); + let maximum = if allow_upwards { + settings.maximum_velocity + } else { + 0.0 + }; + self.velocity = select(maximum - lower, lower, maximum); + let integrated = + ((self.velocity + previous_velocity) * 0.5).mul_add(STEP, displacement); + let height = integrated + target; + self.position = select( + height - settings.minimum_height, + height, + settings.minimum_height, + ); + } + let mut displacement = animation_height - self.position; + if first_ground_update { + let correction = self.velocity * f32::from_bits(0x3C75_C28F); + let lower = select(-0.1 - correction, -0.1, correction); + let down = select(-lower, lower, 0.0); + let combined = down + displacement; + displacement = select(combined, combined, 0.0); + } + if self.kind == 0 + && self.time > settings.ground_minimum_compression_time + && self.previous_animation_height < animation_height + && displacement > 0.0 + { + release = true; + } + if self.kind == 1 && input.flags_2468 & 0x20 != 0 { + release = true; + } + if self.time > 1.5 { + release = true; + } + let force_blend = input.flags_2468 & (1 << 21) != 0; + if !release && blend >= 0.05 || force_blend { + let mut frames = if self.kind == 3 { + settings.coffin_blend_frames + } else { + 15.0 + }; + if force_blend { + self.active = false; + frames = 6.0; + } + offset.refresh_height(displacement * blend, frames); + } else { + self.active = false; + } + self.time += STEP; + self.previous_animation_height = animation_height; + } + self.previous_com_velocity = input.physical_com_velocity_along_up; + self.previous_filtered_state = input.filtered_state; + } +} +fn select(selector: f32, positive: f32, negative: f32) -> f32 { + if selector >= 0.0 { positive } else { negative } +} + +#[cfg(test)] +#[path = "tests/skeleton_landing.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/skeleton_motion.rs b/crates/skate-core/src/physics/skeleton_motion.rs new file mode 100644 index 00000000..66422b2e --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_motion.rs @@ -0,0 +1,96 @@ +//! Trajectory and board-orientation publications inside ProcessData82BD8918. +use super::{ + native_arithmetic::reciprocal_estimate, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, +}; + +pub struct SkeletonMotion { + ///Skeleton12048,12112,12176. Later skeleton modes may set the next frame. + pub trajectory: AnimationPartTransform, + pub inverse_trajectory: AnimationPartTransform, + pub next_trajectory: AnimationPartTransform, + ///Skeleton16320: current trajectory translation/dt, transformed to world. + pub velocity_world: [f32; 4], + ///Skeleton16500, initialized zero by82BD7A6C. + previous_board_at_y: f32, +} + +impl Default for SkeletonMotion { + fn default() -> Self { + //82BD7260 seeds the three frames from82139A10/20/30 and zero pos. + Self { + trajectory: IDENTITY, + inverse_trajectory: IDENTITY, + next_trajectory: IDENTITY, + velocity_world: [0.; 4], + previous_board_at_y: 0., + } + } +} + +impl SkeletonMotion { + ///82BE3538 resets only the board-orientation history, not trajectory. + pub fn reset_board_orientation_history(&mut self) { + self.previous_board_at_y = 0.0; + } + + ///82BD89D0..8BA0. Bone0 is the source trajectory, not the animated foot. + ///Preserve the two reciprocal refinements after the shared estimate model. + pub fn process_trajectory( + &mut self, + trajectory_bone: &AnimationPartTransform, + animation_to_world: &AnimationPartTransform, + dt: f32, + ) { + self.trajectory = compose_affine(trajectory_bone, &self.next_trajectory); + self.next_trajectory = self.trajectory; + let mut reciprocal = reciprocal_estimate(dt); + for _ in 0..2 { + let error = (-reciprocal).mul_add(dt, 1.0); + reciprocal = reciprocal.mul_add(error, reciprocal); + } + let velocity = self.trajectory[3].map(|v| v * reciprocal); + self.velocity_world = std::array::from_fn(|lane| { + let x = animation_to_world[0][lane] * velocity[0]; + let y = animation_to_world[1][lane].mul_add(velocity[1], x); + animation_to_world[2][lane].mul_add(velocity[2], y) + }); + let source = self.trajectory; + self.inverse_trajectory = std::array::from_fn(|axis| { + if axis < 3 { + [source[0][axis], source[1][axis], source[2][axis], 0.] + } else { + [0.; 4] + } + }); + self.inverse_trajectory[3] = std::array::from_fn(|lane| { + let z = (0.0 - source[3][2]) * self.inverse_trajectory[2][lane]; + let y = (0.0 - source[3][1]).mul_add(self.inverse_trajectory[1][lane], z); + (0.0 - source[3][0]).mul_add(self.inverse_trajectory[0][lane], y) + }); + } + + ///82BD8CB0..8D2C precedes the landing/board-offset adjustment. Only sets + ///the side-on flag; this stage does not clear an already published flag. + pub fn publish_unadjusted_board(board: &AnimationPartTransform, flags_2472: &mut u32) { + if board[0][1].abs() > f32::from_bits(0x3f35_c28f) { + *flags_2472 |= 0x8000; + } + } + + ///82BD8E14..8E50 uses board At.y, not height or translation. Returns the + ///difference published to Processed2768 and updates its complementary bits. + pub fn publish_adjusted_board( + &mut self, + board: &AnimationPartTransform, + flags_2468: &mut u32, + ) -> f32 { + let at_y = board[2][1]; + let delta = at_y - self.previous_board_at_y; + self.previous_board_at_y = at_y; + let positive = at_y > 0.0; + *flags_2468 = + (*flags_2468 & !0x1800) | (u32::from(positive) << 12) | (u32::from(!positive) << 11); + delta + } +} diff --git a/crates/skate-core/src/physics/skeleton_output/board.rs b/crates/skate-core/src/physics/skeleton_output/board.rs new file mode 100644 index 00000000..de14e84e --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/board.rs @@ -0,0 +1,179 @@ +//! Board portion of original TU3 physical pose worker82DB993C..A55C. +use crate::physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, +}; +use crate::{math::Vector3, riding::collision_response::signed_angle, trigonometry::sin_cos}; + +#[derive(Clone, Copy)] +pub struct Settings { + pub truck_tilt_scalar: f32, + pub truck_tilt_max_angle: f32, + pub truck_tilt_wobble_scalar: f32, + pub truck_displacement_max: f32, +} +#[derive(Clone, Copy)] +pub struct BoneIndices { + pub front_truck: usize, + pub back_truck: usize, + pub front_left_wheel: usize, + pub front_right_wheel: usize, + pub back_left_wheel: usize, + pub back_right_wheel: usize, +} +impl BoneIndices { + pub fn all(self) -> [usize; 6] { + [ + self.front_truck, + self.back_truck, + self.front_left_wheel, + self.front_right_wheel, + self.back_left_wheel, + self.back_right_wheel, + ] + } +} +pub struct Input<'a> { + /// Raw dynamic-body matrices82585B58, native parts0..5; not82585CB0. + pub bodies: &'a [Transform; 6], + /// SkeletonPhysicalRecord.part0, captured separately by82DB670C. + pub skeleton_board: &'a Transform, + /// SkateboardBody7840/7904, not7712/7776 steering-drive base frames. + pub truck_frames: &'a [Transform; 2], + pub deck_wobble_tilt: f32, + pub deck_wobble_squish: f32, + /// CalculateAverageWheelCompressions82C08968, front8372/back8376. + pub average_compression: [f32; 2], +} +pub fn publish( + input: Input<'_>, + settings: &Settings, + bones: BoneIndices, + locals: &mut [Transform], +) { + let y = [0.0, 1.0, 0.0, 0.0]; + let z = [0.0, 0.0, 1.0, 0.0]; + let wheels: [Transform; 4] = core::array::from_fn(|wheel| { + let up = inverse_direction(&input.bodies[4 + wheel / 2], input.bodies[wheel][1]); + rotation_x(angle(up, y, z)) + }); + //82DB9C60/9E00 choose opposite axle directions at the two ends. + let mut front = inverse_direction( + input.skeleton_board, + normalize_safe(subtract(input.bodies[0][3], input.bodies[1][3])), + ); + let mut back = inverse_direction( + input.skeleton_board, + normalize_safe(subtract(input.bodies[3][3], input.bodies[2][3])), + ); + if 0.5 > dot3(front, front) { + front = input.truck_frames[0][2]; + } + if 0.5 > dot3(back, back) { + back = input.truck_frames[1][2].map(|v| -v); + } + let negative_x = [-1.0, -0.0, -0.0, -0.0]; + let back_angle = wrap(angle(back, negative_x, input.truck_frames[1][0])); + let front_angle = wrap(angle(front, negative_x, input.truck_frames[0][0])); + let wobble = input.deck_wobble_tilt * settings.truck_tilt_wobble_scalar; + let back_tilt = clamp( + (-settings.truck_tilt_scalar).mul_add(back_angle, -wobble), + -settings.truck_tilt_max_angle, + settings.truck_tilt_max_angle, + ); + let front_tilt = clamp( + (-settings.truck_tilt_scalar).mul_add(front_angle, wobble), + -settings.truck_tilt_max_angle, + settings.truck_tilt_max_angle, + ); + for (side, bone, tilt) in [ + (0, bones.front_truck, front_tilt), + (1, bones.back_truck, back_tilt), + ] { + let compression = if input.average_compression[side] < f32::from_bits(0x3A83_126F) { + 0.0 + } else { + input.average_compression[side] + }; + let displacement = clamp( + compression - input.deck_wobble_squish, + 0.0, + settings.truck_displacement_max, + ); + let rotated = compose_affine(&locals[bone], &rotation_x(tilt)); + //82DBA130..A318 left-multiplies the rotated local by translation. + let translation = [ + [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, displacement, 0.0, 0.0], + ]; + locals[bone] = compose_affine(&translation, &rotated); + } + //Native physical-body order and animation-name order differ. + for (wheel, bone) in [ + bones.front_left_wheel, + bones.front_right_wheel, + bones.back_left_wheel, + bones.back_right_wheel, + ] + .into_iter() + .enumerate() + { + locals[bone] = compose_affine(&locals[bone], &wheels[wheel]); + } +} +fn angle(a: [f32; 4], b: [f32; 4], axis: [f32; 4]) -> f32 { + let vector = |v: [f32; 4]| Vector3::new(v[0], v[1], v[2]); + signed_angle(vector(a), vector(b), vector(axis)) +} +fn inverse_direction(frame: &Transform, value: [f32; 4]) -> [f32; 4] { + let mut output = [0.0; 4]; + for lane in 0..3 { + let x = value[0] * frame[lane][0]; + let y = value[1].mul_add(frame[lane][1], x); + output[lane] = value[2].mul_add(frame[lane][2], y); + } + output +} +fn normalize_safe(value: [f32; 4]) -> [f32; 4] { + let squared = dot3(value, value); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + if length > f32::from_bits(0x3586_37BD) { + value.map(|v| v * inverse) + } else { + [0.0; 4] + } +} +fn subtract(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|i| a[i] - b[i]) +} +fn wrap(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3E22_F983); + let fraction = turns - turns.floor(); + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40C9_0FDB) +} +fn clamp(value: f32, minimum: f32, maximum: f32) -> f32 { + let low = if minimum - value >= 0.0 { + minimum + } else { + value + }; + if maximum - low >= 0.0 { low } else { maximum } +} +fn rotation_x(angle: f32) -> Transform { + let (sine, cosine) = sin_cos(angle); + //vperm822FB890 replicates X in W; vrlimi2 replaces only Z. + [ + [1.0, 0.0, 0.0, 1.0], + [0.0, cosine, sine, 0.0], + [0.0, -sine, cosine, 0.0], + [0.0; 4], + ] +} diff --git a/crates/skate-core/src/physics/skeleton_output/correction.rs b/crates/skate-core/src/physics/skeleton_output/correction.rs new file mode 100644 index 00000000..fd8139c8 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/correction.rs @@ -0,0 +1,79 @@ +//! PostWipeoutCheck82BD83E0's error correction before wobble and physical IK. +use crate::physics::{ + native_arithmetic::{dot3, vector_min}, + skeleton_body::SkeletonBody, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct CorrectionState { + ///Skeleton16224, initialized zero at82BD7918. + pub board_prediction_error: [f32; 4], + ///Skeleton16388, initialized false at82BD7998 and set by Ground::Update. + pub pending: bool, +} +impl CorrectionState { + ///UpdatePostPhysics82BD8194..81B0, before collision and error feedback: + ///actual board GetPartTransform translation minus predicted16112. + pub fn observe_board(&mut self, actual: [f32; 4], predicted: [f32; 4]) { + self.board_prediction_error = core::array::from_fn(|i| actual[i] - predicted[i]); + } + pub fn apply( + &mut self, + body: &mut SkeletonBody, + ground_normal: [f32; 4], + wipeout: bool, + flags_2468: u32, + flags_2476: u32, + ) { + apply( + body, + &mut self.pending, + self.board_prediction_error, + ground_normal, + wipeout, + flags_2468, + flags_2476, + ); + } +} + +///Skeleton16224 is the already-produced board prediction error, and16388 says the +///record adjustment remains pending. This function does not synthesize errors. +pub fn apply( + body: &mut SkeletonBody, + pending: &mut bool, + error: [f32; 4], + ground_normal: [f32; 4], + wipeout: bool, + flags_2468: u32, + flags_2476: u32, +) { + if wipeout && flags_2476 & (1 << 30) != 0 { + //82BE2AB0 moves live parts0..23 only when correction length²<1. + if 1.0 > dot3(error, error) { + for part in 0..24 { + let mut frame = body.record.pose[part]; + for lane in 0..4 { + frame[3][lane] += error[lane]; + } + body.set_part_transform(part, frame); + } + } + *pending = false; + } else if !wipeout && flags_2468 & (1 << 18) == 0 && *pending { + let distance = dot3(error, ground_normal); + let allowed = vector_min(0.0, distance); + let offset: [f32; 4] = core::array::from_fn(|i| { + let tangent = error[i] - ground_normal[i] * distance; + ground_normal[i].mul_add(allowed, tangent) + }); + //82BEC2C0 starts at record part1 and translates through23. It leaves + //the observed board, solver states, COM and velocity history alone. + for part in 1..24 { + for lane in 0..4 { + body.record.pose[part][3][lane] += offset[lane]; + } + } + *pending = false; + } +} diff --git a/crates/skate-core/src/physics/skeleton_output/mod.rs b/crates/skate-core/src/physics/skeleton_output/mod.rs new file mode 100644 index 00000000..d985abad --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/mod.rs @@ -0,0 +1,67 @@ +//! Original TU3 physical pose worker82DB95E8, submitted by82DB6698. +//! Updates the existing animation buffers after the physical solve. +pub mod board; +pub mod wobble; +pub mod correction; + +use super::skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}; +use crate::animation::foot_ik::{drive::Geometry, transforms::inverse_rigid}; + +/// Stock SkeletonData ids, nearest physical ancestors, and volume inverses. +pub struct SkeletonOutput { + pub bone_indices: [usize; 24], + pub geometry: Geometry, + pub board_bones: board::BoneIndices, + pub board_settings: board::Settings, +} +pub struct Input<'a> { + /// Skeleton8016, all24 solved physical volume transforms in world space. + pub physical_parts: &'a [Transform; 24], + /// Skeleton11984, captured by82DB66C0 before submitting this worker. + pub world_to_animation: &'a Transform, + pub board: board::Input<'a>, +} +impl SkeletonOutput { + /// Buffers must contain the evaluated stock animation for this same frame. + /// Unrepresented bones retain their animation. Board locals are modified + /// after the mapped globals; the worker does not rebuild the full hierarchy. + pub fn publish( + &self, + input: Input<'_>, + globals: &mut [Transform], + locals: &mut [Transform], + ) -> Result<(), &'static str> { + if globals.len() != locals.len() + || self.bone_indices.iter().any(|&i| i >= globals.len()) + || self.board_bones.all().iter().any(|&i| i >= locals.len()) + { + return Err("physical pose output does not match the stock animation hierarchy"); + } + //82DB96B8..97E4: volume -> animation bone -> animation space. + for part in 0..24 { + let world = compose_affine( + &input.physical_parts[part], + &self.geometry.inverse_part_frames[part], + ); + globals[self.bone_indices[part]] = compose_affine(input.world_to_animation, &world); + } + //82DB97F4..9938 descends through the physical mapping. This inverse + //is a rigid transpose, distinct from the authored volume inverse. + for part in (0..24).rev() { + let bone = self.bone_indices[part]; + locals[bone] = if let Some(parent) = self.geometry.parents[part] { + compose_affine( + &inverse_rigid(&globals[self.bone_indices[parent]]), + &globals[bone], + ) + } else { + globals[bone] + }; + } + board::publish(input.board, &self.board_settings, self.board_bones, locals); + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/physics/skeleton_output/tests.rs b/crates/skate-core/src/physics/skeleton_output/tests.rs new file mode 100644 index 00000000..2671db21 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/tests.rs @@ -0,0 +1,111 @@ +use super::*; +use crate::{physics::skeleton_animation_record::IDENTITY, point_graph::PointGraph}; + +#[test] +fn solved_volume_is_converted_back_to_bone_and_preserves_unmapped_animation() { + let mut forward = [IDENTITY; 24]; + forward[1][3][0] = 2.0; + let mut parents = [None; 24]; + parents[1] = Some(2); + let output = SkeletonOutput { + bone_indices: core::array::from_fn(|i| i), + geometry: Geometry::new(parents, &forward).unwrap(), + board_bones: board::BoneIndices { + front_truck: 25, + back_truck: 26, + front_left_wheel: 27, + front_right_wheel: 28, + back_left_wheel: 29, + back_right_wheel: 30, + }, + board_settings: board::Settings { + truck_tilt_scalar: 1.0, + truck_tilt_max_angle: 1.0, + truck_tilt_wobble_scalar: 1.0, + truck_displacement_max: 1.0, + }, + }; + let mut physical = [IDENTITY; 24]; + physical[1][3][0] = 12.0; + physical[2][3][0] = 7.0; + let mut world_to_anim = IDENTITY; + world_to_anim[3][0] = -3.0; + let mut globals = [IDENTITY; 36]; + let mut locals = [IDENTITY; 36]; + locals[35][3][1] = 9.0; + let mut bodies = [IDENTITY; 6]; + bodies[0][3][0] = -1.0; + bodies[1][3][0] = 1.0; + bodies[2][3][0] = 1.0; + bodies[3][3][0] = -1.0; + output + .publish( + Input { + physical_parts: &physical, + world_to_animation: &world_to_anim, + board: board::Input { + bodies: &bodies, + skeleton_board: &physical[0], + truck_frames: &[IDENTITY; 2], + deck_wobble_tilt: 0.0, + deck_wobble_squish: 0.0, + average_compression: [0.0; 2], + }, + }, + &mut globals, + &mut locals, + ) + .unwrap(); + assert!((globals[1][3][0] - 7.0).abs() < 1e-6); + assert!((locals[1][3][0] - 3.0).abs() < 1e-6); + assert_eq!(locals[35][3][1], 9.0); +} + +#[test] +fn wobble_emits_final_sample_before_expiry_and_selects_real_phase_curves() { + let curve = |value| PointGraph { + x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], + y: [value; 8], + }; + let settings = wobble::Settings { + takeoff_tilt: curve(2.0), + landing_tilt: curve(3.0), + takeoff_squish: curve(4.0), + landing_squish: curve(5.0), + maximum_time: 0.0, + }; + let mut state = wobble::Wobble::default(); + assert!(!state.update(&settings).sampled); + state.trigger(true, true); + let final_sample = state.update(&settings); + assert!(final_sample.sampled); + assert!(!final_sample.remains_active); + assert_eq!(final_sample.tilt, -3.0); + assert_eq!(final_sample.squish, 5.0); + assert_eq!(state.time, 0.0); + assert!(!state.update(&settings).sampled); + state.trigger(false, false); + assert_eq!(state.update(&settings).tilt, 2.0); +} + +#[test] +fn wobble_rotates_about_deck_forward_then_compresses_along_new_up() { + let mut board = IDENTITY; + board[3] = [4.0, 5.0, 6.0, 0.0]; + wobble::apply( + wobble::Output { + sampled: true, + tilt: core::f32::consts::FRAC_PI_2, + squish: 0.25, + remains_active: true, + }, + &mut board, + ); + assert!((board[0][0]).abs() < 2e-6); + assert!((board[0][1] - 1.0).abs() < 2e-6); + assert!((board[1][0] + 1.0).abs() < 2e-6); + assert_eq!(&board[2][..3], &[0.0, 0.0, 1.0]); + assert!((board[3][0] - 3.75).abs() < 2e-6); + assert!((board[3][1] - 5.0).abs() < 2e-6); + assert_eq!(board[3][2], 6.0); +} diff --git a/crates/skate-core/src/physics/skeleton_output/wobble.rs b/crates/skate-core/src/physics/skeleton_output/wobble.rs new file mode 100644 index 00000000..611e8aff --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_output/wobble.rs @@ -0,0 +1,97 @@ +//! SkateboardWobble constructor82BF2E48, trigger82BF2F18, update82BF2FB8. +use crate::point_graph::PointGraph; + +pub struct Settings { + pub takeoff_tilt: PointGraph<8>, + pub landing_tilt: PointGraph<8>, + pub takeoff_squish: PointGraph<8>, + pub landing_squish: PointGraph<8>, + pub maximum_time: f32, +} +pub struct Wobble { + pub active: bool, + pub landing: bool, + pub time: f32, + pub amplitude: f32, + pub direction: f32, + selected_landing_curves: bool, +} +impl Default for Wobble { + fn default() -> Self { + //Constructor copies landing curves while initializing type17=false. + Self { + active: false, + landing: false, + time: 0.0, + amplitude: 0.0, + direction: 1.0, + selected_landing_curves: true, + } + } +} +#[derive(Clone, Copy, Default)] +pub struct Output { + ///Native out0 reports whether this update evaluated the effect, including + ///the final sample whose clock expires it. Return value is active AFTER. + pub sampled: bool, + pub tilt: f32, + pub squish: f32, + pub remains_active: bool, +} +impl Wobble { + pub fn trigger(&mut self, landing: bool, reverse: bool) { + self.selected_landing_curves = landing; + self.landing = landing; + self.active = true; + self.time = 0.0; + self.amplitude = 1.0; + self.direction = if reverse { -1.0 } else { 1.0 }; + } + pub fn update(&mut self, settings: &Settings) -> Output { + if !self.active { + return Output::default(); + } + let (tilt, squish) = if self.selected_landing_curves { + (&settings.landing_tilt, &settings.landing_squish) + } else { + (&settings.takeoff_tilt, &settings.takeoff_squish) + }; + let squish = squish.evaluate(self.time) * self.amplitude; + let tilt = (tilt.evaluate(self.time) * self.direction) * self.amplitude; + self.time += f32::from_bits(0x3C88_8889); + if self.time > settings.maximum_time { + self.active = false; + self.time = 0.0; + } + Output { + sampled: true, + tilt, + squish, + remains_active: self.active, + } + } +} + +/// PostWipeoutCheck82BD857C..86D4 modifies the observed board record only. +///This wobble is not a force or a SetPartTransform on the simulated deck. +pub fn apply( + output: Output, + board: &mut crate::physics::skeleton_animation_record::AnimationPartTransform, +) { + if !output.sampled { + return; + } + use crate::{physics::skeleton_animation_record::compose_affine, trigonometry::sin_cos}; + let (sine, cosine) = sin_cos(output.tilt); + //822FB890 permute and vrlimi mask2 retain the native packed W lanes. + let rotation = [ + [cosine, sine, 0.0, cosine], + [-sine, cosine, 0.0, -sine], + [0.0, 0.0, 1.0, 0.0], + [0.0; 4], + ]; + *board = compose_affine(board, &rotation); + for lane in 0..4 { + board[3][lane] = board[1][lane].mul_add(output.squish, board[3][lane]); + } +} diff --git a/crates/skate-core/src/physics/skeleton_root.rs b/crates/skate-core/src/physics/skeleton_root.rs new file mode 100644 index 00000000..7797eca0 --- /dev/null +++ b/crates/skate-core/src/physics/skeleton_root.rs @@ -0,0 +1,174 @@ +//! Skeleton::UpdateRootTransforms82BE0318: actual board prediction and the +//! retained animation-to-board/world frames. Full Reckoning is an input owner. +use super::{ + board_ground::angle_between, + board_motion_output::inverse_length_squared, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, +}; +use crate::{math::Vector3, trigonometry}; + +#[derive(Clone, Debug)] +pub struct SkeletonRootFrames { + pub board: AnimationPartTransform, + pub inverse_board: AnimationPartTransform, + pub previous_board_position: [f32; 4], + pub predicted_board_position: [f32; 4], + pub supplied_prediction: Option<[f32; 4]>, + pub animation_to_board: AnimationPartTransform, + pub animation_to_world: AnimationPartTransform, + pub world_to_animation: AnimationPartTransform, + pub heading_alignment: AnimationPartTransform, + pub initialize_heading: bool, +} +impl Default for SkeletonRootFrames { + fn default() -> Self { + // Skeleton ctor82BD7260 matrix seeds,16416=0 and16417=1. + Self { + board: IDENTITY, + inverse_board: IDENTITY, + previous_board_position: [0.0; 4], + predicted_board_position: [0.0; 4], + supplied_prediction: None, + animation_to_board: IDENTITY, + animation_to_world: IDENTITY, + world_to_animation: IDENTITY, + heading_alignment: IDENTITY, + initialize_heading: true, + } + } +} +impl SkeletonRootFrames { + ///82BDFE58: teleport prediction uses the current physical deck position; + ///the heading and remaining root composition match82BE0318. + pub fn update_teleport( + &mut self, + board: AnimationPartTransform, + animation_board: &AnimationPartTransform, + reckoning_frame_816: &AnimationPartTransform, + ) { + self.initialize_heading = true; + self.supplied_prediction = Some(board[3]); + self.update(board, [0.0; 4], 0.0, animation_board, reckoning_frame_816); + //82BDFF0C differs from ordinary update's previous-position snapshot. + self.previous_board_position = board[3]; + } + + /// Reset82BD9990 writes these two frames from the initial mapped pose. + /// Other root histories retain their independently initialized state. + pub fn reset_initial_alignment(&mut self, animation_to_world: AnimationPartTransform) { + self.animation_to_world = animation_to_world; + self.world_to_animation = inverse_rigid(&animation_to_world); + } + + pub fn update( + &mut self, + board: AnimationPartTransform, + board_velocity: [f32; 4], + time_step: f32, + animation_board: &AnimationPartTransform, + reckoning: &AnimationPartTransform, + ) { + self.previous_board_position = self.board[3]; + self.board = board; + self.inverse_board = inverse_rigid(&board); + self.predicted_board_position = self.supplied_prediction.take().unwrap_or_else(|| { + std::array::from_fn(|i| board_velocity[i].mul_add(time_step, board[3][i])) + }); + if self.initialize_heading { + //8296EC98 uses one rsqrt refinement, angle in[0,2pi], not atan2. + let at = Vector3::new(animation_board[2][0], 0.0, animation_board[2][2]); + let forward = Vector3::new(0.0, 0.0, 1.0); + let mut angle = angle_between(at, forward); + // Normalize before the sign test, exactly as the angle helper. + let at4 = [at.x, at.y, at.z, 0.0]; + let sq = dot3(at4, at4); + if sq > f32::from_bits(0x38D1_B717) { + let inverse = inverse_length_squared(sq, 1); + let x = at.x * inverse; + // dot(cross(normalized_at, forward), world_up) == -x. + if 0.0 > -x { + angle = f32::from_bits(0x40C9_0FDB) - angle; + } + } + let (sin, cos) = trigonometry::sin_cos(angle); + self.heading_alignment = [ + [cos, 0.0, -sin, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sin, 0.0, cos, 0.0], + [0.0; 4], + ]; + self.initialize_heading = false; + } + let mut remove_board_translation = IDENTITY; + remove_board_translation[3] = animation_board[3].map(|v| -v); + let local = compose_affine(&self.heading_alignment, &remove_board_translation); + self.animation_to_board = compose_affine(reckoning, &local); + let mut world_translation = IDENTITY; + world_translation[3] = self.predicted_board_position; + self.animation_to_world = + orthonormalize(compose_affine(&world_translation, &self.animation_to_board)); + self.world_to_animation = inverse_rigid(&self.animation_to_world); + } +} + +///825C5710: reverse-axis Gram-Schmidt, two rsqrt refinements, translation copy. +pub(crate) fn orthonormalize(source: AnimationPartTransform) -> AnimationPartTransform { + let mut result = source; + for axis in (0..3).rev() { + let mut vector = source[axis]; + for prior in ((axis + 1)..3).rev() { + let projection = dot3(result[prior], source[axis]); + for (lane, value) in vector.iter_mut().enumerate() { + *value -= result[prior][lane] * projection; + } + } + let reciprocal = inverse_length_squared(dot3(vector, vector), 2); + result[axis] = vector.map(|v| v * reciprocal); + } + result +} + +/// Inline rigid inverse: transposeXYZ, zero stored W, translation Z,Y,X FMA. +pub(crate) fn inverse_rigid(source: &AnimationPartTransform) -> AnimationPartTransform { + 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] = std::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 +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn actual_board_prediction_and_source_heading_build_the_animation_frame() { + let mut roots = SkeletonRootFrames::default(); + let mut board = IDENTITY; + board[3] = [10.0, 2.0, 0.0, 0.0]; + let mut animation = IDENTITY; + animation[3] = [1.0, 2.0, 3.0, 0.0]; + roots.update(board, [5.0, 0.0, 0.0, 0.0], 0.02, &animation, &IDENTITY); + assert!((roots.predicted_board_position[0] - 10.1).abs() < 1e-6); + let recentered = compose_affine(&roots.animation_to_board, &animation); + assert!(recentered[3].iter().all(|v| v.abs() < 1e-6)); + assert!(!roots.initialize_heading); + roots.supplied_prediction = Some([30.0, 40.0, 50.0, 0.0]); + roots.update(board, [5.0, 0.0, 0.0, 0.0], 0.02, &animation, &IDENTITY); + assert_eq!(roots.predicted_board_position, [30.0, 40.0, 50.0, 0.0]); + assert_eq!(roots.supplied_prediction, None); + assert_eq!(roots.previous_board_position, board[3]); + let product = compose_affine(&roots.world_to_animation, &roots.animation_to_world); + for axis in 0..4 { + for lane in 0..4 { + assert!((product[axis][lane] - IDENTITY[axis][lane]).abs() < 1e-5); + } + } + } +} diff --git a/crates/skate-core/src/physics/solver.rs b/crates/skate-core/src/physics/solver.rs new file mode 100644 index 00000000..7db35c49 --- /dev/null +++ b/crates/skate-core/src/physics/solver.rs @@ -0,0 +1,66 @@ +//! Shared TU3 constraint iteration (`0x82AE27D0`). +//! +//! Constraint families must exchange reactions on every pass. Solving each +//! family to completion independently changes the physical result. +use super::{ + contact_solver::RetailContactJacobian, drive_solver::RetailDriveRows, + joint_solver::RetailJointJacobian, rigid_body::RetailReactionCorrections, +}; +mod contact; +pub mod contact_build; +mod drive; +mod joint; +pub mod packed; +mod packing; + +pub(crate) fn compile_contact(record: &mut [u32; 64], time_step: f32) { + contact_build::build(record, time_step); +} + +pub(crate) fn solve_joint_record(record: &mut [u32], a: &mut [u32], b: &mut [u32]) { + joint::solve(record, a, b); +} + +/// Host indices replace the two guest reaction pointers stored in a Jacobian. +#[derive(Clone, Debug)] +pub struct JointConstraint { + pub jacobian: RetailJointJacobian, + pub reaction_a: usize, + pub reaction_b: usize, +} + +/// Runs the retail contact → joint → drive ordering. The caller clears the +/// reaction workspace and builds fresh rows once per simulation step. +pub fn solve_constraints( + contacts: &mut [RetailContactJacobian], + joints: &mut [JointConstraint], + drives: &mut [RetailDriveRows], + reactions: &mut [RetailReactionCorrections], + iterations: u32, +) { + let mut c: Vec<_> = contacts.iter().map(packing::contact).collect(); + let mut j: Vec<_> = joints + .iter() + .map(|j| packed::Joint { + words: j.jacobian.words, + reaction_a: j.reaction_a, + reaction_b: j.reaction_b, + }) + .collect(); + let mut d: Vec<_> = drives.iter().map(packing::drive).collect(); + let mut r: Vec<_> = reactions.iter().copied().map(packing::reaction).collect(); + packed::solve(&mut c, &mut j, &mut d, &mut r, iterations); + for (contact, native) in contacts.iter_mut().zip(c) { + contact.words = native.words; + } + for (joint, native) in joints.iter_mut().zip(j) { + joint.jacobian.words = native.words; + } + for (drive, native) in drives.iter_mut().zip(d) { + drive.accumulated_linear_impulse = packing::read_xyz(&native.words, 2); + drive.accumulated_angular_impulse = packing::read_xyz(&native.words, 3); + } + for (reaction, native) in reactions.iter_mut().zip(r) { + *reaction = packing::unpack_reaction(native); + } +} diff --git a/crates/skate-core/src/physics/solver/contact.rs b/crates/skate-core/src/physics/solver/contact.rs new file mode 100644 index 00000000..3c421872 --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact.rs @@ -0,0 +1,73 @@ +//! Contact iteration, TU3 82AE2914..82AE2B8C. +//! +//! The compiled row boundary is decoded once. Calculations preserve the source +//! operation order for finite inputs; console exceptional-float behavior has +//! not been established by hardware captures. +#[path = "contact_data.rs"] +mod data; +use data::{ContactRows, Reaction}; + +pub(super) fn solve(record: &mut [u32], a: &mut [u32], b: &mut [u32]) { + let rows = ContactRows::read(record); + let mut reaction_a = Reaction::read(a); + let mut reaction_b = Reaction::read(b); + let position_a = reaction_a.point_position(rows.arm_a); + let position_b = reaction_b.point_position(rows.arm_b); + let velocity_a = reaction_a.point_velocity(rows.arm_a); + let velocity_b = reaction_b.point_velocity(rows.arm_b); + let position_error: [f32; 4] = core::array::from_fn(|i| position_b[i] - position_a[i]); + let total_error: [f32; 4] = + core::array::from_fn(|i| (velocity_b[i] - velocity_a[i]) + position_error[i]); + + // 82AE2A94..2ABC: the first three impulses respond to combined position + // and velocity correction. The fourth responds only to position error. + let candidate: [f32; 4] = core::array::from_fn(|lane| { + let error = if lane == 3 { + position_error + } else { + total_error + }; + let mut impulse = rows.accumulated[lane] + rows.target[lane]; + for (component, &value) in error[..3].iter().enumerate() { + let coefficient_lane = if lane == 3 { 0 } else { lane }; + impulse = rows.correction_columns[component][coefficient_lane].mul_add(value, impulse); + } + impulse + }); + + // 82AE2AC4..2B08: friction uses the preceding normal impulse. Crossing + // the static limit selects the dynamic limit. The two normal lanes use + // [0, f32::MAX]. Ordered comparisons also preserve the native selectors. + let next: [f32; 4] = core::array::from_fn(|lane| { + let normal = if lane == 1 || lane == 2 { + rows.accumulated[0] + } else { + 0.0 + }; + let upper_offset = if lane == 0 || lane == 3 { + f32::MAX + } else { + 0.0 + }; + let static_low = (-rows.static_friction).mul_add(normal, 0.0); + let static_high = rows.static_friction.mul_add(normal, upper_offset); + let dynamic_low = (-rows.dynamic_friction).mul_add(normal, 0.0); + let dynamic_high = rows.dynamic_friction.mul_add(normal, upper_offset); + let lower_selected = if candidate[lane] >= static_low { + candidate[lane] + } else { + dynamic_low + }; + if static_high >= candidate[lane] { + lower_selected + } else { + dynamic_high + } + }); + let change = core::array::from_fn(|lane| next[lane] - rows.accumulated[lane]); + rows.publish_impulses(record, next); + reaction_a.apply_contact(&rows, change, true); + reaction_b.apply_contact(&rows, change, false); + reaction_a.write(a); + reaction_b.write(b); +} diff --git a/crates/skate-core/src/physics/solver/contact_build.rs b/crates/skate-core/src/physics/solver/contact_build.rs new file mode 100644 index 00000000..e51a182b --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_build.rs @@ -0,0 +1,95 @@ +//! TU3 ContactBatchBuild `82AE10C8`: contact geometry, mass response and targets. +//! +//! Arithmetic is reconstructed for finite inputs and finite intermediates. The +//! reciprocal response uses the isolated accepted Xenon `vrefp` approximation; +//! callers can still inject captured hardware results through +//! [`ContactMassResponse`] without changing the recovered builder order. +mod geometry; +mod input; +mod publication; + +use crate::math::Vector3; + +/// Complete preparation before the native reciprocal-estimate operation. +#[derive(Clone, Debug, PartialEq)] +pub struct ContactPreparation { + pub arms: [Vector3; 2], + pub axes: [Vector3; 3], + pub active: [bool; 2], + pub inverse_mass: [f32; 2], + pub point_acceleration: [Vector3; 2], + /// XYZ inverse-inertia response; W is the native packed carry value. + pub angular_response_a: [[f32; 4]; 3], + pub angular_response_b: [[f32; 4]; 3], + /// Angular response plus both inverse masses, in normal/tangent order. + pub effective_mass: [f32; 3], + pub separation_projection: [f32; 3], + pub restitution_projection: [f32; 3], + pub predicted_separation_projection: [f32; 3], + reaction_ids: [u32; 2], + body_ids: [u32; 2], + static_friction_bits: u32, + dynamic_friction_bits: u32, + contact_tag: u32, + combined_state_bit_8: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ContactBuildError { + ReciprocalEstimateUnavailable { effective_mass: [f32; 3] }, +} + +/// Supplies the three native `vrefp` results without changing their arguments. +pub trait ContactMassResponse { + fn inverse_effective_mass( + &mut self, + effective_mass: [f32; 3], + ) -> Result<[f32; 3], ContactBuildError>; +} + +pub struct UnavailableContactMassResponse; + +impl ContactMassResponse for UnavailableContactMassResponse { + fn inverse_effective_mass( + &mut self, + effective_mass: [f32; 3], + ) -> Result<[f32; 3], ContactBuildError> { + Err(ContactBuildError::ReciprocalEstimateUnavailable { effective_mass }) + } +} + +/// Default host implementation of the three `vrefp` lanes at `0x82AE158C`. +/// The approximation is isolated in `native_arithmetic` so hardware traces can +/// replace it without changing contact geometry or publication. +pub struct NativeContactMassResponse; + +impl ContactMassResponse for NativeContactMassResponse { + fn inverse_effective_mass( + &mut self, + effective_mass: [f32; 3], + ) -> Result<[f32; 3], ContactBuildError> { + Ok(effective_mass.map(crate::physics::native_arithmetic::reciprocal_estimate)) + } +} + +pub fn prepare_contact(record: &[u32; 64], time_step: f32) -> ContactPreparation { + geometry::prepare(input::ContactInput::decode(record), time_step) +} + +/// Builds all rows privately and publishes only after mass response succeeds. +pub fn build_with_response( + record: &mut [u32; 64], + time_step: f32, + response: &mut impl ContactMassResponse, +) -> Result<(), ContactBuildError> { + let prepared = prepare_contact(record, time_step); + let inverse_response = response.inverse_effective_mass(prepared.effective_mass)?; + *record = publication::compile(&prepared, inverse_response); + Ok(()) +} + +#[track_caller] +pub(super) fn build(record: &mut [u32; 64], time_step: f32) { + build_with_response(record, time_step, &mut NativeContactMassResponse) + .expect("the native contact mass response is infallible"); +} diff --git a/crates/skate-core/src/physics/solver/contact_build/geometry.rs b/crates/skate-core/src/physics/solver/contact_build/geometry.rs new file mode 100644 index 00000000..9940486c --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_build/geometry.rs @@ -0,0 +1,119 @@ +//! Contact arms and inverse-inertia response, `82AE11BC..82AE1588`. +use super::{input::ContactInput, ContactPreparation}; +use crate::math::Vector3; + +fn components(v: Vector3) -> [f32; 3] { + [v.x, v.y, v.z] +} + +fn difference(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} + +fn cross(arm: Vector3, axis: Vector3) -> [f32; 3] { + [ + (-arm.z).mul_add(axis.y, arm.y.mul_add(axis.z, 0.0)), + (-arm.x).mul_add(axis.z, arm.z.mul_add(axis.x, 0.0)), + (-arm.y).mul_add(axis.x, arm.x.mul_add(axis.y, 0.0)), + ] +} + +fn inertia_response(full: Vector3, split: Vector3, mass: f32, jacobian: [f32; 3]) -> [f32; 4] { + let columns = [ + [full.x, full.y, full.z, mass], + [full.y, split.y, split.z, mass], + [full.z, split.z, split.x, mass], + ]; + core::array::from_fn(|component| { + let first = columns[0][component].mul_add(jacobian[0], 0.0); + let second = columns[1][component].mul_add(jacobian[1], first); + columns[2][component].mul_add(jacobian[2], second) + }) +} + +fn project(axes: [Vector3; 3], value: Vector3) -> [f32; 3] { + axes.map(|axis| { + let x = axis.x.mul_add(value.x, 0.0); + let xy = axis.y.mul_add(value.y, x); + axis.z.mul_add(value.z, xy) + }) +} + +pub(super) fn prepare(input: ContactInput, time_step: f32) -> ContactPreparation { + let arms = + core::array::from_fn(|body| difference(input.positions[body], input.bodies[body].center)); + let active = input.bodies.each_ref().map(|body| body.state & 4 == 4); + let inverse_mass = core::array::from_fn(|body| { + if active[body] { + input.bodies[body].inverse_mass + } else { + 0.0 + } + }); + let jacobians: [[[f32; 3]; 3]; 2] = arms.map(|arm| input.axes.map(|axis| cross(arm, axis))); + let responses: [[[f32; 4]; 3]; 2] = core::array::from_fn(|body| { + let (full, split) = if active[body] { + ( + input.bodies[body].inverse_inertia_full, + input.bodies[body].inverse_inertia_split, + ) + } else { + (Vector3::ZERO, Vector3::ZERO) + }; + jacobians[body].map(|jacobian| inertia_response(full, split, inverse_mass[body], jacobian)) + }); + let effective_mass = core::array::from_fn(|axis| { + let products: [f32; 3] = core::array::from_fn(|component| { + let a = jacobians[0][axis][component].mul_add(responses[0][axis][component], 0.0); + jacobians[1][axis][component].mul_add(responses[1][axis][component], a) + }); + (products[2] + (products[0] + inverse_mass[0])) + (products[1] + inverse_mass[1]) + }); + let point_acceleration = core::array::from_fn(|body| { + if !active[body] { + return Vector3::ZERO; + } + let arm = arms[body]; + let force = input.bodies[body].force_acceleration; + let torque = input.bodies[body].torque_acceleration; + Vector3::new( + arm.z.mul_add(torque.y, (-arm.y).mul_add(torque.z, force.x)), + arm.x.mul_add(torque.z, (-arm.z).mul_add(torque.x, force.y)), + arm.y.mul_add(torque.x, (-arm.x).mul_add(torque.y, force.z)), + ) + }); + let separation = difference(input.positions[1], input.positions[0]); + let relative_acceleration = difference(point_acceleration[1], point_acceleration[0]); + let scaled_velocity = components(input.relative_velocity).map(|v| time_step.mul_add(v, 0.0)); + let acceleration = + components(relative_acceleration).map(|v| v.mul_add(time_step * time_step, 0.0)); + let predicted = core::array::from_fn::<_, 3, _>(|i| { + scaled_velocity[i] + (components(separation)[i] + acceleration[i]) + }); + let restitution = scaled_velocity.map(|v| (-v).mul_add(input.restitution, 0.0)); + ContactPreparation { + arms, + axes: input.axes, + active, + inverse_mass, + point_acceleration, + angular_response_a: responses[0], + angular_response_b: responses[1], + effective_mass, + separation_projection: project(input.axes, separation), + restitution_projection: project( + input.axes, + Vector3::new(restitution[0], restitution[1], restitution[2]), + ), + predicted_separation_projection: project( + input.axes, + Vector3::new(predicted[0], predicted[1], predicted[2]), + ), + reaction_ids: input.bodies.each_ref().map(|body| body.reaction_id), + body_ids: input.body_ids, + static_friction_bits: input.static_friction_bits, + dynamic_friction_bits: input.dynamic_friction_bits, + contact_tag: input.contact_tag, + combined_state_bit_8: (input.bodies[0].state | input.bodies[1].state) & 8, + } +} diff --git a/crates/skate-core/src/physics/solver/contact_build/input.rs b/crates/skate-core/src/physics/solver/contact_build/input.rs new file mode 100644 index 00000000..66335442 --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_build/input.rs @@ -0,0 +1,58 @@ +//! Decodes the uncompiled contact copied by the native contact producer. +use crate::math::Vector3; + +pub(super) struct BodyInput { + pub center: Vector3, + pub inverse_inertia_full: Vector3, + pub inverse_inertia_split: Vector3, + pub inverse_mass: f32, + pub state: u32, + pub force_acceleration: Vector3, + pub torque_acceleration: Vector3, + pub reaction_id: u32, +} + +pub(super) struct ContactInput { + pub positions: [Vector3; 2], + pub axes: [Vector3; 3], + pub relative_velocity: Vector3, + pub restitution: f32, + pub bodies: [BodyInput; 2], + pub body_ids: [u32; 2], + pub static_friction_bits: u32, + pub dynamic_friction_bits: u32, + pub contact_tag: u32, +} + +fn vector(words: &[u32; 64], row: usize) -> Vector3 { + Vector3::new( + f32::from_bits(words[row * 4]), + f32::from_bits(words[row * 4 + 1]), + f32::from_bits(words[row * 4 + 2]), + ) +} + +impl ContactInput { + pub fn decode(words: &[u32; 64]) -> Self { + Self { + positions: [vector(words, 0), vector(words, 1)], + axes: core::array::from_fn(|axis| vector(words, 2 + axis)), + relative_velocity: vector(words, 5), + restitution: f32::from_bits(words[11]), + bodies: core::array::from_fn(|body| BodyInput { + center: vector(words, 6 + body), + inverse_inertia_full: vector(words, 8 + body), + inverse_inertia_split: vector(words, 10 + body), + inverse_mass: f32::from_bits(words[(8 + body) * 4 + 3]), + state: words[(10 + body) * 4 + 3], + force_acceleration: vector(words, 12 + body), + torque_acceleration: vector(words, 14 + body), + reaction_id: words[(6 + body) * 4 + 3], + }), + body_ids: [words[3], words[7]], + static_friction_bits: words[15], + dynamic_friction_bits: words[19], + contact_tag: words[23], + } + } +} diff --git a/crates/skate-core/src/physics/solver/contact_build/publication.rs b/crates/skate-core/src/physics/solver/contact_build/publication.rs new file mode 100644 index 00000000..d85beed7 --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_build/publication.rs @@ -0,0 +1,94 @@ +//! Scaled Jacobian and restitution target publication, `82AE1590..82AE15F4`. +use super::ContactPreparation; +use crate::math::Vector3; + +fn vector_row(words: &mut [u32; 64], row: usize, xyz: Vector3, carry: u32) { + words[row * 4..row * 4 + 4].copy_from_slice(&[ + xyz.x.to_bits(), + xyz.y.to_bits(), + xyz.z.to_bits(), + carry, + ]); +} + +fn float_row(words: &mut [u32; 64], row: usize, values: [f32; 4]) { + words[row * 4..row * 4 + 4].copy_from_slice(&values.map(f32::to_bits)); +} + +pub(super) fn compile(prepared: &ContactPreparation, inverse_response: [f32; 3]) -> [u32; 64] { + let mut words = [0; 64]; + for body in 0..2 { + vector_row( + &mut words, + body, + prepared.arms[body], + prepared.reaction_ids[body], + ); + } + let axes = prepared.axes.map(|axis| [axis.x, axis.y, axis.z]); + let carry = [ + prepared.combined_state_bit_8, + prepared.static_friction_bits, + prepared.dynamic_friction_bits, + ]; + for component in 0..3 { + let column = core::array::from_fn::<_, 3, _>(|axis| { + axes[axis][component].mul_add(inverse_response[axis], 0.0) + }); + vector_row( + &mut words, + 2 + component, + Vector3::new(column[0], column[1], column[2]), + carry[component], + ); + } + // Row 5 is the newly cleared accumulated impulse, including its carry. + let separation = prepared.separation_projection[0].mul_add(inverse_response[0], 0.0); + let restitution = prepared.restitution_projection[0].mul_add(inverse_response[0], 0.0); + let predicted = core::array::from_fn::<_, 3, _>(|axis| { + prepared.predicted_separation_projection[axis].mul_add(inverse_response[axis], 0.0) + }); + let mut normal_adjustment = if restitution >= 0.0 { + 0.0 + } else { + separation + restitution + }; + if separation >= 0.0 { + normal_adjustment = restitution; + } + if 0.0 >= predicted[0] { + normal_adjustment = 0.0; + } + float_row( + &mut words, + 6, + [ + predicted[0] - normal_adjustment, + predicted[1] - 0.0, + predicted[2] - 0.0, + separation, + ], + ); + let identifiers = [ + prepared.body_ids[0], + prepared.body_ids[1], + prepared.contact_tag, + ]; + for axis in 0..3 { + vector_row( + &mut words, + 7 + axis * 3, + prepared.axes[axis], + identifiers[axis], + ); + let mut a = prepared.angular_response_a[axis]; + let mut b = prepared.angular_response_b[axis]; + if axis == 0 { + a[3] = prepared.inverse_mass[0]; + b[3] = prepared.inverse_mass[1]; + } + float_row(&mut words, 8 + axis * 3, a); + float_row(&mut words, 9 + axis * 3, b); + } + words +} diff --git a/crates/skate-core/src/physics/solver/contact_data.rs b/crates/skate-core/src/physics/solver/contact_data.rs new file mode 100644 index 00000000..decd583d --- /dev/null +++ b/crates/skate-core/src/physics/solver/contact_data.rs @@ -0,0 +1,121 @@ +//! Named view of the compiled contact and shared reaction records. +//! Fourth components retain the native packed layout, including inverse mass +//! alongside each body's first angular response. +type Components = [f32; 4]; + +pub(super) struct ContactRows { + pub arm_a: Components, + pub arm_b: Components, + pub correction_columns: [Components; 3], + pub static_friction: f32, + pub dynamic_friction: f32, + pub accumulated: Components, + pub target: Components, + axes: [Components; 3], + angular_a: [Components; 3], + angular_b: [Components; 3], +} +impl ContactRows { + pub fn read(words: &[u32]) -> Self { + assert_eq!(words.len(), 64); + Self { + arm_a: read(words, 0), + arm_b: read(words, 1), + correction_columns: core::array::from_fn(|i| read(words, 2 + i)), + static_friction: f32::from_bits(words[15]), + dynamic_friction: f32::from_bits(words[19]), + accumulated: read(words, 5), + target: read(words, 6), + axes: core::array::from_fn(|i| read(words, 7 + 3 * i)), + angular_a: core::array::from_fn(|i| read(words, 8 + 3 * i)), + angular_b: core::array::from_fn(|i| read(words, 9 + 3 * i)), + } + } + pub fn publish_impulses(&self, words: &mut [u32], impulse: Components) { + write(words, 5, impulse); + } +} + +pub(super) struct Reaction { + linear: Components, + position: Components, + angular: Components, + orientation: Components, +} +impl Reaction { + pub fn read(words: &[u32]) -> Self { + assert_eq!(words.len(), 16); + Self { + linear: read(words, 0), + position: read(words, 1), + angular: read(words, 2), + orientation: read(words, 3), + } + } + pub fn point_position(&self, arm: Components) -> Components { + point_correction(arm, self.position, self.orientation) + } + pub fn point_velocity(&self, arm: Components) -> Components { + point_correction(arm, self.linear, self.angular) + } + pub fn apply_contact(&mut self, rows: &ContactRows, change: Components, body_a: bool) { + let response = if body_a { + &rows.angular_a + } else { + &rows.angular_b + }; + let inverse_mass = response[0][3]; + for lane in 0..4 { + let mut linear_impulse = rows.axes[0][lane].mul_add(change[0], 0.0); + linear_impulse = rows.axes[1][lane].mul_add(change[1], linear_impulse); + linear_impulse = rows.axes[2][lane].mul_add(change[2], linear_impulse); + let position_impulse = rows.axes[0][lane].mul_add(change[3], 0.0); + if body_a { + for (axis, &impulse) in change[..3].iter().enumerate() { + self.angular[lane] = response[axis][lane].mul_add(impulse, self.angular[lane]); + } + self.orientation[lane] = + response[0][lane].mul_add(change[3], self.orientation[lane]); + self.linear[lane] = linear_impulse.mul_add(inverse_mass, self.linear[lane]); + self.position[lane] = position_impulse.mul_add(inverse_mass, self.position[lane]); + } else { + for (axis, &impulse) in change[..3].iter().enumerate() { + self.angular[lane] = + (-response[axis][lane]).mul_add(impulse, self.angular[lane]); + } + self.orientation[lane] = + (-response[0][lane]).mul_add(change[3], self.orientation[lane]); + self.linear[lane] = (-linear_impulse).mul_add(inverse_mass, self.linear[lane]); + self.position[lane] = + (-position_impulse).mul_add(inverse_mass, self.position[lane]); + } + } + } + pub fn write(&self, words: &mut [u32]) { + for (row, value) in [self.linear, self.position, self.angular, self.orientation] + .into_iter() + .enumerate() + { + write(words, row, value); + } + } +} + +// 82AE297C..2A18: linear + angular cross arm. Fuse the first product into +// linear before subtracting the second; a separate cross product rounds early. +fn point_correction(arm: Components, linear: Components, angular: Components) -> Components { + core::array::from_fn(|lane| { + let next = if lane == 3 { 3 } else { (lane + 1) % 3 }; + let previous = if lane == 3 { 3 } else { (lane + 2) % 3 }; + let first = angular[next].mul_add(arm[previous], linear[lane]); + (-angular[previous]).mul_add(arm[next], first) + }) +} +fn read(words: &[u32], row: usize) -> Components { + core::array::from_fn(|lane| f32::from_bits(words[4 * row + lane])) +} +fn write(words: &mut [u32], row: usize, value: Components) { + for (lane, value) in value.into_iter().enumerate() { + words[4 * row + lane] = value.to_bits(); + } +} diff --git a/crates/skate-core/src/physics/solver/drive.rs b/crates/skate-core/src/physics/solver/drive.rs new file mode 100644 index 00000000..d6a43793 --- /dev/null +++ b/crates/skate-core/src/physics/solver/drive.rs @@ -0,0 +1,65 @@ +//! TU3 drive pass 82AE2E78..82AE30D8, reconstructed from direct disassembly. +//! Finite compiled coefficients are required; exceptional SIMD arithmetic is +//! not claimed to match hardware. Linear/angular softness lanes are retained. + +use super::joint::data::{ + ConstraintGeometry, ReactionState, project_correction, publish_reactions, read_column, + write_column, xyz, +}; + +pub(super) fn solve(record: &mut [u32], a: &mut [u32], b: &mut [u32]) { + let geometry = ConstraintGeometry::decode(record); + let mut body_a = ReactionState::decode(a); + let mut body_b = ReactionState::decode(b); + let (relative_point, relative_angular) = geometry.relative_corrections(&body_a, &body_b); + let old_linear = read_column(record, 32); + let old_angular = read_column(record, 48); + let linear_response = + project_correction(&geometry.linear_projection, relative_point, old_linear); + let angular_response = + project_correction(&geometry.angular_projection, relative_angular, old_angular); + let linear_target = read_column(record, 160); + let angular_target = read_column(record, 176); + let linear_limits = [ + read_column(record, 96)[3], + read_column(record, 112)[3], + linear_target[3], + ]; + let angular_limits = [ + read_column(record, 128)[3], + read_column(record, 144)[3], + angular_target[3], + ]; + + let mut linear_impulse = old_linear; + let mut angular_impulse = old_angular; + for axis in 0..3 { + let linear_candidate = linear_response[axis].mul_add(old_linear[3], linear_target[axis]); + let angular_candidate = + angular_response[axis].mul_add(old_angular[3], angular_target[axis]); + linear_impulse[axis] = symmetric_impulse_limit(linear_candidate, linear_limits[axis]); + angular_impulse[axis] = symmetric_impulse_limit(angular_candidate, angular_limits[axis]); + } + let linear_change = xyz(core::array::from_fn(|axis| { + linear_impulse[axis] - old_linear[axis] + })); + let angular_change = xyz(core::array::from_fn(|axis| { + angular_impulse[axis] - old_angular[axis] + })); + // 82AE2FB8/2FC4 select XYZ from the new impulses while retaining each + // accumulator's fourth (softness) lane from its old value. + write_column(record, 32, linear_impulse); + write_column(record, 48, angular_impulse); + geometry.apply_impulse_changes(&mut body_a, &mut body_b, linear_change, angular_change); + publish_reactions(&body_a, &body_b, a, b); +} + +fn symmetric_impulse_limit(candidate: f32, limit: f32) -> f32 { + let upper_limited = if candidate < limit { candidate } else { limit }; + let negative_limit = 0.0 - limit; + if upper_limited > negative_limit { + upper_limited + } else { + negative_limit + } +} diff --git a/crates/skate-core/src/physics/solver/joint.rs b/crates/skate-core/src/physics/solver/joint.rs new file mode 100644 index 00000000..07167c59 --- /dev/null +++ b/crates/skate-core/src/physics/solver/joint.rs @@ -0,0 +1,75 @@ +//! TU3 joint pass 82AE2BC8..82AE2E44, reconstructed from direct disassembly. +//! Finite compiled coefficients are required; exceptional SIMD arithmetic is +//! not claimed to match hardware. The packed public boundary remains unchanged. + +#[path = "joint_data.rs"] +pub(super) mod data; +use data::{ + ConstraintGeometry, ReactionState, project_correction, publish_reactions, read_column, + write_column, xyz, +}; + +pub(super) fn solve(record: &mut [u32], a: &mut [u32], b: &mut [u32]) { + let geometry = ConstraintGeometry::decode(record); + let mut body_a = ReactionState::decode(a); + let mut body_b = ReactionState::decode(b); + let (relative_point, relative_angular) = geometry.relative_corrections(&body_a, &body_b); + let old_linear = read_column(record, 32); + let old_angular = read_column(record, 48); + let linear_candidate = + project_correction(&geometry.linear_projection, relative_point, old_linear); + let angular_candidate = + project_correction(&geometry.angular_projection, relative_angular, old_angular); + + let linear_low = read_column(record, 160); + let linear_high = read_column(record, 176); + // 82AE2C90..2CAC gathers angular bounds from packed fourth lanes. The + // fourth output duplicates the Z bound; joint accumulators store all lanes. + let angular_low = [ + read_column(record, 96)[3], + read_column(record, 112)[3], + linear_low[3], + linear_low[3], + ]; + let angular_high = [ + read_column(record, 128)[3], + read_column(record, 144)[3], + linear_high[3], + linear_high[3], + ]; + let angular_impulse = core::array::from_fn(|axis| { + joint_limit_correction( + angular_candidate[axis], + angular_low[axis], + angular_high[axis], + ) + }); + let linear_impulse = core::array::from_fn(|axis| { + joint_limit_correction(linear_candidate[axis], linear_low[axis], linear_high[axis]) + }); + let angular_change = xyz(core::array::from_fn(|axis| { + angular_impulse[axis] - old_angular[axis] + })); + let linear_change = xyz(core::array::from_fn(|axis| { + linear_impulse[axis] - old_linear[axis] + })); + + // The two candidates above used the same incoming body corrections. Native + // stores angular accumulation first, then linear accumulation, then applies + // the changes through both bodies' inverse mass and inertia. + write_column(record, 48, angular_impulse); + write_column(record, 32, linear_impulse); + geometry.apply_impulse_changes(&mut body_a, &mut body_b, linear_change, angular_change); + publish_reactions(&body_a, &body_b, a, b); +} + +/// Native two-sided limit projection at 82AE2CCC..82AE2D00. These offsets are +/// preconditioned limit errors from the builder, not impulse min/max bounds. +/// An error inside the permitted interval produces zero correction. +fn joint_limit_correction(candidate: f32, low: f32, high: f32) -> f32 { + let high_error = high + candidate; + let low_error = low + candidate; + let negative_correction = if high_error < 0.0 { high_error } else { 0.0 }; + let positive_correction = if low_error > 0.0 { low_error } else { 0.0 }; + positive_correction + negative_correction +} diff --git a/crates/skate-core/src/physics/solver/joint_data.rs b/crates/skate-core/src/physics/solver/joint_data.rs new file mode 100644 index 00000000..cb28aa30 --- /dev/null +++ b/crates/skate-core/src/physics/solver/joint_data.rs @@ -0,0 +1,171 @@ +//! Decoded joint/drive geometry and shared reaction application. +//! +//! Record boundaries are four-float columns because native solver stores also +//! update their fourth lanes. Physical vectors/impulses have three components; +//! the fourth values are packed carry fields, not extra spatial dimensions. + +use crate::math::Vector3; + +pub(in crate::physics::solver) fn read_column(words: &[u32], byte_offset: usize) -> [f32; 4] { + core::array::from_fn(|component| f32::from_bits(words[byte_offset / 4 + component])) +} + +pub(in crate::physics::solver) fn write_column( + words: &mut [u32], + byte_offset: usize, + values: [f32; 4], +) { + for (destination, value) in words[byte_offset / 4..byte_offset / 4 + 4] + .iter_mut() + .zip(values) + { + *destination = value.to_bits(); + } +} + +pub(in crate::physics::solver) fn xyz(values: [f32; 4]) -> Vector3 { + Vector3::new(values[0], values[1], values[2]) +} + +/// `82AE1AE8` and `82AE3BC8` write these same response fields. Columns at +/// 64/96/128 and 80/112/144 project relative corrections into constraint axes; +/// 192..224 and 240..272 convert impulse changes back into world space. +pub(in crate::physics::solver) struct ConstraintGeometry { + pub arm_a: Vector3, + pub arm_b: Vector3, + pub linear_projection: [[f32; 4]; 3], + pub angular_projection: [[f32; 4]; 3], + linear_axes: [[f32; 4]; 3], + angular_axes: [[f32; 4]; 3], + inverse_inertia_a: [[f32; 4]; 3], + inverse_inertia_b: [[f32; 4]; 3], +} + +impl ConstraintGeometry { + pub fn decode(words: &[u32]) -> Self { + assert_eq!(words.len(), 96); + Self { + arm_a: xyz(read_column(words, 0)), + arm_b: xyz(read_column(words, 16)), + linear_projection: core::array::from_fn(|axis| read_column(words, 64 + axis * 32)), + angular_projection: core::array::from_fn(|axis| read_column(words, 80 + axis * 32)), + linear_axes: core::array::from_fn(|axis| read_column(words, 192 + axis * 16)), + angular_axes: core::array::from_fn(|axis| read_column(words, 240 + axis * 16)), + inverse_inertia_a: core::array::from_fn(|axis| read_column(words, 288 + axis * 16)), + inverse_inertia_b: core::array::from_fn(|axis| read_column(words, 336 + axis * 16)), + } + } + + /// Both candidates must read this incoming snapshot before either impulse + /// is applied: 82AE2C00..2CC4 (joint), 82AE2E90..2F80 (drive). + pub fn relative_corrections(&self, a: &ReactionState, b: &ReactionState) -> (Vector3, Vector3) { + let point_a = point_correction(a, self.arm_a); + let point_b = point_correction(b, self.arm_b); + ( + Vector3::new( + point_b.x - point_a.x, + point_b.y - point_a.y, + point_b.z - point_a.z, + ), + Vector3::new( + b.angular[0] - a.angular[0], + b.angular[1] - a.angular[1], + b.angular[2] - a.angular[2], + ), + ) + } + + /// 82AE2D24..2E40 / 82AE2FE0..30D4. One linear and one angular impulse + /// change affect both bodies through their own arm and inverse inertia. + pub fn apply_impulse_changes( + &self, + a: &mut ReactionState, + b: &mut ReactionState, + linear_change: Vector3, + angular_change: Vector3, + ) { + let linear_impulse = project_correction(&self.linear_axes, linear_change, [0.0; 4]); + let angular_impulse = xyz(project_correction( + &self.angular_axes, + angular_change, + [0.0; 4], + )); + let torque_a = torque_at_offset(self.arm_a, xyz(linear_impulse), angular_impulse); + let torque_b = torque_at_offset(self.arm_b, xyz(linear_impulse), angular_impulse); + let inverse_mass_a = self.inverse_inertia_a[0][3]; + let inverse_mass_b = self.inverse_inertia_b[0][3]; + // Native publication order: linear A, linear B, angular A, angular B. + for (component, impulse) in linear_impulse.into_iter().enumerate() { + a.linear[component] = impulse.mul_add(inverse_mass_a, a.linear[component]); + b.linear[component] = (-impulse).mul_add(inverse_mass_b, b.linear[component]); + } + a.angular = project_correction(&self.inverse_inertia_a, torque_a, a.angular); + for component in 0..4 { + let after_x = + (-self.inverse_inertia_b[0][component]).mul_add(torque_b.x, b.angular[component]); + let after_y = (-self.inverse_inertia_b[1][component]).mul_add(torque_b.y, after_x); + b.angular[component] = + (-self.inverse_inertia_b[2][component]).mul_add(torque_b.z, after_y); + } + } +} + +/// Velocity-producing displacement corrections at reaction offsets 0/32. +/// Contact-only pose corrections at 16/48 are neither read nor overwritten. +pub(in crate::physics::solver) struct ReactionState { + linear: [f32; 4], + angular: [f32; 4], +} + +impl ReactionState { + pub fn decode(words: &[u32]) -> Self { + assert_eq!(words.len(), 16); + Self { + linear: read_column(words, 0), + angular: read_column(words, 32), + } + } +} + +pub(in crate::physics::solver) fn publish_reactions( + a: &ReactionState, + b: &ReactionState, + words_a: &mut [u32], + words_b: &mut [u32], +) { + write_column(words_a, 0, a.linear); + write_column(words_b, 0, b.linear); + write_column(words_a, 32, a.angular); + write_column(words_b, 32, b.angular); +} + +/// Matrix projection with the native X, Y, Z fused accumulation order. The +/// incoming accumulated impulse is the initial value for candidate projection. +pub(in crate::physics::solver) fn project_correction( + columns: &[[f32; 4]; 3], + correction: Vector3, + initial: [f32; 4], +) -> [f32; 4] { + core::array::from_fn(|component| { + let after_x = columns[0][component].mul_add(correction.x, initial[component]); + let after_y = columns[1][component].mul_add(correction.y, after_x); + columns[2][component].mul_add(correction.z, after_y) + }) +} + +fn point_correction(reaction: &ReactionState, arm: Vector3) -> Vector3 { + let [x, y, z, _] = reaction.angular; + Vector3::new( + (-z).mul_add(arm.y, y.mul_add(arm.z, reaction.linear[0])), + (-x).mul_add(arm.z, z.mul_add(arm.x, reaction.linear[1])), + (-y).mul_add(arm.x, x.mul_add(arm.y, reaction.linear[2])), + ) +} + +fn torque_at_offset(arm: Vector3, impulse: Vector3, angular_impulse: Vector3) -> Vector3 { + Vector3::new( + (-arm.z).mul_add(impulse.y, arm.y.mul_add(impulse.z, angular_impulse.x)), + (-arm.x).mul_add(impulse.z, arm.z.mul_add(impulse.x, angular_impulse.y)), + (-arm.y).mul_add(impulse.x, arm.x.mul_add(impulse.y, angular_impulse.z)), + ) +} diff --git a/crates/skate-core/src/physics/solver/packed.rs b/crates/skate-core/src/physics/solver/packed.rs new file mode 100644 index 00000000..560eeb7e --- /dev/null +++ b/crates/skate-core/src/physics/solver/packed.rs @@ -0,0 +1,58 @@ +//! Native solver records with host indices replacing guest reaction pointers. +use super::{contact, drive, joint}; + +#[derive(Clone, Debug)] +pub struct Constraint { + pub words: [u32; WORDS], + pub reaction_a: usize, + pub reaction_b: usize, +} + +pub type Contact = Constraint<64>; +pub type Joint = Constraint<96>; +pub type Drive = Constraint<96>; +pub type Reaction = [u32; 16]; + +/// Complete contact → joint → drive loops of HorusPipeline (82AE27D0). +/// Contact position corrections remain distinct from velocity-producing +/// reactions; all families see preceding writes during the same iteration. +pub fn solve( + contacts: &mut [Contact], + joints: &mut [Joint], + drives: &mut [Drive], + reactions: &mut [Reaction], + iterations: u32, +) { + validate(contacts, reactions.len()); + validate(joints, reactions.len()); + validate(drives, reactions.len()); + for _ in 0..iterations { + pass(contacts, reactions, contact::solve); + pass(joints, reactions, joint::solve); + pass(drives, reactions, drive::solve); + } +} + +fn validate(constraints: &[Constraint], count: usize) { + for c in constraints { + assert_ne!(c.reaction_a, c.reaction_b); + assert!(c.reaction_a < count && c.reaction_b < count); + } +} + +fn pass( + constraints: &mut [Constraint], + reactions: &mut [Reaction], + kernel: fn(&mut [u32], &mut [u32], &mut [u32]), +) { + for c in constraints { + let (a, b) = if c.reaction_a < c.reaction_b { + let (left, right) = reactions.split_at_mut(c.reaction_b); + (&mut left[c.reaction_a], &mut right[0]) + } else { + let (left, right) = reactions.split_at_mut(c.reaction_a); + (&mut right[0], &mut left[c.reaction_b]) + }; + kernel(&mut c.words, a, b); + } +} diff --git a/crates/skate-core/src/physics/solver/packing.rs b/crates/skate-core/src/physics/solver/packing.rs new file mode 100644 index 00000000..14b46489 --- /dev/null +++ b/crates/skate-core/src/physics/solver/packing.rs @@ -0,0 +1,136 @@ +//! Semantic builder outputs → native compiled-record layout. +use super::{packed, *}; +use crate::math::Vector3; +use crate::physics::rigid_body::RetailPackedWorldInverseInertia; + +pub(super) fn contact(c: &RetailContactJacobian) -> packed::Contact { + packed::Contact { + words: *c.words(), + reaction_a: c.reaction_index_a, + reaction_b: c.reaction_index_b, + } +} + +pub(super) fn drive(d: &RetailDriveRows) -> packed::Drive { + let mut words = [0; 96]; + vector(&mut words, 0, d.arm_a, 0.0); + vector(&mut words, 1, d.arm_b, 0.0); + row( + &mut words, + 2, + d.accumulated_linear_impulse, + d.linear_softness, + ); + row( + &mut words, + 3, + d.accumulated_angular_impulse, + d.angular_softness, + ); + for component in 0..3 { + row( + &mut words, + 4 + component * 2, + core::array::from_fn(|r| { + xyz(d.linear_axes[r])[component] * d.linear_inverse_effective_mass[r] + }), + 0.0, + ); + row( + &mut words, + 5 + component * 2, + core::array::from_fn(|r| { + xyz(d.angular_axes[r])[component] * d.angular_inverse_effective_mass[r] + }), + 0.0, + ); + vector(&mut words, 12 + component, d.linear_axes[component], 0.0); + vector(&mut words, 15 + component, d.angular_axes[component], 0.0); + } + row( + &mut words, + 10, + d.linear_target_impulse, + d.linear_maximum_impulse[2], + ); + row( + &mut words, + 11, + d.angular_target_impulse, + d.angular_maximum_impulse[2], + ); + words[6 * 4 + 3] = d.linear_maximum_impulse[0].to_bits(); + words[7 * 4 + 3] = d.linear_maximum_impulse[1].to_bits(); + words[8 * 4 + 3] = d.angular_maximum_impulse[0].to_bits(); + words[9 * 4 + 3] = d.angular_maximum_impulse[1].to_bits(); + for (index, body) in [(18, d.frame_a_body), (21, d.frame_b_body)] { + if body.state & 4 != 0 { + let columns = inertia_columns(body.world_inverse_inertia); + for (i, column) in columns.into_iter().enumerate() { + vector( + &mut words, + index + i, + column, + if i == 0 { body.inverse_mass } else { 0.0 }, + ); + } + } + } + packed::Drive { + words, + reaction_a: d.frame_a_body.reaction_index, + reaction_b: d.frame_b_body.reaction_index, + } +} + +pub(super) fn reaction(r: RetailReactionCorrections) -> packed::Reaction { + let mut words = [0; 16]; + for (i, v) in [ + r.linear_displacement, + r.position_displacement, + r.angular_displacement, + r.orientation_displacement, + ] + .into_iter() + .enumerate() + { + vector(&mut words, i, v, 0.0); + } + words +} +pub(super) fn unpack_reaction(words: packed::Reaction) -> RetailReactionCorrections { + let v = |i| { + let v = read_xyz(&words, i); + Vector3::new(v[0], v[1], v[2]) + }; + RetailReactionCorrections { + linear_displacement: v(0), + position_displacement: v(1), + angular_displacement: v(2), + orientation_displacement: v(3), + } +} +pub(super) fn read_xyz(words: &[u32], i: usize) -> [f32; 3] { + core::array::from_fn(|j| f32::from_bits(words[4 * i + j])) +} +fn row(words: &mut [u32], i: usize, v: [f32; 3], w: f32) { + words[4 * i..4 * i + 4].copy_from_slice(&[ + v[0].to_bits(), + v[1].to_bits(), + v[2].to_bits(), + w.to_bits(), + ]); +} +fn vector(words: &mut [u32], i: usize, v: Vector3, w: f32) { + row(words, i, xyz(v), w); +} +fn xyz(v: Vector3) -> [f32; 3] { + [v.x, v.y, v.z] +} +fn inertia_columns(i: RetailPackedWorldInverseInertia) -> [Vector3; 3] { + [ + Vector3::new(i.full.x, i.full.y, i.full.z), + Vector3::new(i.full.y, i.split.y, i.split.z), + Vector3::new(i.full.z, i.split.z, i.split.x), + ] +} diff --git a/crates/skate-core/src/physics/startup/mod.rs b/crates/skate-core/src/physics/startup/mod.rs new file mode 100644 index 00000000..8c27deb7 --- /dev/null +++ b/crates/skate-core/src/physics/startup/mod.rs @@ -0,0 +1,7 @@ +//! Recovered TU3 board reset operations. Caller-owned spawn selection, state +//! transitions and activation are separate from resetting these native records. + +mod reset; +mod skateboard; +pub use reset::{reset_board_body, reset_collision_info}; +pub use skateboard::reset_skateboard; diff --git a/crates/skate-core/src/physics/startup/reset.rs b/crates/skate-core/src/physics/startup/reset.rs new file mode 100644 index 00000000..d4d13bfa --- /dev/null +++ b/crates/skate-core/src/physics/startup/reset.rs @@ -0,0 +1,76 @@ +//! Complete 82C0D680 and its 82C00D68 dependency, guest big-endian record bytes. + +fn word(bytes: &[u8], offset: usize) -> u32 { + u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) +} +fn put(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); +} +fn up(bytes: &mut [u8], offset: usize) { + bytes[offset..offset + 16].fill(0); + put(bytes, offset + 4, 0x3f80_0000); +} + +/// Complete CollisionInfo initialization/reset at 82C00D68. This is distinct +/// from the selective per-frame reset at 82C00CA0. Unwritten bytes survive. +pub fn reset_collision_info(info: &mut [u8; 812]) { + up(info, 0); + up(info, 16); + for i in 0..7 { + up(info, 32 + i * 16); + for offset in [144, 256, 368, 480] { + info[offset + i * 16..offset + i * 16 + 16].fill(0); + } + for offset in [692, 720, 748] { + put(info, offset + i * 4, 0); + } + info[780 + i] = 0; + } + info[592..608].fill(0); + for i in 0..4 { + up(info, 608 + i * 16); + put(info, 672 + i * 4, 0); + info[688 + i] = 0; + } + for offset in [776, 788, 792, 796, 800] { + put(info, offset, 0); + } + info[804] = 0; + info[805] = 0; + let flags = word(info, 808) & 0x01ff_ffff; + put(info, 808, flags); +} + +/// Complete SkateboardBody reset at 82C0D680. `bodies` are assembly parts in +/// their native order; each gravity vector is read from that body's Simulation +/// at +144. The hook assembly is separate and is not traversed here. +/// +/// XYZ rates/torque clear; force XYZ receives the simulation vector. Fourth +/// lanes, pose, body flags/cooldown and all other body words survive. Ancillary +/// board bytes are preserved except for stores present in the native function. +/// Returns the original byte at board+8384, matching native r3. +pub fn reset_board_body( + board: &mut [u8; 8400], + bodies: &mut [[u32; 44]], + simulation_gravity: &[[u32; 4]], +) -> u8 { + assert_eq!(bodies.len(), simulation_gravity.len()); + for (body, gravity) in bodies.iter_mut().zip(simulation_gravity) { + body[8..11].fill(0); + body[12..15].fill(0); + body[36..39].copy_from_slice(&gravity[..3]); + body[40..43].fill(0); + } + put(board, 7680, 0); + put(board, 7684, 0); + reset_collision_info((&mut board[64..876]).try_into().unwrap()); + let old_flags = board[8384]; + board[8384] &= 0x3f; + for offset in [7968, 8032] { + board[offset..offset + 52].fill(0); + board[offset + 52] = 0; + } + board[1008..1044].fill(0); + board[1044] = 0; + old_flags +} diff --git a/crates/skate-core/src/physics/startup/skateboard.rs b/crates/skate-core/src/physics/startup/skateboard.rs new file mode 100644 index 00000000..8de5bccc --- /dev/null +++ b/crates/skate-core/src/physics/startup/skateboard.rs @@ -0,0 +1,61 @@ +use super::reset_board_body; +use crate::physics::board_pose::{PartPose, PoseMatrix, set_board_transform, set_part_transform}; +/// Complete 82C05F50 reset orchestration. `authored` supplies the seven matrices +/// produced by InitializeTransforms82C0ADF0 for the current stock geometry; +/// deck6 is published first, then wheels0..3 and trucks4..5. This explicit input +/// does not select a spawn or change joint anchors to a presumed settled pose. +/// +/// `wrapper` is the native Skateboard prefix; `wrapper_base` only reconstructs +/// its three force-queue pointers. `body_state` is its SkateboardBody record. +/// Parts must have live bodies, as in the native seven-part board assembly. +/// Processed+2468 bit20 flips all four lanes of Ri and At before SetTransform. +/// The hook receives that target separately and its body rates are not reset. +#[allow(clippy::too_many_arguments)] +pub fn reset_skateboard( + wrapper: &mut [u8; 1360], + wrapper_base: u32, + body_state: &mut [u8; 8400], + parts: &mut [PartPose; 7], + hook: &mut PartPose, + authored: [PoseMatrix; 7], + mut requested: PoseMatrix, + processed_flags_2468: u32, + gravity: &[[u32; 4]; 7], +) -> u32 { + assert!(parts.iter().all(|part| part.body.is_some())); + for i in [6, 0, 1, 2, 3, 4, 5] { + set_part_transform(&mut parts[i], authored[i]); + } + if processed_flags_2468 & 0x0010_0000 != 0 { + for i in [0, 1, 2, 3, 8, 9, 10, 11] { + requested[i] ^= 0x8000_0000; + } + } + set_board_transform(parts, hook, requested); + let mut bodies = core::array::from_fn::<_, 7, _>(|i| parts[i].body.unwrap()); + reset_board_body(body_state, &mut bodies, gravity); + for (part, body) in parts.iter_mut().zip(bodies) { + part.body = Some(body); + } + put(wrapper, 256, 0); + put(body_state, 7680, 0); + put(body_state, 7684, 0); + wrapper[240..256].fill(0); + put(wrapper, 252, 0x3f80_0000); + wrapper[290] = 0; + body_state[960..992].fill(0); + put(body_state, 972, 2); + put(body_state, 988, 2); + put(wrapper, 324, wrapper_base.wrapping_add(352)); + put(wrapper, 320, wrapper_base.wrapping_add(352)); + put(wrapper, 328, wrapper_base.wrapping_add(1360)); + put(wrapper, 260, 0); + wrapper[192..208].fill(0); + put(wrapper, 196, 0x3f80_0000); + wrapper[291] = 0; + 240 +} + +fn put(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_be_bytes()); +} diff --git a/crates/skate-core/src/physics/tests/assembly.rs b/crates/skate-core/src/physics/tests/assembly.rs new file mode 100644 index 00000000..10e942e9 --- /dev/null +++ b/crates/skate-core/src/physics/tests/assembly.rs @@ -0,0 +1,134 @@ +use super::*; +use crate::{ + math::Vector3, + physics::{ + drive_frames::{ + default_live_body_orientations, default_live_body_transforms, default_truck_transforms, + }, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + mass::default_skateboard_mass_properties, + rigid_body::{RetailReactionCorrections, basis_from_quaternion, world_inverse_inertia}, + solver::solve_constraints, + }, +}; + +fn bodies() -> [BodySnapshot; BODY_COUNT] { + let poses = default_live_body_transforms(); + let orientations = default_live_body_orientations(); + let masses = default_skateboard_mass_properties(); + core::array::from_fn(|i| { + let basis = basis_from_quaternion(orientations[i]); + BodySnapshot { + state_flags: 4, + inertia: masses[i].dynamics, + rates: RetailBodyRates { + orientation: orientations[i], + basis, + world_inverse_inertia: world_inverse_inertia( + basis, + masses[i].dynamics.inverse_tensor, + ), + position: poses[i].translation, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }, + } + }) +} + +fn inactive_hook() -> BoardHook { + let mut body = bodies()[6]; + body.state_flags = 0; + BoardHook { + body, + drive: super::super::hook_drive::HookDriveState::initial(), + } +} + +fn solve(targets: [f32; 2]) -> [RetailReactionCorrections; BODY_COUNT] { + let mut hook = inactive_hook(); + let frames = prepare_drive_frames(default_truck_transforms(), targets, &mut hook); + let mut constraints = BoardConstraints::build( + &bodies(), + &hook, + frames, + retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + f32::from_bits(0x3C88_8889), + ); + let mut reactions = [RetailReactionCorrections::default(); BODY_COUNT + 1]; + solve_constraints( + &mut [], + &mut constraints.joints, + &mut constraints.drives, + &mut reactions, + 25, + ); + reactions[..BODY_COUNT].try_into().unwrap() +} + +#[test] +fn native_pair_order_and_stock_constraint_counts_are_preserved() { + let mut hook = inactive_hook(); + let frames = prepare_drive_frames(default_truck_transforms(), [0.0; 2], &mut hook); + let constraints = BoardConstraints::build( + &bodies(), + &hook, + frames, + retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + f32::from_bits(0x3C88_8889), + ); + let pairs: Vec<_> = constraints + .joints + .into_iter() + .map(|j| (j.reaction_a, j.reaction_b)) + .collect(); + assert_eq!(pairs, [(4, 6), (5, 6), (0, 4), (1, 4), (2, 5), (3, 5)]); + assert_eq!( + constraints + .drives + .into_iter() + .map(|d| (d.frame_a_body.reaction_index, d.frame_b_body.reaction_index)) + .collect::>(), + [(4, 6), (5, 6), (7, 6)] + ); +} + +#[test] +fn steering_reaches_wheels_through_shared_joint_and_drive_reactions() { + let rest = solve([0.0; 2]); + let turn = solve([0.3; 2]); + for body in BodyId::ORDER { + let r = turn[body.index()]; + assert_ne!( + r.angular_displacement, + rest[body.index()].angular_displacement, + "{body:?}" + ); + for v in [r.linear_displacement, r.angular_displacement] { + assert!( + v.x.is_finite() && v.y.is_finite() && v.z.is_finite(), + "{body:?}" + ); + } + } + // Internal constraints exchange momentum; they do not propel the board. + let snapshots = bodies(); + let momentum = turn + .iter() + .zip(snapshots) + .fold(Vector3::ZERO, |sum, (r, b)| { + Vector3::new( + sum.x + r.linear_displacement.x / b.inertia.inverse_mass, + sum.y + r.linear_displacement.y / b.inertia.inverse_mass, + sum.z + r.linear_displacement.z / b.inertia.inverse_mass, + ) + }); + assert!( + momentum.x.abs() < 1e-5 && momentum.y.abs() < 1e-5 && momentum.z.abs() < 1e-5, + "{momentum:?}" + ); +} diff --git a/crates/skate-core/src/physics/tests/board_ground.rs b/crates/skate-core/src/physics/tests/board_ground.rs new file mode 100644 index 00000000..f77e0e08 --- /dev/null +++ b/crates/skate-core/src/physics/tests/board_ground.rs @@ -0,0 +1,132 @@ +use super::*; + +fn report(part: BodyId, normal: Vector3) -> BoardContactReport { + BoardContactReport { + part, other: CollisionBody::StaticWorld, is_body_a: true, + normal, position: Vector3::ZERO, relative_linear_velocity: Vector3::ZERO, + other_surface: 0, normal_force_on_a: UP, friction_force_on_a: Vector3::ZERO, + tangents: [Vector3::ZERO; 2], + } +} + +#[test] +fn lost_wheel_support_keeps_wheel_normal_but_rebuilds_overall_normal() { + let mut ground = BoardGroundState::default(); + let mut lines = WheelLineState::default(); + lines.publish([Some(WheelLineHit { fraction: 0.1, normal: UP, surface_tag: 0 }); 4]); + ground.update(&[report(BodyId::RightFrontWheel, UP)], &lines, UP, 80.0, false); + assert_eq!(ground.wheel_contact_count, 1); + let retained = ground.wheel_normal; + ground.update(&[report(BodyId::Deck, Vector3::new(1.0, 0.0, 0.0))], &lines, UP, 80.0, false); + assert_eq!(ground.wheel_contact_count, 0); + assert_eq!(ground.part_contact_count, 1); + assert_eq!(ground.wheel_normal, retained); + assert!(ground.overall_normal.x > 0.99999); + assert!(ground.overall_normal.y.abs() < 0.00001); +} + +#[test] +fn no_hit_preserves_line_geometry_and_contact_status_does_not_come_from_lines() { + let mut lines = WheelLineState::default(); + let normal = Vector3::new(0.6, 0.8, 0.0); + lines.publish([Some(WheelLineHit { fraction: 0.2, normal, surface_tag: 3 << 7 }); 4]); + let distances = lines.distances; + lines.publish([None; 4]); + assert_eq!(lines.normals, [normal; 4]); + assert_eq!(lines.distances, distances); + assert_eq!(lines.physics_surfaces, [0; 4]); + assert_eq!(lines.minimum_distance, WHEEL_LINE_LENGTH); + let mut ground = BoardGroundState::default(); + ground.update(&[], &lines, UP, 80.0, false); + assert_eq!(ground.part_contact_count, 0); + assert_eq!(ground.wheel_contact_count, 0); + assert_eq!(ground.wheel_normal, UP); + assert_eq!(ground.overall_normal, UP); +} + +#[test] +fn per_part_selection_uses_highest_y_and_keeps_first_on_ties() { + let first = report(BodyId::Deck, Vector3::new(0.6, 0.8, 0.0)); + let tied = report(BodyId::Deck, Vector3::new(-0.6, 0.8, 0.0)); + let lower = report(BodyId::Deck, Vector3::new(0.0, 0.4, 0.9)); + let mut ground = BoardGroundState::default(); + ground.update(&[first, tied, lower], &WheelLineState::default(), UP, 80.0, false); + assert_eq!(ground.parts[6].normal, first.normal); + assert_eq!(ground.part_contact_count, 1); +} + +#[test] +fn observed_acceleration_retains_each_part_velocity_and_resets_with_collision_info() { + let mut state = super::BoardGroundState::default(); + let first = core::array::from_fn(|i| crate::math::Vector3::new(i as f32, 2.0, -1.0)); + state.sample_accelerations(first, 0.5); + assert_eq!(state.accelerations[6], crate::math::Vector3::new(12.0, 4.0, -2.0)); + state.sample_accelerations(first, 0.25); + assert_eq!(state.accelerations, [crate::math::Vector3::ZERO; 7]); + let mut next = first; + next[6].x += 1.0; + state.sample_accelerations(next, 0.25); + assert_eq!(state.accelerations[6], crate::math::Vector3::new(4.0, 0.0, 0.0)); + assert_eq!(state.accelerations[5], crate::math::Vector3::ZERO); + let reset = super::BoardGroundState::default(); + assert_eq!(reset.previous_velocities, [crate::math::Vector3::ZERO; 7]); + assert_eq!(reset.accelerations, [crate::math::Vector3::ZERO; 7]); +} + +#[test] +fn impact_uses_preceding_velocity_even_after_the_solver_stops_the_board() { + let mut ground = BoardGroundState::default(); + let mut before = [Vector3::ZERO; BODY_COUNT]; + before[6] = Vector3::new(-8.0, -3.0, 0.0); + ground.sample_accelerations(before, 1.0 / 60.0); + let wall = report(BodyId::Deck, Vector3::new(1.0, 0.0, 0.0)); + let floor = report(BodyId::Deck, UP); + //Both actual solver reports have zero remaining relative velocity. The + //wall still has the larger incoming impact, while the floor wins normal Y. + ground.update(&[wall, floor], &WheelLineState::default(), UP, 80.0, false); + ground.sample_accelerations([Vector3::ZERO; BODY_COUNT], 1.0 / 60.0); + assert_eq!(ground.closing_velocity, Vector3::new(-8.0, -0.0, -0.0)); + assert_eq!(ground.maximum_closing_speed, 8.0); + assert_eq!(ground.parts[6].normal, UP); + ground.update(&[], &WheelLineState::default(), UP, 80.0, false); + assert_eq!(ground.closing_velocity, Vector3::ZERO); + assert_eq!(ground.maximum_closing_speed, 0.0); +} + +#[test] +fn surface_flags_use_actual_contacts_and_reset_each_frame() { + let mut ground = BoardGroundState::default(); + let mut first = report(BodyId::Deck, UP); + first.other_surface = 12 << 7; + first.position.y = 2.0; + let mut last = first; + last.position.y = 5.0; + let mut lines = WheelLineState::default(); + lines.physics_surfaces[0] = 8; + ground.update(&[first, last], &lines, UP, 80.0, false); + assert_eq!(ground.collision_flags, 1 << 25); + assert_eq!(ground.surface_twelve_height, 5.0); + //A wheel ray reporting surface8 alone does not set the physical flag. + ground.update(&[report(BodyId::RightFrontWheel, UP)], &lines, UP, 80.0, false); + assert_eq!(ground.collision_flags, 1 << 31); + ground.update(&[], &lines, UP, 80.0, false); + assert_eq!(ground.collision_flags, 0); + assert_eq!(ground.surface_twelve_height, 0.0); +} + +#[test] +fn opposing_contacts_use_deck_projection_range_and_reset_without_deck_contacts() { + let mut state = BoardGroundState::default(); + let lines = WheelLineState::default(); + let reports = [ + report(BodyId::Deck, Vector3::new(0.0, 0.75, 0.0)), + report(BodyId::Deck, Vector3::new(0.0, -0.5, 0.0)), + report(BodyId::FrontTruck, Vector3::new(0.0, -1.0, 0.0)), + ]; + state.update(&reports, &lines, UP, 80.0, false); + assert_eq!(state.opposing_contact, 1.25); + state.update(&reports, &lines, Vector3::new(1.0, 0.0, 0.0), 80.0, false); + assert_eq!(state.opposing_contact, 0.0); + state.update(&reports[2..], &lines, UP, 80.0, false); + assert_eq!(state.opposing_contact, 0.0); +} diff --git a/crates/skate-core/src/physics/tests/board_runtime.rs b/crates/skate-core/src/physics/tests/board_runtime.rs new file mode 100644 index 00000000..1e269445 --- /dev/null +++ b/crates/skate-core/src/physics/tests/board_runtime.rs @@ -0,0 +1,207 @@ +use super::*; +use crate::physics::{ + drive_frames::{AuthoredTransformInputs, authored_body_transforms, default_truck_transforms}, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + force_queue::QueuedPointForce, +}; + +fn masses() -> [RetailBodyMassProperties; BODY_COUNT] { + core::array::from_fn(|i| RetailBodyMassProperties { + local_mass_frame: RetailLocalMassFrame::IDENTITY, + dynamics: RetailInertiaDynamics { + inverse_tensor: Vector3::new(2.0 + i as f32, 3.0 + i as f32, 4.0 + i as f32), + inverse_mass: 0.5, + spherical: 0.0, + maximum_linear_velocity: 1000.0, + maximum_angular_velocity: 1000.0, + linear_drag: 0.0, + angular_drag: 0.0, + }, + }) +} + +fn simulation(gravity: Vector3) -> RetailSimulationStep { + RetailSimulationStep::fixed_60_hz(30, 0.001, gravity) +} + +fn runtime(gravity: Vector3, mode: BoardMotion) -> BoardRuntime { + BoardRuntime::new( + masses(), + authored_body_transforms(AuthoredTransformInputs::STOCK), + RetailAffineTransform::IDENTITY, + simulation(gravity), + mode, + ) +} + +fn settings(gravity: Vector3, iterations: u32) -> BoardStepSettings { + BoardStepSettings { + simulation: simulation(gravity), + iterations, + base_truck_transforms: default_truck_transforms(), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + force_point_y_offset: 0.0, + } +} + +fn close(actual: Vector3, expected: Vector3) { + assert!( + (actual.x - expected.x).abs() < 2e-5, + "{actual:?} != {expected:?}" + ); + assert!( + (actual.y - expected.y).abs() < 2e-5, + "{actual:?} != {expected:?}" + ); + assert!( + (actual.z - expected.z).abs() < 2e-5, + "{actual:?} != {expected:?}" + ); +} + +#[test] +fn spawn_preserves_each_mass_frame_and_keeps_the_hook_separate() { + let mut data = masses(); + data[BodyId::Deck.index()].local_mass_frame = RetailLocalMassFrame { + basis: Basis3 { + columns: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + }, + translation: Vector3::new(0.2, -0.03, 0.01), + }; + let spawn = RetailAffineTransform { + basis: Basis3 { + columns: [[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]], + }, + translation: Vector3::new(3.0, 4.0, 5.0), + }; + let gravity = Vector3::new(0.0, -9.81, 0.0); + let board = BoardRuntime::new( + data, + authored_body_transforms(AuthoredTransformInputs::STOCK), + spawn, + simulation(gravity), + BoardMotion::Active, + ); + close( + board.body_transform(BodyId::Deck).translation, + Vector3::new(2.99, 4.2, 4.97), + ); + let deck_part = board.part_transforms()[BodyId::Deck.index()]; + close(deck_part.translation, spawn.translation); + for i in 0..3 { + close( + Vector3::new( + deck_part.basis.columns[i][0], + deck_part.basis.columns[i][1], + deck_part.basis.columns[i][2], + ), + Vector3::new( + spawn.basis.columns[i][0], + spawn.basis.columns[i][1], + spawn.basis.columns[i][2], + ), + ); + } + for (i, body) in board.bodies().iter().enumerate() { + assert_eq!(body.inertia, data[i].dynamics); + assert_eq!(body.state_flags, 4); + assert_eq!(body.rates.force_acceleration, gravity); + assert_eq!(body.rates.kinetic_energy, f32::MAX); + assert_eq!(body.rates.cool_down, 0); + } + close(board.hook_transform().translation, spawn.translation); + assert_ne!( + board.hook_transform().translation, + board.body_transform(BodyId::Deck).translation + ); + assert_eq!(board.hook().body.state_flags, 1); + assert_eq!(board.hook().body.inertia.inverse_mass, 0.0); + assert_eq!( + board.hook().body.rates.world_inverse_inertia.columns, + [[0.0; 3]; 3] + ); + assert_eq!(board.hook().body.rates.force_acceleration, Vector3::ZERO); +} + +#[test] +fn integration_updates_the_same_state_read_by_presentation_and_force_producers() { + let mut board = runtime(Vector3::ZERO, BoardMotion::Active); + board.forces_mut().append(QueuedPointForce { + tag: 7, + force_world: Vector3::new(0.0, 0.0, 14.0), + point_body: Vector3::ZERO, + }); + let initial = board.body_transform(BodyId::Deck).translation; + let config = settings(Vector3::ZERO, 0); + let dt = config.simulation.time_step; + board.advance(&[], [0.0; 2], config); + let deck = board.bodies()[BodyId::Deck.index()]; + close(deck.rates.linear_velocity, Vector3::new(0.0, 0.0, 7.0 * dt)); + close( + board.part_transforms()[BodyId::Deck.index()].translation, + Vector3::new(initial.x, initial.y, initial.z + 7.0 * dt * dt), + ); + assert_eq!(board.forces().entries().len(), 1); + board.clear_forces(); + board.advance(&[], [0.0; 2], config); + close( + board.bodies()[BodyId::Deck.index()].rates.linear_velocity, + deck.rates.linear_velocity, + ); + close( + board.part_transforms()[BodyId::Deck.index()].translation, + Vector3::new(initial.x, initial.y, initial.z + 14.0 * dt * dt), + ); +} + +#[test] +fn moving_the_static_hook_changes_constraint_response_without_moving_its_body() { + let mut target = runtime(Vector3::ZERO, BoardMotion::Active); + let mut baseline = runtime(Vector3::ZERO, BoardMotion::Active); + let mut animated = 0; + target.hook_mut().drive.enable_animation_soft(&mut animated); + baseline + .hook_mut() + .drive + .enable_animation_soft(&mut animated); + let requested = RetailAffineTransform { + translation: Vector3::new(0.0, 0.2, 0.0), + ..RetailAffineTransform::IDENTITY + }; + target.set_hook_transform(requested); + let config = settings(Vector3::ZERO, 25); + target.advance(&[], [0.0; 2], config); + baseline.advance(&[], [0.0; 2], config); + assert!( + target.bodies()[BodyId::Deck.index()] + .rates + .linear_velocity + .y + > baseline.bodies()[BodyId::Deck.index()] + .rates + .linear_velocity + .y + ); + assert_eq!(target.hook_transform().translation, requested.translation); + assert_eq!(target.hook().body.rates.linear_velocity, Vector3::ZERO); + assert_eq!(target.hook().body.rates.angular_velocity, Vector3::ZERO); + assert_eq!(target.hook().body.inertia, ZERO_INERTIA); +} + +#[test] +fn explicit_inactive_modes_do_not_integrate_under_gravity() { + let gravity = Vector3::new(0.0, -9.81, 0.0); + for mode in [BoardMotion::Frozen, BoardMotion::Static] { + let mut board = runtime(gravity, mode); + let before = board.body_transform(BodyId::Deck); + board.advance(&[], [0.0; 2], settings(gravity, 25)); + assert_eq!(board.body_transform(BodyId::Deck), before); + assert_eq!(board.bodies()[6].state_flags, mode.flags()); + assert_eq!(board.bodies()[6].rates.cool_down, 30); + if mode == BoardMotion::Static { + assert_eq!(board.bodies()[6].inertia.inverse_mass, 0.0); + } else { + assert_eq!(board.bodies()[6].inertia, masses()[6].dynamics); + } + } +} diff --git a/crates/skate-core/src/physics/tests/board_step.rs b/crates/skate-core/src/physics/tests/board_step.rs new file mode 100644 index 00000000..ab76e082 --- /dev/null +++ b/crates/skate-core/src/physics/tests/board_step.rs @@ -0,0 +1,331 @@ +use super::*; +use crate::physics::{ + drive_frames::{ + default_live_body_orientations, default_live_body_transforms, default_truck_transforms, + }, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + force_queue::QueuedPointForce, + mass::default_skateboard_mass_properties, + rigid_body::{RetailBodyRates, basis_from_quaternion, world_inverse_inertia}, +}; +use crate::riding::braking::{BrakeInput, BrakeSettings, calculate_braking}; +use crate::riding::push::{PushInput, PushLimits, enqueue_push}; + +#[path = "hook_step.rs"] +mod hook_step; +#[path = "contact_reports.rs"] +mod contact_reports; + +fn bodies() -> [BodySnapshot; BODY_COUNT] { + let poses = default_live_body_transforms(); + let orientations = default_live_body_orientations(); + let masses = default_skateboard_mass_properties(); + core::array::from_fn(|i| { + let basis = basis_from_quaternion(orientations[i]); + BodySnapshot { + state_flags: 4, + inertia: masses[i].dynamics, + rates: RetailBodyRates { + orientation: orientations[i], + basis, + world_inverse_inertia: world_inverse_inertia( + basis, + masses[i].dynamics.inverse_tensor, + ), + position: poses[i].translation, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }, + } + }) +} + +// Deliberately inactive synthetic hook isolates the existing board-only cases. +// Its body values are test inputs, not a gameplay initialization contract. +fn inactive_hook() -> BoardHook { + let mut body = bodies()[6]; + body.state_flags = 0; + BoardHook { + body, + drive: super::super::hook_drive::HookDriveState::initial(), + } +} + +fn settings(iterations: u32) -> BoardStepSettings { + BoardStepSettings { + simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, Vector3::ZERO), + iterations, + base_truck_transforms: default_truck_transforms(), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + force_point_y_offset: 0.0, + } +} + +fn momentum_z(bodies: &[BodySnapshot; BODY_COUNT]) -> f32 { + bodies + .iter() + .map(|b| b.rates.linear_velocity.z / b.inertia.inverse_mass) + .sum() +} + +#[test] +fn push_is_delivered_through_constraints_into_all_seven_integrated_bodies() { + let mut bodies = bodies(); + let before = bodies; + let mut unforced = before; + let mut queue = BoardForceQueue::default(); + let config = settings(25); + let push = enqueue_push( + PushInput { + flags_2468: 0x0200_0000, + flags_2472: 0, + target_speed: 3.0, + current_speed: 0.0, + absolute_body_speed: 0.0, + scale: f32::NAN, + delta_seconds: config.simulation.time_step, + direction: Vector3::new(0.0, 0.0, 1.0), + }, + PushLimits { + maximum_pushable_speed: 10.0, + low_speed_change: 0.1, + high_speed_change: 0.05, + }, + &bodies.map(|b| b.inertia.inverse_mass), + &mut queue, + ); + let mut tick = BoardStep::default(); + // The preserved research explicitly characterizes noncoincident anchors + // in the constructor pose (TRANSITION_PHYSICS_EVIDENCE.md). Compare the + // push against that same unforced pose, not an invented settled board. + BoardStep::default().advance( + &mut unforced, + &mut inactive_hook(), + &BoardForceQueue::default(), + &[], + [0.0; 2], + config, + ); + tick.advance( + &mut bodies, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + for id in BodyId::ORDER { + let after = bodies[id.index()].rates; + assert!( + after.linear_velocity.z > unforced[id.index()].rates.linear_velocity.z, + "{id:?}" + ); + assert_ne!(after.position, before[id.index()].rates.position, "{id:?}"); + assert_eq!(after.force_acceleration, Vector3::ZERO); + assert_eq!(after.torque_acceleration, Vector3::ZERO); + } + let expected_momentum = push.vector.z * config.simulation.time_step; + assert!((momentum_z(&bodies) - expected_momentum).abs() < 1e-4); + assert_eq!( + queue.entries().len(), + 1, + "native consumer leaves queue intact" + ); + assert_eq!( + tick.reactions, + [RetailReactionCorrections::default(); REACTION_COUNT] + ); + // Clearing is owned by GeneralUpdate. A subsequent coast tick must not + // repeat either the push or last frame's accumulated constraint impulses. + queue.clear(); + tick.advance( + &mut bodies, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + assert!((momentum_z(&bodies) - expected_momentum).abs() < 1e-4); +} + +#[test] +fn simulation_uses_the_supplied_iteration_count_and_one_timestep() { + let mut zero_pass = bodies(); + let mut solved = zero_pass; + let mut queue = BoardForceQueue::default(); + queue.append(QueuedPointForce { + tag: 3, + force_world: Vector3::new(0.0, 0.0, 60.0), + point_body: Vector3::ZERO, + }); + let mut config = settings(0); + config.simulation.time_step = 1.0 / 120.0; + config.simulation.frequency = 1.0; // StartFrame must replace a stale reciprocal. + BoardStep::default().advance( + &mut zero_pass, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + config.iterations = 25; + BoardStep::default().advance( + &mut solved, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + assert_eq!(zero_pass[0].rates.linear_velocity, Vector3::ZERO); + assert!(solved[0].rates.linear_velocity.z > 0.0); + assert!((momentum_z(&solved) - 0.5).abs() < 1e-4); + assert!((momentum_z(&zero_pass) - 0.5).abs() < 1e-4); +} + +#[test] +fn steering_changes_integrated_truck_wheel_and_deck_orientations() { + let mut straight = bodies(); + let mut turning = straight; + let queue = BoardForceQueue::default(); + BoardStep::default().advance( + &mut straight, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + settings(25), + ); + BoardStep::default().advance( + &mut turning, + &mut inactive_hook(), + &queue, + &[], + [0.3; 2], + settings(25), + ); + for id in BodyId::ORDER { + assert_ne!( + straight[id.index()].rates.orientation, + turning[id.index()].rates.orientation, + "{id:?}" + ); + } +} + +#[test] +fn native_braking_force_reaches_whole_assembly_momentum() { + for speed in [-4.0, 4.0] { + let mut bodies = bodies(); + for body in &mut bodies { + body.rates.linear_velocity.z = speed; + } + let before = momentum_z(&bodies); + let brake = calculate_braking( + BrakeInput { + flags_2468: 0x4000_0000, + input_2728: 0.5, + signed_speed: speed, + absolute_body_speed: speed.abs(), + surface_factor: 0.7, + direction: Vector3::new(0.0, 0.0, 1.0), + }, + BrakeSettings { + input_force: 200.0, + override_force: 100.0, + minimum_speed: 0.8, + }, + ); + let mut queue = BoardForceQueue::default(); + queue.append(brake); + let config = settings(25); + BoardStep::default().advance( + &mut bodies, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + let after = momentum_z(&bodies); + assert!(after.abs() < before.abs()); + assert!((after - before - brake.force_world.z * config.simulation.time_step).abs() < 1e-4); + } +} + +#[test] +fn no_active_bodies_skips_compile_solve_and_integration() { + let mut bodies = bodies(); + for b in &mut bodies { + b.state_flags = 0; + } + let before = bodies.map(|b| b.rates); + BoardStep::default().advance( + &mut bodies, + &mut inactive_hook(), + &BoardForceQueue::default(), + &[], + [0.3; 2], + settings(25), + ); + assert_eq!(before, bodies.map(|b| b.rates)); +} + +#[test] +fn contact_workspace_contains_this_ticks_queued_force() { + let mut bodies = bodies(); + let point = bodies[6].rates.position; + let collision = BoardCollision { + body_a: CollisionBody::StaticWorld, + body_b: CollisionBody::Board(BodyId::Deck), + contact: RetailContactInput { + position_on_a: point, + position_on_b: point, + normal: Vector3::new(0.0, -1.0, 0.0), + restitution: 0.0, + static_friction: 0.0, + dynamic_friction: 0.0, + tag: 0, + }, + }; + let mut queue = BoardForceQueue::default(); + queue.append(QueuedPointForce { + tag: 2, + force_world: Vector3::new(0.0, -60.0, 0.0), + point_body: Vector3::ZERO, + }); + let mut tick = BoardStep::default(); + let config = settings(25); + let mut free = bodies; + BoardStep::default().advance( + &mut free, + &mut inactive_hook(), + &queue, + &[], + [0.0; 2], + config, + ); + tick.advance( + &mut bodies, + &mut inactive_hook(), + &queue, + &[collision], + [0.0; 2], + config, + ); + let row = tick.contacts[0]; + assert!(row.accumulated_impulse()[0] > 0.0); + assert!(bodies[6].rates.linear_velocity.y.abs() < free[6].rates.linear_velocity.y.abs()); + let expected_target = config.simulation.time_step.powi(2) * 60.0; + assert!((row.target_impulse()[0] - expected_target).abs() < 1e-9); + assert_eq!( + tick.reactions[WORLD_REACTION], + RetailReactionCorrections::default() + ); +} diff --git a/crates/skate-core/src/physics/tests/board_world.rs b/crates/skate-core/src/physics/tests/board_world.rs new file mode 100644 index 00000000..b8ac4ea1 --- /dev/null +++ b/crates/skate-core/src/physics/tests/board_world.rs @@ -0,0 +1,261 @@ +use super::*; +use crate::{ + math::Basis3, + physics::{ + board::{BODY_COUNT, RETAIL_TRUCK_Y_POSITION, RETAIL_WHEEL_RADIUS}, + board_runtime::BoardMotion, + board_step::BoardStepSettings, + collision::TriangleFeature, + drive_frames::{ + AuthoredTransformInputs, RetailAffineTransform, authored_body_transforms, + default_truck_transforms, + }, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + rigid_body::{ + RetailBodyMassProperties, RetailInertiaDynamics, RetailLocalMassFrame, + RetailSimulationStep, + }, + }, +}; + +fn material(a: f32, b: f32, c: f32) -> RetailContactMaterial { + RetailContactMaterial { + static_friction: a, + dynamic_friction: b, + restitution: c, + } +} +fn triangle() -> WorldTriangle { + WorldTriangle::from_vertices( + [ + Vector3::new(-10.0, 0.0, -10.0), + Vector3::new(0.0, 0.0, 10.0), + Vector3::new(10.0, 0.0, -10.0), + ], + material(0.8, 0.6, 0.2), + 42, + TriangleFeature::ONE_SIDED | 0xe0, + [1.0; 3], + 0.0, + ) + .unwrap() +} +fn config() -> WheelWorldSettings { + WheelWorldSettings { + radius: RETAIL_WHEEL_RADIUS, + material: material(0.2, 0.1, 0.8), + query: WorldContactSettings { + volume_padding: 0.0, + maximum_separating_distance: 0.0, + edge_cos_bend_normal_threshold: -1.0, + convexity_epsilon: 0.0, + is_object: false, + }, + retention: ContactRetentionSettings { + capacity: 100, + duplicate_distance_squared: 0.000001, + deferred_reduction: false, + }, + } +} +fn board(gap: f32, gravity: Vector3) -> BoardRuntime { + let mass = RetailBodyMassProperties { + local_mass_frame: RetailLocalMassFrame::IDENTITY, + dynamics: RetailInertiaDynamics { + inverse_tensor: Vector3::new(3.0, 3.0, 3.0), + inverse_mass: 0.5, + spherical: 0.0, + maximum_linear_velocity: 1000.0, + maximum_angular_velocity: 1000.0, + linear_drag: 0.0, + angular_drag: 0.0, + }, + }; + BoardRuntime::new( + [mass; BODY_COUNT], + authored_body_transforms(AuthoredTransformInputs::STOCK), + RetailAffineTransform { + translation: Vector3::new( + 0.0, + RETAIL_WHEEL_RADIUS - RETAIL_TRUCK_Y_POSITION + gap, + 0.0, + ), + ..RetailAffineTransform::IDENTITY + }, + RetailSimulationStep::fixed_60_hz(30, 0.001, gravity), + BoardMotion::Active, + ) +} +fn close(a: f32, b: f32) { + assert!((a - b).abs() < 2e-6, "{a} != {b}"); +} + +#[test] +fn finite_world_geometry_produces_four_ordered_wheel_contacts_with_combined_materials() { + let mut world = BoardWorld::new(vec![triangle()]); + let board = board(0.0, Vector3::ZERO); + let contacts = world.query(&board, config()); + assert_eq!(contacts.len(), 4); + for (index, collision) in contacts.iter().enumerate() { + assert_eq!(collision.body_a, CollisionBody::Board(BodyId::ORDER[index])); + assert_eq!(collision.body_b, CollisionBody::StaticWorld); + close(collision.contact.normal.y, 1.0); + close(collision.contact.position_on_a.y, 0.0); + close(collision.contact.position_on_b.y, 0.0); + assert_eq!(collision.contact.static_friction, 0.8); + assert_eq!(collision.contact.dynamic_friction, 0.6); + assert_eq!(collision.contact.restitution, 0.2); + assert_eq!(collision.contact.tag, 42); + } +} + +#[test] +fn contact_query_uses_live_poses_and_rejects_air_remote_geometry_and_backfaces() { + let mut world = BoardWorld::new(vec![triangle()]); + let mut board = board(0.0, Vector3::ZERO); + assert_eq!(world.query(&board, config()).len(), 4); + for body in board.bodies_mut() { + body.rates.position.y += 1.0; + } + assert!(world.query(&board, config()).is_empty()); + for body in board.bodies_mut() { + body.rates.position.y -= 1.0; + body.rates.position.x += 30.0; + } + assert!(world.query(&board, config()).is_empty()); + for body in board.bodies_mut() { + body.rates.position.x -= 30.0; + body.rates.position.y -= RETAIL_WHEEL_RADIUS * 1.5; + } + assert!(world.query(&board, config()).is_empty()); +} + +#[test] +fn approaching_velocity_padding_and_world_fatness_control_real_contact_acceptance() { + let mut world = BoardWorld::new(vec![triangle()]); + let mut board = board(0.01, Vector3::ZERO); + let mut settings = config(); + settings.query.maximum_separating_distance = 0.05; + assert!(world.query(&board, settings).is_empty()); + for body in board.bodies_mut() { + body.rates.linear_velocity.y = -1.0; + } + assert_eq!(world.query(&board, settings).len(), 4); + settings.query.maximum_separating_distance = 0.004; + assert!(world.query(&board, settings).is_empty()); + for body in board.bodies_mut() { + body.rates.linear_velocity.y = 1.0; + } + settings.query.volume_padding = 0.02; + assert_eq!(world.query(&board, settings).len(), 4); + settings.query.volume_padding = 0.0; + let mut thick = triangle(); + thick.triangle.fatness = 0.02; + let mut thick_world = BoardWorld::new(vec![thick]); + let contacts = thick_world.query(&board, settings); + assert_eq!(contacts.len(), 4); + close(contacts[0].contact.position_on_b.y, 0.02); +} + +#[test] +fn duplicate_triangles_and_explicit_contact_capacity_do_not_multiply_solver_rows() { + let board = board(0.0, Vector3::ZERO); + let mut world = BoardWorld::new(vec![triangle(), triangle()]); + assert_eq!(world.query(&board, config()).len(), 4); + assert_eq!(world.dropped_contacts(), 4); + let mut settings = config(); + settings.retention.capacity = 3; + assert_eq!(world.query(&board, settings).len(), 3); + assert!(world.dropped_contacts() > 0); + settings.retention.capacity = 100; + settings.retention.deferred_reduction = true; + let mut repeated = BoardWorld::new(vec![triangle(); 8]); + assert_eq!(repeated.query(&board, settings).len(), 4); +} + +#[test] +fn world_contacts_feed_supporting_reactions_into_the_persistent_board_solver() { + let gravity = Vector3::new(0.0, -9.8, 0.0); + let mut supported = board(0.0, gravity); + let mut falling = board(0.0, gravity); + let mut world = BoardWorld::new(vec![triangle()]); + let collisions = world.query(&supported, config()); + assert_eq!(collisions.len(), 4); + let settings = BoardStepSettings { + simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, gravity), + iterations: 25, + base_truck_transforms: default_truck_transforms(), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + force_point_y_offset: 0.0, + }; + supported.advance(collisions, [0.0; 2], settings); + falling.advance(&[], [0.0; 2], settings); + let momentum = |board: &BoardRuntime| { + board + .bodies() + .iter() + .map(|body| body.rates.linear_velocity.y / body.inertia.inverse_mass) + .sum::() + }; + assert!(momentum(&supported) > momentum(&falling) + 0.01); + assert_eq!( + supported.hook().body.rates.world_inverse_inertia, + Basis3 { + columns: [[0.0; 3]; 3] + } + ); +} + +#[test] +fn host_geometry_rejects_degenerate_triangles() { + assert!( + WorldTriangle::from_vertices( + [Vector3::ZERO; 3], + material(0.0, 0.0, 0.0), + 0, + 0, + [0.0; 3], + 0.0 + ) + .is_none() + ); +} + +#[test] +fn attached_volume_contact_reaches_its_actual_solver_body() { + use crate::physics::board_step::AttachedStep; + let mut board = board(0.0, Vector3::ZERO); + let mut attached = board.bodies()[0]; + attached.rates.position = Vector3::new(0.0, RETAIL_WHEEL_RADIUS, 0.0); + attached.rates.linear_velocity.y = -1.0; + let before = attached.rates.linear_velocity.y; + let volume = BoardWorldVolume { + body: CollisionBody::Attached(0), + primitive: ContactPrimitive::Sphere(Sphere { + center: attached.rates.position, + radius: RETAIL_WHEEL_RADIUS, + }), + linear_velocity: attached.rates.linear_velocity, + material: material(0.0, 0.0, 0.0), + }; + let mut world = BoardWorld::new(vec![triangle(), triangle()]); + let config = config(); + let contacts = world.query_primitives(&[volume], config.query, config.retention); + assert_eq!(contacts.len(), 1, "retention must preserve attached body identity"); + assert_eq!(contacts[0].body_a, CollisionBody::Attached(0)); + let settings = BoardStepSettings { + simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, Vector3::ZERO), + iterations: 25, + base_truck_transforms: default_truck_transforms(), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), + force_point_y_offset: 0.0, + }; + board.advance_attached(contacts, [0.0; 2], settings, AttachedStep { + bodies: vec![&mut attached], + contacts: &mut [], + joints: &mut [], + drives: &mut [], + }); + assert!(attached.rates.linear_velocity.y > before + 0.5); + assert!(board.contact_reports().is_empty(), "skater contacts are not board observations"); +} diff --git a/crates/skate-core/src/physics/tests/contact.rs b/crates/skate-core/src/physics/tests/contact.rs new file mode 100644 index 00000000..64adaa9c --- /dev/null +++ b/crates/skate-core/src/physics/tests/contact.rs @@ -0,0 +1,134 @@ +use super::*; + +fn body(contact_body_id: u32, center_of_mass: Vector3) -> RetailContactBodyState { + RetailContactBodyState { + contact_body_id, + center_of_mass, + reaction_id: contact_body_id + 10, + inverse_inertia_full: Vector3::new(1.0, 2.0, 3.0), + inverse_mass: 0.25, + inverse_inertia_split: Vector3::new(4.0, 5.0, 6.0), + state: 7, + force_acceleration: Vector3::new(8.0, 9.0, 10.0), + kinetic_energy: 11.0, + torque_acceleration: Vector3::new(12.0, 13.0, 14.0), + cool_down: 15, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + } +} + +fn input(normal: Vector3) -> RetailContactInput { + RetailContactInput { + position_on_a: Vector3::new(2.0, 3.0, 4.0), + position_on_b: Vector3::new(2.0, 3.0, 4.0), + normal, + restitution: 0.1, + static_friction: 0.8, + dynamic_friction: 0.7, + tag: 0x1234_5678, + } +} + +#[test] +fn material_combine_matches_tu3_branch_directions() { + let combined = combine_contact_materials( + RetailContactMaterial { + static_friction: f32::from_bits(0x3F4C_CCCD), + dynamic_friction: f32::from_bits(0x3F33_3333), + restitution: f32::from_bits(0x3E4C_CCCD), + }, + RetailContactMaterial { + static_friction: f32::from_bits(0x3F00_0000), + dynamic_friction: f32::from_bits(0x3F66_6666), + restitution: f32::from_bits(0x3DCC_CCCD), + }, + ); + + assert_eq!(combined.static_friction.to_bits(), 0x3F4C_CCCD); + assert_eq!(combined.dynamic_friction.to_bits(), 0x3F66_6666); + assert_eq!(combined.restitution.to_bits(), 0x3DCC_CCCD); +} + +#[test] +fn material_combine_is_commutative_for_finite_values() { + let a = RetailContactMaterial { + static_friction: 0.8, + dynamic_friction: 0.7, + restitution: 0.0, + }; + let b = RetailContactMaterial { + static_friction: 0.5, + dynamic_friction: 0.9, + restitution: 0.2, + }; + + assert_eq!( + combine_contact_materials(a, b), + combine_contact_materials(b, a) + ); +} + +fn assert_close(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= 1.0e-6, + "actual {actual}, expected {expected}" + ); +} + +fn assert_vec_close(actual: Vector3, expected: Vector3) { + assert_close(actual.x, expected.x); + assert_close(actual.y, expected.y); + assert_close(actual.z, expected.z); +} + +#[test] +fn angular_contact_rates_use_omega_cross_arm() { + let mut body_a = body(1, Vector3::new(1.0, 3.0, 4.0)); + body_a.angular_velocity = Vector3::new(0.0, 0.0, 2.0); + let contact = generate_contact( + input(Vector3::new(0.0, -1.0, 0.0)), + body_a, + body(2, Vector3::new(2.0, 3.0, 4.0)), + ); + + assert_vec_close(contact.relative_velocity, Vector3::new(0.0, -2.0, 0.0)); +} + +#[test] +fn contact_frame_is_orthogonal() { + let normal = Vector3::new(0.36, 0.48, 0.8); + let mut body_b = body(2, Vector3::ZERO); + body_b.linear_velocity = Vector3::new(2.0, -1.0, 0.5); + let contact = generate_contact(input(normal), body(1, Vector3::ZERO), body_b); + + assert_close(dot(contact.normal, contact.tangent_0), 0.0); + assert_close(dot(contact.normal, contact.tangent_1), 0.0); + assert_close(dot(contact.tangent_0, contact.tangent_1), 0.0); + assert_vec_close(cross(contact.normal, contact.tangent_0), contact.tangent_1); +} + +#[test] +fn contact_copies_header_ids_materials_tag_and_body_workspaces() { + let body_a = body(41, Vector3::new(1.0, 2.0, 3.0)); + let body_b = body(52, Vector3::new(4.0, 5.0, 6.0)); + let collision = input(Vector3::new(0.0, 1.0, 0.0)); + let contact = generate_contact(collision, body_a, body_b); + + assert_eq!(contact.body_a_id, 41); + assert_eq!(contact.body_b_id, 52); + assert_eq!(contact.restitution, collision.restitution); + assert_eq!(contact.static_friction, collision.static_friction); + assert_eq!(contact.dynamic_friction, collision.dynamic_friction); + assert_eq!(contact.tag, collision.tag); + assert_eq!(contact.body_a_workspace, body_a.into()); + assert_eq!(contact.body_b_workspace, body_b.into()); + assert_eq!(contact.body_a_workspace.reaction_id, 51); + assert_eq!(contact.body_b_workspace.reaction_id, 62); +} + +#[test] +fn recovered_tangent_constants_keep_their_exact_binary32_bits() { + assert_eq!(CONTACT_TANGENT_MINIMUM_SQUARED.to_bits(), 0x0080_0000); + assert_eq!(CONTACT_FALLBACK_HALF.to_bits(), 0x3F00_0000); +} diff --git a/crates/skate-core/src/physics/tests/contact_reports.rs b/crates/skate-core/src/physics/tests/contact_reports.rs new file mode 100644 index 00000000..97a7d9d8 --- /dev/null +++ b/crates/skate-core/src/physics/tests/contact_reports.rs @@ -0,0 +1,102 @@ +use super::*; + +#[test] +fn reports_use_solved_forces_and_post_integration_pose_and_velocity() { + for is_a in [true, false] { + let mut body_state = bodies(); + let point = body_state[6].rates.position; + let board = CollisionBody::Board(BodyId::Deck); + let world = CollisionBody::StaticWorld; + let collision = BoardCollision { + body_a: if is_a { board } else { world }, + body_b: if is_a { world } else { board }, + contact: RetailContactInput { + position_on_a: point, + position_on_b: point, + normal: Vector3::new(0.0, if is_a { 1.0 } else { -1.0 }, 0.0), + restitution: 0.0, + static_friction: 0.0, + dynamic_friction: 0.0, + tag: if is_a { 5 << 16 | 7 } else { 7 << 16 | 5 }, + }, + }; + let mut queue = BoardForceQueue::default(); + queue.append(QueuedPointForce { + tag: 2, + force_world: Vector3::new(0.0, -60.0, 0.0), + point_body: Vector3::ZERO, + }); + let mut tick = BoardStep::default(); + let config = settings(25); + tick.advance( + &mut body_state, + &mut inactive_hook(), + &queue, + &[collision], + [0.0; 2], + config, + ); + let report = tick.contact_reports()[0]; + assert_eq!(report.part, BodyId::Deck); + assert_eq!(report.other_surface, 7); + assert_eq!(report.is_body_a, is_a); + assert_eq!(report.normal.y, 1.0); + let expected = body_state[6].rates.position; + assert!((report.position.x - expected.x).abs() < 1e-10); + assert!((report.position.y - expected.y).abs() < 1e-10); + assert!((report.position.z - expected.z).abs() < 1e-10); + assert_eq!( + report.relative_linear_velocity, + body_state[6].rates.linear_velocity + ); + assert!(report.normal_force_on_a.y * if is_a { 1.0 } else { -1.0 } > 0.0); + + // A later empty/inactive tick must not expose the previous contact. + for body in &mut body_state { + body.state_flags = 0; + } + tick.advance( + &mut body_state, + &mut inactive_hook(), + &BoardForceQueue::default(), + &[], + [0.0; 2], + config, + ); + assert!(tick.contact_reports().is_empty()); + } +} + +#[test] +fn report_budget_is_shared_by_parts_and_keeps_native_contact_order() { + let mut rows = Vec::new(); + for i in 0..20 { + let mut words = [0; 64]; + words[11] = 8; + words[20] = 1.0_f32.to_bits(); + words[29] = 1.0_f32.to_bits(); + words[31] = i % 7; + words[35] = 1.0_f32.to_bits(); + words[43] = u32::MAX; + words[55] = i + 1; + rows.push(RetailContactJacobian { + words, + reaction_index_a: (i % 7) as usize, + reaction_index_b: WORLD_REACTION, + }); + } + let mut output = Vec::new(); + board_reports::collect(&mut output, &rows, &bodies(), 60.0); + assert_eq!( + output.iter().map(|r| r.other_surface).collect::>(), + (1..=16).collect::>() + ); + rows[0].words[20] = 0; + rows[1].words[11] = 0; + rows[2].words[43] = 6; // Different part, same board: excluded from reports. + board_reports::collect(&mut output, &rows, &bodies(), 60.0); + assert_eq!( + output.iter().map(|r| r.other_surface).collect::>(), + (4..=19).collect::>() + ); +} diff --git a/crates/skate-core/src/physics/tests/contact_solver.rs b/crates/skate-core/src/physics/tests/contact_solver.rs new file mode 100644 index 00000000..87a97ae6 --- /dev/null +++ b/crates/skate-core/src/physics/tests/contact_solver.rs @@ -0,0 +1 @@ +// Generated-output comparisons were removed. Independent solver validation is pending. diff --git a/crates/skate-core/src/physics/tests/deck_geometry.rs b/crates/skate-core/src/physics/tests/deck_geometry.rs new file mode 100644 index 00000000..163f3948 --- /dev/null +++ b/crates/skate-core/src/physics/tests/deck_geometry.rs @@ -0,0 +1,174 @@ +use super::{DeckChild, DeckGeometry, DeckGeometrySettings, DeckShape, MassMoments}; +use crate::{math::Vector3, physics::drive_frames::RetailAffineTransform}; + +fn close(actual: f32, expected: f32, tolerance: f32) { + assert!( + (actual - expected).abs() <= tolerance, + "{actual} != {expected}" + ); +} + +#[test] +fn stock_deck_has_actual_child_order_dimensions_and_source_flags() { + let deck = DeckGeometry::new(DeckGeometrySettings::STOCK); + assert_eq!(deck.children.len(), 15); + let DeckShape::RoundedBox { + half_extents, + radius, + } = deck.children[0].shape + else { + panic!() + }; + close(radius, 0.00675, 1e-9); + close(half_extents.x + radius, 0.12, 1e-8); + close(half_extents.y + radius, 0.0075, 1e-9); + close(half_extents.z + radius, 0.295, 3e-8); + for (index, x) in [(1, 0.1125), (2, -0.1125)] { + let DeckShape::Capsule { + radius, + half_length, + } = deck.children[index].shape + else { + panic!() + }; + close(radius, 0.0075, 1e-9); + close(half_length, 0.295, 3e-8); + close(deck.children[index].transform.translation.x, x, 1e-8); + assert_eq!( + deck.children[index].transform.basis, + RetailAffineTransform::IDENTITY.basis + ); + } + for (index, z) in [(3, 0.235), (4, -0.235)] { + assert_eq!( + deck.children[index].shape, + DeckShape::Sphere { radius: 0.035 } + ); + assert_eq!( + deck.children[index].transform.translation, + Vector3::new(0.0, -0.02, z) + ); + } + for (index, child) in deck.children.iter().enumerate().skip(5) { + let DeckShape::Triangle { + vertices, + fatness, + edge_cosines, + volume_flags, + } = child.shape + else { + panic!() + }; + assert_eq!(fatness, 0.0075); + assert_eq!(edge_cosines, [1.0, -1.0, 1.0]); + assert_eq!(volume_flags, 0x3E2); + assert_eq!( + vertices[0], + Vector3::new(0.0, 0.0, if index % 2 == 1 { -0.295 } else { 0.295 }) + ); + } +} + +#[test] +fn fans_join_at_shared_vertices_and_use_independent_end_angles() { + let deck = DeckGeometry::new(DeckGeometrySettings::STOCK); + for side in [0, 1] { + let triangles: Vec<_> = deck.children[5 + side..] + .iter() + .step_by(2) + .map(|child| { + let DeckShape::Triangle { vertices, .. } = child.shape else { + panic!() + }; + vertices + }) + .collect(); + for pair in triangles.windows(2) { + assert_eq!(pair[0][2], pair[1][1]); + } + close(triangles[0][1].x, 0.1125, 1e-8); + close(triangles[4][2].x, -0.1125, 2e-7); + for vertices in triangles { + assert!(vertices[2].y >= -1e-7); + let from_center = (vertices[2].z - vertices[0].z).abs(); + if from_center > 0.01 { + let actual_angle = (vertices[2].y / from_center).atan().to_degrees(); + close(actual_angle, if side == 0 { 12.5 } else { 13.0 }, 0.0001); + } + } + } +} + +#[test] +fn collision_toggles_do_not_remove_mass_and_spheres_remain_enabled() { + let stock = DeckGeometry::new(DeckGeometrySettings::STOCK); + let mut settings = DeckGeometrySettings::STOCK; + settings.enable_deck_volume_collisions = false; + settings.enable_end_volume_collisions = false; + let disabled = DeckGeometry::new(settings); + assert_eq!(stock.mass_moments(), disabled.mass_moments()); + for (index, child) in disabled.children.iter().enumerate() { + assert_eq!(child.collision_enabled, index == 3 || index == 4); + } +} + +#[test] +fn triangle_mass_uses_source_fat_aabb_instead_of_triangle_area() { + let child = DeckChild { + shape: DeckShape::Triangle { + vertices: [ + Vector3::new(-2.0, 1.0, -1.0), + Vector3::new(3.0, 1.0, -1.0), + Vector3::new(1.0, 1.0, 5.0), + ], + fatness: 0.5, + edge_cosines: [1.0, -1.0, 1.0], + volume_flags: 0x3E2, + }, + transform: RetailAffineTransform::IDENTITY, + collision_enabled: true, + }; + let mut moments = child.mass_moments(); + let properties = moments.principal_properties(); + // Its fat AABB is [-2.5,0.5,-1.5]..[3.5,1.5,5.5]. The source rounded-box + // minimum radius is tiny but nonzero; allow its documented numerical floor. + close(properties.volume, 42.0, 0.0001); + close(properties.local_mass_frame.translation.x, 0.5, 2e-6); + close(properties.local_mass_frame.translation.y, 1.0, 2e-6); + close(properties.local_mass_frame.translation.z, 2.0, 2e-6); +} + +#[test] +fn stock_mass_is_computed_from_children_and_changes_with_authored_dimensions() { + let stock = DeckGeometry::new(DeckGeometrySettings::STOCK); + let mut moments = stock.mass_moments(); + assert_ne!(moments, MassMoments::ZERO); + let properties = moments.principal_properties(); + assert!(properties.volume > 0.0); + close(properties.local_mass_frame.translation.x, 0.0, 1e-6); + assert!(properties.local_mass_frame.translation.y.is_finite()); + let inertia = properties.moments_per_unit_mass; + assert!(inertia.x > 0.0 && inertia.y > 0.0 && inertia.z > 0.0); + let mut settings = DeckGeometrySettings::STOCK; + settings.back_end_size *= 1.5; + let extended = DeckGeometry::new(settings); + let mut extended_moments = extended.mass_moments(); + let extended_properties = extended_moments.principal_properties(); + assert!(extended_properties.volume > properties.volume); + assert_ne!(extended_moments, moments); + for index in 0..5 { + assert_eq!(stock.children[index], extended.children[index]); + } + for index in 5..15 { + assert_ne!(stock.children[index], extended.children[index]); + } +} + +#[test] +fn authored_count_controls_both_end_fans() { + let mut settings = DeckGeometrySettings::STOCK; + settings.end_capsule_count = 3; + assert_eq!(DeckGeometry::new(settings).children.len(), 11); + settings.end_capsule_count = 0; + assert_eq!(DeckGeometry::new(settings).children.len(), 5); +} diff --git a/crates/skate-core/src/physics/tests/drive_frames.rs b/crates/skate-core/src/physics/tests/drive_frames.rs new file mode 100644 index 00000000..d3fa8b15 --- /dev/null +++ b/crates/skate-core/src/physics/tests/drive_frames.rs @@ -0,0 +1,102 @@ +use super::*; + +#[test] +fn identity_pair_has_bit_exact_identity_matrix_stage() { + let parent_frame = relative_parent_transform( + RetailAffineTransform::IDENTITY, + RetailAffineTransform::IDENTITY, + ); + + assert_eq!( + basis_bits(parent_frame.basis), + [ + [0x3F80_0000, 0, 0], + [0, 0x3F80_0000, 0], + [0, 0, 0x3F80_0000], + ] + ); + assert_eq!(vector_bits(parent_frame.translation), [0; 3]); +} + +#[test] +fn default_truck_transforms_match_tu3_stock_finite_path_bits() { + let [slot_7712, slot_7776] = default_truck_transforms(); + + assert_eq!( + basis_bits(slot_7712.basis), + [ + [0xB34B_C7FA, 0x3F03_D987, 0xBF5B_6F50], + [0, 0x3F5B_6F51, 0x3F03_D988], + [0x3F7F_FFFF, 0x32D1_E8FB, 0xB32E_ACAF], + ] + ); + assert_eq!( + vector_bits(slot_7712.translation), + [0, 0xBD67_6C8B, 0xBE78_D4FD,] + ); + + assert_eq!( + basis_bits(slot_7776.basis), + [ + [0xB34B_C7FA, 0x3F03_D987, 0x3F5B_6F50], + [0, 0x3F5B_6F51, 0xBF03_D988], + [0xBF7F_FFFF, 0xB2D1_E8FB, 0xB32E_ACAF], + ] + ); + assert_eq!( + vector_bits(slot_7776.translation), + [0, 0xBD67_6C8B, 0x3E78_D4FD,] + ); +} + +#[test] +fn matrix_stage_preserves_tu3_vmx_fused_order() { + let [slot_7712, slot_7776] = default_truck_transforms(); + let relative = relative_parent_transform(slot_7712, slot_7776); + assert_eq!( + basis_bits(relative.basis), + [ + [0xBEF0_5E95, 0x3F62_08D7, 0xB395_B9AE], + [0x3F62_08D7, 0x3EF0_5E96, 0x3333_ED95], + [0x3395_B9AE, 0xB333_ED95, 0xBF7F_FFFE], + ] + ); + + // This finite vector distinguishes TU3's six-stage translation + // accumulation from pre-subtracting the two translations before a dot. + let parent_translation = vector_from_bits([0x43D5_62ED, 0xC356_E21F, 0x4408_E252]); + let child_translation = vector_from_bits([0x43FA_50C2, 0xC28B_22BA, 0xC3E6_0F58]); + let translation = + transpose_relative_translation(slot_7712.basis, parent_translation, child_translation); + assert_eq!( + vector_bits(translation), + [0x446A_A4A8, 0xC3C5_35F5, 0x4293_B75A,] + ); +} + +#[test] +fn derived_level_spawn_places_all_four_wheel_spheres_on_y_zero() { + let bodies = default_body_transforms(); + + for wheel in &bodies[..4] { + let wheel_bottom = + RETAIL_LEVEL_GROUND_DECK_CENTER_HEIGHT + wheel.translation.y - RETAIL_WHEEL_RADIUS; + assert!(wheel_bottom.abs() <= f32::EPSILON); + } +} + +fn basis_bits(basis: Basis3) -> [[u32; 3]; 3] { + basis.columns.map(|column| column.map(f32::to_bits)) +} + +fn vector_bits(vector: Vector3) -> [u32; 3] { + [vector.x.to_bits(), vector.y.to_bits(), vector.z.to_bits()] +} + +fn vector_from_bits(bits: [u32; 3]) -> Vector3 { + Vector3::new( + f32::from_bits(bits[0]), + f32::from_bits(bits[1]), + f32::from_bits(bits[2]), + ) +} diff --git a/crates/skate-core/src/physics/tests/drive_solver.rs b/crates/skate-core/src/physics/tests/drive_solver.rs new file mode 100644 index 00000000..f9d9ebc3 --- /dev/null +++ b/crates/skate-core/src/physics/tests/drive_solver.rs @@ -0,0 +1,213 @@ +use super::*; + +const DT: f32 = f32::from_bits(0x3C88_8889); + +fn active_body(reaction_index: usize) -> RetailDriveBodyState { + RetailDriveBodyState { + reaction_index, + state: ACTIVE_BODY, + orientation: RetailQuaternion::IDENTITY, + basis: crate::math::Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + center_of_mass: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::new(0.0, -9.8, 0.0), + torque_acceleration: Vector3::ZERO, + inverse_mass: 1.0, + world_inverse_inertia: RetailPackedWorldInverseInertia { + full: Vector3::new(1.0, 0.0, 0.0), + split: Vector3::new(1.0, 1.0, 0.0), + }, + } +} + +fn no_drive() -> RetailDriveParams { + RetailDriveParams { + spring_or_max_velocity: 0.0, + damping: 0.0, + max_strength: 0.0, + drive_type: RetailDriveType::NoDrive, + } +} + +fn frames_with_translation(translation: Vector3) -> RetailDriveFrames { + RetailDriveFrames { + body_a: RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation: Vector3::ZERO, + }, + body_b: RetailDriveFrame { + orientation: RetailQuaternion::IDENTITY, + translation, + }, + } +} + +fn assert_near(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= 1.0e-6, + "actual={actual:?} expected={expected:?}" + ); +} + +#[test] +fn rate_and_acceleration_terms_use_retail_dt_and_dt_squared() { + let mut frame_b = active_body(1); + frame_b.linear_velocity = Vector3::new(1.0, 0.0, 0.0); + frame_b.force_acceleration = Vector3::new(1.0, -9.8, 0.0); + let rows = build_drive_rows( + active_body(0), + frame_b, + frames_with_translation(Vector3::ZERO), + RetailDriveDynamics { + linear: RetailDriveParams { + spring_or_max_velocity: 100.0, + damping: 0.0, + max_strength: 100000.0, + drive_type: RetailDriveType::HardDrive, + }, + angular: no_drive(), + }, + DT, + ); + + assert_near(rows.linear_target_impulse[0], 0.5 * (DT + DT * DT)); +} +#[test] +fn off_center_linear_impulse_does_not_change_same_pass_angular_candidate() { + let mut frames = frames_with_translation(Vector3::ZERO); + frames.body_a.translation = Vector3::new(0.0, 1.0, 0.0); + frames.body_b.translation = Vector3::new(0.0, 1.0, 0.0); + let params = RetailDriveParams { + spring_or_max_velocity: 100.0, + damping: 0.0, + max_strength: 100000.0, + drive_type: RetailDriveType::HardDrive, + }; + let mut rows = [build_drive_rows( + active_body(0), + active_body(1), + frames, + RetailDriveDynamics { + linear: params, + angular: params, + }, + DT, + )]; + rows[0].linear_target_impulse = [0.0, 0.0, 0.1]; + rows[0].angular_target_impulse = [0.0; 3]; + let mut reactions = [RetailReactionCorrections::default(); 2]; + solve_drive_iteration(&mut rows, &mut reactions); + // 82AE2ED0 reads the angular difference BEFORE the linear reaction writes + // at 82AE307C. The former port incorrectly canceled this induced torque. + assert_eq!(rows[0].accumulated_angular_impulse, [0.0; 3]); + assert!(reactions[0].angular_displacement.x > 0.0); + assert!(reactions[1].angular_displacement.x < 0.0); + solve_drive_iteration(&mut rows, &mut reactions); + assert!(rows[0].accumulated_angular_impulse[0] < 0.0); +} + +fn moving_drive(params: RetailDriveParams) -> RetailDriveRows { + let mut b = active_body(1); + b.center_of_mass = Vector3::new(2.0, 0.0, 0.0); + b.linear_velocity = Vector3::new(2.0, 0.0, 0.0); + b.force_acceleration.x = 4.0; + build_drive_rows( + active_body(0), + b, + frames_with_translation(Vector3::ZERO), + RetailDriveDynamics { + linear: params, + angular: no_drive(), + }, + 0.5, + ) +} + +#[test] +fn hard_drive_caps_position_before_adding_rate_and_acceleration() { + // Original 82AE1F78..20D0: position 2 is capped to 1, then weighted by + // 1/(1+c*dt)=1/2. Rate and acceleration each contribute 1 afterward. + let rows = moving_drive(RetailDriveParams { + spring_or_max_velocity: 2.0, + damping: 2.0, + max_strength: 16.0, + drive_type: RetailDriveType::HardDrive, + }); + assert_near(rows.linear_target_impulse[0], 1.25); + assert_near(rows.linear_softness, 1.0); + assert_near(rows.linear_maximum_impulse[0], 2.0); +} + +#[test] +fn soft_drive_weights_spring_damping_and_acceleration_separately() { + // Original 82AE1E68..1F30: k*dt^2=2, c*dt=1, denominator=4. + // Position/rate/acceleration weights are 1/2, 1/4, 3/4 respectively. + let rows = moving_drive(RetailDriveParams { + spring_or_max_velocity: 8.0, + damping: 2.0, + max_strength: 16.0, + drive_type: RetailDriveType::SoftDrive, + }); + assert_near(rows.linear_target_impulse[0], 1.0); + assert_near(rows.linear_softness, 0.75); +} + +#[test] +fn saturated_quaternion_component_uses_all_frame_b_axes() { + let mut b = active_body(1); + b.orientation = RetailQuaternion { + x: 1.0, + y: 0.0, + z: 0.0, + w: 0.0, + }; + b.basis.columns = [[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]]; + let rows = build_drive_rows( + active_body(0), + b, + frames_with_translation(Vector3::ZERO), + RetailDriveDynamics { + linear: no_drive(), + angular: RetailDriveParams { + spring_or_max_velocity: 100.0, + damping: 0.0, + max_strength: 100.0, + drive_type: RetailDriveType::HardDrive, + }, + }, + 0.5, + ); + // Original 82AE20A0..216C uses one saturated component to select all axes. + for i in 0..3 { + let a = rows.angular_axes[i]; + assert_near(a.x, b.basis.columns[i][0]); + assert_near(a.y, b.basis.columns[i][1]); + assert_near(a.z, b.basis.columns[i][2]); + } + assert_near(rows.angular_target_impulse[0], 1.0); + assert!(rows.angular_target_impulse.iter().all(|v| v.is_finite())); +} + +#[test] +fn drive_anchor_uses_independent_body_basis() { + let mut a = active_body(0); + a.basis.columns = [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]; + let mut frames = frames_with_translation(Vector3::ZERO); + frames.body_a.translation = Vector3::new(2.0, 0.0, 0.0); + let rows = build_drive_rows( + a, + active_body(1), + frames, + RetailDriveDynamics { + linear: no_drive(), + angular: no_drive(), + }, + DT, + ); + // The body quaternion deliberately remains identity: the source reads + // the independently stored Body+64/+80/+96 for this point transformation. + assert_eq!(rows.arm_a, Vector3::new(0.0, 0.0, 2.0)); +} diff --git a/crates/skate-core/src/physics/tests/filtered_state.rs b/crates/skate-core/src/physics/tests/filtered_state.rs new file mode 100644 index 00000000..1c0d9bbb --- /dev/null +++ b/crates/skate-core/src/physics/tests/filtered_state.rs @@ -0,0 +1,52 @@ +use super::*; + +fn input(state: i32, category: i32) -> FilteredStateInput { + FilteredStateInput { + physics_state: state, physics_category: category, + anything_in_contact: false, physics_surface_type: 0, + wall_ride_exit: false, targeting_grind: false, + offboard_has_landed: false, offboard_on_deck: false, + grind: GrindState::default(), last_grind_distance: 0.0, + } +} + +#[test] +fn stairs_preserve_ground_then_contact_returns_air_to_ground() { + let mut state = FilteredState::default(); + let mut stairs = input(100, 100); + stairs.physics_surface_type = 8; + assert_eq!(state.update(stairs).category, FilteredCategory::Ground); + for _ in 0..9 { + assert_eq!(state.update(input(200, 200)).category, FilteredCategory::Ground); + } + assert_eq!(state.update(input(200, 200)).category, FilteredCategory::Air); + let mut touching = input(200, 200); + touching.anything_in_contact = true; + let output = state.update(touching); + assert_eq!(output.category, FilteredCategory::Ground); + assert_eq!(output.previous_category, FilteredCategory::Air); + state.update(input(702, 700)); + assert_eq!(state.update(input(200, 200)).category, FilteredCategory::Air); +} + +#[test] +fn nonspecific_keeps_grind_metadata_and_leaving_clears_only_publication() { + let mut state = FilteredState::default(); + let mut grinding = input(403, 400); + grinding.grind = GrindState { + kind: 3, scorable_id: 21, name: encode(b"fs smith"), + scoring_name: encode(b"smith"), on_front: true, crouch: 0.25, + pathed_guid: 0x123456789abcdef0, local_guid: 0xfedcba9876543210, + }; + let entered = state.update(grinding); + assert_eq!(entered.grind, grinding.grind); + let retained = state.update(input(701, 700)); + assert!(retained.grinding); + assert_eq!(retained.grind, grinding.grind); + let left = state.update(input(100, 100)); + assert!(!left.grinding); + assert_eq!(left.grind, GrindState::default()); + assert_eq!(state.cached_grind, grinding.grind); + state.reset(); + assert_eq!(state.cached_grind, GrindState::default()); +} \ No newline at end of file diff --git a/crates/skate-core/src/physics/tests/hook_step.rs b/crates/skate-core/src/physics/tests/hook_step.rs new file mode 100644 index 00000000..ee7ee2b4 --- /dev/null +++ b/crates/skate-core/src/physics/tests/hook_step.rs @@ -0,0 +1,67 @@ +use super::*; + +#[test] +fn an_attached_body_exchanges_reactions_with_the_board_before_both_integrate() { + let mut expected = bodies(); + let mut actual = expected; + let mut target = inactive_hook(); + target.body.state_flags = 4; + target.body.rates.position.x += 0.2; + target.drive.enable_animation_soft(&mut 0); + let mut attached_body = target.body; + let config = settings(25); + let frames = prepare_drive_frames(config.base_truck_transforms, [0.;2], &mut target); + let mut rows = BoardConstraints::build(&actual, &target, frames, + config.truck_dynamics, config.simulation.time_step).drives; + let mut row = rows.pop().unwrap(); + row.frame_a_body.reaction_index = ATTACHED_REACTION_BASE; + let mut attached_drives = [row]; + let queue = BoardForceQueue::default(); + BoardStep::default().advance(&mut expected, &mut target, &queue, &[], [0.;2], config); + BoardStep::default().advance_attached(&mut actual, &mut inactive_hook(), &queue, + &[], [0.;2], config, AttachedStep { bodies: vec![&mut attached_body], + contacts: &mut [], joints: &mut [], drives: &mut attached_drives }); + for (a,b) in actual.iter().zip(expected.iter()) { + assert_eq!(a.rates.position, b.rates.position); + assert_eq!(a.rates.linear_velocity, b.rates.linear_velocity); + } + assert_eq!(attached_body.rates.position, target.body.rates.position); + assert_eq!(attached_body.rates.linear_velocity, target.body.rates.linear_velocity); + assert!(attached_drives[0].accumulated_linear_impulse.iter().any(|v| *v != 0.)); +} + +#[test] +fn hook_drive_exchanges_momentum_and_integrates_without_teleporting_deck() { + let mut parts = bodies(); + let mut hook = inactive_hook(); + hook.body.state_flags = 4; + hook.body.rates.position.x += 0.2; + let mut animated = 0; + hook.drive.enable_animation_soft(&mut animated); + let mut disabled = hook.clone(); + disabled.drive.disable_animation(&mut animated); + let mut free = parts; + let original_deck = parts[6].rates.position; + let queue = BoardForceQueue::default(); + let mut tick = BoardStep::default(); + let config = settings(25); + tick.advance(&mut parts, &mut hook, &queue, &[], [0.0; 2], config); + BoardStep::default().advance(&mut free, &mut disabled, &queue, &[], [0.0; 2], config); + assert_ne!(parts[6].rates.position.x, free[6].rates.position.x); + assert_ne!(hook.body.rates.position.x, disabled.body.rates.position.x); + assert_ne!(parts[6].rates.position.x, hook.body.rates.position.x); + assert_ne!(parts[6].rates.position, original_deck); + let momentum = parts + .iter() + .chain(core::iter::once(&hook.body)) + .map(|b| b.rates.linear_velocity.x / b.inertia.inverse_mass) + .sum::(); + assert!( + momentum.abs() < 1e-4, + "internal hook drive changed momentum: {momentum}" + ); + assert_eq!( + tick.reactions, + [RetailReactionCorrections::default(); REACTION_COUNT] + ); +} diff --git a/crates/skate-core/src/physics/tests/joint_builder.rs b/crates/skate-core/src/physics/tests/joint_builder.rs new file mode 100644 index 00000000..7c738311 --- /dev/null +++ b/crates/skate-core/src/physics/tests/joint_builder.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn output_stride_is_the_observed_three_cache_lines() { + assert_eq!(core::mem::size_of::(), 0x180); +} + +#[test] +fn inactive_body_contributes_no_mass_or_inertia() { + // Synthetic identity bodies replace removed generated constructor output. + let body = RetailJointBodyInput { + reaction_guest_address: 0, + state: 0, + orientation: RetailQuaternion::IDENTITY, + center_of_mass: Vector3::ZERO, + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + inverse_mass: 1.0, + world_inverse_inertia: RetailPackedWorldInverseInertia { + full: Vector3::new(1.0, 0.0, 0.0), + split: Vector3::new(1.0, 1.0, 0.0), + }, + }; + let mut frames = RetailJointFramesRaw { words: [0; 20] }; + for index in [3, 11, 19] { + frames.words[index] = 1.0f32.to_bits(); + } + let built = build_retail_joint_jacobian(RetailJointBuildInput { + parameters: RetailJointParametersRaw { words: [0; 16] }, + frames, + body_a: body, + body_b: body, + time_step: 0.25, + joint_guest_address: 0, + }); + assert_eq!(&built.words[84..96], &[0; 12]); +} + +fn angular_parameters(swing_mode: u32, twist_mode: u32) -> JointParameters { + JointParameters { + linear_position_allowance: Vector3::ZERO, + linear_velocity_allowance: Vector3::ZERO, + twist_velocity_allowance: 0.0, + swing_velocity_allowance: 0.0, + swing_threshold: 0.5, + twist_threshold: 0.5, + swing_mode, + twist_mode, + } +} + +fn identity_basis() -> Basis3 { + Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + } +} + +#[test] +fn angular_limit_zero_denominators_retain_native_intervals() { + let basis = identity_basis(); + let relative = basis.columns; + let rqd = create_rqd(RetailQuaternion::IDENTITY, RetailQuaternion::IDENTITY); + for mode in [1, 2] { + let (axes, low, high) = + angular_rows(angular_parameters(mode, 1), basis, basis, relative, rqd); + assert_eq!(axes, basis_columns(basis)); + // Original cone cosine guard, planar zero reciprocal guard and twist + // zero reciprocal guard retain the initialized unlimited intervals. + assert_eq!(low[0], -tu3::FINITE_INFINITY); + assert_eq!(high[0], tu3::FINITE_INFINITY); + assert_eq!(low[1], -tu3::FINITE_INFINITY); + assert_eq!(high[1], tu3::FINITE_INFINITY); + if mode == 2 { + assert_eq!(low[2], 0.0); + assert_eq!(high[2], 0.0); + } + } +} + +#[test] +fn cone_inside_limit_still_publishes_predictive_bound() { + let a = identity_basis(); + let b = Basis3 { + columns: [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]], + }; + let relative = [[0.8, -0.6, 0.0], [0.6, 0.8, 0.0], [0.0, 0.0, 1.0]]; + let (_, low, _) = angular_rows( + angular_parameters(1, 2), + a, + b, + relative, + create_rqd(RetailQuaternion::IDENTITY, RetailQuaternion::IDENTITY), + ); + // 82AE4238 writes (0.5-0.8)/0.6 even though the current angle is inside + // the limit; the velocity bounds can then constrain this tick's motion. + assert!((low[1] + 0.5).abs() < 1.0e-6); +} + +#[test] +fn rqd_rows_are_bilinear_without_unit_quaternion_reconstruction() { + let q = RetailQuaternion { + x: 1.0, + y: 2.0, + z: 3.0, + w: 4.0, + }; + let rqd = create_rqd(q, RetailQuaternion::IDENTITY); + // Original 82AE0DB0's bilinear products reduce to these values when B=1. + assert_eq!( + rqd.axes, + [ + Vector3::new(4.0, 3.0, -2.0), + Vector3::new(-3.0, 4.0, 1.0), + Vector3::new(2.0, -1.0, 4.0) + ] + ); + assert_eq!( + rqd.relative, + RetailQuaternion { + x: -1.0, + y: -2.0, + z: -3.0, + w: 4.0 + } + ); +} diff --git a/crates/skate-core/src/physics/tests/joint_records.rs b/crates/skate-core/src/physics/tests/joint_records.rs new file mode 100644 index 00000000..f8009c9a --- /dev/null +++ b/crates/skate-core/src/physics/tests/joint_records.rs @@ -0,0 +1,146 @@ +use core::mem::size_of; + +use super::*; + +#[test] +fn raw_record_sizes_match_tu3_owner_strides() { + assert_eq!(size_of::(), 64); + assert_eq!(size_of::(), 80); +} + +#[test] +fn stock_records_preserve_unrolled_definition_and_live_pair_order() { + let joints = default_joint_records(); + assert_eq!( + joints.map(|joint| (joint.definition_body_0, joint.definition_body_1)), + [ + (BodyId::Deck, BodyId::FrontTruck), + (BodyId::Deck, BodyId::BackTruck), + (BodyId::FrontTruck, BodyId::RightFrontWheel), + (BodyId::FrontTruck, BodyId::LeftFrontWheel), + (BodyId::BackTruck, BodyId::RightBackWheel), + (BodyId::BackTruck, BodyId::LeftBackWheel), + ] + ); + assert_eq!( + joints.map(|joint| (joint.live_body_a(), joint.live_body_b())), + [ + (BodyId::FrontTruck, BodyId::Deck), + (BodyId::BackTruck, BodyId::Deck), + (BodyId::RightFrontWheel, BodyId::FrontTruck), + (BodyId::LeftFrontWheel, BodyId::FrontTruck), + (BodyId::RightBackWheel, BodyId::BackTruck), + (BodyId::LeftBackWheel, BodyId::BackTruck), + ] + ); +} + +#[test] +fn stock_parameter_arithmetic_matches_create_joints_words() { + let joints = default_joint_records(); + assert_eq!( + joints[0].parameters.words, + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0x42B4_53D1, + 0, + 0, + 0x3E7A_35DD, + 0x3F80_0000, + 0x3F78_654D, + 0, + 1, + ] + ); + assert_eq!( + joints[2].parameters.words, + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0x497F_A9D8, + 0, + 0, + 0x3F80_0000, + 0x3F80_0000, + 3, + 0, + ] + ); +} + +#[test] +fn stock_frame_construction_matches_native_payloads() { + let joints = default_joint_records(); + assert_eq!( + joints[0].frames.words, + [ + 0, + 0, + 0, + 0x3F80_0000, + 0, + 0, + 0, + 0, + 0xBE41_8051, + 0xBF2E_6F8D, + 0x3E41_804F, + 0x3F2E_6F8D, + 0, + 0xBD67_6C8B, + 0x3E78_D4FD, + 0, + 0xBE41_8051, + 0xBF2E_6F8D, + 0x3E41_804F, + 0x3F2E_6F8D, + ] + ); + assert_eq!( + joints[1].frames.words, + [ + 0, + 0, + 0, + 0x3F80_0000, + 0, + 0, + 0, + 0, + 0x3E41_8051, + 0x3F2E_6F8D, + 0x3E41_804F, + 0x3F2E_6F8D, + 0, + 0xBD67_6C8B, + 0xBE78_D4FD, + 0, + 0x3E41_8051, + 0x3F2E_6F8D, + 0x3E41_804F, + 0x3F2E_6F8D, + ] + ); + let wheel_orientation = [0x3F35_04F3, 0, 0, 0x3F35_04F3]; + assert_eq!(&joints[2].frames.words[0..4], &wheel_orientation); + assert_eq!(&joints[2].frames.words[8..12], &wheel_orientation); + assert_eq!(&joints[2].frames.words[16..20], &wheel_orientation); + assert_eq!(joints[2].frames.words[14], 0x3DC2_8F5C); + assert_eq!(joints[3].frames.words[14], 0xBDC2_8F5C); + assert_eq!(joints[4].frames, joints[2].frames); + assert_eq!(joints[5].frames, joints[3].frames); +} diff --git a/crates/skate-core/src/physics/tests/joint_solver.rs b/crates/skate-core/src/physics/tests/joint_solver.rs new file mode 100644 index 00000000..c2a85e62 --- /dev/null +++ b/crates/skate-core/src/physics/tests/joint_solver.rs @@ -0,0 +1,38 @@ +use super::*; + +#[test] +fn raw_layout_matches_tu3_strides() { + assert_eq!(core::mem::size_of::(), 384); + assert_eq!(core::mem::size_of::(), 64); +} + +#[test] +fn reaction_bridge_preserves_contact_only_position_corrections() { + let mut reactions = RetailReactionCorrections { + linear_displacement: crate::math::Vector3::new(1.0, 2.0, 3.0), + position_displacement: crate::math::Vector3::new(4.0, 5.0, 6.0), + angular_displacement: crate::math::Vector3::new(7.0, 8.0, 9.0), + orientation_displacement: crate::math::Vector3::new(10.0, 11.0, 12.0), + }; + let mut block = RetailJointReactionBlock::from_reactions(reactions); + block.words[0] = 13.0f32.to_bits(); + block.words[9] = 14.0f32.to_bits(); + block.write_velocity_reactions(&mut reactions); + + assert_eq!( + reactions.linear_displacement, + crate::math::Vector3::new(13.0, 2.0, 3.0) + ); + assert_eq!( + reactions.angular_displacement, + crate::math::Vector3::new(7.0, 14.0, 9.0) + ); + assert_eq!( + reactions.position_displacement, + crate::math::Vector3::new(4.0, 5.0, 6.0) + ); + assert_eq!( + reactions.orientation_displacement, + crate::math::Vector3::new(10.0, 11.0, 12.0) + ); +} diff --git a/crates/skate-core/src/physics/tests/mass_moments.rs b/crates/skate-core/src/physics/tests/mass_moments.rs new file mode 100644 index 00000000..85e73fc3 --- /dev/null +++ b/crates/skate-core/src/physics/tests/mass_moments.rs @@ -0,0 +1,141 @@ +use crate::{ + math::{Basis3, Vector3}, + physics::mass::{MassMoments, PrimitiveMass}, +}; + +fn close(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 2.0e-5, "{actual} != {expected}"); +} + +#[test] +fn translated_primitive_recovers_center_without_parallel_axis_inertia() { + let mut moments = MassMoments::from_primitive(PrimitiveMass { + moments_per_unit_mass: Vector3::new(2.0, 3.0, 4.0), + volume: 2.0, + }); + let identity = Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }; + moments.transform(identity, Vector3::new(2.0, -1.0, 3.0)); + let result = moments.principal_properties(); + assert_eq!( + result.local_mass_frame.translation, + Vector3::new(2.0, -1.0, 3.0) + ); + close(result.moments_per_unit_mass.x, 2.0); + close(result.moments_per_unit_mass.y, 3.0); + close(result.moments_per_unit_mass.z, 4.0); +} + +#[test] +fn rotated_mass_reconstructs_independent_world_inertia() { + let sine = 0.6; + let cosine = 0.8; + let basis = Basis3 { + columns: [[cosine, sine, 0.0], [-sine, cosine, 0.0], [0.0, 0.0, 1.0]], + }; + let mut moments = MassMoments::from_primitive(PrimitiveMass { + moments_per_unit_mass: Vector3::new(2.0, 3.0, 4.0), + volume: 2.0, + }); + moments.transform(basis, Vector3::new(0.0, 0.0, 0.0)); + let result = moments.principal_properties(); + let columns = result.local_mass_frame.basis.columns; + let diagonal = [ + result.moments_per_unit_mass.x, + result.moments_per_unit_mass.y, + result.moments_per_unit_mass.z, + ]; + // R diag(2,3,4) R^T for the independently specified rotation. + let expected = [[2.36, -0.48, 0.0], [-0.48, 2.64, 0.0], [0.0, 0.0, 4.0]]; + for row in 0..3 { + for column in 0..3 { + let actual: f32 = (0..3) + .map(|axis| columns[axis][row] * diagonal[axis] * columns[axis][column]) + .sum(); + close(actual, expected[row][column]); + } + } +} + +#[test] +fn two_translated_equal_volumes_preserve_parallel_axis_contribution() { + let primitive = PrimitiveMass { + moments_per_unit_mass: Vector3::new(1.0, 1.0, 1.0), + volume: 3.0, + }; + let identity = Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }; + let mut total = MassMoments::ZERO; + for x in [-2.0, 2.0] { + let mut child = MassMoments::from_primitive(primitive); + child.transform(identity, Vector3::new(x, 0.0, 0.0)); + total.add(child); + } + let result = total.principal_properties(); + assert_eq!(result.volume, 6.0); + close(result.moments_per_unit_mass.x, 1.0); + close(result.moments_per_unit_mass.y, 5.0); + close(result.moments_per_unit_mass.z, 5.0); +} + +#[test] +fn aggregate_finalizer_stores_inverse_frame_and_keeps_world_inertia() { + use crate::physics::mass::aggregate_mass_properties; + let rotation = Basis3 { + columns: [[0.8, 0.6, 0.0], [-0.6, 0.8, 0.0], [0.0, 0.0, 1.0]], + }; + let center = Vector3::new(2.0, -1.0, 3.0); + let mut moments = MassMoments::from_primitive(PrimitiveMass { + moments_per_unit_mass: Vector3::new(2.0, 3.0, 4.0), + volume: 2.0, + }); + moments.transform(rotation, center); + let result = aggregate_mass_properties(moments, 2.0, 20.0, 0.25); + let inverse = result.local_mass_frame; + let translation = [ + inverse.translation.x, + inverse.translation.y, + inverse.translation.z, + ]; + for row in 0..3 { + // Applying inverseBodyLTM to the authored COM must produce the origin. + let local_center = inverse.basis.columns[0][row] * center.x + + inverse.basis.columns[1][row] * center.y + + inverse.basis.columns[2][row] * center.z + + translation[row]; + close(local_center, 0.0); + } + let tensor = result.dynamics.inverse_tensor; + let principal = [1.0 / tensor.x, 1.0 / tensor.y, 1.0 / tensor.z]; + // I_world = transpose(inverse_basis) * I_principal * inverse_basis. + let expected = [[4.72, -0.96, 0.0], [-0.96, 5.28, 0.0], [0.0, 0.0, 8.0]]; + for row in 0..3 { + for column in 0..3 { + let actual: f32 = (0..3) + .map(|axis| { + inverse.basis.columns[row][axis] + * principal[axis] + * inverse.basis.columns[column][axis] + }) + .sum(); + close(actual, expected[row][column]); + } + } +} + +#[test] +fn finalizer_omits_source_negligible_frame_correction() { + use crate::physics::{mass::aggregate_mass_properties, rigid_body::RetailLocalMassFrame}; + let mut moments = MassMoments::from_primitive(PrimitiveMass { + moments_per_unit_mass: Vector3::new(2.0, 3.0, 4.0), + volume: 2.0, + }); + moments.transform( + RetailLocalMassFrame::IDENTITY.basis, + Vector3::new(0.0001, 0.0, 0.0), + ); + let result = aggregate_mass_properties(moments, 2.0, 20.0, 0.0); + assert_eq!(result.local_mass_frame, RetailLocalMassFrame::IDENTITY); +} diff --git a/crates/skate-core/src/physics/tests/native_arithmetic.rs b/crates/skate-core/src/physics/tests/native_arithmetic.rs new file mode 100644 index 00000000..b4cef0f0 --- /dev/null +++ b/crates/skate-core/src/physics/tests/native_arithmetic.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn host_math_normalizes_a_direction_without_using_its_homogeneous_lane() { + let direction = [3.0, 4.0, 0.0, 100.0]; + let inverse_length = reciprocal_square_root_estimate(dot3(direction, direction)); + let unit = direction.map(|v| v * inverse_length); + assert!((dot3(unit, unit) - 1.0).abs() < 1.0e-6); + assert_eq!(dot3(direction, [4.0, -3.0, 0.0, 50.0]), 0.0); + assert_eq!(dot4(direction, [0.0, 0.0, 0.0, 1.0]), 100.0); +} + +#[test] +fn host_math_handles_scalar_geometry_and_ordinary_float_boundaries() { + assert_eq!(reciprocal_estimate(4.0), 0.25); + assert_eq!(reciprocal_square_root_estimate(16.0), 0.25); + assert_eq!(reciprocal_estimate(-0.0), f32::NEG_INFINITY); + assert_eq!(reciprocal_square_root_estimate(f32::INFINITY), 0.0); + assert!(reciprocal_square_root_estimate(-1.0).is_nan()); + assert_eq!(vector_min(-2.0, 3.0), -2.0); + assert_eq!(vector_max(-2.0, 3.0), 3.0); +} diff --git a/crates/skate-core/src/physics/tests/primitive_query.rs b/crates/skate-core/src/physics/tests/primitive_query.rs new file mode 100644 index 00000000..0cdc5034 --- /dev/null +++ b/crates/skate-core/src/physics/tests/primitive_query.rs @@ -0,0 +1,277 @@ +use crate::{ + math::{Basis3, Vector3}, + physics::{ + collision::{ + Sphere, Triangle, TriangleFeature, WorldContactSettings, sphere_triangle_world_contact, + }, + world_contact::{ + ContactPrimitive, primitive_triangle_world_contacts, transform_triangle_volume, + triangle_from_volume, + }, + }, +}; + +fn v(x: f32, y: f32, z: f32) -> Vector3 { + Vector3::new(x, y, z) +} +fn floor() -> Triangle { + triangle_from_volume( + [v(-5.0, 0.0, -5.0), v(0.0, 0.0, 5.0), v(5.0, 0.0, -5.0)], + 0.0, + [-1.0; 3], + 2, + ) +} +fn settings(padding: f32) -> WorldContactSettings { + WorldContactSettings { + volume_padding: padding, + maximum_separating_distance: 1.0, + edge_cos_bend_normal_threshold: -1.0, + convexity_epsilon: 0.0, + is_object: false, + } +} +fn close(a: f32, b: f32) { + assert!((a - b).abs() < 2.0e-5, "{a} != {b}"); +} +fn same(a: Vector3, b: Vector3) { + close(a.x, b.x); + close(a.y, b.y); + close(a.z, b.z); +} +fn identity() -> Basis3 { + Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + } +} + +#[test] +fn volume_builder_uses_native_winding_edges_and_flags() { + let triangle = triangle_from_volume( + [v(0.0, 0.0, 0.0), v(0.0, 0.0, 4.0), v(3.0, 0.0, 0.0)], + 0.02, + [1.0, -1.0, 1.0], + 0x3e3, + ); + same(triangle.feature.normal, v(0.0, 1.0, 0.0)); + same(triangle.feature.edges[0], v(1.0, 0.0, 0.0)); + same(triangle.feature.edges[1], v(-0.6, 0.0, 0.8)); + same(triangle.feature.edges[2], v(0.0, 0.0, -1.0)); + for (actual, expected) in triangle.edge_lengths.into_iter().zip([3.0, 5.0, 4.0]) { + close(actual, expected); + } + assert_eq!(triangle.feature.flags, 0x3e1); + assert_eq!(triangle.feature.edge_cosines, [1.0, -1.0, 1.0]); + assert_eq!(triangle.fatness, 0.02); +} + +#[test] +fn volume_transform_keeps_local_normal_and_rebuilds_world_edges() { + let triangle = transform_triangle_volume( + [v(0.0, 0.0, 0.0), v(0.0, 0.0, 4.0), v(3.0, 0.0, 0.0)], + 0.02, + [-1.0; 3], + 2, + Basis3 { + columns: [[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + }, + v(4.0, 5.0, 6.0), + ); + same(triangle.feature.normal, v(-1.0, 0.0, 0.0)); + same(triangle.vertices[2], v(4.0, 8.0, 6.0)); + same(triangle.feature.edges[0], v(0.0, 1.0, 0.0)); + close(triangle.edge_lengths[1], 5.0); +} + +#[test] +fn generic_sphere_world_path_matches_existing_independent_sphere_branch() { + let sphere = Sphere { + center: v(0.4, 0.13, -0.2), + radius: 0.15, + }; + let mut triangle = floor(); + triangle.fatness = 0.01; + let generic = primitive_triangle_world_contacts( + ContactPrimitive::Sphere(sphere), + triangle, + Vector3::ZERO, + settings(0.0), + ) + .unwrap(); + let previous = + sphere_triangle_world_contact(sphere, triangle, Vector3::ZERO, settings(0.0)).unwrap(); + assert_eq!(generic.count, 1); + same(generic.normal, previous.normal); + same(generic.points[0].a, previous.points.a); + same(generic.points[0].b, previous.points.b); + same(generic.normal, v(0.0, 1.0, 0.0)); + close(generic.points[0].a.y, -0.02); + close(generic.points[0].b.y, 0.01); +} + +#[test] +fn capsule_face_keeps_both_axial_end_contacts() { + let capsule = ContactPrimitive::Capsule { + center: v(0.0, 0.1, 0.0), + axis: v(1.0, 0.0, 0.0), + half_length: 0.4, + radius: 0.15, + }; + let result = + primitive_triangle_world_contacts(capsule, floor(), Vector3::ZERO, settings(0.0)).unwrap(); + assert_eq!(result.count, 2); + same(result.normal, v(0.0, 1.0, 0.0)); + let mut xs = result.points[..2].iter().map(|p| p.a.x).collect::>(); + xs.sort_by(f32::total_cmp); + close(xs[0], -0.4); + close(xs[1], 0.4); + for p in &result.points[..2] { + close(p.a.y, -0.05); + close(p.b.y, 0.0); + close(p.a.x, p.b.x); + } +} + +#[test] +fn rounded_box_face_produces_all_four_corner_contacts() { + let shape = ContactPrimitive::RoundedBox { + center: v(0.0, 0.09, 0.0), + basis: identity(), + half_extents: v(0.4, 0.08, 0.2), + radius: 0.02, + }; + let result = + primitive_triangle_world_contacts(shape, floor(), Vector3::ZERO, settings(0.0)).unwrap(); + assert_eq!(result.count, 4); + same(result.normal, v(0.0, 1.0, 0.0)); + for p in &result.points[..result.count] { + close(p.a.y, -0.01); + close(p.b.y, 0.0); + close(p.a.x.abs(), 0.4); + close(p.a.z.abs(), 0.2); + } +} + +#[test] +fn moving_triangle_face_preserves_three_vertex_contacts() { + let moving = triangle_from_volume( + [v(-0.4, 0.01, -0.2), v(0.0, 0.01, 0.2), v(0.4, 0.01, -0.2)], + 0.02, + [-1.0; 3], + 2, + ); + let result = primitive_triangle_world_contacts( + ContactPrimitive::Triangle(moving), + floor(), + Vector3::ZERO, + settings(0.0), + ) + .unwrap(); + assert_eq!(result.count, 3); + same(result.normal, v(0.0, 1.0, 0.0)); + for p in &result.points[..3] { + close(p.a.y, -0.01); + close(p.b.y, 0.0); + } +} + +#[test] +fn prediction_uses_fixed_sixtieth_and_separate_volume_padding() { + let sphere = ContactPrimitive::Sphere(Sphere { + center: v(0.0, 0.2, 0.0), + radius: 0.1, + }); + assert!( + primitive_triangle_world_contacts(sphere, floor(), Vector3::ZERO, settings(0.0)).is_none() + ); + let predicted = + primitive_triangle_world_contacts(sphere, floor(), v(0.0, -6.0, 0.0), settings(0.001)) + .unwrap(); + close(predicted.points[0].a.y, 0.1); + assert!( + primitive_triangle_world_contacts(sphere, floor(), v(0.0, 6.0, 0.0), settings(0.001)) + .is_none() + ); +} + +#[test] +fn one_sided_world_triangle_rejects_below_surface() { + let mut triangle = floor(); + triangle.feature.flags |= TriangleFeature::ONE_SIDED; + let below = ContactPrimitive::Sphere(Sphere { + center: v(0.0, -0.05, 0.0), + radius: 0.1, + }); + assert!( + primitive_triangle_world_contacts(below, triangle, Vector3::ZERO, settings(0.0)).is_none() + ); + let above = ContactPrimitive::Sphere(Sphere { + center: v(0.0, 0.05, 0.0), + radius: 0.1, + }); + assert!( + primitive_triangle_world_contacts(above, triangle, Vector3::ZERO, settings(0.0)).is_some() + ); +} + +#[test] +fn triangle_box_edge_cross_axis_rejects_despite_overlapping_face_intervals() { + // All three box-axis intervals and the triangle normal overlap. The + // diagonal triangle edge x+z=2.1 is sqrt(0.005) beyond box corner(1,1), + // so this separation can only be found by an edge-cross SAT direction. + let triangle = triangle_from_volume( + [v(0.0, 0.0, 2.1), v(3.0, 0.0, 3.0), v(2.1, 0.0, 0.0)], + 0.0, + [-1.0; 3], + 0xe2, + ); + let shape = |radius| ContactPrimitive::RoundedBox { + center: Vector3::ZERO, + basis: identity(), + half_extents: v(1.0, 1.0, 1.0), + radius, + }; + assert!( + primitive_triangle_world_contacts(shape(0.05), triangle, Vector3::ZERO, settings(0.0)) + .is_none() + ); + let result = + primitive_triangle_world_contacts(shape(0.08), triangle, Vector3::ZERO, settings(0.0)) + .unwrap(); + assert_eq!(result.count, 1); + same( + result.normal, + v( + -std::f32::consts::FRAC_1_SQRT_2, + 0.0, + -std::f32::consts::FRAC_1_SQRT_2, + ), + ); + same(result.points[0].b, v(1.05, 0.0, 1.05)); + let offset = 0.08 * std::f32::consts::FRAC_1_SQRT_2; + same(result.points[0].a, v(1.0 + offset, 0.0, 1.0 + offset)); +} + +#[test] +fn rotated_box_and_triangle_keep_the_plane_manifold() { + let c = 0.8660254; + let basis = Basis3 { + columns: [[c, 0.5, 0.0], [-0.5, c, 0.0], [0.0, 0.0, 1.0]], + }; + let triangle = + transform_triangle_volume(floor().vertices, 0.0, [-1.0; 3], 2, basis, v(2.0, 3.0, 4.0)); + let shape = ContactPrimitive::RoundedBox { + center: v(2.0 - 0.09 * 0.5, 3.0 + 0.09 * c, 4.0), + basis, + half_extents: v(0.4, 0.08, 0.2), + radius: 0.02, + }; + let result = + primitive_triangle_world_contacts(shape, triangle, Vector3::ZERO, settings(0.0)).unwrap(); + assert_eq!(result.count, 4); + same(result.normal, v(-0.5, c, 0.0)); + for p in &result.points[..result.count] { + close((p.a.x - 2.0) * -0.5 + (p.a.y - 3.0) * c, -0.01); + close((p.b.x - 2.0) * -0.5 + (p.b.y - 3.0) * c, 0.0); + } +} diff --git a/crates/skate-core/src/physics/tests/rigid_body.rs b/crates/skate-core/src/physics/tests/rigid_body.rs new file mode 100644 index 00000000..e94638c1 --- /dev/null +++ b/crates/skate-core/src/physics/tests/rigid_body.rs @@ -0,0 +1,274 @@ +use super::*; +use crate::physics::mass::*; + +const IDENTITY: Basis3 = Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], +}; + +fn assert_close(actual: f32, expected: f32) { + assert!( + (actual - expected).abs() <= 1.0e-6, + "actual {actual}, expected {expected}" + ); +} + +fn assert_vec_close(actual: Vector3, expected: Vector3) { + assert_close(actual.x, expected.x); + assert_close(actual.y, expected.y); + assert_close(actual.z, expected.z); +} + +#[test] +fn recovered_deck_mass_frame_is_finite_right_handed_and_orthonormal() { + let frame = retail_deck_mass_properties().local_mass_frame; + let right = column(frame.basis, 0); + let up = column(frame.basis, 1); + let at = column(frame.basis, 2); + for value in [ + right.x, + right.y, + right.z, + up.x, + up.y, + up.z, + at.x, + at.y, + at.z, + frame.translation.x, + frame.translation.y, + frame.translation.z, + ] { + assert!(value.is_finite()); + } + assert!((length_squared(right) - 1.0).abs() < 2.0e-5); + assert!((length_squared(up) - 1.0).abs() < 2.0e-5); + assert!((length_squared(at) - 1.0).abs() < 2.0e-5); + assert!(dot(right, up).abs() < 2.0e-5); + assert!(dot(right, at).abs() < 2.0e-5); + assert!(dot(up, at).abs() < 2.0e-5); + assert!(dot(cross(right, up), at) > 0.99998); +} + +#[test] +fn force_accumulator_applies_inverse_mass_and_point_torque() { + let output = accumulate_point_force( + RetailForceAccumulator { + force_acceleration: Vector3::new(1.0, 2.0, 3.0), + torque_acceleration: Vector3::ZERO, + cool_down: 7, + }, + Vector3::new(0.0, 12.0, 0.0), + Vector3::new(0.0, 0.0, 2.0), + IDENTITY, + 0.5, + IDENTITY, + ); + assert_vec_close(output.force_acceleration, Vector3::new(1.0, 8.0, 3.0)); + assert_vec_close(output.torque_acceleration, Vector3::new(-24.0, 0.0, 0.0)); + assert_eq!(output.cool_down, 0); +} + +#[test] +fn body_step_uses_solver_displacements_without_tangent_projection() { + let simulation = RetailSimulationStep { + time_step: 0.25, + frequency: 4.0, + cool_down: 8, + minimum_energy: 0.0, + gravity_acceleration: Vector3::new(0.0, -9.8, 0.0), + }; + let output = integrate_body_rates( + RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: IDENTITY, + world_inverse_inertia: IDENTITY, + position: Vector3::new(1.0, 2.0, 3.0), + linear_velocity: Vector3::new(4.0, 0.0, 0.0), + angular_velocity: Vector3::new(0.0, 2.0, 0.0), + force_acceleration: Vector3::new(0.0, -4.0, 0.0), + torque_acceleration: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }, + RetailInertiaDynamics { + inverse_tensor: Vector3::new(1.0, 1.0, 1.0), + inverse_mass: 0.5, + spherical: 1.0, + maximum_linear_velocity: 100.0, + maximum_angular_velocity: 100.0, + linear_drag: 0.0, + angular_drag: 0.0, + }, + simulation, + RetailReactionCorrections { + linear_displacement: Vector3::new(0.0, 0.25, 0.0), + position_displacement: Vector3::new(0.0, 0.0, 0.5), + angular_displacement: Vector3::new(0.0, 0.0, 0.25), + orientation_displacement: Vector3::new(0.125, 0.0, 0.0), + }, + ); + + assert_vec_close(output.state.position, Vector3::new(2.0, 2.0, 3.5)); + assert_vec_close(output.state.linear_velocity, Vector3::new(4.0, 0.0, 0.0)); + assert_vec_close(output.state.angular_velocity, Vector3::new(0.0, 2.0, 1.0)); + assert_vec_close( + output.orientation_displacement, + Vector3::new(0.125, 0.5, 0.25), + ); + assert_vec_close( + output.state.force_acceleration, + simulation.gravity_acceleration, + ); + assert_vec_close(output.state.torque_acceleration, Vector3::ZERO); + assert_eq!( + output.state.orientation, + integrate_orientation(RetailQuaternion::IDENTITY, output.orientation_displacement) + ); +} + +#[test] +fn drag_is_frequency_minus_drag_on_frame_displacement() { + let output = integrate_body_rates( + RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: IDENTITY, + world_inverse_inertia: IDENTITY, + position: Vector3::ZERO, + linear_velocity: Vector3::new(8.0, 0.0, 0.0), + angular_velocity: Vector3::new(0.0, 6.0, 0.0), + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 100.0, + cool_down: 0, + }, + RetailInertiaDynamics { + inverse_tensor: Vector3::new(1.0, 1.0, 1.0), + inverse_mass: 1.0, + spherical: 1.0, + maximum_linear_velocity: 100.0, + maximum_angular_velocity: 100.0, + linear_drag: 2.0, + angular_drag: 3.0, + }, + RetailSimulationStep { + time_step: 0.25, + frequency: 4.0, + cool_down: 8, + minimum_energy: 0.0, + gravity_acceleration: Vector3::ZERO, + }, + RetailReactionCorrections::default(), + ); + assert_vec_close(output.state.linear_velocity, Vector3::new(4.0, 0.0, 0.0)); + assert_vec_close(output.state.angular_velocity, Vector3::new(0.0, 1.5, 0.0)); +} + +#[test] +fn speed_caps_and_retail_cool_down_branch_are_separate() { + let mut body = RetailBodyRates { + orientation: RetailQuaternion::IDENTITY, + basis: IDENTITY, + world_inverse_inertia: IDENTITY, + position: Vector3::ZERO, + linear_velocity: Vector3::new(10.0, 0.0, 0.0), + angular_velocity: Vector3::new(0.0, 8.0, 0.0), + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + kinetic_energy: 200.0, + cool_down: 2, + }; + let inertia = RetailInertiaDynamics { + inverse_tensor: Vector3::new(1.0, 1.0, 1.0), + inverse_mass: 0.5, + spherical: 2.0, + maximum_linear_velocity: 3.0, + maximum_angular_velocity: 2.0, + linear_drag: 0.0, + angular_drag: 0.0, + }; + let simulation = RetailSimulationStep { + time_step: 0.25, + frequency: 4.0, + cool_down: 4, + minimum_energy: 20.0, + gravity_acceleration: Vector3::ZERO, + }; + let first = integrate_body_rates( + body, + inertia, + simulation, + RetailReactionCorrections::default(), + ); + assert_close(first.state.linear_velocity.x, 3.0); + assert_close(first.state.angular_velocity.y, 2.0); + assert_close(first.state.kinetic_energy, 13.0); + assert_eq!(first.state.cool_down, 3); + + body = first.state; + let second = integrate_body_rates( + body, + inertia, + simulation, + RetailReactionCorrections::default(), + ); + assert_eq!(second.state.cool_down, 4); +} + +#[test] +fn quaternion_branch_uses_retail_left_multiplication_and_normalizes() { + let quarter_turn_y = RetailQuaternion { + x: 0.0, + y: core::f32::consts::FRAC_1_SQRT_2, + z: 0.0, + w: core::f32::consts::FRAC_1_SQRT_2, + }; + let angular = Vector3::new(0.2, 0.3, 0.4); + let actual = integrate_orientation(quarter_turn_y, angular); + + let old_vector = Vector3::new(quarter_turn_y.x, quarter_turn_y.y, quarter_turn_y.z); + let left_product_vector = add(scale(angular, quarter_turn_y.w), cross(angular, old_vector)); + let expected = normalize_quaternion(RetailQuaternion { + x: quarter_turn_y.x + 0.5 * left_product_vector.x, + y: quarter_turn_y.y + 0.5 * left_product_vector.y, + z: quarter_turn_y.z + 0.5 * left_product_vector.z, + w: quarter_turn_y.w - 0.5 * dot(angular, old_vector), + }); + assert_eq!(actual, expected); + assert_close( + actual.x * actual.x + actual.y * actual.y + actual.z * actual.z + actual.w * actual.w, + 1.0, + ); +} + +#[test] +fn quaternion_basis_and_world_inverse_inertia_preserve_body_axes() { + let quarter_turn_y = RetailQuaternion { + x: 0.0, + y: core::f32::consts::FRAC_1_SQRT_2, + z: 0.0, + w: core::f32::consts::FRAC_1_SQRT_2, + }; + let basis = basis_from_quaternion(quarter_turn_y); + assert_vec_close(column(basis, 0), Vector3::new(0.0, 0.0, -1.0)); + assert_vec_close(column(basis, 1), Vector3::new(0.0, 1.0, 0.0)); + assert_vec_close(column(basis, 2), Vector3::new(1.0, 0.0, 0.0)); + + let world = world_inverse_inertia(basis, Vector3::new(2.0, 3.0, 5.0)); + assert_vec_close(column(world, 0), Vector3::new(5.0, 0.0, 0.0)); + assert_vec_close(column(world, 1), Vector3::new(0.0, 3.0, 0.0)); + assert_vec_close(column(world, 2), Vector3::new(0.0, 0.0, 2.0)); +} + +#[test] +fn packed_world_inverse_inertia_uses_retail_lane_order() { + let symmetric = Basis3 { + columns: [[2.0, 3.0, 5.0], [3.0, 7.0, 11.0], [5.0, 11.0, 13.0]], + }; + let packed = pack_world_inverse_inertia(symmetric); + assert_eq!(packed.full, Vector3::new(2.0, 3.0, 5.0)); + assert_eq!(packed.split, Vector3::new(13.0, 7.0, 11.0)); + assert_vec_close( + multiply_packed_world_inverse_inertia(packed, Vector3::new(17.0, 19.0, 23.0)), + multiply_basis(symmetric, Vector3::new(17.0, 19.0, 23.0)), + ); +} diff --git a/crates/skate-core/src/physics/tests/skeleton_animation_record.rs b/crates/skate-core/src/physics/tests/skeleton_animation_record.rs new file mode 100644 index 00000000..107fda32 --- /dev/null +++ b/crates/skate-core/src/physics/tests/skeleton_animation_record.rs @@ -0,0 +1,55 @@ +use super::*; + +#[test] +fn com_uses_original_bone_volume_excludes_board_and_preserves_reset_order() { + let mut sizes = [Vector3::new(0.0, 0.0, 0.0); 24]; + sizes[0] = Vector3::new(100.0, 100.0, 100.0); + sizes[1] = Vector3::new(1.0, 1.0, 1.0); + sizes[2] = Vector3::new(1.0, 1.0, 3.0); + let masses = SkeletonAnimationMasses::from_bone_data(sizes, [1; 24], false); + assert_eq!(masses.total, 4.0); + assert_eq!(masses.fractional[0..3], [0.0, 0.25, 0.75]); + let mut pose = [IDENTITY; 24]; + pose[0][3] = [1.0, 0.0, 0.0, 0.0]; + pose[1][3] = [1.0, 2.0, 0.0, 0.0]; + pose[2][3] = [1.0, 6.0, 0.0, 0.0]; + let mut to_board = IDENTITY; + to_board[3] = [100.0, 20.0, -10.0, 0.0]; + let mut record = SkeletonAnimationRecord::default(); + record.update(&pose, &to_board, &masses); + assert_eq!(record.centre_of_mass, [1.0, 5.0, 0.0, 0.0]); + assert_eq!(record.com_to_deck_world, [0.0, 5.0, 0.0, 0.0]); + assert_eq!(record.com_to_deck_world_delta, [0.0; 4]); + pose[1][3][1] += 4.0; + record.update(&pose, &to_board, &masses); + assert_eq!(record.com_to_deck_world_delta, [0.0, 1.0, 0.0, 0.0]); + record.reset_history(); + assert_eq!(record.reset_scalar, 1.0); + assert_eq!(record.pose, pose); +} +#[test] +fn global_bone_then_physics_frame_preserves_native_mapping_and_lanes() { + let local = physics_bone_frame([0.0, 0.0, 0.0, 1.0], [1.0, 2.0, 3.0, 7.0]); + assert_eq!(local[0], [1.0, 0.0, 0.0, 1.0]); + assert_eq!(local[1], [0.0, 1.0, 0.0, 0.0]); + assert_eq!(local[2], [0.0, 0.0, 1.0, 0.0]); + // A global quarter turn around Z makes multiplication order observable. + let global = [ + [0.0, 1.0, 0.0, 11.0], + [-1.0, 0.0, 0.0, 13.0], + [0.0, 0.0, 1.0, 17.0], + [10.0, 20.0, 30.0, 19.0], + ]; + let mut indices = [0; 24]; + indices[1] = 1; + let mut frames = [IDENTITY; 24]; + frames[1] = local; + let mapped = map_animation_parts(&[IDENTITY, global], &indices, &frames).unwrap(); + assert_eq!(mapped[0], IDENTITY); + assert_eq!(mapped[1][0], global[0]); + assert_eq!(mapped[1][1], global[1]); + assert_eq!(mapped[1][2], global[2]); + assert_eq!(mapped[1][3], [8.0, 21.0, 33.0, 107.0]); + indices[23] = 2; + assert!(map_animation_parts(&[IDENTITY, global], &indices, &frames).is_err()); +} diff --git a/crates/skate-core/src/physics/tests/skeleton_board_offset.rs b/crates/skate-core/src/physics/tests/skeleton_board_offset.rs new file mode 100644 index 00000000..b5378448 --- /dev/null +++ b/crates/skate-core/src/physics/tests/skeleton_board_offset.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn refresh_applies_without_decay_then_height_and_xz_decay_independently() { + let mut offset = SkateboardOffset::default(); + let mut transform = IDENTITY; + transform[3] = [15.0, 30.0, 45.0, 0.0]; + offset.refresh_transform(transform); + let mut board = IDENTITY; + let mut targets = [IDENTITY; 4]; + offset.update(&mut board, &mut targets); + assert_eq!(board[3], transform[3]); + assert_eq!(targets[3][3], transform[3]); + assert_eq!(offset.orientation_frames, 15.0); + offset.refresh_height(10.0, 7.0); + board = IDENTITY; + targets = [IDENTITY; 4]; + offset.update(&mut board, &mut targets); + let ratio = 14.0f32 / 15.0; + assert_eq!(offset.orientation_frames, 14.0); + assert_eq!(offset.height_frames, 7.0); + assert_eq!(board[3][0], 15.0 * (ratio * ratio)); + assert_eq!(board[3][1], 10.0); + assert_eq!(board, targets[0]); + offset.orientation_frames = 0.0; + offset.height_frames = 1.0; + board = IDENTITY; + offset.update(&mut board, &mut targets); + assert_eq!(offset.height_frames, 0.0); + assert_eq!(offset.transform[3][1], 0.0); + assert!(!offset.height_refreshed); +} diff --git a/crates/skate-core/src/physics/tests/skeleton_landing.rs b/crates/skate-core/src/physics/tests/skeleton_landing.rs new file mode 100644 index 00000000..1581e2f2 --- /dev/null +++ b/crates/skate-core/src/physics/tests/skeleton_landing.rs @@ -0,0 +1,40 @@ +use super::*; + +fn settings() -> LandingSettings { + let graph = PointGraph { x: [0.0, 0.3, 0.6, 1.0], y: [1.0;4] }; + LandingSettings { + manual_blend: graph, grind_blend: graph, coffin_height: graph, + minimum_height: 0.5, maximum_velocity: 8.0, manual_damping: 17.5, + manual_spring: 60.0, ground_minimum_compression_time: 0.05, + ground_damping: 6.0, ground_spring: 60.0, grind_animation_target_time: 0.15, + grind_damping: 10.0, grind_spring: 60.0, grind_target_delta: 0.01, + desired_com_height: 0.85, coffin_time: 0.04, coffin_maximum_velocity: 5.0, + coffin_blend_frames: 7.0, coffin_base_height: 0.2, + } +} +#[test] +fn ground_landing_uses_physical_height_velocity_and_preserves_release_order() { + let mut adjustment = LandingAdjustment::default(); + let mut offset = SkateboardOffset::default(); + let mut input = LandingInput { + filtered_state: 2, flags_2468: 0, flags_2472: 0, flags_2476: 0, + balance: 0.0, physical_com_velocity_along_up: -2.0, + physical_com_height: 0.8, animation_com_height: 1.0, + }; + adjustment.update(input, &settings(), &mut offset); + assert!(!adjustment.active); + input.filtered_state = 1; + input.flags_2476 = 0x1000_0000; + adjustment.update(input, &settings(), &mut offset); + assert!(adjustment.active); + assert_eq!(adjustment.kind, 0); + assert!((adjustment.velocity + 1.75).abs() < 1e-6); + assert!((offset.transform[3][1] - 0.205).abs() < 1e-6); + assert_eq!(offset.height_frames, 15.0); + assert!(offset.height_refreshed); + input.filtered_state = 2; + adjustment.update(input, &settings(), &mut offset); + assert!(!adjustment.active); + assert_eq!(adjustment.time, STEP + STEP); + assert_eq!(adjustment.previous_filtered_state, 2); +} \ No newline at end of file diff --git a/crates/skate-core/src/physics/tests/triangle_query.rs b/crates/skate-core/src/physics/tests/triangle_query.rs new file mode 100644 index 00000000..e32fded4 --- /dev/null +++ b/crates/skate-core/src/physics/tests/triangle_query.rs @@ -0,0 +1,114 @@ +use super::*; + +fn vertices() -> [Vector3; 3] { + [ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(0.0, 0.0, 1.0), + Vector3::new(1.0, 0.0, 0.0), + ] +} + +#[test] +fn wheel_segment_preserves_winding_fraction_and_barycentric_coordinates() { + let hit = thin_triangle( + Vector3::new(0.25, 0.1, 0.25), + Vector3::new(0.0, -0.2, 0.0), + vertices(), + ) + .unwrap(); + assert!((hit.fraction - 0.5).abs() < 1e-6); + assert!(hit.position.y.abs() < 1e-7); + assert!((hit.normal.y - 1.0).abs() < 1e-6); + assert_eq!(hit.volume_parameter, [0.25, 0.25, 0.0]); + assert!( + thin_triangle( + Vector3::new(0.25, -0.1, 0.25), + Vector3::new(0.0, 0.2, 0.0), + vertices() + ) + .is_none() + ); + assert!( + thin_triangle( + Vector3::new(0.75, 0.1, 0.75), + Vector3::new(0.0, -0.2, 0.0), + vertices() + ) + .is_none() + ); +} + +#[test] +fn native_tolerance_accepts_small_endpoint_overrun_without_clamping() { + let start = Vector3::new(0.25, 0.200001, 0.25); + let hit = thin_triangle(start, Vector3::new(0.0, -0.2, 0.0), vertices()).unwrap(); + assert!(hit.fraction > 1.0); + assert!( + thin_triangle( + Vector3::new(0.25, 0.201, 0.25), + Vector3::new(0.0, -0.2, 0.0), + vertices() + ) + .is_none() + ); + assert!(thin_triangle(start, Vector3::ZERO, vertices()).is_none()); +} + +fn sweep(start: Vector3, delta: Vector3, radius: f32, fatness: f32) -> Option { + let mut result = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.0; 3], + }; + triangle_segment(&mut result, start, delta, vertices(), radius, fatness).then_some(result) +} + +#[test] +fn camera_sweep_resolves_face_edges_vertices_and_query_radius_offset() { + let delta = Vector3::new(0.0, -2.0, 0.0); + let face = sweep(Vector3::new(0.25, 1.0, 0.25), delta, 0.1, 0.0).unwrap(); + assert!((face.fraction - 0.45).abs() < 1e-6); + assert!(face.position.y.abs() < 1e-6); + let rounded_face = sweep(Vector3::new(0.25, 1.0, 0.25), delta, 0.1, 0.2).unwrap(); + assert!((rounded_face.fraction - 0.35).abs() < 1e-6); + assert!((rounded_face.position.y - 0.2).abs() < 1e-6); + let edge = sweep(Vector3::new(-0.06, 1.0, 0.5), delta, 0.1, 0.0).unwrap(); + assert!((edge.fraction - 0.46).abs() < 1e-5); + assert!((edge.normal.x + 0.6).abs() < 1e-4); + assert!((edge.normal.y - 0.8).abs() < 1e-4); + assert!(edge.position.x.abs() < 1e-5 && edge.position.y.abs() < 1e-5); + assert_eq!(edge.volume_parameter, [0.5, 0.0, 0.0]); + let corner = sweep(Vector3::new(-0.04, 1.0, -0.03), delta, 0.1, 0.0).unwrap(); + let expected = (1.0 - (0.01_f32 - 0.0025).sqrt()) * 0.5; + assert!((corner.fraction - expected).abs() < 1e-5); + assert!(dot(corner.position, corner.position) < 1e-9); + assert_eq!(corner.volume_parameter, [0.0; 3]); + assert!(sweep(Vector3::new(-0.2, 1.0, 0.5), delta, 0.1, 0.0).is_none()); +} + +#[test] +fn rounded_query_handles_reverse_face_overlap_and_parallel_motion() { + let reverse = sweep( + Vector3::new(0.25, -1.0, 0.25), + Vector3::new(0.0, 2.0, 0.0), + 0.1, + 0.0, + ) + .unwrap(); + assert!((reverse.fraction - 0.45).abs() < 1e-6); + assert!((reverse.normal.y + 1.0).abs() < 1e-6); + let overlap = sweep(Vector3::new(0.25, 0.05, 0.25), Vector3::ZERO, 0.1, 0.0).unwrap(); + assert_eq!(overlap.fraction, 0.0); + assert!((overlap.volume_parameter[2] - 0.0075).abs() < 1e-6); + let parallel = sweep( + Vector3::new(-1.0, 0.06, 0.5), + Vector3::new(2.0, 0.0, 0.0), + 0.1, + 0.0, + ) + .unwrap(); + assert!((parallel.fraction - 0.46).abs() < 1e-5); + assert!((parallel.normal.x + 0.8).abs() < 1e-4); + assert!((parallel.normal.y - 0.6).abs() < 1e-4); +} diff --git a/crates/skate-core/src/physics/tests/truck_frames.rs b/crates/skate-core/src/physics/tests/truck_frames.rs new file mode 100644 index 00000000..09030175 --- /dev/null +++ b/crates/skate-core/src/physics/tests/truck_frames.rs @@ -0,0 +1,16 @@ +use super::*; +use crate::physics::drive_frames::default_truck_transforms; + +#[test] +fn each_target_changes_only_its_native_drive_and_keeps_anchor() { + let base = default_truck_transforms(); + let rest = steering_drive_frames(base, [0.0; 2]); + let first = steering_drive_frames(base, [0.4, 0.0]); + assert_eq!(first[0], rest[0]); + assert_ne!(first[1].body_b.orientation, rest[1].body_b.orientation); + assert_eq!(first[1].body_b.translation, rest[1].body_b.translation); + let second = steering_drive_frames(base, [0.0, 0.4]); + assert_eq!(second[1], rest[1]); + assert_ne!(second[0].body_b.orientation, rest[0].body_b.orientation); + assert_eq!(second[0].body_b.translation, rest[0].body_b.translation); +} diff --git a/crates/skate-core/src/physics/tests/world_dispatch.rs b/crates/skate-core/src/physics/tests/world_dispatch.rs new file mode 100644 index 00000000..ba763320 --- /dev/null +++ b/crates/skate-core/src/physics/tests/world_dispatch.rs @@ -0,0 +1,351 @@ +use super::world_contact::{FeaturePrism, MaximumFeature, find_feature_intersection_prism}; + +const NORMAL: [u32; 4] = [0, 1.0f32.to_bits(), 0, 0]; +const SENTINEL: u32 = 0xdead_beef; + +fn vector(value: [f32; 4]) -> [u32; 4] { + value.map(f32::to_bits) +} +fn point(x: f32, y: f32, z: f32) -> MaximumFeature { + let mut feature = [0; 144]; + feature[0] = 100; + feature[136..140].copy_from_slice(&vector([x, y, z, 1.0])); + feature +} +fn segment(start: [f32; 3], end: [f32; 3]) -> MaximumFeature { + let mut feature = [0; 144]; + feature[0] = 100; + feature[140] = 1; + let delta: [f32; 3] = std::array::from_fn(|i| end[i] - start[i]); + let length = delta.iter().map(|v| v * v).sum::().sqrt(); + feature[4..8].copy_from_slice(&vector([start[0], start[1], start[2], 1.0])); + feature[8..12].copy_from_slice(&vector([ + delta[0] / length, + delta[1] / length, + delta[2] / length, + 0.0, + ])); + feature[16..20].fill(length.to_bits()); + feature +} +fn face(vertices: &[[f32; 2]], height: f32) -> MaximumFeature { + let mut feature = [0; 144]; + feature[0] = 100; + feature[140] = vertices.len() as u32; + feature[132..136].copy_from_slice(&NORMAL); + for (edge, &start) in vertices.iter().enumerate() { + let end = vertices[(edge + 1) % vertices.len()]; + let line = segment([start[0], height, start[1]], [end[0], height, end[1]]); + let offset = 4 + edge * 16; + feature[offset..offset + 16].copy_from_slice(&line[4..20]); + let dx = f32::from_bits(line[8]); + let dz = f32::from_bits(line[10]); + feature[offset + 8..offset + 12].copy_from_slice(&vector([dz, 0.0, -dx, 0.0])); + } + feature +} +fn square(x: f32, z: f32, height: f32) -> MaximumFeature { + face( + &[ + [x - 1.0, z - 1.0], + [x + 1.0, z - 1.0], + [x + 1.0, z + 1.0], + [x - 1.0, z + 1.0], + ], + height, + ) +} +fn query(a: &mut MaximumFeature, b: &mut MaximumFeature) -> FeaturePrism { + let mut result = [SENTINEL; 136]; + result[128..132].copy_from_slice(&NORMAL); + result[133] = 0; + assert_eq!( + find_feature_intersection_prism(&mut result, a, b, NORMAL), + 1 + ); + result +} +fn assert_point(output: &FeaturePrism, side: usize, index: usize, expected: [f32; 3]) { + for axis in 0..3 { + let actual = f32::from_bits(output[side * 64 + index * 4 + axis]); + assert!( + (actual - expected[axis]).abs() < 2.0e-5, + "side={side} point={index} axis={axis}: {actual} != {}", + expected[axis] + ); + } +} +fn assert_untouched_tail(output: &FeaturePrism) { + let count = output[132] as usize; + assert!(output[count * 4..64].iter().all(|&v| v == SENTINEL)); + assert!(output[64 + count * 4..128].iter().all(|&v| v == SENTINEL)); + assert_eq!(&output[134..136], &[SENTINEL; 2]); +} + +#[test] +fn point_point_copies_both_points_and_preserves_prism_state() { + let mut a = point(2.0, 3.0, 4.0); + let mut b = point(-3.0, 8.0, 6.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 1); + assert_eq!(&output[..4], &a[136..140]); + assert_eq!(&output[64..68], &b[136..140]); + assert_eq!(&output[128..132], &NORMAL); + assert_eq!(output[133], 0); + assert_eq!((a[0], b[0]), (100, 100)); + assert_untouched_tail(&output); +} + +#[test] +fn point_segment_selects_before_interior_and_after_tags_in_both_orders() { + for (x, projected, tag) in [ + (-1.0, 0.0, 101), + (0.0, 0.0, 100), + (1.0, 1.0, 100), + (2.0, 2.0, 100), + (3.0, 2.0, 99), + ] { + for reverse in [false, true] { + let mut line = segment([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]); + let mut p = point(x, 2.0, 0.0); + let output = if reverse { + query(&mut p, &mut line) + } else { + query(&mut line, &mut p) + }; + assert_eq!(output[132], 1); + assert_point(&output, usize::from(reverse), 0, [projected, 0.0, 0.0]); + assert_point(&output, usize::from(!reverse), 0, [x, 2.0, 0.0]); + assert_eq!(line[0], tag); + assert_eq!(p[0], 100); + assert_untouched_tail(&output); + } + } +} + +#[test] +fn point_face_projects_and_clamps_to_boundary_in_both_orders() { + for (z, projected, tag) in [(0.0, 0.0, 100), (-2.0, -1.0, 102)] { + for reverse in [false, true] { + let mut polygon = square(0.0, 0.0, 0.0); + let mut p = point(0.0, 2.0, z); + let output = if reverse { + query(&mut p, &mut polygon) + } else { + query(&mut polygon, &mut p) + }; + assert_eq!(output[132], 1); + assert_point(&output, usize::from(reverse), 0, [0.0, 0.0, projected]); + assert_point(&output, usize::from(!reverse), 0, [0.0, 2.0, z]); + assert_eq!(polygon[0], tag); + assert_untouched_tail(&output); + } + } +} + +#[test] +fn segment_segment_covers_crossing_overlap_and_disjoint_endpoints() { + let mut a = segment([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]); + let mut b = segment([0.0, 1.0, -1.0], [0.0, 1.0, 1.0]); + let crossing = query(&mut a, &mut b); + assert_eq!(crossing[132], 1); + assert_point(&crossing, 0, 0, [0.0, 0.0, 0.0]); + assert_point(&crossing, 1, 0, [0.0, 1.0, 0.0]); + let mut b = segment([0.0, 1.0, 0.0], [2.0, 1.0, 0.0]); + let overlap = query(&mut a, &mut b); + assert_eq!(overlap[132], 2); + for side in 0..2 { + assert_point(&overlap, side, 0, [0.0, side as f32, 0.0]); + assert_point(&overlap, side, 1, [1.0, side as f32, 0.0]); + } + let mut b = segment([3.0, 1.0, 0.0], [4.0, 1.0, 0.0]); + let disjoint = query(&mut a, &mut b); + assert_eq!(disjoint[132], 1); + assert_point(&disjoint, 0, 0, [1.0, 0.0, 0.0]); + assert_point(&disjoint, 1, 0, [3.0, 1.0, 0.0]); +} + +#[test] +fn segment_face_keeps_clipped_two_point_manifold_in_both_orders() { + for reverse in [false, true] { + let mut polygon = square(0.0, 0.0, 0.0); + let mut line = segment([-2.0, 1.0, 0.0], [2.0, 1.0, 0.0]); + // For this winding, -Y cross edge points into the clipping polygon. + let direction = vector([0.0, -1.0, 0.0, 0.0]); + let mut output = [SENTINEL; 136]; + output[128..132].copy_from_slice(&direction); + output[133] = 0; + let (a, b) = if reverse { + (&mut line, &mut polygon) + } else { + (&mut polygon, &mut line) + }; + assert_eq!(find_feature_intersection_prism(&mut output, a, b, direction), 1); + assert_eq!(output[132], 2); + for (index, x) in [-1.0, 1.0].into_iter().enumerate() { + assert_point(&output, usize::from(reverse), index, [x, 0.0, 0.0]); + assert_point(&output, usize::from(!reverse), index, [x, 1.0, 0.0]); + } + assert_untouched_tail(&output); + } +} + +#[test] +fn quad_triangle_emits_edge_pairs_then_inside_vertex_with_stable_swapped_order() { + let expected = [[1.0, -0.5], [1.0, 0.5], [0.5, 1.0], [0.0, 1.0], [0.0, -0.5]]; + for reverse in [false, true] { + let mut quad = square(0.0, 0.0, 0.0); + let mut triangle = face(&[[0.0, -0.5], [2.0, -0.5], [0.0, 1.5]], 1.0); + let output = if reverse { + query(&mut triangle, &mut quad) + } else { + query(&mut quad, &mut triangle) + }; + assert_eq!(output[132], 5); + for (index, [x, z]) in expected.into_iter().enumerate() { + assert_point(&output, usize::from(reverse), index, [x, 0.0, z]); + assert_point(&output, usize::from(!reverse), index, [x, 1.0, z]); + } + assert_eq!(output[133], 0); + assert_eq!((quad[0], triangle[0]), (100, 100)); + assert_untouched_tail(&output); + } +} + +#[test] +fn quad_quad_orders_crossings_before_both_containment_passes() { + let mut a = square(0.0, 0.0, 0.0); + let mut b = square(1.0, 0.5, 1.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 4); + for (index, [x, z]) in [[1.0, -0.5], [0.0, 1.0], [1.0, 1.0], [0.0, -0.5]] + .into_iter() + .enumerate() + { + assert_point(&output, 0, index, [x, 0.0, z]); + assert_point(&output, 1, index, [x, 1.0, z]); + } + assert_untouched_tail(&output); +} + +#[test] +fn coincident_quad_boundaries_keep_native_duplicate_edge_intersections() { + let mut a = square(0.0, 0.0, 0.0); + let mut b = square(0.0, 0.0, 1.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 8); + for index in 0..8 { + assert_eq!(f32::from_bits(output[index * 4]).abs(), 1.0); + assert_eq!(f32::from_bits(output[index * 4 + 2]).abs(), 1.0); + } + assert_untouched_tail(&output); +} + +#[test] +fn general_triangle_containment_returns_b_vertices_without_duplicates() { + let vertices = [[-1.0, -1.0], [1.0, -1.0], [0.0, 1.0]]; + let mut a = face(&vertices, 0.0); + let mut b = face(&vertices, 1.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 3); + for (index, [x, z]) in vertices.into_iter().enumerate() { + assert_point(&output, 0, index, [x, 0.0, z]); + assert_point(&output, 1, index, [x, 1.0, z]); + } + assert_untouched_tail(&output); +} + +#[test] +fn general_triangle_crossings_form_the_six_corner_intersection() { + let mut a = face(&[[-2.0, -1.0], [2.0, -1.0], [0.0, 2.0]], 0.0); + let mut b = face(&[[-2.0, 1.0], [0.0, -2.0], [2.0, 1.0]], 1.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 6); + let expected = [ + [-4.0 / 3.0, 0.0], + [-2.0 / 3.0, -1.0], + [2.0 / 3.0, -1.0], + [4.0 / 3.0, 0.0], + [2.0 / 3.0, 1.0], + [-2.0 / 3.0, 1.0], + ]; + for [x, z] in expected { + assert!((0..6).any(|index| { + (f32::from_bits(output[index * 4]) - x).abs() < 2.0e-5 + && (f32::from_bits(output[index * 4 + 2]) - z).abs() < 2.0e-5 + })); + } + for index in 0..6 { + let x = f32::from_bits(output[index * 4]); + let z = f32::from_bits(output[index * 4 + 2]); + assert_point(&output, 1, index, [x, 1.0, z]); + } + assert_untouched_tail(&output); +} + +#[test] +fn disjoint_faces_fall_back_to_closest_features_and_correct_normal() { + let mut a = face(&[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], 0.0); + let mut b = face(&[[3.0, 3.0], [4.0, 3.0], [3.0, 4.0]], 0.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 1); + assert_point(&output, 0, 0, [0.5, 0.0, 0.5]); + assert_point(&output, 1, 0, [3.0, 0.0, 3.0]); + assert_eq!((a[0], b[0]), (4, 1)); + assert_eq!(output[133], 1); + assert!((f32::from_bits(output[128]) - std::f32::consts::FRAC_1_SQRT_2).abs() < 2.0e-5); + assert!((f32::from_bits(output[130]) - std::f32::consts::FRAC_1_SQRT_2).abs() < 2.0e-5); + assert_untouched_tail(&output); +} + +#[test] +fn specialized_quad_rejection_reaches_parallel_edge_fallback() { + let mut a = square(0.0, 0.0, 0.0); + let mut b = square(3.0, 0.0, 0.0); + let output = query(&mut a, &mut b); + assert_eq!(output[132], 2); + for (index, z) in [-1.0, 1.0].into_iter().enumerate() { + assert_point(&output, 0, index, [1.0, 0.0, z]); + assert_point(&output, 1, index, [2.0, 0.0, z]); + } + assert_eq!((a[0], b[0]), (4, 8)); + assert_eq!(output[133], 1); + assert_eq!(&output[128..132], &vector([1.0, 0.0, 0.0, 0.0])); + assert_untouched_tail(&output); +} + +#[test] +fn specialized_containment_handles_each_face_as_the_enclosed_feature() { + for triangle_is_enclosed in [false, true] { + for reverse in [false, true] { + let (mut quad, mut triangle, expected) = if triangle_is_enclosed { + ( + square(0.0, 0.0, 0.0), + face(&[[-0.5, -0.5], [0.5, -0.5], [0.0, 0.5]], 1.0), + vec![[-0.5, -0.5], [0.5, -0.5], [0.0, 0.5]], + ) + } else { + ( + square(0.0, 0.0, 0.0), + face(&[[-5.0, -3.0], [5.0, -3.0], [0.0, 5.0]], 1.0), + vec![[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]], + ) + }; + let output = if reverse { + query(&mut triangle, &mut quad) + } else { + query(&mut quad, &mut triangle) + }; + assert_eq!(output[132] as usize, expected.len()); + for (index, [x, z]) in expected.into_iter().enumerate() { + assert_point(&output, usize::from(reverse), index, [x, 0.0, z]); + assert_point(&output, usize::from(!reverse), index, [x, 1.0, z]); + assert_eq!(output[index * 4 + 3], 1.0f32.to_bits()); + assert_eq!(output[64 + index * 4 + 3], 1.0f32.to_bits()); + } + assert_eq!(output[133], 0); + assert_untouched_tail(&output); + } + } +} + diff --git a/crates/skate-core/src/physics/triangle_closest.rs b/crates/skate-core/src/physics/triangle_closest.rs new file mode 100644 index 00000000..2afcdeb9 --- /dev/null +++ b/crates/skate-core/src/physics/triangle_closest.rs @@ -0,0 +1,92 @@ +//! TU3 triangle closest-feature calculation82ADE8B8. +use super::triangle_query::{dot, madd, sub}; +use crate::math::Vector3; + +pub(super) struct ClosestTrianglePoint { + pub point: Vector3, + /// Vertex0..2, edge AB/AC/BC3..5, face6, as used by82ADEB70. + pub region: u32, + pub u: f32, + pub v: f32, +} + +pub(super) fn closest(point: Vector3, vertices: [Vector3; 3]) -> ClosestTrianglePoint { + let [origin, b, c] = vertices; + let edge_b = sub(b, origin); + let edge_c = sub(c, origin); + let toward_origin = sub(origin, point); + let bb = dot(edge_b, edge_b); + let cc = dot(edge_c, edge_c); + let bc = dot(edge_b, edge_c); + let bp = dot(edge_b, toward_origin); + let cp = dot(edge_c, toward_origin); + let determinant = cc.mul_add(bb, -(bc * bc)); + let u_numerator = cp.mul_add(bc, -(bp * cc)); + let v_numerator = bp.mul_add(bc, -(cp * bb)); + let opposite_length_squared = (-bc).mul_add(2.0, bb) + cc; + let feature = if determinant < f32::from_bits(0x0020_0000) { + if bb > cc { + if bb > opposite_length_squared { 0 } else { 2 } + } else if cc > opposite_length_squared { + 1 + } else { + 2 + } + } else if u_numerator + v_numerator > determinant { + if u_numerator < 0.0 { + if cp + cc < bp + bc { 1 } else { 2 } + } else if v_numerator < 0.0 { + if bp + bb < cp + bc { 0 } else { 2 } + } else { + 2 + } + } else if u_numerator < 0.0 { + if -cp > 0.0 { 1 } else { 0 } + } else if v_numerator < 0.0 { + if -bp > 0.0 { 0 } else { 1 } + } else { + 3 + }; + + let (u, v, region) = match feature { + 0 => { + if !(bp < 0.0) { + (0.0, 0.0, 0) + } else if !(-bp < bb) { + (1.0, 0.0, 1) + } else { + (-(bp / bb), 0.0, 3) + } + } + 1 => { + if !(cp < 0.0) { + (0.0, 0.0, 0) + } else if !(-cp < cc) { + (0.0, 1.0, 2) + } else { + (0.0, -(cp / cc), 4) + } + } + 2 => { + let numerator = ((cp + cc) - bc) - bp; + if !(numerator > 0.0) { + (0.0, 1.0, 2) + } else if !(numerator < opposite_length_squared) { + (1.0, 0.0, 1) + } else { + let u = numerator / opposite_length_squared; + (u, 1.0 - u, 5) + } + } + _ => { + let inverse = 1.0 / determinant; + (inverse * u_numerator, inverse * v_numerator, 6) + } + }; + ClosestTrianglePoint { + point: madd(edge_c, v, madd(edge_b, u, origin)), + region, + u, + v, + } +} diff --git a/crates/skate-core/src/physics/triangle_query.rs b/crates/skate-core/src/physics/triangle_query.rs new file mode 100644 index 00000000..e8b38eaf --- /dev/null +++ b/crates/skate-core/src/physics/triangle_query.rs @@ -0,0 +1,123 @@ +//! Native triangle segment queries used by wheel probes and camera sweeps. +//! Thin test82AC7858 and dispatcher82ADF5D8, TU3. The world owns candidate +//! storage; these routines retain the source winding, tolerances and arithmetic. +use super::native_arithmetic; +use crate::math::Vector3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TriangleLineHit { + pub position: Vector3, + pub normal: Vector3, + pub fraction: f32, + /// Native result48.xy: weights for vertices1 and2; z is penetration data. + pub volume_parameter: [f32; 3], +} + +/// TU3 dispatcher82ADF5D8. The rounded query uses the sum of the two radii, +/// then shifts its center hit back by the query radius. As in the native output +/// structure, fields not written by a branch retain the caller's values. +pub fn triangle_segment( + result: &mut TriangleLineHit, + start: Vector3, + direction: Vector3, + vertices: [Vector3; 3], + line_radius: f32, + triangle_fatness: f32, +) -> bool { + let radius = line_radius + triangle_fatness; + if radius == 0.0 { + if let Some(hit) = thin_triangle(start, direction, vertices) { + *result = hit; + return true; + } + return false; + } + let normal = cross(sub(vertices[0], vertices[1]), sub(vertices[0], vertices[2])); + result.normal = scale(normal, inverse_length(dot(normal, normal))); + if super::triangle_sweep::sweep(result, start, direction, vertices, radius) { + result.position = sub(result.position, scale(result.normal, line_radius)); + true + } else { + false + } +} + +/// Complete zero-total-radius branch. `direction` is end minus start, not a +/// normalized ray. TU3 accepts a small margin beyond both segment endpoints; +/// do not clamp its returned fraction or replace the determinant tolerance. +pub fn thin_triangle( + start: Vector3, + direction: Vector3, + vertices: [Vector3; 3], +) -> Option { + let [a, b, c] = vertices; + let ac = sub(c, a); + let ab = sub(b, a); + let perpendicular = cross(direction, ac); + let determinant = dot(ab, perpendicular); + if !(determinant > f32::from_bits(0x322b_cc77)) { + return None; + } + let from_a = sub(start, a); + let lower = determinant * f32::from_bits(0xb727_c5ac); + let upper = determinant - lower; + let u = dot(from_a, perpendicular); + if u < lower || u > upper { + return None; + } + let perpendicular = cross(from_a, ab); + let v = dot(direction, perpendicular); + if v < lower || v + u > upper { + return None; + } + let distance = dot(ac, perpendicular); + if distance < lower || distance > upper { + return None; + } + let inverse = 1.0 / determinant; + let fraction = distance * inverse; + // Dispatcher82ADF61C..688 recomputes and normalizes this winding with + // two refinements; it does not use the Volume's cached contact normal. + let normal = cross(sub(a, b), sub(a, c)); + Some(TriangleLineHit { + position: madd(direction, fraction, start), + normal: scale(normal, inverse_length(dot(normal, normal))), + fraction, + volume_parameter: [u * inverse, v * inverse, 0.0], + }) +} + +pub(super) fn dot(a: Vector3, b: Vector3) -> f32 { + native_arithmetic::dot3([a.x, a.y, a.z, 0.0], [b.x, b.y, b.z, 0.0]) +} +pub(super) fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(super) fn scale(v: Vector3, scale: f32) -> Vector3 { + Vector3::new(v.x * scale, v.y * scale, v.z * scale) +} +pub(super) fn madd(a: Vector3, scale: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(scale, b.x), + a.y.mul_add(scale, b.y), + a.z.mul_add(scale, b.z), + ) +} +pub(super) fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +pub(super) fn inverse_length(squared: f32) -> f32 { + let mut inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + inverse +} + +#[cfg(test)] +#[path = "tests/triangle_query.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/triangle_sweep.rs b/crates/skate-core/src/physics/triangle_sweep.rs new file mode 100644 index 00000000..2c7f4f6d --- /dev/null +++ b/crates/skate-core/src/physics/triangle_sweep.rs @@ -0,0 +1,104 @@ +//! Rounded triangle query82ADEB70: bounds, face, overlap, then feature walk. +use super::{ + native_arithmetic, + triangle_closest::closest, + triangle_query::{TriangleLineHit, cross, dot, madd, scale, sub}, + triangle_sweep_walk, +}; +use crate::math::Vector3; + +/// Literal8212BF74, also used by closest-feature and walk denominator gates. +pub(super) const MIN_RECIPROCAL: f32 = f32::from_bits(0x0020_0000); + +pub(super) fn sweep( + result: &mut TriangleLineHit, + start: Vector3, + mut delta: Vector3, + vertices: [Vector3; 3], + radius: f32, +) -> bool { + result.fraction = 0.0; + let [a, b, c] = vertices; + let ab = sub(b, a); + let ac = sub(c, a); + let mut from_a = sub(start, a); + let cross_ac = cross(delta, ac); + let mut determinant = dot(ab, cross_ac); + let mut side = 1.0; + if determinant < 0.0 { + side = -1.0; + determinant = -determinant; + } + if dot(result.normal, from_a) * side < -radius { + return false; + } + let u = dot(from_a, cross_ac) * side; + // This TU3 body uses the L1 sum, not the SDK's vector magnitude bound. + let u_margin = l1(cross_ac) * radius; + if u < -u_margin || u > u_margin + determinant { + return false; + } + let cross_ab = cross(ab, delta); + let v = dot(from_a, cross_ab) * side; + let v_margin = l1(cross_ab) * radius; + if v < -v_margin || v > v_margin + determinant || v + u > (u_margin + v_margin) + determinant { + return false; + } + if determinant > MIN_RECIPROCAL { + let offset = sub(from_a, scale(result.normal, side * radius)); + let distance = -(dot(offset, cross(ac, ab)) * side); + if distance > determinant { + return false; + } + if !(distance < 0.0) { + let inverse = 1.0 / determinant; + result.fraction = inverse * distance; + let u = dot(offset, cross_ac) * side; + if !(u < 0.0) && !(u > determinant) { + let v = dot(offset, cross_ab) * side; + if !(v < 0.0) && !(v + u > determinant) { + result.position = madd(delta, result.fraction, start); + result.normal = scale(result.normal, side); + result.volume_parameter = [inverse * u, inverse * v, 0.0]; + return true; + } + } + from_a = madd(delta, result.fraction, from_a); + delta = scale(delta, 1.0 - result.fraction); + } + } + let start = add(from_a, a); + let feature = closest(start, vertices); + let separation = sub(start, feature.point); + let squared = dot(separation, separation); + let penetration = radius.mul_add(radius, -squared); + if penetration > 0.0 { + result.position = start; + if squared > 0.0 { + let inverse = native_arithmetic::reciprocal_square_root_estimate(squared); + let inverse = + (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + result.normal = scale(separation, inverse); + } + result.volume_parameter = [feature.u, feature.v, penetration]; + return true; + } + if feature.region == 6 { + result.position = start; + //82ADEFA0 compares the absolute determinant retained in f1; it has + // already been negated by82ADEC30 for a reverse-facing query. + if determinant < 0.0 { + result.normal = scale(result.normal, -1.0); + } + result.volume_parameter = [feature.u, feature.v, 0.0]; + return true; + } + triangle_sweep_walk::walk(result, start, delta, vertices, feature, radius) +} + +pub(super) fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +fn l1(v: Vector3) -> f32 { + (v.y.abs() + v.z.abs()) + v.x.abs() +} diff --git a/crates/skate-core/src/physics/triangle_sweep_roots.rs b/crates/skate-core/src/physics/triangle_sweep_roots.rs new file mode 100644 index 00000000..edef44bc --- /dev/null +++ b/crates/skate-core/src/physics/triangle_sweep_roots.rs @@ -0,0 +1,89 @@ +//! Vertex/edge roots in TU3's triangle feature walk:82ADDB48/82ADA3E0. +use super::triangle_query::{cross, dot, sub}; +use crate::math::Vector3; + +#[derive(Clone, Copy)] +pub(super) struct Fraction { + pub numerator: f32, + pub denominator: f32, +} +impl Fraction { + pub fn value(self) -> f32 { + self.numerator / self.denominator + } + pub fn before(self, other: Self) -> bool { + self.numerator * other.denominator < other.numerator * self.denominator + } +} +pub(super) enum Root { + Away, + Miss, + Hit(Fraction), +} + +pub(super) fn sphere(start: Vector3, delta: Vector3, center: Vector3, radius: f32) -> Root { + let toward_center = sub(center, start); + let radius_squared = radius * radius; + if dot(toward_center, toward_center) < radius_squared { + return Root::Hit(Fraction { + numerator: 0.0, + denominator: 1.0, + }); + } + let approach = dot(toward_center, delta); + if !(approach > 0.0) { + return Root::Away; + } + let delta_squared = dot(delta, delta); + let perpendicular = cross(toward_center, delta); + let discriminant = delta_squared.mul_add(radius_squared, -dot(perpendicular, perpendicular)); + if discriminant < 0.0 { + return Root::Miss; + } + let overrun = approach - delta_squared; + if overrun > 0.0 && overrun * overrun > discriminant { + return Root::Miss; + } + Root::Hit(Fraction { + numerator: approach - discriminant.sqrt(), + denominator: delta_squared, + }) +} + +pub(super) fn cylinder( + start: Vector3, + delta: Vector3, + origin: Vector3, + edge: Vector3, + radius: f32, +) -> Root { + let perpendicular = cross(sub(origin, start), edge); + let distance_squared = dot(perpendicular, perpendicular); + let radius_squared = (dot(edge, edge) * radius) * radius; + if distance_squared < radius_squared { + return Root::Hit(Fraction { + numerator: 0.0, + denominator: 1.0, + }); + } + let delta_perpendicular = cross(delta, edge); + let approach = dot(perpendicular, delta_perpendicular); + if !(approach > 0.0) { + return Root::Away; + } + let delta_squared = dot(delta_perpendicular, delta_perpendicular); + let residual = approach.mul_add(approach, -(delta_squared * distance_squared)); + let discriminant = delta_squared.mul_add(radius_squared, residual); + if discriminant < 0.0 { + return Root::Miss; + } + let overrun = approach - delta_squared; + // Cylinder rejects equality here; the sphere helper uses strict greater. + if !(overrun < 0.0) && !(overrun * overrun < discriminant) { + return Root::Miss; + } + Root::Hit(Fraction { + numerator: approach - discriminant.sqrt(), + denominator: delta_squared, + }) +} diff --git a/crates/skate-core/src/physics/triangle_sweep_walk.rs b/crates/skate-core/src/physics/triangle_sweep_walk.rs new file mode 100644 index 00000000..63e4caaf --- /dev/null +++ b/crates/skate-core/src/physics/triangle_sweep_walk.rs @@ -0,0 +1,130 @@ +//! Five-step vertex/edge region walk82ADF008..82ADF5BC. +use super::{ + native_arithmetic, + triangle_closest::ClosestTrianglePoint, + triangle_query::{TriangleLineHit, dot, madd, scale, sub}, + triangle_sweep::{MIN_RECIPROCAL, add}, + triangle_sweep_roots::{self as roots, Fraction, Root}, +}; +use crate::math::Vector3; + +pub(super) fn walk( + result: &mut TriangleLineHit, + mut start: Vector3, + mut delta: Vector3, + vertices: [Vector3; 3], + closest: ClosestTrianglePoint, + radius: f32, +) -> bool { + let mut region = closest.region; + let mut vertex = closest.point; + for _ in 0..5 { + let fraction; + if region <= 2 { + let mut hit = match roots::sphere(start, delta, vertex, radius) { + Root::Away => return false, + Root::Miss => None, + Root::Hit(fraction) => Some(fraction), + }; + let adjacent = match region { + 0 => [sub(vertices[1], vertices[0]), sub(vertices[2], vertices[0])], + 1 => [sub(vertices[0], vertices[1]), sub(vertices[2], vertices[1])], + _ => [sub(vertices[0], vertices[2]), sub(vertices[1], vertices[2])], + }; + let mut next_region = region; + for (index, edge) in adjacent.into_iter().enumerate() { + let candidate = Fraction { + numerator: dot(sub(vertex, start), edge), + denominator: dot(delta, edge), + }; + if replaces(hit, candidate) { + hit = Some(candidate); + next_region = (region + if index == 0 { 6 } else { 9 }) / 2; + } + } + let Some(hit) = hit else { return false }; + fraction = hit.value(); + if next_region <= 2 { + advance_fraction(result, fraction); + result.position = madd(delta, fraction, start); + result.normal = scale(sub(result.position, vertex), inverse_radius(radius)); + result.volume_parameter = [ + if region == 1 { 1.0 } else { 0.0 }, + if region == 2 { 1.0 } else { 0.0 }, + 0.0, + ]; + return true; + } + region = next_region; + } else { + let (origin, edge) = match region { + 3 => (vertices[0], sub(vertices[1], vertices[0])), + 4 => (vertices[0], sub(vertices[2], vertices[0])), + _ => (vertices[1], sub(vertices[2], vertices[1])), + }; + vertex = origin; + let mut hit = match roots::cylinder(start, delta, origin, edge, radius) { + Root::Away => return false, + Root::Miss => None, + Root::Hit(fraction) => Some(fraction), + }; + let end = add(origin, edge); + let forward = Fraction { + numerator: dot(sub(end, start), edge), + denominator: dot(delta, edge), + }; + let backward = Fraction { + numerator: dot(sub(start, origin), edge), + denominator: -forward.denominator, + }; + if replaces(hit, forward) { + hit = Some(forward); + vertex = end; + region /= 2; + } else if replaces(hit, backward) { + hit = Some(backward); + region = (region - 3) / 2; + } + let Some(hit) = hit else { return false }; + fraction = hit.value(); + if region > 2 { + advance_fraction(result, fraction); + result.position = madd(delta, fraction, start); + let along_edge = dot(sub(result.position, origin), edge) * (1.0 / dot(edge, edge)); + let nearest = madd(edge, along_edge, origin); + result.normal = scale(sub(result.position, nearest), inverse_radius(radius)); + result.volume_parameter = match region { + 3 => [along_edge, 0.0, 0.0], + 4 => [0.0, along_edge, 0.0], + _ => [1.0 - along_edge, along_edge, 0.0], + }; + return true; + } + } + advance_fraction(result, fraction); + start = madd(delta, fraction, start); + delta = scale(delta, 1.0 - fraction); + if fraction > 1.0 { + return false; + } + } + // The source returns success after its fifth transition. Position/normal/ + // parameters have not been written by the walk on this path; preserve them. + true +} + +fn replaces(hit: Option, candidate: Fraction) -> bool { + candidate.denominator > MIN_RECIPROCAL + && candidate.numerator > 0.0 + && hit.is_none_or(|hit| !hit.before(candidate)) +} +fn advance_fraction(result: &mut TriangleLineHit, fraction: f32) { + result.fraction = (1.0 - result.fraction).mul_add(fraction, result.fraction); +} +fn inverse_radius(radius: f32) -> f32 { + let mut inverse = native_arithmetic::reciprocal_estimate(radius); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(radius, 1.0), inverse); + } + inverse +} diff --git a/crates/skate-core/src/physics/truck_frames.rs b/crates/skate-core/src/physics/truck_frames.rs new file mode 100644 index 00000000..b140a9b3 --- /dev/null +++ b/crates/skate-core/src/physics/truck_frames.rs @@ -0,0 +1,46 @@ +//! Dynamic truck frames, TU3 `SkateboardBody::SetTruckDriveFrames` (0x82C0B9C0). +use super::drive_frames::{RetailAffineTransform, RetailDriveFrames, set_drive_frames_2}; +use crate::{math::Basis3, trigonometry}; + +/// `base` is CalculateTruckTransforms output in native +7712/+7776 order. +/// Targets are +7680/+7684. Frame output follows +6768/+6832 drive order. +pub fn steering_drive_frames( + base: [RetailAffineTransform; 2], + targets: [f32; 2], +) -> [RetailDriveFrames; 2] { + let [front, back] = steering_truck_transforms(base, targets); + let transformed = [back, front]; + transformed.map(|target| set_drive_frames_2(RetailAffineTransform::IDENTITY, target)) +} + +///82C0B9C0 caches front7840/back7904 for both physical drives and the +///physical pose worker82DB6698. Preserve this separately from the base frames. +pub fn steering_truck_transforms( + base: [RetailAffineTransform; 2], + targets: [f32; 2], +) -> [RetailAffineTransform; 2] { + [ + rotate_truck(base[0], -targets[0]), + rotate_truck(base[1], targets[1]), + ] +} + +fn rotate_truck(base: RetailAffineTransform, angle: f32) -> RetailAffineTransform { + let (sin, cos) = trigonometry::sin_cos(angle); + let rotation = [[1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]]; + let columns = rotation.map(|column| { + core::array::from_fn(|lane| { + let first = column[0] * base.basis.columns[0][lane]; + let second = column[1].mul_add(base.basis.columns[1][lane], first); + column[2].mul_add(base.basis.columns[2][lane], second) + }) + }); + RetailAffineTransform { + basis: Basis3 { columns }, + translation: base.translation, + } +} + +#[cfg(test)] +#[path = "tests/truck_frames.rs"] +mod tests; diff --git a/crates/skate-core/src/physics/world_contact/arithmetic.rs b/crates/skate-core/src/physics/world_contact/arithmetic.rs new file mode 100644 index 00000000..5c336335 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/arithmetic.rs @@ -0,0 +1,25 @@ +pub(super) type V = [f32; 3]; + +pub(super) fn vector(record: &[u32], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(record[offset + i])) +} + +pub(super) fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} + +pub(super) fn dot(a: V, b: V) -> f32 { + crate::physics::native_arithmetic::dot3([a[0], a[1], a[2], 0.0], [b[0], b[1], b[2], 0.0]) +} + +pub(super) 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]), + ] +} + +pub(super) fn scale(a: V, b: f32) -> V { + a.map(|v| v * b) +} diff --git a/crates/skate-core/src/physics/world_contact/axes.rs b/crates/skate-core/src/physics/world_contact/axes.rs new file mode 100644 index 00000000..468be3f2 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/axes.rs @@ -0,0 +1,120 @@ +use crate::physics::reciprocal_sqrt::estimate; + +type V = [f32; 4]; + +/// Complete TU3 82ACEA30 candidate producer. GP records are the native 192-byte +/// representation: origin +0, normals +16/+32/+48, edges +64 onward, counts +/// bytes +140/+141. All four output lanes and speculative rejected stores are +/// preserved. Returns zero when the native function returns null. +pub fn separating_axis_candidates( + a: &[u32; 48], + b: &[u32; 48], + output: &mut [[u32; 4]; 16], +) -> usize { + let mut count = 0; + for gp in [a, b] { + let normals = gp[35] >> 24; + if (1..=3).contains(&normals) { + for index in (1..=normals as usize).rev() { + output[count].copy_from_slice(&gp[index * 4..index * 4 + 4]); + count += 1; + } + } + } + let ae = ((a[35] >> 16) & 255) as usize; + let be = ((b[35] >> 16) & 255) as usize; + assert!(ae <= 3 && be <= 3, "native GP contains at most three edges"); + for i in 0..ae { + for j in 0..be { + let candidate = cross(load(a, 16 + 4 * i), load(b, 16 + 4 * j)); + let length_squared = dot(candidate); + output[count] = candidate.map(f32::to_bits); + if length_squared > f32::from_bits(0x3a83_126f) { + output[count] = normalized(candidate, length_squared).map(f32::to_bits); + count += 1; + } + } + } + if ae == 1 && be == 1 { + let edge_a = load(a, 16); + let edge_b = load(b, 16); + let perpendicular = cross(edge_a, edge_b); + if length(perpendicular) > f32::from_bits(0x3400_0000) { + // Unlike the edge-pair loop, these two secondary directions are + // unconditionally normalized once the common perpendicular passes. + for edge in [edge_a, edge_b] { + let candidate = cross(edge, perpendicular); + output[count] = normalized(candidate, dot(candidate)).map(f32::to_bits); + count += 1; + } + } + } + if count != 0 { + return count; + } + let delta = sub(load(a, 0), load(b, 0)); + if ae == 1 && be == 1 { + let edge = load(a, 16); + append(cross(cross(delta, edge), edge), output, &mut count); + } + if ae + be == 1 { + let edge = if ae != 0 { load(a, 16) } else { load(b, 16) }; + append(cross(cross(delta, edge), edge), output, &mut count); + } + if count != 0 { + return count; + } + append(delta, output, &mut count); + count +} + +fn append(candidate: V, output: &mut [[u32; 4]; 16], count: &mut usize) { + output[*count] = candidate.map(f32::to_bits); + if length(candidate) > f32::from_bits(0x3400_0000) { + output[*count] = normalized(candidate, dot(candidate)).map(f32::to_bits); + *count += 1; + } +} + +fn load(gp: &[u32; 48], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(gp[offset + i])) +} + +fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} + +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]), + ] +} + +fn dot(a: V) -> f32 { + super::arithmetic::dot([a[0], a[1], a[2]], [a[0], a[1], a[2]]) +} + +fn inverse_length(squared: f32) -> f32 { + let mut r = estimate(squared); + for _ in 0..2 { + r = (r * 0.5).mul_add((-squared).mul_add(r * r, 1.0), r); + } + r +} + +fn normalized(a: V, squared: f32) -> V { + let reciprocal = inverse_length(squared); + a.map(|v| v * reciprocal) +} + +fn length(a: V) -> f32 { + let squared = dot(a); + if squared == 0.0 { + 0.0 + } else { + squared * inverse_length(squared) + } +} diff --git a/crates/skate-core/src/physics/world_contact/box_box_sat.rs b/crates/skate-core/src/physics/world_contact/box_box_sat.rs new file mode 100644 index 00000000..fbd76e0b --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/box_box_sat.rs @@ -0,0 +1,51 @@ +use crate::physics::{reciprocal_sqrt::estimate, world_contact::prism_math::*}; + +/// TU3 82ACF448. Unlike the triangle/box entry, edge crosses use the raw +/// reciprocal-square-root estimate without refinement. The last cross is +/// repeated to fill its final four-candidate batch; equal scores retain the +/// earlier axis. Shape fatness is excluded from these intervals. +pub(in super::super) fn box_box(a: &[u32; 48], b: &[u32; 48]) -> ([u32; 4], [u32; 4]) { + let mut axes = [[0.0; 4]; 16]; + for i in 0..3 { + axes[i] = load(a, 12 - i * 4); + axes[3 + i] = load(b, 12 - i * 4); + } + for edge_a in 0..3 { + for edge_b in 0..3 { + let axis = cross(load(a, 16 + edge_a * 4), load(b, 16 + edge_b * 4)); + axes[6 + edge_a * 3 + edge_b] = scale(axis, estimate(dot(axis, axis))); + } + } + axes[15] = axes[14]; + let scaled_a: [V; 3] = + std::array::from_fn(|i| scale(load(a, 4 + i * 4), f32::from_bits(a[28 + i]))); + let scaled_b: [V; 3] = + std::array::from_fn(|i| scale(load(b, 4 + i * 4), f32::from_bits(b[28 + i]))); + let mut best = 0.0; + let mut selected = axes[0]; + let mut selected_sign = 1.0; + for (index, axis) in axes.into_iter().enumerate() { + let a_center = dot(load(a, 0), axis); + let b_center = dot(load(b, 0), axis); + let a_extents = scaled_a.map(|extent| dot(extent, axis).abs()); + let b_extents = scaled_b.map(|extent| dot(extent, axis).abs()); + let a_radius = (a_extents[0] + a_extents[1]) + a_extents[2]; + let b_radius = (b_extents[0] + b_extents[1]) + b_extents[2]; + let forward = (a_center - a_radius) - (b_center + b_radius); + let reverse = (b_center - b_radius) - (a_center + a_radius); + let (separation, sign) = if forward > reverse { + (forward, -1.0) + } else { + (reverse, 1.0) + }; + if index == 0 || separation > best { + best = separation; + selected = axis; + selected_sign = sign; + } + } + ( + [best.to_bits(); 4], + scale(selected, selected_sign).map(f32::to_bits), + ) +} diff --git a/crates/skate-core/src/physics/world_contact/box_feature.rs b/crates/skate-core/src/physics/world_contact/box_feature.rs new file mode 100644 index 00000000..b7acf359 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/box_feature.rs @@ -0,0 +1,195 @@ +use super::{MaximumFeature, initialize_feature_segment}; +use crate::physics::reciprocal_sqrt::estimate; + +type V = [f32; 4]; +const OTHER: [[usize; 2]; 3] = [[1, 2], [0, 2], [0, 1]]; // 83044248 +const SIGNS: [[f32; 2]; 4] = [[1.0, 1.0], [1.0, -1.0], [-1.0, -1.0], [-1.0, 1.0]]; //83044260 + +/// Complete native box maximum-feature callback82AD7A98. Face winding uses the +/// full integer mode. `incoming_edge_plane` is VMX v60 at entry, copied only in +/// the edge branch without initialization by the native function. +pub fn box_maximum_feature( + gp: &[u32; 48], + mode: u32, + direction: [u32; 4], + output: &mut MaximumFeature, + incoming_edge_plane: [u32; 4], +) { + let center = load(gp, 0); + let axes: [V; 3] = std::array::from_fn(|i| load(gp, 4 + i * 4)); + let half: [f32; 3] = std::array::from_fn(|i| f32::from_bits(gp[28 + i])); + let direction = direction.map(f32::from_bits); + let mut small = 0; + let mut small_axis = 0; + let mut large_axis = 0; + for i in 0..3 { + if dot(direction, axes[i]).abs() < f32::from_bits(0x3e4c_cccd) { + small += 1; + small_axis = i; + } else { + large_axis = i; + } + } + if small == 2 { + face(center, axes, half, large_axis, mode, direction, output); + } else if small == 1 { + let [u, v] = OTHER[small_axis]; + let mut best = [0.0; 4]; + let mut score = 0.0; + for (i, sign) in SIGNS.into_iter().enumerate() { + let point = madd( + axes[v], + half[v] * sign[1], + madd(axes[u], half[u] * sign[0], center), + ); + let projected = dot(point, direction); + if i == 0 || projected > score { + best = point; + score = projected; + } + } + let negative = sub(best, scale(axes[small_axis], half[small_axis])); + let positive = madd(axes[small_axis], half[small_axis], best); + let mut segment = [0; 16]; + segment[8..12].copy_from_slice(&incoming_edge_plane); + initialize_feature_segment( + &mut segment, + negative.map(f32::to_bits), + positive.map(f32::to_bits), + ); + output[4..20].copy_from_slice(&segment); + output[140] = 1; + } else { + let mut best = [0.0; 4]; + let mut score = 0.0; + for i in 0..8 { + let sign = if i > 3 { 1.0 } else { -1.0 }; + let pair = SIGNS[i % 4]; + let point = madd( + axes[2], + half[2] * pair[1], + madd( + axes[1], + half[1] * pair[0], + madd(axes[0], half[0] * sign, center), + ), + ); + let projected = dot(point, direction); + if i == 0 || projected > score { + best = point; + score = projected; + } + } + store(output, 136, best); + output[140] = 0; + } + output[0] = 0; +} + +fn face( + center: V, + axes: [V; 3], + half: [f32; 3], + fixed: usize, + mode: u32, + direction: V, + output: &mut MaximumFeature, +) { + let sign = if dot(axes[fixed], direction) > 0.0 { + 1.0 + } else { + -1.0 + }; + let face_center = madd(axes[fixed], half[fixed] * sign, center); + let winding_sign = if fixed == 1 { -sign } else { sign }; + let [u, v] = OTHER[fixed]; + let positive_u = scale(axes[u], half[u]); + let negative_u = scale(axes[u], -half[u]); + let positive_v = scale(axes[v], half[v]); + let negative_v = scale(axes[v], -half[v]); + let p = plane(axes[v], direction, mode); + let q = plane(axes[u], direction, mode); + let len_u = half[u] * 2.0; + let len_v = half[v] * 2.0; + let points = [ + add(add(face_center, positive_u), positive_v), + add(add(face_center, positive_u), negative_v), + add(add(face_center, negative_u), negative_v), + add(add(face_center, negative_u), positive_v), + ]; + let negative_axis_u = axes[u].map(|v| -v); + let negative_axis_v = axes[v].map(|v| -v); + let (indices, directions, planes, lengths) = if u32::from(winding_sign < 0.0) == mode { + ( + [0, 1, 2, 3], + [negative_axis_v, negative_axis_u, axes[v], axes[u]], + [neg(p), neg(q), p, q], + [len_v, len_u, len_v, len_u], + ) + } else { + ( + [0, 3, 2, 1], + [negative_axis_u, negative_axis_v, axes[u], axes[v]], + [neg(q), neg(p), q, p], + [len_u, len_v, len_u, len_v], + ) + }; + for i in 0..4 { + let offset = 4 + i * 16; + store(output, offset, points[indices[i]]); + store(output, offset + 4, directions[i]); + store(output, offset + 8, planes[i]); + output[offset + 12..offset + 16].fill(lengths[i].to_bits()); + } + store(output, 132, scale(axes[fixed], -winding_sign)); + output[140] = 4; +} + +fn plane(axis: V, direction: V, mode: u32) -> V { + let mut result = if mode != 0 { + cross(axis, direction) + } else { + cross(direction, axis) + }; + let squared = dot(result, result); + if squared > f32::from_bits(0x3400_0000) { + let mut r = estimate(squared); + for _ in 0..2 { + r = (r * 0.5).mul_add((-squared).mul_add(r * r, 1.0), r); + } + result = scale(result, r); + } + result +} +fn load(words: &[u32], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} +fn store(words: &mut [u32], offset: usize, v: V) { + words[offset..offset + 4].copy_from_slice(&v.map(f32::to_bits)); +} +fn dot(a: V, b: V) -> f32 { + super::arithmetic::dot(a[..3].try_into().unwrap(), b[..3].try_into().unwrap()) +} +fn scale(a: V, b: f32) -> V { + a.map(|v| v * b) +} +fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} +fn add(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] + b[i]) +} +fn madd(a: V, b: f32, c: V) -> V { + std::array::from_fn(|i| a[i].mul_add(b, c[i])) +} +fn neg(a: V) -> V { + a.map(|x| -x) +} +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]), + ] +} diff --git a/crates/skate-core/src/physics/world_contact/buffer.rs b/crates/skate-core/src/physics/world_contact/buffer.rs new file mode 100644 index 00000000..4407f9f3 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/buffer.rs @@ -0,0 +1,95 @@ +use super::ContactRecord; +use super::arithmetic::{dot, sub, vector}; +use super::reduction::reduce; + +/// Native TempContactBuffer state (+0/+4/+8/+12/+16, +128 records, +/// +12940/+12941/+12942 flags). Caller supplies the actual constructor settings. +/// Guest allocator/publication pointers are represented by the flush callback. +#[derive(Clone, Debug)] +pub struct ContactBuffer { + pub count: u32, + pub flushed: u32, + pub capacity: u32, + pub dropped: u32, + pub distance_squared_threshold: f32, + pub allow_flush: u8, + pub deferred_reduction: u8, + pub full: u8, + pub records: [ContactRecord; 50], +} + +impl ContactBuffer { + /// Complete 82779D60. A full physical buffer with flushing disabled sets + /// the full flag but does not increment dropped until a subsequent call. + pub fn allocate(&mut self, sink: &mut impl FnMut(&[ContactRecord])) -> Option { + assert!(self.count <= 50); + if self.count.wrapping_add(self.flushed) >= self.capacity { + if self.full == 0 { + if self.deferred_reduction != 0 { + self.reduce(); + } + if self.count.wrapping_add(self.flushed) >= self.capacity { + self.full = 1; + } + } + if self.full != 0 { + self.dropped = self.dropped.wrapping_add(1); + return None; + } + } + if self.count >= 50 { + if self.allow_flush == 0 { + self.full = 1; + return None; + } + self.flush(sink); + } + let index = self.count as usize; + self.count += 1; + Some(index) + } + + /// Complete 82779C18. Tests the most recently allocated record against all + /// previous records. The native caller, not this function, decrements count. + pub fn last_is_duplicate(&mut self) -> bool { + if self.distance_squared_threshold < 0.0 || self.deferred_reduction != 0 { + return false; + } + assert!(self.count > 0 && self.count <= 50); + let current = &self.records[self.count as usize - 1]; + for previous in &self.records[..self.count as usize - 1] { + if previous[3] != current[3] || previous[7] != current[7] { + continue; + } + let a = sub(vector(previous, 0), vector(current, 0)); + let b = sub(vector(previous, 4), vector(current, 4)); + if self.distance_squared_threshold > dot(a, a) + && dot(b, b) < self.distance_squared_threshold + && f64::from(dot(vector(previous, 8), vector(current, 8))) + > f64::from_bits(0x3fee_147a_e147_ae14) + { + self.dropped = self.dropped.wrapping_add(1); + return true; + } + } + false + } + + pub fn reduce(&mut self) { + reduce(&mut self.records, &mut self.count); + } + + /// Complete 82779E40; sink is the caller's 8277B428 publication operation. + /// Flushed counts advance by the reduced count, irrespective of sink result. + pub fn flush(&mut self, sink: &mut impl FnMut(&[ContactRecord])) { + if self.count == 0 { + return; + } + if self.deferred_reduction != 0 { + self.reduce(); + } + sink(&self.records[..self.count as usize]); + self.flushed = self.flushed.wrapping_add(self.count); + self.count = 0; + } +} diff --git a/crates/skate-core/src/physics/world_contact/dispatch.rs b/crates/skate-core/src/physics/world_contact/dispatch.rs new file mode 100644 index 00000000..16e2f594 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/dispatch.rs @@ -0,0 +1,79 @@ +use super::{ + FeaturePrism, MaximumFeature, closest_feature_segment, intersect_feature_segments, + intersect_point_face, intersect_segment_face, +}; + +#[path = "face_face.rs"] +mod face_face; +#[path = "face_specialized.rs"] +mod face_specialized; + +/// TU3 82ACE190, FindFeatureIntersectionPrism. Features contain a point (zero +/// edges), segment (one), or convex face. Both feature tags may be updated by +/// closest-feature selection. The caller owns the initial normal/override flag; +/// they change only when the recovered helpers explicitly correct the normal. +/// +/// Numerical helpers retain the core's current host arithmetic approximation; +/// this source port has not been compared with a Skate 3 hardware capture. +pub fn find_feature_intersection_prism( + output: &mut FeaturePrism, + a: &mut MaximumFeature, + b: &mut MaximumFeature, + normal: [u32; 4], +) -> u32 { + let (a_edges, b_edges) = (a[140], b[140]); + assert!( + a_edges <= 8 && b_edges <= 8, + "maximum-feature storage exceeded" + ); + match (a_edges, b_edges) { + (0, 0) => { + output[132] = 1; + output[..4].copy_from_slice(&a[136..140]); + output[64..68].copy_from_slice(&b[136..140]); + return 1; + } + (1, 0) => return segment_point(output, a, b, true), + (0, 1) => return segment_point(output, b, a, false), + (0, _) => return intersect_point_face(output, b, a, normal, false), + (_, 0) => return intersect_point_face(output, a, b, normal, true), + (1, 1) => return intersect_feature_segments(output, a, b, normal, true), + (1, _) => return intersect_segment_face(output, b, a, normal, false), + (_, 1) => return intersect_segment_face(output, a, b, normal, true), + (4, 3) => { + if face_specialized::quad_triangle(output, a, b, normal, true) != 0 { + return 1; + } + } + (3, 4) => { + if face_specialized::quad_triangle(output, b, a, normal, false) != 0 { + return 1; + } + } + (4, 4) => { + if face_specialized::quad_quad(output, a, b, normal) != 0 { + return 1; + } + } + _ => {} + } + face_face::intersect(output, a, b) +} + +fn segment_point( + output: &mut FeaturePrism, + segment: &mut MaximumFeature, + point: &MaximumFeature, + segment_is_a: bool, +) -> u32 { + output[132] = 1; + output[..4].copy_from_slice(&point[136..140]); + output[64..68].copy_from_slice(&point[136..140]); + let offset = if segment_is_a { 0 } else { 64 }; + let region = closest_feature_segment( + segment[4..20].try_into().unwrap(), + (&mut output[offset..offset + 4]).try_into().unwrap(), + ); + segment[0] = segment[0].wrapping_sub(region).wrapping_add(2); + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/face_clip.rs b/crates/skate-core/src/physics/world_contact/face_clip.rs new file mode 100644 index 00000000..28940ffa --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/face_clip.rs @@ -0,0 +1,106 @@ +use super::{MaximumFeature, closest_feature_segment, prism_math::*}; + +/// Complete TU3 82ACC298. Projects an exterior point onto the selected edge; +/// returns its edge/region code without modifying the feature header itself. +pub fn clamp_point_to_feature( + face: &MaximumFeature, + normal: [u32; 4], + point: &mut [u32; 4], +) -> u32 { + let normal = normal.map(f32::from_bits); + let p = point.map(f32::from_bits); + let count = (face[140] as i32).max(0) as usize; + assert!(count <= 8, "native maximum-feature storage exceeded"); + let mut selected = 0; + let mut minimum = 0.0; + let mut selected_plane = [0.0; 4]; + for edge in 0..count { + let mut plane = cross(normal, load(face, 8 + edge * 16)); + if dot(plane, plane) > f32::from_bits(0x3400_0000) { + plane = normalized(plane); + } + let value = dot(plane, sub(p, load(face, 4 + edge * 16))); + if edge == 0 || minimum > value { + minimum = value; + selected = edge; + selected_plane = plane; + } + } + if 0.0 > minimum { + *point = sub(p, scale(selected_plane, minimum)).map(f32::to_bits); + closest_feature_segment( + face[4 + selected * 16..20 + selected * 16] + .try_into() + .unwrap(), + point, + ) + selected as u32 * 2 + } else { + 0 + } +} + +/// Complete TU3 82ACC400 interval clipping. `interval` stores the two scalar +/// limits; `outside` stores the last rejecting edge origin and normalized plane. +/// Those vectors are preserved unless a near-parallel exterior edge writes them. +pub fn clip_segment_to_feature( + face: &MaximumFeature, + segment: &MaximumFeature, + normal: [u32; 4], + interval: &mut [u32; 2], + outside: &mut [u32; 8], +) -> u32 { + interval[0] = 0; + // lfs/stfs quiet an input signalling NaN in the scalar length field. + interval[1] = if f32::from_bits(segment[16]).is_nan() { + segment[16] | 0x0040_0000 + } else { + segment[16] + }; + let normal = normal.map(f32::from_bits); + let count = (face[140] as i32).max(0) as usize; + assert!(count <= 8, "native maximum-feature storage exceeded"); + let mut rejected = false; + for edge in 0..count { + let plane = cross(normal, load(face, 8 + edge * 16)); + let squared = dot(plane, plane); + let length = if squared == 0.0 { + 0.0 + } else { + squared * super::prism_math::inverse_length(squared) + }; + if length < f32::from_bits(0x3a83_126f) { + continue; + } + let plane = normalized(plane); + let origin = load(face, 4 + edge * 16); + let denominator = dot(load(segment, 8), plane); + let numerator = dot(sub(origin, load(segment, 4)), plane); + if denominator.abs() < f32::from_bits(0x3d4c_cccd) { + if numerator > 0.0 { + rejected = true; + outside[..4].copy_from_slice(&face[4 + edge * 16..8 + edge * 16]); + outside[4..].copy_from_slice(&plane.map(f32::to_bits)); + } + } else { + let position = numerator / denominator; + if denominator > 0.0 { + if position > f32::from_bits(interval[0]) { + interval[0] = position.to_bits(); + } + if f32::from_bits(interval[0]) > f32::from_bits(interval[1]) { + interval[0] = interval[1]; + return 0; + } + } else { + if position < f32::from_bits(interval[1]) { + interval[1] = position.to_bits(); + } + if f32::from_bits(interval[1]) < f32::from_bits(interval[0]) { + interval[1] = interval[0]; + return 0; + } + } + } + } + u32::from(!rejected) +} diff --git a/crates/skate-core/src/physics/world_contact/face_edge.rs b/crates/skate-core/src/physics/world_contact/face_edge.rs new file mode 100644 index 00000000..a655ac67 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/face_edge.rs @@ -0,0 +1,111 @@ +use super::{FeaturePrism, MaximumFeature, prism_math::*}; + +fn store(output: &mut FeaturePrism, offset: usize, point: V) { + output[offset..offset + 4].copy_from_slice(&point.map(f32::to_bits)); +} + +fn end(feature: &MaximumFeature, edge: usize) -> V { + let origin = load(feature, 4 + edge * 16); + let direction = load(feature, 8 + edge * 16); + let length = load(feature, 16 + edge * 16); + std::array::from_fn(|i| direction[i].mul_add(length[i], origin[i])) +} + +/// Complete TU3 82ACCA28 corner/edge fallback. Returns one or two point pairs; +/// the caller writes the prism count. Both feature headers are replaced here. +pub fn intersect_feature_corner_edge( + output: &mut FeaturePrism, + a: &mut MaximumFeature, + b: &mut MaximumFeature, + corner: usize, + edge: usize, + a_is_first: bool, +) -> u32 { + let (ao, bo) = if a_is_first { (0, 64) } else { (64, 0) }; + let previous = if corner != 0 { + corner - 1 + } else { + a[140] as usize - 1 + }; + assert!(corner < 8 && edge < 8 && previous < 8); + let bd = load(b, 8 + edge * 16); + let mut selected = previous; + let nearly_parallel = + |index| f32::from_bits(0x3d4c_cccd) > dot(load(a, 12 + index * 16), bd).abs(); + let parallel = if nearly_parallel(previous) { + true + } else { + selected = corner; + nearly_parallel(corner) + }; + let b_origin = load(b, 4 + edge * 16); + if !parallel { + a[0] = (corner as u32) * 2 + 1; + b[0] = (edge as u32 + 1) * 2; + let origin = load(a, 4 + corner * 16); + let mut position = dot(bd, sub(origin, b_origin)); + if position < f32::MIN_POSITIVE { + b[0] = b[0].wrapping_sub(1); + position = 0.0; + } else { + let length = load(b, 16 + edge * 16); + if length.iter().all(|&v| position > v) { + b[0] = b[0].wrapping_add(1); + position = length[0]; + } + } + store(output, ao, origin); + store(output, bo, madd(bd, position, b_origin)); + return 1; + } + a[0] = (selected as u32 + 1) * 2; + b[0] = (edge as u32 + 1) * 2; + let a_origin = load(a, 4 + selected * 16); + let ad = load(a, 8 + selected * 16); + let distance = dot(ad, sub(b_origin, a_origin)); + if distance < f32::MIN_POSITIVE { + store(output, ao, a_origin); + store(output, bo, b_origin); + a[0] = a[0].wrapping_sub(1); + b[0] = b[0].wrapping_sub(1); + return 1; + } + let al = load(a, 16 + selected * 16); + let bl = load(b, 16 + edge * 16); + if (0..4).all(|i| f32::MIN_POSITIVE > (al[i] + bl[i]) - distance) { + store(output, ao, end(a, selected)); + store(output, bo, end(b, edge)); + a[0] = a[0].wrapping_add(1); + b[0] = b[0].wrapping_add(1); + return 1; + } + if al.iter().all(|&v| f32::MIN_POSITIVE > v - distance) { + store(output, ao, end(a, selected)); + store( + output, + bo, + sub( + b_origin, + std::array::from_fn(|i| bd[i] * (al[i] - distance)), + ), + ); + } else { + store(output, ao, madd(ad, distance, a_origin)); + store(output, bo, b_origin); + } + if bl.iter().all(|&v| f32::MIN_POSITIVE > v - distance) { + store( + output, + ao + 4, + sub( + a_origin, + std::array::from_fn(|i| ad[i] * (bl[i] - distance)), + ), + ); + store(output, bo + 4, end(b, edge)); + } else { + store(output, ao + 4, a_origin); + store(output, bo + 4, madd(bd, distance, b_origin)); + } + 2 +} diff --git a/crates/skate-core/src/physics/world_contact/face_face.rs b/crates/skate-core/src/physics/world_contact/face_face.rs new file mode 100644 index 00000000..ad60b1b5 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/face_face.rs @@ -0,0 +1,153 @@ +use crate::physics::world_contact::{ + FeaturePrism, MaximumFeature, intersect_feature_corner_edge, prism_math::*, +}; + +const OUTSIDE_A: u32 = 0x4000_0000; +const OUTSIDE_B: u32 = 0x2000_0000; + +fn vertex(feature: &MaximumFeature, index: usize) -> V { + load(feature, 4 + index * 16) +} + +fn plane_distance(feature: &MaximumFeature, edge: usize, point: V) -> f32 { + dot( + load(feature, 12 + edge * 16), + sub(point, vertex(feature, edge)), + ) +} + +fn project_to_face(face: &MaximumFeature, point: V) -> V { + let normal = load(face, 132); + madd(normal, dot(normal, sub(vertex(face, 0), point)), point) +} + +fn append(output: &mut FeaturePrism, a: V, b: V) { + let offset = output[132] as usize * 4; + assert!(offset < 64, "feature prism point capacity exceeded"); + output[offset..offset + 4].copy_from_slice(&a.map(f32::to_bits)); + output[64 + offset..68 + offset].copy_from_slice(&b.map(f32::to_bits)); + output[132] += 1; +} + +/// General convex-face branch at 82ACE3D8, after specialized paths decline. +/// Retains native candidate order: B vertices, edge crossings, then A vertices. +/// Plane-touching vertices/crossings are included without deduplication here. +pub(super) fn intersect( + output: &mut FeaturePrism, + a: &mut MaximumFeature, + b: &mut MaximumFeature, +) -> u32 { + let (a_count, b_count) = (a[140] as usize, b[140] as usize); + let mut masks = [0u32; 8]; + let mut nearest_corner = 0; + let mut nearest_edge = 0; + let mut edge_is_a = false; + let mut largest_minimum = -f32::MAX; + output[132] = 0; + + for edge in 0..a_count { + let mut previous = b_count - 1; + let mut previous_distance = plane_distance(a, edge, vertex(b, previous)); + let mut minimum = previous_distance; + let mut minimum_corner = previous; + for corner in 0..b_count { + let distance = plane_distance(a, edge, vertex(b, corner)); + if distance < minimum { + minimum = distance; + minimum_corner = corner; + } + if distance > 0.0 { + masks[corner] |= OUTSIDE_B; + } + if !(distance * previous_distance > 0.0) { + masks[previous] |= 1 << edge; + } + previous = corner; + previous_distance = distance; + } + if minimum > largest_minimum { + largest_minimum = minimum; + nearest_corner = minimum_corner; + nearest_edge = edge; + edge_is_a = true; + } + } + + for corner in 0..b_count { + if masks[corner] & OUTSIDE_B == 0 { + let point = vertex(b, corner); + append(output, project_to_face(a, point), point); + } + } + // Containment is an early return; it must not duplicate all of A's vertices + // when coincident faces have already contributed every B vertex. + if output[132] as usize == b_count { + return 1; + } + + for edge in 0..b_count { + let mut previous = a_count - 1; + let mut previous_distance = plane_distance(b, edge, vertex(a, previous)); + let mut minimum = previous_distance; + let mut minimum_corner = previous; + for corner in 0..a_count { + let distance = plane_distance(b, edge, vertex(a, corner)); + if distance < minimum { + minimum = distance; + minimum_corner = corner; + } + if distance > 0.0 { + masks[corner] |= OUTSIDE_A; + } + let difference = previous_distance - distance; + if !(distance * previous_distance > 0.0) + && masks[edge] & (1 << previous) != 0 + && difference.abs() > f32::MIN_POSITIVE + { + let inverse_difference = 1.0 / difference; + let origin = vertex(a, previous); + let direction = load(a, 8 + previous * 16); + let length = load(a, 16 + previous * 16); + let point = std::array::from_fn(|lane| { + let numerator = length[lane] * previous_distance; + let along = inverse_difference * numerator; + direction[lane].mul_add(along, origin[lane]) + }); + append(output, point, project_to_face(b, point)); + } + previous = corner; + previous_distance = distance; + } + if minimum > largest_minimum { + largest_minimum = minimum; + nearest_corner = minimum_corner; + nearest_edge = edge; + edge_is_a = false; + } + } + for corner in 0..a_count { + if masks[corner] & OUTSIDE_A == 0 { + let point = vertex(a, corner); + append(output, point, project_to_face(b, point)); + } + } + if output[132] == 0 { + output[132] = if edge_is_a { + intersect_feature_corner_edge(output, b, a, nearest_corner, nearest_edge, false) + } else { + intersect_feature_corner_edge(output, a, b, nearest_corner, nearest_edge, true) + }; + let delta = sub(load(output, 64), load(output, 0)); + let squared = dot(delta, delta); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse_length(squared) + }; + if length > f32::MIN_POSITIVE { + output[128..132].copy_from_slice(&scale(delta, 1.0 / length).map(f32::to_bits)); + output[133] = 1; + } + } + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/face_specialized.rs b/crates/skate-core/src/physics/world_contact/face_specialized.rs new file mode 100644 index 00000000..cc5a5e64 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/face_specialized.rs @@ -0,0 +1,116 @@ +use crate::physics::world_contact::{FeaturePrism, MaximumFeature, prism_math::*}; + +/// TU3 82ACCEF0. The quad owns the outer edge-intersection loop even when it is +/// the second feature in the caller's pair. This order reaches contact reduction. +pub(super) fn quad_triangle( + output: &mut FeaturePrism, + quad: &MaximumFeature, + triangle: &MaximumFeature, + normal: [u32; 4], + quad_is_a: bool, +) -> u32 { + intersect(output, quad, triangle, normal, quad_is_a) +} + +/// TU3 82ACD7D8. Its calculations match 82ACCEF0 with four inner edges and four +/// vertices in both containment passes; the source transposes these into lanes. +pub(super) fn quad_quad( + output: &mut FeaturePrism, + a: &MaximumFeature, + b: &MaximumFeature, + normal: [u32; 4], +) -> u32 { + intersect(output, a, b, normal, true) +} + +fn vertex(feature: &MaximumFeature, edge: usize) -> V { + load(feature, 4 + edge * 16) +} + +fn project_to_face(face: &MaximumFeature, point: V) -> V { + let normal = load(face, 132); + madd(normal, dot(normal, sub(vertex(face, 0), point)), point) +} + +fn append( + output: &mut FeaturePrism, + count: &mut usize, + a: &MaximumFeature, + b: &MaximumFeature, + point: V, + a_is_first: bool, +) { + assert!(*count < 16, "feature prism point capacity exceeded"); + let (ao, bo) = if a_is_first { (0, 64) } else { (64, 0) }; + let offset = *count * 4; + output[ao + offset..ao + offset + 4] + .copy_from_slice(&project_to_face(a, point).map(f32::to_bits)); + output[bo + offset..bo + offset + 4] + .copy_from_slice(&project_to_face(b, point).map(f32::to_bits)); + *count += 1; +} + +// The contained-vertex passes use lane-wise multiply/FMA operations, with a +// rounded Y product followed by X and Z FMAs, instead of the VMX three-lane dot. +fn containment_dot(delta: V, normal: V) -> f32 { + delta[2].mul_add(normal[2], delta[0].mul_add(normal[0], delta[1] * normal[1])) +} + +fn intersect( + output: &mut FeaturePrism, + a: &MaximumFeature, + b: &MaximumFeature, + normal: [u32; 4], + a_is_first: bool, +) -> u32 { + let mut count = 0; + let b_count = b[140] as usize; + // Full native edge-pair validity masks, including endpoint equality. + // Only the first lane of each mask controls publication, so broadcast + // feature lengths are read from their first lane here as in the source. + for edge_a in 0..4 { + let a_origin = vertex(a, edge_a); + let a_direction = load(a, 8 + edge_a * 16); + let a_plane = load(a, 12 + edge_a * 16); + let a_length = f32::from_bits(a[16 + edge_a * 16]); + for edge_b in 0..b_count { + let b_origin = vertex(b, edge_b); + let b_direction = load(b, 8 + edge_b * 16); + let b_length = f32::from_bits(b[16 + edge_b * 16]); + let denominator = dot(b_direction, a_plane); + let offset = dot(sub(a_origin, b_origin), a_plane); + let point = madd(b_direction, reciprocal(denominator) * offset, b_origin); + let along_a = dot(sub(point, a_origin), a_direction); + let valid = denominator.abs() > f32::from_bits(0x3400_0000) + && !(offset.abs() > (b_length * denominator).abs()) + && !(0.0 > offset * denominator) + && !(along_a > a_length) + && !(0.0 > along_a); + if valid { + append(output, &mut count, a, b, point, a_is_first); + } + } + } + let normal = normal.map(f32::from_bits); + for (source, target, source_count, target_count) in [(a, b, 4, b_count), (b, a, b_count, 4)] { + for corner in 0..source_count { + let origin = vertex(source, corner); + let distance = containment_dot(sub(vertex(target, 0), origin), normal); + // The source transposes XYZ back into points with a literal W=1. + let mut point = madd(normal, distance, origin); + point[3] = 1.0; + let inside = (0..target_count).all(|edge| { + let distance = containment_dot( + sub(point, vertex(target, edge)), + load(target, 12 + edge * 16), + ); + 0.0 > distance + }); + if inside { + append(output, &mut count, a, b, point, a_is_first); + } + } + } + output[132] = count as u32; + u32::from(count != 0) +} diff --git a/crates/skate-core/src/physics/world_contact/feature.rs b/crates/skate-core/src/physics/world_contact/feature.rs new file mode 100644 index 00000000..d9ea903a --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/feature.rs @@ -0,0 +1,79 @@ +use super::MaximumFeature; +use crate::physics::reciprocal_sqrt::estimate; + +fn load(words: &[u32], offset: usize) -> [f32; 4] { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} + +/// Complete 82AC6E50 segment initialization. Writes origin, normalized direction +/// and broadcast length; preserves the unwritten plane vector at words8..12. +pub fn initialize_feature_segment(output: &mut [u32; 16], origin: [u32; 4], end: [u32; 4]) { + output[..4].copy_from_slice(&origin); + let origin = origin.map(f32::from_bits); + let end = end.map(f32::from_bits); + let delta: [f32; 4] = std::array::from_fn(|i| end[i] - origin[i]); + let squared = super::arithmetic::dot( + delta[..3].try_into().unwrap(), + delta[..3].try_into().unwrap(), + ); + // One rsqrt refinement, then one reciprocal refinement. Runtime initializer + // 82F837D8 broadcasts 8212BF78 (34000000) to threshold vector830BDD40. + let mut reciprocal = estimate(squared); + reciprocal = + (reciprocal * 0.5).mul_add((-squared).mul_add(reciprocal * reciprocal, 1.0), reciprocal); + let length = if squared > f32::from_bits(0x3400_0000) { + if squared == 0.0 { + 0.0 + } else { + squared * reciprocal + } + } else { + 0.0 + }; + output[12..16].fill(length.to_bits()); + let r = super::prism_math::reciprocal_estimate(length); + let r = r.mul_add((-r).mul_add(length, 1.0), r); + let direction = if squared > f32::from_bits(0x3400_0000) { + delta.map(|x| x * r) + } else { + [0.0; 4] + }; + output[4..8].copy_from_slice(&direction.map(f32::to_bits)); +} + +/// Complete capsule maximum-feature callback82AD98A0. `segment_scratch` carries +/// the native local64-byte segment, including its unwritten plane vector. +/// No fabricated value is assigned to those copied scratch words. Native r4 +/// mode is unused by this capsule callback. +pub fn capsule_maximum_feature( + gp: &[u32; 48], + direction: [u32; 4], + output: &mut MaximumFeature, + segment_scratch: &mut [u32; 16], +) { + let axis = load(gp, 16); + let normal = direction.map(f32::from_bits); + let projection = super::arithmetic::dot( + axis[..3].try_into().unwrap(), + normal[..3].try_into().unwrap(), + ); + let half = f32::from_bits(gp[28]); + let center = load(gp, 0); + if projection.abs() < f32::from_bits(0x3d4c_cccd) { + let scaled = axis.map(|v| v * half); + let plus = std::array::from_fn(|i| (center[i] + scaled[i]).to_bits()); + let minus = std::array::from_fn(|i| (center[i] - scaled[i]).to_bits()); + initialize_feature_segment(segment_scratch, plus, minus); + output[4..20].copy_from_slice(segment_scratch); + output[140] = 1; + } else { + let point: [f32; 4] = if projection > 0.0 { + std::array::from_fn(|i| axis[i].mul_add(half, center[i])) + } else { + std::array::from_fn(|i| center[i] - axis[i] * half) + }; + output[136..140].copy_from_slice(&point.map(f32::to_bits)); + output[140] = 0; + } + output[0] = 0; +} diff --git a/crates/skate-core/src/physics/world_contact/mod.rs b/crates/skate-core/src/physics/world_contact/mod.rs new file mode 100644 index 00000000..8f0c7d87 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/mod.rs @@ -0,0 +1,48 @@ +//! TU3 world-contact production and native temporary-contact retention. +//! General primitive dispatch remains a separate provider dependency. +mod arithmetic; +mod axes; +mod box_feature; +mod buffer; +mod dispatch; +mod face_clip; +mod face_edge; +mod feature; +mod point_feature; +mod prism_math; +mod primitive_query; +mod primitive_pair; +pub use primitive_pair::{PrimitivePairSettings, primitive_pair_contacts}; +mod projection; +mod reduction; +mod segment_face; +mod segment_prism; +mod triangle_feature; + +pub use axes::separating_axis_candidates; +pub use box_feature::box_maximum_feature; +pub use buffer::ContactBuffer; +pub use dispatch::find_feature_intersection_prism; +pub use primitive_query::{ + ContactPrimitive, PrimitiveContactManifold, primitive_triangle_world_contacts, + transform_triangle_volume, triangle_from_volume, +}; +pub use face_clip::{clamp_point_to_feature, clip_segment_to_feature}; +pub use face_edge::intersect_feature_corner_edge; +pub use feature::{capsule_maximum_feature, initialize_feature_segment}; +pub use point_feature::{closest_feature_segment, intersect_point_face}; +pub use projection::{ + PrimitiveKind, best_separating_direction, project_direction, project_directions, +}; +pub use segment_face::intersect_segment_face; +pub use segment_prism::intersect_feature_segments; +pub use triangle_feature::{build_feature_edge_planes, triangle_maximum_feature}; + +/// Maximum-feature record consumed by 82ACE190. +pub type MaximumFeature = [u32; 144]; +/// Native two point arrays, normal, count, flags and untouched trailing padding. +pub type FeaturePrism = [u32; 136]; +pub use reduction::{coplanar_contacts, select_contact_points}; + +/// Native 256-byte contact record, including both body workspaces. +pub type ContactRecord = [u32; 64]; diff --git a/crates/skate-core/src/physics/world_contact/point_feature.rs b/crates/skate-core/src/physics/world_contact/point_feature.rs new file mode 100644 index 00000000..89de46a4 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/point_feature.rs @@ -0,0 +1,78 @@ +use super::{FeaturePrism, MaximumFeature, arithmetic::dot}; + +fn load(words: &[u32], offset: usize) -> [f32; 4] { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} + +fn dot3(a: [f32; 4], b: [f32; 4]) -> f32 { + dot(a[..3].try_into().unwrap(), b[..3].try_into().unwrap()) +} + +/// Complete TU3 82AC6EE8. Region codes are before=1, interior=2, after=3. +/// The upper-end comparison tests all four length lanes; equality is interior. +pub fn closest_feature_segment(segment: &[u32; 16], point: &mut [u32; 4]) -> u32 { + let origin = load(segment, 0); + let direction = load(segment, 4); + let p = point.map(f32::from_bits); + let along = dot3(std::array::from_fn(|i| p[i] - origin[i]), direction); + if 0.0 > along { + point.copy_from_slice(&segment[..4]); + return 1; + } + let length = load(segment, 12); + let after = length.iter().all(|&v| along > v); + *point = std::array::from_fn(|i| { + direction[i] + .mul_add(if after { length[i] } else { along }, origin[i]) + .to_bits() + }); + if after { 3 } else { 2 } +} + +/// Complete TU3 82ACC160 point/face helper. `face_is_a` selects its two native +/// destination pointers within the prism; normal and untouched records survive. +/// The face header receives the selected edge index and segment-region code. +pub fn intersect_point_face( + output: &mut FeaturePrism, + face: &mut MaximumFeature, + point: &MaximumFeature, + normal: [u32; 4], + face_is_a: bool, +) -> u32 { + output[132] = 1; + let p = load(point, 136); + let normal = normal.map(f32::from_bits); + let origin = load(face, 4); + let distance = dot3(normal, std::array::from_fn(|i| origin[i] - p[i])); + let mut projected = std::array::from_fn(|i| normal[i].mul_add(distance, p[i])); + if (face[140] as i32) > 0 { + let count = face[140] as usize; + assert!(count <= 8, "native maximum-feature storage exceeded"); + let mut selected = 0; + let mut worst = 0.0; + for edge in 0..count { + let start = load(face, 4 + edge * 16); + let plane = load(face, 12 + edge * 16); + let value = dot3(plane, std::array::from_fn(|i| projected[i] - start[i])); + if edge == 0 || value > worst { + worst = value; + selected = edge; + } + } + if worst > 0.0 { + let plane = load(face, 12 + selected * 16); + let mut projected_bits = + std::array::from_fn(|i| (projected[i] - plane[i] * worst).to_bits()); + let segment = face[4 + selected * 16..20 + selected * 16] + .try_into() + .unwrap(); + let region = closest_feature_segment(segment, &mut projected_bits); + face[0] = face[0].wrapping_add((selected as u32) * 2 + region); + projected = projected_bits.map(f32::from_bits); + } + } + let (face_offset, point_offset) = if face_is_a { (0, 64) } else { (64, 0) }; + output[face_offset..face_offset + 4].copy_from_slice(&projected.map(f32::to_bits)); + output[point_offset..point_offset + 4].copy_from_slice(&point[136..140]); + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/primitive_geometry.rs b/crates/skate-core/src/physics/world_contact/primitive_geometry.rs new file mode 100644 index 00000000..54cb0590 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/primitive_geometry.rs @@ -0,0 +1,164 @@ +use super::super::{PrimitiveKind, prism_math::*}; +use crate::{ + math::{Basis3, Vector3}, + physics::{ + collision::{Sphere, Triangle, TriangleFeature}, + reciprocal_sqrt::estimate, + }, +}; + +/// World-space board geometry. Capsule axes and box basis columns are the +/// transformed native volume axes; shape radii are applied after feature fixup. +#[derive(Clone, Copy, Debug)] +pub enum ContactPrimitive { + Sphere(Sphere), + Capsule { + center: Vector3, + axis: Vector3, + half_length: f32, + radius: f32, + }, + Triangle(Triangle), + RoundedBox { + center: Vector3, + basis: Basis3, + half_extents: Vector3, + radius: f32, + }, +} + +/// Authored triangle Volume initialization and CreateGPInstance geometry +/// (82AC75B8, 82ADE468). The normal cache's dirty bit is cleared before copying +/// the Volume flags to GP flags; all other flags and edge cosines copy directly. +/// This constructor recomputes authored geometry. A cached native GPTriangle can +/// instead be passed directly as ContactPrimitive::Triangle. +pub fn triangle_from_volume( + vertices: [Vector3; 3], + fatness: f32, + edge_cosines: [f32; 3], + volume_flags: u32, +) -> Triangle { + let points = vertices.map(|v| lanes(v, 1.0)); + let raw_normal = cross(sub(points[1], points[0]), sub(points[2], points[0])); + let squared = dot(raw_normal, raw_normal); + let normal = if squared > f32::from_bits(0x3400_0000) { + scale(raw_normal, refined_inverse(squared)) + } else { + raw_normal + }; + let raw_edges = [ + sub(points[2], points[0]), + sub(points[1], points[2]), + sub(points[0], points[1]), + ]; + let inverse = raw_edges.map(|e| refined_inverse(dot(e, e))); + Triangle { + vertices, + feature: TriangleFeature { + normal: xyz(normal), + edges: std::array::from_fn(|i| xyz(scale(raw_edges[i], inverse[i]))), + flags: volume_flags & !2, + edge_cosines, + }, + // 82ADE66C..: lengths are the twice-refined reciprocal of the refined + // inverse length, not squared length multiplied by inverse length. + edge_lengths: inverse.map(reciprocal), + fatness, + } +} + +/// TriangleVolume::CreateGPInstance with a rigid Volume-to-world transform. +/// Source order computes the local normal first, transforms it without a new +/// normalization, and then builds directions and lengths from world vertices. +pub fn transform_triangle_volume( + vertices: [Vector3; 3], + fatness: f32, + edge_cosines: [f32; 3], + volume_flags: u32, + basis: Basis3, + translation: Vector3, +) -> Triangle { + let local = triangle_from_volume(vertices, fatness, edge_cosines, volume_flags); + let axes = basis.columns.map(|c| [c[0], c[1], c[2], 0.0]); + let world_vertices = vertices.map(|v| { + xyz(madd( + axes[2], + v.z, + madd(axes[1], v.y, madd(axes[0], v.x, lanes(translation, 1.0))), + )) + }); + let n = local.feature.normal; + let normal = xyz(madd(axes[2], n.z, madd(axes[1], n.y, scale(axes[0], n.x)))); + let mut result = triangle_from_volume(world_vertices, fatness, edge_cosines, volume_flags); + result.feature.normal = normal; + result +} +fn refined_inverse(squared: f32) -> f32 { + let seed = estimate(squared); + (seed * 0.5).mul_add((-squared).mul_add(seed * seed, 1.0), seed) +} + +pub(in super::super) fn pack(shape: ContactPrimitive) -> ([u32; 48], PrimitiveKind) { + let mut gp = [0; 48]; + let (kind, radius, counts, id) = match shape { + ContactPrimitive::Sphere(sphere) => { + put(&mut gp, 0, lanes(sphere.center, 1.0)); + (PrimitiveKind::Sphere, sphere.radius, 0, 1) + } + ContactPrimitive::Capsule { + center, + axis, + half_length, + radius, + } => { + put(&mut gp, 0, lanes(center, 1.0)); + put(&mut gp, 16, lanes(axis, 0.0)); + gp[28] = half_length.to_bits(); + (PrimitiveKind::Capsule, radius, 0x0001_0000, 2) + } + ContactPrimitive::Triangle(triangle) => { + put(&mut gp, 0, lanes(triangle.vertices[0], 1.0)); + put(&mut gp, 4, lanes(triangle.feature.normal, 0.0)); + put(&mut gp, 8, lanes(triangle.vertices[1], 1.0)); + put(&mut gp, 12, lanes(triangle.vertices[2], 1.0)); + for i in 0..3 { + put(&mut gp, 16 + i * 4, lanes(triangle.feature.edges[i], 0.0)); + gp[28 + i] = triangle.edge_lengths[i].to_bits(); + gp[38 + i] = triangle.feature.edge_cosines[i].to_bits(); + } + gp[37] = triangle.feature.flags; + (PrimitiveKind::Triangle, triangle.fatness, 0x0103_0000, 3) + } + ContactPrimitive::RoundedBox { + center, + basis, + half_extents, + radius, + } => { + put(&mut gp, 0, lanes(center, 1.0)); + let half = [half_extents.x, half_extents.y, half_extents.z]; + for i in 0..3 { + let v = basis.columns[i]; + let axis = [v[0], v[1], v[2], 0.0]; + put(&mut gp, 4 + i * 4, axis); + put(&mut gp, 16 + i * 4, axis); + gp[28 + i] = half[i].to_bits(); + } + (PrimitiveKind::Box, radius, 0x0303_0000, 4) + } + }; + gp[32] = radius.to_bits(); + gp[35] = counts; + gp[36] = id; + (gp, kind) +} + +pub(in super::super) fn lanes(v: Vector3, w: f32) -> V { + [v.x, v.y, v.z, w] +} +pub(in super::super) fn xyz(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn put(out: &mut [u32], offset: usize, v: V) { + out[offset..offset + 4].copy_from_slice(&v.map(f32::to_bits)); +} diff --git a/crates/skate-core/src/physics/world_contact/primitive_pair.rs b/crates/skate-core/src/physics/world_contact/primitive_pair.rs new file mode 100644 index 00000000..f635018f --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/primitive_pair.rs @@ -0,0 +1,278 @@ +//! Simulation GP-pair contact query, original Skate 3 TU3 82AD43A8. +//! +//! 8277A508 calls this after its pair-culling bitmap and bounds checks, including +//! the same-assembly branch at 8277B2C0. This is not PrimitivePairIntersect +//! 82AD3CD8: that query has a squared-distance gate and different publication. +//! Host geometry is already transformed into world space. Native GP dispatch, +//! feature/prism order, normal refinement and radius application are retained. + +use super::ContactPrimitive; +use super::{ + PrimitiveContactManifold, PrimitiveKind, best_separating_direction, + find_feature_intersection_prism, + primitive_query::{ + box_box_sat, + geometry::{pack, xyz}, + maximum, triangle_box_sat, + }, + prism_math::*, + project_direction, +}; +use crate::{ + math::Vector3, + physics::collision::{ContactPair, TriangleFixup, fix_up_triangle}, +}; + +#[derive(Clone, Copy, Debug)] +pub struct PrimitivePairSettings { + /// Volume-record +112 for A/B in 8277A508, separate from shape fatness. + pub padding_a: f32, + pub padding_b: f32, + /// Batch job +36, passed by 8277AB70; added after both volume paddings. + pub additional_padding: f32, + /// Native f2/f3 of 82AD43A8, used only for triangle feature correction. + pub edge_cos_bend_normal_threshold: f32, + pub convexity_epsilon: f32, +} + +impl PrimitivePairSettings { + /// Original skater/skater batch settings. SerializedAssembly::Create + /// 82DC3AE4/82DC3B84 stores 82165A00 into every volume's padding (+112). + /// Island::InitWorld 82765544 initializes additionalPadding to zero; + /// 82DC1848 -> 82DC19E8 -> 8277B2A4 passes it to this query. The triangle + /// constants are loaded by 8277ABEC..AC1C and forwarded at 8277A7DC..7EC. + pub const fn skater_self_collision() -> Self { + Self { + padding_a: f32::from_bits(0x3D4C_CCCD), + padding_b: f32::from_bits(0x3D4C_CCCD), + additional_padding: 0.0, + edge_cos_bend_normal_threshold: f32::from_bits(0x3F7F_BE77), + convexity_epsilon: f32::from_bits(0x3C23_D70A), + } + } +} + +/// Contacts between two simulation volumes. The normal points from B toward A; +/// `points[i].a/b` belong to the corresponding input volume. Positive geometric +/// gaps inside the native padding are retained for the solver to classify. +pub fn primitive_pair_contacts( + primitive_a: ContactPrimitive, + primitive_b: ContactPrimitive, + settings: PrimitivePairSettings, +) -> Option { + let (a, a_kind) = pack(primitive_a); + let (b, b_kind) = pack(primitive_b); + let (separation, initial_normal) = separating_direction(&a, a_kind, &b, b_kind); + // 8277A720..744 computes the pair padding before 82AD43F8..4414 adds + // geometric fatness, preserving all four scalar additions in order. + let limit = (settings.padding_b + settings.padding_a) + settings.additional_padding; + let fat_a = f32::from_bits(a[32]); + let fat_b = f32::from_bits(b[32]); + if separation > (fat_b + limit) + fat_a { + return None; + } + let mut feature_a = maximum(&a, a_kind, 1, initial_normal); + let mut feature_b = maximum(&b, b_kind, 0, initial_normal.map(|w| w ^ 0x8000_0000)); + let mut prism = [0; 136]; + if find_feature_intersection_prism(&mut prism, &mut feature_a, &mut feature_b, initial_normal) + == 0 + { + return None; + } + let count = prism[132] as usize; + let mut normal = initial_normal.map(f32::from_bits); + if count == 1 { + // 82AD44B0..4540 compares *length*, not squared length. The source + // computes both reciprocal-square-root refinements before selecting + // zero length for a zero squared distance. + let delta = sub(load(&prism, 64), load(&prism, 0)); + let squared = dot(delta, delta); + let inverse = inverse_length(squared); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + if length > f32::from_bits(0x3400_0000) { + prism[128..132].copy_from_slice(&scale(delta, inverse).map(f32::to_bits)); + prism[133] = 1; + } + } + if prism[133] != 0 { + let direction: [u32; 4] = prism[128..132].try_into().unwrap(); + let mut interval_a = [0; 12]; + let mut interval_b = [0; 12]; + project_direction(&a, a_kind, direction, &mut interval_a); + project_direction(&b, b_kind, direction, &mut interval_b); + let forward = f32::from_bits(interval_a[0]) - f32::from_bits(interval_b[4]); + let reverse = f32::from_bits(interval_b[0]) - f32::from_bits(interval_a[4]); + let flip = forward > reverse; + let refined = if flip { forward } else { reverse }; + if refined >= separation { + normal = direction.map(|w| f32::from_bits(if flip { w ^ 0x8000_0000 } else { w })); + } + } + let mut points = [ContactPair { + a: Vector3::ZERO, + b: Vector3::ZERO, + }; 16]; + for (i, point) in points[..count].iter_mut().enumerate() { + point.a = xyz(load(&prism, 4 * i)); + point.b = xyz(load(&prism, 64 + 4 * i)); + } + // General GP query corrects either triangle, A first. The skater-specific + // world query corrects only B and supplies a different is_object argument. + let mut pair_normal = xyz(normal); + for (primitive, reverse) in [(primitive_a, false), (primitive_b, true)] { + if let ContactPrimitive::Triangle(triangle) = primitive { + if !fix_up_triangle( + triangle.feature, + &mut pair_normal, + &mut points[..count], + TriangleFixup { + reverse, + edge_cos_bend_normal_threshold: settings.edge_cos_bend_normal_threshold, + convexity_epsilon: settings.convexity_epsilon, + is_object: false, + }, + ) { + return None; + } + } + } + let direction = [pair_normal.x, pair_normal.y, pair_normal.z, normal[3]]; + let offset_a = scale(direction, fat_a); + let offset_b = scale(direction, fat_b); + for point in &mut points[..count] { + // 82AD46EC..4724 multiplies before adding/subtracting, not fused. + point.a = Vector3::new( + point.a.x + offset_a[0], + point.a.y + offset_a[1], + point.a.z + offset_a[2], + ); + point.b = Vector3::new( + point.b.x - offset_b[0], + point.b.y - offset_b[1], + point.b.z - offset_b[2], + ); + } + Some(PrimitiveContactManifold { + normal: Vector3::new(-pair_normal.x, -pair_normal.y, -pair_normal.z), + points, + count, + }) +} + +/// Exact non-cylinder entries of original dispatch table 82FD56F0. +fn separating_direction( + a: &[u32; 48], + a_kind: PrimitiveKind, + b: &[u32; 48], + b_kind: PrimitiveKind, +) -> (f32, [u32; 4]) { + let (separation, direction) = match (a_kind, b_kind) { + (PrimitiveKind::Sphere, PrimitiveKind::Sphere) => { + // Specialized 82ACE9C0. The generic no-edge fallback is Z, so it + // cannot substitute for this center-to-center sphere-pair axis. + let delta = sub(load(b, 0), load(a, 0)); + let squared = dot(delta, delta); + let inverse = inverse_length(squared); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + ( + [length.to_bits(); 4], + scale(delta, inverse).map(f32::to_bits), + ) + } + (PrimitiveKind::Box, PrimitiveKind::Box) => box_box_sat::box_box(a, b), + (PrimitiveKind::Triangle, PrimitiveKind::Box) => triangle_box_sat::triangle_box(a, b), + (PrimitiveKind::Box, PrimitiveKind::Triangle) => { + let (separation, normal) = triangle_box_sat::triangle_box(b, a); + (separation, normal.map(|w| w ^ 0x8000_0000)) + } + _ => best_separating_direction(a, a_kind, b, b_kind), + }; + (f32::from_bits(separation[0]), direction) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{math::Basis3, physics::collision::Sphere}; + + fn settings() -> PrimitivePairSettings { + PrimitivePairSettings { + padding_a: 0.0, + padding_b: 0.0, + additional_padding: 0.0, + edge_cos_bend_normal_threshold: 0.0, + convexity_epsilon: 0.0, + } + } + fn sphere(center: Vector3, radius: f32) -> ContactPrimitive { + ContactPrimitive::Sphere(Sphere { center, radius }) + } + fn close(a: f32, b: f32) { + assert!((a - b).abs() < 0.00001, "{a} != {b}"); + } + #[test] + fn sphere_pair_publishes_both_surfaces_and_solver_normal() { + let a = sphere(Vector3::ZERO, 1.0); + let b = sphere(Vector3::new(1.2, 1.6, 0.0), 1.1); + let result = primitive_pair_contacts(a, b, settings()).unwrap(); + assert_eq!(result.count, 1); + close(result.normal.x, -0.6); + close(result.normal.y, -0.8); + close(result.points[0].a.x, 0.6); + close(result.points[0].a.y, 0.8); + close(result.points[0].b.x, 0.54); + close(result.points[0].b.y, 0.72); + let reverse = primitive_pair_contacts(b, a, settings()).unwrap(); + close(reverse.normal.x, -result.normal.x); + close(reverse.points[0].a.x, result.points[0].b.x); + } + #[test] + fn separated_spheres_keep_padding_contacts_without_inflating_surface_points() { + let a = sphere(Vector3::ZERO, 1.0); + let b = sphere(Vector3::new(2.25, 0.0, 0.0), 1.0); + assert!(primitive_pair_contacts(a, b, settings()).is_none()); + let s = PrimitivePairSettings { + padding_a: 0.125, + padding_b: 0.125, + ..settings() + }; + let result = primitive_pair_contacts(a, b, s).unwrap(); + close(result.points[0].a.x, 1.0); + close(result.points[0].b.x, 1.25); + } + #[test] + fn box_capsule_pair_supports_reversed_native_feature_dispatch() { + let a = ContactPrimitive::RoundedBox { + center: Vector3::ZERO, + basis: Basis3 { + columns: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }, + half_extents: Vector3::new(1.0, 1.0, 1.0), + radius: 0.1, + }; + let b = ContactPrimitive::Capsule { + center: Vector3::new(1.4, 0.0, 0.0), + axis: Vector3::new(0.0, 1.0, 0.0), + half_length: 0.5, + radius: 0.5, + }; + let result = primitive_pair_contacts(a, b, settings()).unwrap(); + assert!(result.count > 0); + close(result.normal.x, -1.0); + for point in &result.points[..result.count] { + close(point.a.x, 1.1); + close(point.b.x, 0.9); + } + let reversed = primitive_pair_contacts(b, a, settings()).unwrap(); + close(reversed.normal.x, 1.0); + assert_eq!(reversed.count, result.count); + } +} diff --git a/crates/skate-core/src/physics/world_contact/primitive_query.rs b/crates/skate-core/src/physics/world_contact/primitive_query.rs new file mode 100644 index 00000000..999aacc8 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/primitive_query.rs @@ -0,0 +1,182 @@ +//! Physics/world primitive query 8277B720 and moving-volume/triangle dispatch +//! 8277BC58. Geometry lives in typed host-owned records; only the recovered +//! kernels' GP and feature scratch boundaries use native word layouts. +#[path = "box_box_sat.rs"] +pub(super) mod box_box_sat; +#[path = "primitive_geometry.rs"] +pub(super) mod geometry; +#[path = "triangle_box_sat.rs"] +pub(super) mod triangle_box_sat; + +use super::{ + MaximumFeature, PrimitiveKind, best_separating_direction, box_maximum_feature, + capsule_maximum_feature, find_feature_intersection_prism, prism_math::*, project_direction, + triangle_maximum_feature, +}; +use crate::{ + math::Vector3, + physics::collision::{ + ContactPair, Triangle, TriangleFixup, WorldContactSettings, fix_up_triangle, + world_separation_limit, + }, +}; +pub use geometry::{ContactPrimitive, transform_triangle_volume, triangle_from_volume}; +use geometry::{lanes, pack, xyz}; + +#[derive(Clone, Copy, Debug)] +pub struct PrimitiveContactManifold { + /// World primitive B toward moving primitive A, as expected by the solver. + pub normal: Vector3, + /// Native prism order is retained, including coincident points. + pub points: [ContactPair; 16], + pub count: usize, +} + +/// Full moving sphere/capsule/triangle/rounded-box query against a world +/// triangle. The caller retains native world-triangle then moving-volume order. +pub fn primitive_triangle_world_contacts( + primitive: ContactPrimitive, + triangle: Triangle, + velocity: Vector3, + settings: WorldContactSettings, +) -> Option { + let limit = world_separation_limit( + velocity, + triangle.feature.normal, + settings.volume_padding, + settings.maximum_separating_distance, + ); + let (a, a_kind) = pack(primitive); + let (b, b_kind) = pack(ContactPrimitive::Triangle(triangle)); + let (separation, normal) = if matches!(a_kind, PrimitiveKind::Box) { + // Reversed specialized entry82ACE968 calls triangle/box then flips + // every sign bit. Native pair direction always points A toward B. + let (separation, normal) = triangle_box_sat::triangle_box(&b, &a); + (separation, normal.map(|v| v ^ 0x8000_0000)) + } else { + best_separating_direction(&a, a_kind, &b, b_kind) + }; + contact_points( + &a, + a_kind, + &b, + b_kind, + triangle, + f32::from_bits(separation[0]), + normal, + limit, + settings, + ) +} + +fn contact_points( + a: &[u32; 48], + a_kind: PrimitiveKind, + b: &[u32; 48], + b_kind: PrimitiveKind, + triangle: Triangle, + separation: f32, + initial_normal: [u32; 4], + limit: f32, + settings: WorldContactSettings, +) -> Option { + let fat_a = f32::from_bits(a[32]); + let fat_b = f32::from_bits(b[32]); + if separation > (fat_b + limit) + fat_a { + return None; + } + let mut feature_a = maximum(a, a_kind, 1, initial_normal); + let mut feature_b = maximum(b, b_kind, 0, initial_normal.map(|v| v ^ 0x8000_0000)); + let mut prism = [0; 136]; + if find_feature_intersection_prism(&mut prism, &mut feature_a, &mut feature_b, initial_normal) + == 0 + { + return None; + } + let count = prism[132] as usize; + let mut normal = initial_normal.map(f32::from_bits); + if count == 1 { + let delta = sub(load(&prism, 64), load(&prism, 0)); + let squared = dot(delta, delta); + let inverse = inverse_length(squared); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + // This world-query gate compares length. The general Volume query + // has a different squared-distance gate and publication sequence. + if length > f32::from_bits(0x3400_0000) { + prism[128..132].copy_from_slice(&scale(delta, inverse).map(f32::to_bits)); + prism[133] = 1; + } + } + if prism[133] != 0 { + let direction: [u32; 4] = prism[128..132].try_into().unwrap(); + let mut ai = [0; 12]; + let mut bi = [0; 12]; + project_direction(a, a_kind, direction, &mut ai); + project_direction(b, b_kind, direction, &mut bi); + let forward = f32::from_bits(ai[0]) - f32::from_bits(bi[4]); + let reverse = f32::from_bits(bi[0]) - f32::from_bits(ai[4]); + let flip = forward > reverse; + let refined = if flip { forward } else { reverse }; + if refined >= separation { + normal = direction.map(|v| f32::from_bits(if flip { v ^ 0x8000_0000 } else { v })); + } + } + let mut points = [ContactPair { + a: Vector3::ZERO, + b: Vector3::ZERO, + }; 16]; + for (i, point) in points[..count].iter_mut().enumerate() { + point.a = xyz(load(&prism, 4 * i)); + point.b = xyz(load(&prism, 64 + 4 * i)); + } + let mut pair_normal = xyz(normal); + if !fix_up_triangle( + triangle.feature, + &mut pair_normal, + &mut points[..count], + TriangleFixup { + reverse: true, + edge_cos_bend_normal_threshold: settings.edge_cos_bend_normal_threshold, + convexity_epsilon: settings.convexity_epsilon, + is_object: settings.is_object, + }, + ) { + return None; + } + let normal = lanes(pair_normal, 0.0); + let a_offset = scale(normal, fat_a); + let b_offset = scale(normal, fat_b); + for point in &mut points[..count] { + // Separate multiply and add/subtract in8277BA50..BA60, after fixup. + let a = lanes(point.a, 1.0); + point.a = xyz(std::array::from_fn(|i| a[i] + a_offset[i])); + point.b = xyz(sub(lanes(point.b, 1.0), b_offset)); + } + Some(PrimitiveContactManifold { + normal: xyz(normal.map(|v| -v)), + points, + count, + }) +} + +pub(super) fn maximum(gp: &[u32; 48], kind: PrimitiveKind, mode: u32, direction: [u32; 4]) -> MaximumFeature { + let mut feature = [0; 144]; + match kind { + PrimitiveKind::Sphere => { + // Complete sphere callback82ADD7E0: center, point type, header0. + feature[136..140].copy_from_slice(&gp[..4]); + feature[140] = 0; + feature[0] = 0; + } + PrimitiveKind::Capsule => { + capsule_maximum_feature(gp, direction, &mut feature, &mut [0; 16]) + } + PrimitiveKind::Triangle => triangle_maximum_feature(gp, mode, direction, &mut feature), + PrimitiveKind::Box => box_maximum_feature(gp, mode, direction, &mut feature, [0; 4]), + } + feature +} diff --git a/crates/skate-core/src/physics/world_contact/prism_math.rs b/crates/skate-core/src/physics/world_contact/prism_math.rs new file mode 100644 index 00000000..1f6c7ed3 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/prism_math.rs @@ -0,0 +1,57 @@ +use crate::physics::reciprocal_sqrt::estimate; + +pub(super) type V = [f32; 4]; + +pub(super) fn load(words: &[u32], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} + +pub(super) fn dot(a: V, b: V) -> f32 { + super::arithmetic::dot(a[..3].try_into().unwrap(), b[..3].try_into().unwrap()) +} + +pub(super) fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} + +pub(super) fn scale(a: V, s: f32) -> V { + a.map(|v| v * s) +} + +pub(super) fn madd(a: V, s: f32, b: V) -> V { + std::array::from_fn(|i| a[i].mul_add(s, b[i])) +} + +pub(super) 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]), + ] +} + +pub(super) fn normalized(a: V) -> V { + let squared = dot(a, a); + scale(a, inverse_length(squared)) +} + +pub(super) fn inverse_length(squared: f32) -> f32 { + let mut r = estimate(squared); + for _ in 0..2 { + r = (r * 0.5).mul_add((-squared).mul_add(r * r, 1.0), r); + } + r +} + +pub(super) fn reciprocal(value: f32) -> f32 { + let mut r = reciprocal_estimate(value); + for _ in 0..2 { + r = r.mul_add((-r).mul_add(value, 1.0), r); + } + r +} + +pub(super) fn reciprocal_estimate(value: f32) -> f32 { + crate::physics::native_arithmetic::reciprocal_estimate(value) +} diff --git a/crates/skate-core/src/physics/world_contact/projection.rs b/crates/skate-core/src/physics/world_contact/projection.rs new file mode 100644 index 00000000..0721702b --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/projection.rs @@ -0,0 +1,128 @@ +use super::arithmetic::{dot, scale, vector}; +use super::separating_axis_candidates; + +/// Verified GP dispatch IDs from mapped TU3 table 82FD5870. Cylinder (5) and +/// invalid (0) are outside the board primitive set and have no fallback here. +#[derive(Clone, Copy, Debug)] +pub enum PrimitiveKind { + Sphere, + Capsule, + Triangle, + Box, +} + +/// Full single-direction GP interval callbacks: sphere82ADD800, +/// capsule82AD99C8, triangle82ADE3B8, box82AD8508. Output is the native48-byte +/// interval; the last16bytes are untouched. Radius/padding is handled by caller. +pub fn project_direction( + gp: &[u32; 48], + kind: PrimitiveKind, + direction: [u32; 4], + output: &mut [u32; 12], +) { + project(gp, kind, direction, output, false); +} + +/// Full batch GP callbacks: sphere82ADD820, capsule82AD9A88, +/// triangle82ADE400, box82AD86B0. Box batch scales the axes *before* dot products; +/// its single callback scales the dot products instead. Preserve that difference. +pub fn project_directions( + gp: &[u32; 48], + kind: PrimitiveKind, + directions: &[[u32; 4]], + output: &mut [[u32; 12]], +) { + assert!(output.len() >= directions.len()); + for (&direction, interval) in directions.iter().zip(output) { + project(gp, kind, direction, interval, true); + } +} + +fn project( + gp: &[u32; 48], + kind: PrimitiveKind, + direction: [u32; 4], + output: &mut [u32; 12], + batch: bool, +) { + let direction = vector(&direction, 0); + let center = dot(vector(gp, 0), direction); + let (min, max) = match kind { + PrimitiveKind::Sphere => (center, center), + PrimitiveKind::Capsule => { + let radius = dot(vector(gp, 16), direction).abs() * f32::from_bits(gp[28]); + (center - radius, center + radius) + } + PrimitiveKind::Triangle => { + let second = dot(vector(gp, 8), direction); + let third = dot(vector(gp, 12), direction); + // VMX min/max select the second operand on unordered or equal. + let min = if center < second { center } else { second }; + let max = if center > second { center } else { second }; + ( + if min < third { min } else { third }, + if max > third { max } else { third }, + ) + } + PrimitiveKind::Box => { + let extent: [f32; 3] = std::array::from_fn(|i| { + let axis = vector(gp, 4 + i * 4); + let half = f32::from_bits(gp[28 + i]); + if batch { + dot(scale(axis, half), direction).abs() + } else { + (dot(axis, direction) * half).abs() + } + }); + let radius = (extent[0] + extent[1]) + extent[2]; + (center - radius, center + radius) + } + }; + output[..4].fill(min.to_bits()); + output[4..8].fill(max.to_bits()); +} + +/// Complete generic82ACF070 plus recovered GP batch callbacks for board shapes. +/// Specialized triangle/box82ACF950 and box/box82ACF448 dispatch entries must +/// still be selected by the caller; this function is only the generic entry. +/// Returns (broadcast separation, oriented four-lane axis). +pub fn best_separating_direction( + a: &[u32; 48], + a_kind: PrimitiveKind, + b: &[u32; 48], + b_kind: PrimitiveKind, +) -> ([u32; 4], [u32; 4]) { + let mut directions = [[0; 4]; 16]; + let mut count = separating_axis_candidates(a, b, &mut directions); + if count == 0 { + directions[0] = [0, 0, 1.0f32.to_bits(), 0]; + count = 1; + } + let mut a_intervals = [[0; 12]; 16]; + let mut b_intervals = [[0; 12]; 16]; + project_directions(a, a_kind, &directions[..count], &mut a_intervals); + project_directions(b, b_kind, &directions[..count], &mut b_intervals); + let mut selected = 0; + let mut best = 0.0; + let mut flip = false; + for index in 0..count { + let forward = f32::from_bits(a_intervals[index][0]) - f32::from_bits(b_intervals[index][4]); + let reverse = f32::from_bits(b_intervals[index][0]) - f32::from_bits(a_intervals[index][4]); + // fcmpu followed by ble (!gt): unordered selects reverse without flip. + let (separation, reversed) = if forward > reverse { + (forward, true) + } else { + (reverse, false) + }; + if index == 0 || separation > best { + best = separation; + selected = index; + flip = reversed; + } + } + let mut normal = directions[selected]; + if flip { + normal.iter_mut().for_each(|v| *v ^= 0x8000_0000); + } + ([best.to_bits(); 4], normal) +} diff --git a/crates/skate-core/src/physics/world_contact/reduction.rs b/crates/skate-core/src/physics/world_contact/reduction.rs new file mode 100644 index 00000000..cd007203 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/reduction.rs @@ -0,0 +1,152 @@ +use super::ContactRecord; +use super::arithmetic::{V, cross, dot, scale, sub, vector}; + +/// Complete geometric predicate 8277A208. Body IDs are checked by its caller. +/// The native comparison uses the *smaller* absolute plane displacement. +pub fn coplanar_contacts(a: &ContactRecord, b: &ContactRecord) -> bool { + let normal = vector(a, 8); + if f32::from_bits(0x3f7f_be77) > dot(normal, vector(b, 8)) { + return false; + } + let da = dot(normal, sub(vector(a, 0), vector(b, 0))).abs(); + let db = dot(normal, sub(vector(a, 4), vector(b, 4))).abs(); + let selected = if da - db >= 0.0 { db } else { da }; + selected < f32::from_bits(0x3c23_d70a) +} + +/// Full 827798F8 point selection. Inputs are paired world-space positions; +/// output slots beyond the returned count retain the native write behavior. +/// The caller provides at least one pair, as the native function requires. +pub fn select_contact_points(a: &[V], b: &[V], normal: V, output: &mut [u32; 4]) -> usize { + assert!(!a.is_empty() && a.len() == b.len()); + let mut first = 0; + let mut origin = a[0]; + let mut depth = dot(normal, sub(a[0], b[0])); + for i in 1..a.len() { + let candidate = dot(normal, sub(a[i], b[i])); + if depth > candidate { + depth = candidate; + first = i; + origin = a[i]; + } + } + let mut third_delta = sub(a[0], origin); + let mut far_delta = third_delta; + let mut far = 0; + let mut distance = dot(far_delta, far_delta); + for (i, &point) in a.iter().enumerate().skip(1) { + let delta = sub(point, origin); + let candidate = dot(delta, delta); + if candidate > distance { + distance = candidate; + far = i; + far_delta = delta; + } + } + if first == far { + output[0] = first as u32; + return 1; + } + let mut third = 0; + let initial_area = cross(third_delta, far_delta); + let mut area = dot(initial_area, initial_area); + for (i, &point) in a.iter().enumerate().skip(1) { + let delta = sub(point, origin); + let area_vector = cross(delta, far_delta); + let candidate = dot(area_vector, area_vector); + if candidate > area { + area = candidate; + third = i; + third_delta = delta; + } + } + output[..3].copy_from_slice(&[first as u32, far as u32, third as u32]); + if third == first || third == far { + return 2; + } + let side = cross(normal, far_delta); + let sign = if dot(side, third_delta) >= 0.0 { + 1.0 + } else { + -1.0 + }; + let far_side = scale(side, sign); + let third_side = scale(cross(normal, third_delta), sign); + let edge = sub(third_delta, far_delta); + let mut fourth = 9999; + let mut best = -1.0; + for (i, &point) in a.iter().enumerate() { + let delta = sub(point, origin); + let selected = if dot(delta, far_side) > 0.0 { + if dot(delta, third_side) > 0.0 { + cross(delta, third_delta) + } else { + cross(sub(delta, far_delta), edge) + } + } else { + cross(delta, far_delta) + }; + let candidate = dot(selected, selected); + if candidate > best { + best = candidate; + fourth = i; + } + } + output[3] = fourth as u32; + 3 + usize::from(fourth != first && fourth != far && fourth != third && fourth != 9999) +} + +/// Complete 82779EB0 grouping/reduction. Retained contacts are restored to +/// original order; discarded tail records are not cleared. No drop count change. +pub(super) fn reduce(records: &mut [ContactRecord; 50], count: &mut u32) { + let original = *count as usize; + assert!(original <= 50); + let mut assigned = [false; 50]; + let mut retained = Vec::with_capacity(original); + for first in 0..original { + if assigned[first] { + continue; + } + assigned[first] = true; + let mut group = vec![first]; + for next in first + 1..original { + if !assigned[next] + && records[first][3] == records[next][3] + && records[first][7] == records[next][7] + && coplanar_contacts(&records[first], &records[next]) + { + assigned[next] = true; + group.push(next); + } + } + if group.len() <= 4 { + retained.extend(group); + } else { + let a: Vec<_> = group.iter().map(|&i| vector(&records[i], 0)).collect(); + let b: Vec<_> = group.iter().map(|&i| vector(&records[i], 4)).collect(); + let mut selected = [0, 1, 2, 3]; + let length = select_contact_points(&a, &b, vector(&records[first], 8), &mut selected); + retained.extend(selected[..length].iter().map(|&i| group[i as usize])); + } + } + if retained.len() < original { + retained.sort_unstable(); + *count = retained.len() as u32; + for (destination, source) in retained.into_iter().enumerate() { + if destination != source { + // Native 8277A300 loads/stores float fields individually. Its + // f32→f64→f32 copy quiets sNaNs; IDs/tag remain integer stores. + records[destination] = records[source].map(|bits| { + if bits & 0x7f80_0000 == 0x7f80_0000 && bits & 0x007f_ffff != 0 { + bits | 0x0040_0000 + } else { + bits + } + }); + for i in [3, 7, 23] { + records[destination][i] = records[source][i]; + } + } + } + } +} diff --git a/crates/skate-core/src/physics/world_contact/segment_face.rs b/crates/skate-core/src/physics/world_contact/segment_face.rs new file mode 100644 index 00000000..86bbab74 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/segment_face.rs @@ -0,0 +1,92 @@ +use super::{ + FeaturePrism, MaximumFeature, clamp_point_to_feature, clip_segment_to_feature, + closest_feature_segment, prism_math::*, +}; + +fn store(output: &mut FeaturePrism, offset: usize, point: V) { + output[offset..offset + 4].copy_from_slice(&point.map(f32::to_bits)); +} + +/// Complete TU3 82ACC5F0 segment/face prism and its interval/edge-clamp callees. +/// Keeps the normal correction flag, feature tags and both output-pointer orders. +pub fn intersect_segment_face( + output: &mut FeaturePrism, + face: &mut MaximumFeature, + segment: &mut MaximumFeature, + normal_bits: [u32; 4], + face_is_a: bool, +) -> u32 { + let (fo, so) = if face_is_a { (0, 64) } else { (64, 0) }; + let mut interval = [0; 2]; + // The native caller explicitly zeroes both scratch vectors before clipping. + let mut outside = [0; 8]; + let inside = clip_segment_to_feature(face, segment, normal_bits, &mut interval, &mut outside); + let normal = normal_bits.map(f32::from_bits); + let origin = load(segment, 4); + let direction = load(segment, 8); + let face_origin = load(face, 4); + let low = f32::from_bits(interval[0]); + let high = f32::from_bits(interval[1]); + if inside != 0 { + output[132] = 2; + let face_normal = load(face, 132); + for (i, distance) in [low, high].into_iter().enumerate() { + let p = madd(direction, distance, origin); + store(output, so + i * 4, p); + let t = dot(sub(face_origin, p), face_normal) / dot(normal, face_normal); + store(output, fo + i * 4, madd(normal, t, p)); + } + return 1; + } + let span = high - low; + if span > f32::from_bits(0x3a83_126f) { + output[132] = 2; + let outside_origin = load(&outside, 0); + let outside_plane = load(&outside, 4); + let projected = madd(normal, dot(normal, sub(face_origin, origin)), origin); + let projected = madd( + outside_plane, + dot(outside_plane, sub(outside_origin, projected)), + projected, + ); + let first = madd(direction, low, projected); + let second = madd(direction, span, first); + let mut first = first.map(f32::to_bits); + let mut second = second.map(f32::to_bits); + let first_code = clamp_point_to_feature(face, normal_bits, &mut first); + let second_code = clamp_point_to_feature(face, normal_bits, &mut second); + face[0] = face[0].wrapping_add(first_code.max(second_code) & !1); + output[fo..fo + 4].copy_from_slice(&first); + output[fo + 4..fo + 8].copy_from_slice(&second); + closest_feature_segment(segment[4..20].try_into().unwrap(), &mut first); + closest_feature_segment(segment[4..20].try_into().unwrap(), &mut second); + output[so..so + 4].copy_from_slice(&first); + output[so + 4..so + 8].copy_from_slice(&second); + let delta = sub(load(output, fo), first.map(f32::from_bits)); + let squared = dot(delta, delta); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse_length(squared) + }; + if length > f32::MIN_POSITIVE { + output[133] = 1; + store( + output, + 128, + normalized(cross(direction, cross(delta, direction))), + ); + } + } else { + output[132] = 1; + let p = madd(direction, low, origin); + let mut p = madd(normal, dot(normal, sub(face_origin, p)), p).map(f32::to_bits); + let code = clamp_point_to_feature(face, normal_bits, &mut p); + face[0] = face[0].wrapping_add(code); + output[fo..fo + 4].copy_from_slice(&p); + let region = closest_feature_segment(segment[4..20].try_into().unwrap(), &mut p); + segment[0] = segment[0].wrapping_sub(region).wrapping_add(2); + output[so..so + 4].copy_from_slice(&p); + } + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/segment_prism.rs b/crates/skate-core/src/physics/world_contact/segment_prism.rs new file mode 100644 index 00000000..3a2eafd4 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/segment_prism.rs @@ -0,0 +1,104 @@ +use super::{FeaturePrism, MaximumFeature, closest_feature_segment, prism_math::*}; + +fn store(output: &mut FeaturePrism, offset: usize, point: V) { + output[offset..offset + 4].copy_from_slice(&point.map(f32::to_bits)); +} + +fn endpoint(feature: &mut MaximumFeature, start: bool) -> V { + feature[0] = if start { + feature[0].wrapping_sub(1) + } else { + feature[0].wrapping_add(1) + }; + let origin = load(feature, 4); + if start { + origin + } else { + let direction = load(feature, 8); + let length = load(feature, 16); + std::array::from_fn(|i| direction[i].mul_add(length[i], origin[i])) + } +} + +/// Complete TU3 82ACBAD0 segment/segment prism, including the parallel overlap +/// and disjoint endpoint branches. Normal/flags and untouched point slots survive. +/// `a_is_first` represents the helper's two independently supplied output pointers. +pub fn intersect_feature_segments( + output: &mut FeaturePrism, + a: &mut MaximumFeature, + b: &mut MaximumFeature, + normal: [u32; 4], + a_is_first: bool, +) -> u32 { + let (ao, bo) = if a_is_first { (0, 64) } else { (64, 0) }; + let ad = load(a, 8); + let bd = load(b, 8); + let start_a = load(a, 4); + let start_b = load(b, 4); + let perpendicular = cross(ad, bd); + if dot(perpendicular, perpendicular) > f32::from_bits(0x3400_0000) { + let plane = cross(normalized(perpendicular), bd); + let mut along = dot(sub(start_b, start_a), plane) / dot(ad, plane); + if along < 0.0 { + along = 0.0; + } + let length = load(a, 16); + if length.iter().all(|&v| along > v) { + along = length[0]; + } + output[132] = 1; + let mut point = madd(ad, along, start_a).map(f32::to_bits); + let region = closest_feature_segment(b[4..20].try_into().unwrap(), &mut point); + b[0] = b[0].wrapping_sub(region).wrapping_add(2); + output[bo..bo + 4].copy_from_slice(&point); + let region = closest_feature_segment(a[4..20].try_into().unwrap(), &mut point); + a[0] = a[0].wrapping_sub(region).wrapping_add(2); + output[ao..ao + 4].copy_from_slice(&point); + return 1; + } + let normal = normal.map(f32::from_bits); + let axis = if dot(normal, ad).abs() < dot(normal, bd).abs() { + ad + } else { + bd + }; + let al = load(a, 16); + let bl = load(b, 16); + let end_a = std::array::from_fn(|i| ad[i].mul_add(al[i], start_a[i])); + let end_b = std::array::from_fn(|i| bd[i].mul_add(bl[i], start_b[i])); + let a0 = dot(axis, start_a); + let b0 = dot(axis, start_b); + let a1 = dot(axis, end_a); + let b1 = dot(axis, end_b); + // Native compare/select ordering also specifies unordered/equal operands. + let (amin, amax) = if a1 > a0 { (a0, a1) } else { (a1, a0) }; + let (bmin, bmax) = if b1 > b0 { (b0, b1) } else { (b1, b0) }; + let low = if amin > bmin { amin } else { bmin }; + let high = if bmax > amax { amax } else { bmax }; + if high > low { + let ap = normalized(cross(cross(normal, ad), ad)); + let bp = normalized(cross(cross(normal, bd), bd)); + for (index, along) in [low, high].into_iter().enumerate() { + let anchor = scale(axis, along); + for (offset, origin, direction, plane) in [(ao, start_a, ad, ap), (bo, start_b, bd, bp)] + { + let inverse = reciprocal(dot(normal, plane)); + let distance = inverse * dot(sub(origin, anchor), plane); + let projected = madd(normal, distance, anchor); + let projected = madd(direction, dot(sub(projected, origin), direction), origin); + store(output, offset + index * 4, projected); + } + } + output[132] = 2; + } else { + output[132] = 1; + let (a_start, b_start) = if bmin > amin { + (a0 > a1, b0 < b1) + } else { + (a0 < a1, b0 > b1) + }; + store(output, ao, endpoint(a, a_start)); + store(output, bo, endpoint(b, b_start)); + } + 1 +} diff --git a/crates/skate-core/src/physics/world_contact/triangle_box_sat.rs b/crates/skate-core/src/physics/world_contact/triangle_box_sat.rs new file mode 100644 index 00000000..97838a97 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/triangle_box_sat.rs @@ -0,0 +1,67 @@ +use crate::physics::{reciprocal_sqrt::estimate, world_contact::prism_math::*}; + +/// TU3 82ACF950. Triangle normal first, then box Z/Y/X and all nine edge +/// crosses. Cross axes receive exactly one reciprocal-square-root refinement; +/// there is no small-cross rejection or normalization fallback in this entry. +pub(in super::super) fn triangle_box(triangle: &[u32; 48], box_shape: &[u32; 48]) -> ([u32; 4], [u32; 4]) { + let mut axes = [[0.0; 4]; 13]; + axes[0] = load(triangle, 4); + for i in 0..3 { + axes[1 + i] = load(box_shape, 12 - i * 4); + } + for edge in 0..3 { + for box_edge in 0..3 { + let axis = cross( + load(triangle, 16 + edge * 4), + load(box_shape, 16 + box_edge * 4), + ); + let squared = dot(axis, axis); + let seed = estimate(squared); + let inverse = (seed * 0.5).mul_add((-squared).mul_add(seed * seed, 1.0), seed); + axes[4 + edge * 3 + box_edge] = scale(axis, inverse); + } + } + let scaled_box: [V; 3] = std::array::from_fn(|i| { + scale( + load(box_shape, 4 + i * 4), + f32::from_bits(box_shape[28 + i]), + ) + }); + let mut best = 0.0; + let mut selected = axes[0]; + let mut selected_sign = 1.0; + for (index, axis) in axes.into_iter().enumerate() { + let p0 = dot(load(triangle, 0), axis); + let p1 = dot(load(triangle, 8), axis); + let p2 = dot(load(triangle, 12), axis); + // The source compares vertices 1 and 2, then combines vertex 0. + let minimum = minimum(p0, minimum(p1, p2)); + let maximum = maximum(p0, maximum(p1, p2)); + let center = dot(load(box_shape, 0), axis); + let extents = scaled_box.map(|extent| dot(extent, axis).abs()); + let radius = (extents[0] + extents[1]) + extents[2]; + let forward = minimum - (center + radius); + let reverse = (center - radius) - maximum; + let (separation, sign) = if forward > reverse { + (forward, -1.0) + } else { + (reverse, 1.0) + }; + if index == 0 || separation > best { + best = separation; + selected = axis; + selected_sign = sign; + } + } + ( + [best.to_bits(); 4], + scale(selected, selected_sign).map(f32::to_bits), + ) +} + +fn minimum(a: f32, b: f32) -> f32 { + if a < b { a } else { b } +} +fn maximum(a: f32, b: f32) -> f32 { + if a > b { a } else { b } +} diff --git a/crates/skate-core/src/physics/world_contact/triangle_feature.rs b/crates/skate-core/src/physics/world_contact/triangle_feature.rs new file mode 100644 index 00000000..81aaeaa5 --- /dev/null +++ b/crates/skate-core/src/physics/world_contact/triangle_feature.rs @@ -0,0 +1,134 @@ +use super::MaximumFeature; +use crate::physics::reciprocal_sqrt::estimate; + +type V = [f32; 4]; + +/// Full triangle maximum-feature callback82ADDD68, including near-tangent +/// edge/vertex branches. Header word0 is preserved outside the face branch. +pub fn triangle_maximum_feature( + gp: &[u32; 48], + mode: u32, + direction: [u32; 4], + out: &mut MaximumFeature, +) { + let normal = load(gp, 4); + let query = direction.map(f32::from_bits); + let projected = dot(query, normal); + let points = [load(gp, 0), load(gp, 8), load(gp, 12)]; + let edges = [load(gp, 16), load(gp, 20), load(gp, 24)]; + if projected.abs() > f32::from_bits(0x3f73_3333) { + if u32::from(projected < 0.0) == mode { + for (i, point) in [0, 2, 1].into_iter().enumerate() { + segment(out, i, points[point], edges[i], gp[28 + i]); + } + out[0] = 8; + } else { + for (i, point) in [0, 1, 2].into_iter().enumerate() { + segment(out, i, points[point], edges[2 - i].map(|x| -x), gp[30 - i]); + } + out[0] = 0; + } + store(out, 132, normal); + out[140] = 3; + } else { + let p = points.map(|x| dot(query, x)); + let e = edges.map(|x| dot(query, x).abs()); + let threshold = f32::from_bits(0x3d4c_cccd); + let (point, edge) = if p[0] > p[1] && p[0] > p[2] { + ( + 0, + if threshold > e[0] && e[2] > e[0] { + Some(0) + } else if threshold > e[2] { + Some(2) + } else { + None + }, + ) + } else if p[1] > p[2] { + ( + 1, + if threshold > e[1] && e[2] > e[1] { + Some(1) + } else if threshold > e[2] { + Some(2) + } else { + None + }, + ) + } else { + ( + 2, + if threshold > e[0] && e[1] > e[0] { + Some(0) + } else if threshold > e[1] { + Some(1) + } else { + None + }, + ) + }; + if let Some(edge) = edge { + segment(out, 0, points[[0, 2, 1][edge]], edges[edge], gp[28 + edge]); + out[140] = 1; + } else { + store(out, 136, points[point]); + out[140] = 0; + } + } + build_feature_edge_planes(out, mode, direction); +} + +/// Complete82AC6F88. Only edge plane vectors are changed. Mode0 negates the +/// incoming direction before crossing, preserving the native arithmetic order. +pub fn build_feature_edge_planes(feature: &mut MaximumFeature, mode: u32, direction: [u32; 4]) { + let count = feature[140] as i32; + if count <= 0 { + return; + } + assert!( + count <= 8, + "native feature storage holds at most eight segments" + ); + let normal = direction + .map(f32::from_bits) + .map(|x| if mode == 0 { -x } else { x }); + for i in 0..count as usize { + let offset = 4 + i * 16; + let edge = load(feature, offset + 4); + let plane = cross(edge, normal); + let squared = dot(plane, plane); + let mut r = estimate(squared); + r = (r * 0.5).mul_add((-squared).mul_add(r * r, 1.0), r); + let scale = if squared > f32::from_bits(0x3400_0000) { + r + } else { + 0.0 + }; + store(feature, offset + 8, plane.map(|x| x * scale)); + } +} + +fn segment(out: &mut MaximumFeature, index: usize, point: V, edge: V, length: u32) { + let offset = 4 + index * 16; + store(out, offset, point); + store(out, offset + 4, edge); + out[offset + 12..offset + 16].fill(length); +} +fn load(words: &[u32], offset: usize) -> V { + std::array::from_fn(|i| f32::from_bits(words[offset + i])) +} +fn store(words: &mut [u32], offset: usize, v: V) { + words[offset..offset + 4].copy_from_slice(&v.map(f32::to_bits)); +} +fn dot(a: V, b: V) -> f32 { + super::arithmetic::dot(a[..3].try_into().unwrap(), b[..3].try_into().unwrap()) +} +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]), + ] +} diff --git a/crates/skate-core/src/player/adjust.rs b/crates/skate-core/src/player/adjust.rs new file mode 100644 index 00000000..e3337c3b --- /dev/null +++ b/crates/skate-core/src/player/adjust.rs @@ -0,0 +1,65 @@ +//! TU3 `PhysicalPlayerHiLOD::Adjust` (`0x82DB6CF0`). +//! +//! This phase runs only after the solver completion fence. It publishes the +//! solved board condition, performs the first skeleton adjustment, calls the +//! current state's post-physics slot, then performs the final skeleton update. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AdjustFields { + /// ProcessedPhysIn+2512. + pub processed_word_2512: u32, + /// ProcessedPhysIn+2604. + pub timestep_2604: f32, + /// SkateboardController+448. + pub controller_state_448: u32, + /// Player+1876 output staging +42. + pub output_flag_42: bool, + /// Player+1876 output staging +144. + pub output_scalar_144: f32, + /// Player+1876 output staging +200. + pub output_counter_200: u32, +} + +/// Required object calls in the exact order used by `0x82DB6CF0`. +pub trait AdjustServices { + /// Player vtable `+116`, then SkateboardBody `0x82C02138`. + fn board_condition_82c02138(&mut self) -> u32; + + /// BoardBody vtable `+4`, byte `+868`. Called only in controller mode 1. + fn controller_mode_one_board_flag_868(&mut self) -> bool; + + /// Skeleton update `0x82BD80D8` with the board flag and frame timestep. + fn adjust_skeleton_82bd80d8(&mut self, board_flag: bool, timestep: f32); + + /// Current physical-state vtable slot `+40`. + fn update_current_state_post_physics_vtable_40(&mut self); + + /// Player helper `0x82DB9100`; its exact result is forwarded unchanged. + fn player_output_selector_82db9100(&mut self) -> u32; + + /// Final skeleton update `0x82BD83E0`. + fn finish_skeleton_adjustment_82bd83e0(&mut self, selector: u32); +} + +/// Runs the complete TU3 Adjust wrapper after solver completion. +pub fn run_adjust(fields: &mut AdjustFields, services: &mut impl AdjustServices) { + if services.board_condition_82c02138() == 1 && fields.processed_word_2512 != 500 { + fields.output_flag_42 = true; + fields.output_scalar_144 = 0.0; + fields.output_counter_200 = fields.output_counter_200.wrapping_add(1); + } + + let board_flag = if fields.controller_state_448 == 1 { + services.controller_mode_one_board_flag_868() + } else { + false + }; + services.adjust_skeleton_82bd80d8(board_flag, fields.timestep_2604); + services.update_current_state_post_physics_vtable_40(); + let selector = services.player_output_selector_82db9100(); + services.finish_skeleton_adjustment_82bd83e0(selector); +} + +#[cfg(test)] +#[path = "tests/adjust.rs"] +mod tests; diff --git a/crates/skate-core/src/player/conditioner_capabilities.rs b/crates/skate-core/src/player/conditioner_capabilities.rs new file mode 100644 index 00000000..6b7f49ed --- /dev/null +++ b/crates/skate-core/src/player/conditioner_capabilities.rs @@ -0,0 +1,40 @@ +//! Conditioner capability publication from original Skate 3 TU3 0x8275F430. +//! +//! World writes ExternalInput+2780, then 0x82DA7888 copies that word to +//! PhysOutScoring2+204. The word gates controller and camera behavior; it is +//! not a count or mask of currently broken bones. + +/// Host settings needed by the original whole-word capability calculation. +/// There is deliberately no implicit default for these gameplay choices. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConditionerCapabilityContext { + /// Original FEBackEnd state equals 2 (in front end). This is a menu gate. + pub in_front_end: bool, + /// Original game settings +184 equals 1. UI option 29 is Hall of Meat mode. + pub hall_of_meat_enabled: bool, + /// Original challenge subsystem +328 virtual method at vtable+12 is nonzero. + /// Its precise challenge interface name remains unidentified. A host with + /// no challenge system supplies false rather than inventing that system. + pub challenge_query_active: bool, + /// Original challenge subsystem +512 points to a record whose byte 44 is + /// nonzero. Only consulted when the challenge query above is active. + pub challenge_configuration_enabled: bool, +} + +impl ConditionerCapabilityContext { + /// Preserve all bits and branch priority from 0x8275F4D8..0x8275F558. + pub fn capabilities(self) -> u32 { + if self.in_front_end { + 0 + } else if self.challenge_query_active && self.challenge_configuration_enabled { + 0xffff_fff7 + } else if self.hall_of_meat_enabled && !self.challenge_query_active { + 0xffff_f7f7 + } else { + 0x0000_01c0 + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/conditioner_capabilities/tests.rs b/crates/skate-core/src/player/conditioner_capabilities/tests.rs new file mode 100644 index 00000000..abc05f55 --- /dev/null +++ b/crates/skate-core/src/player/conditioner_capabilities/tests.rs @@ -0,0 +1,35 @@ +use super::ConditionerCapabilityContext; + +#[test] +fn complete_source_capability_branch_table() { + // Literal results from 8275F4D8..8275F558, indexed as FE/HoM/query/config. + // In particular, active challenge configuration overrides the HoM setting; + // an inactive query does not consult that configuration. FE wins over both. + let source_words = [ + 0x0000_01c0, + 0x0000_01c0, + 0x0000_01c0, + 0xffff_fff7, + 0xffff_f7f7, + 0xffff_f7f7, + 0x0000_01c0, + 0xffff_fff7, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ]; + for (combination, expected) in source_words.into_iter().enumerate() { + let context = ConditionerCapabilityContext { + in_front_end: combination & 8 != 0, + hall_of_meat_enabled: combination & 4 != 0, + challenge_query_active: combination & 2 != 0, + challenge_configuration_enabled: combination & 1 != 0, + }; + assert_eq!(context.capabilities(), expected, "{context:?}"); + } +} diff --git a/crates/skate-core/src/player/frame.rs b/crates/skate-core/src/player/frame.rs new file mode 100644 index 00000000..53630c52 --- /dev/null +++ b/crates/skate-core/src/player/frame.rs @@ -0,0 +1,225 @@ +//! Stock frame order recovered from TU3 `0x82859E70`, `0x8275EB10` and +//! `0x8275ED68`. The solver boundary intentionally splits the frame because +//! the native game submits jobs before returning for the post-solver pass. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameStage { + StartFrame, + WorldPreFrame, + AnimationPhaseOne, + AnimationPhaseTwo, + WorldPrepare, + AnimationPhaseThree, + WorldPrecursor, + ActorPhysicsPublication, + PostZeroJobs, + Input, + WorldAfterInput, + PostInput, + Logic, + PreState, + State, + PostState, + CollectSolverInputs, + PrepareSolverJobs, + SubmitSolver, + WaitSolver, + Adjust, + PostAdjust, + Output, + PostOutput, + FinishOutputs, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SchedulerState { + Ready, + Running, + SolverPending, + Faulted, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ScheduleError { + WrongState { + expected: SchedulerState, + actual: SchedulerState, + }, + Runtime { + stage: FrameStage, + source: E, + }, +} + +pub trait AnimationPhases { + type Error; + + fn phase_one(&mut self) -> Result<(), Self::Error>; + fn phase_two(&mut self) -> Result<(), Self::Error>; + fn phase_three(&mut self) -> Result<(), Self::Error>; + fn publish_physics_input(&mut self) -> Result<(), Self::Error>; +} + +pub trait WorldPhases { + type Error; + + /// `82DC2FB8`; writes the shared timestep and its reciprocal and clears the + /// current job/island counters. + fn start_frame(&mut self, timestep: f32) -> Result<(), Self::Error>; + fn pre_frame(&mut self) -> Result<(), Self::Error>; + fn prepare(&mut self) -> Result<(), Self::Error>; + fn precursor(&mut self) -> Result<(), Self::Error>; + /// World-owned work at `8275E6E8`, between all Input and Post-Input calls. + fn after_input(&mut self) -> Result<(), Self::Error>; + /// `8275E928`; filtered physicals publish solver-facing work. + fn collect_solver_inputs(&mut self) -> Result<(), Self::Error>; + /// Conditioners, contact reports and final world output after Post 5-Jobs. + fn finish_outputs(&mut self) -> Result<(), Self::Error>; +} + +pub trait PhysicalPhases { + type Error; + + fn post_zero_jobs(&mut self) -> Result<(), Self::Error>; + fn input(&mut self) -> Result<(), Self::Error>; + fn post_input(&mut self) -> Result<(), Self::Error>; + fn logic(&mut self) -> Result<(), Self::Error>; + fn pre_state(&mut self) -> Result<(), Self::Error>; + fn state(&mut self) -> Result<(), Self::Error>; + fn post_state(&mut self) -> Result<(), Self::Error>; + fn adjust(&mut self) -> Result<(), Self::Error>; + fn post_adjust(&mut self) -> Result<(), Self::Error>; + fn output(&mut self) -> Result<(), Self::Error>; + fn post_output(&mut self) -> Result<(), Self::Error>; +} + +pub trait SolverPhases { + type Error; + + /// `82764480` / `82DC35E0`; builds the dependency tree after every state + /// has submitted contacts, joints, drives and external forces. + fn prepare_jobs(&mut self) -> Result<(), Self::Error>; + /// `82763B08`; finalizes collision data and submits the dependency tree. + fn submit(&mut self) -> Result<(), Self::Error>; + /// `82763D18`; the Adjust phase cannot run until this fence completes. + fn wait(&mut self) -> Result<(), Self::Error>; +} + +#[derive(Clone, Copy, Debug)] +pub struct FrameScheduler { + state: SchedulerState, +} + +impl Default for FrameScheduler { + fn default() -> Self { + Self { + state: SchedulerState::Ready, + } + } +} + +impl FrameScheduler { + pub const fn state(&self) -> SchedulerState { + self.state + } + + /// Runs the complete pre-solver half of the stock player frame. Successful + /// return means jobs were submitted and their inputs must remain alive. + pub fn run_before_solver( + &mut self, + timestep: f32, + animation: &mut A, + world: &mut W, + physical: &mut P, + solver: &mut S, + ) -> Result<(), ScheduleError> + where + A: AnimationPhases, + W: WorldPhases, + P: PhysicalPhases, + S: SolverPhases, + { + self.require(SchedulerState::Ready)?; + self.state = SchedulerState::Running; + + self.call(FrameStage::StartFrame, world.start_frame(timestep))?; + self.call(FrameStage::WorldPreFrame, world.pre_frame())?; + self.call(FrameStage::AnimationPhaseOne, animation.phase_one())?; + self.call(FrameStage::AnimationPhaseTwo, animation.phase_two())?; + self.call(FrameStage::WorldPrepare, world.prepare())?; + self.call(FrameStage::AnimationPhaseThree, animation.phase_three())?; + self.call(FrameStage::WorldPrecursor, world.precursor())?; + self.call( + FrameStage::ActorPhysicsPublication, + animation.publish_physics_input(), + )?; + self.call(FrameStage::PostZeroJobs, physical.post_zero_jobs())?; + self.call(FrameStage::Input, physical.input())?; + self.call(FrameStage::WorldAfterInput, world.after_input())?; + self.call(FrameStage::PostInput, physical.post_input())?; + self.call(FrameStage::Logic, physical.logic())?; + self.call(FrameStage::PreState, physical.pre_state())?; + self.call(FrameStage::State, physical.state())?; + self.call(FrameStage::PostState, physical.post_state())?; + self.call( + FrameStage::CollectSolverInputs, + world.collect_solver_inputs(), + )?; + self.call(FrameStage::PrepareSolverJobs, solver.prepare_jobs())?; + self.call(FrameStage::SubmitSolver, solver.submit())?; + self.state = SchedulerState::SolverPending; + Ok(()) + } + + /// Completes the stock post-solver half. The scheduler becomes reusable + /// only after every output/conditioner phase succeeds. + pub fn run_after_solver( + &mut self, + world: &mut W, + physical: &mut P, + solver: &mut S, + ) -> Result<(), ScheduleError> + where + W: WorldPhases, + P: PhysicalPhases, + S: SolverPhases, + { + self.require(SchedulerState::SolverPending)?; + self.state = SchedulerState::Running; + + self.call(FrameStage::WaitSolver, solver.wait())?; + self.call(FrameStage::Adjust, physical.adjust())?; + self.call(FrameStage::PostAdjust, physical.post_adjust())?; + self.call(FrameStage::Output, physical.output())?; + self.call(FrameStage::PostOutput, physical.post_output())?; + self.call(FrameStage::FinishOutputs, world.finish_outputs())?; + self.state = SchedulerState::Ready; + Ok(()) + } + + fn require(&self, expected: SchedulerState) -> Result<(), ScheduleError> { + if self.state == expected { + Ok(()) + } else { + Err(ScheduleError::WrongState { + expected, + actual: self.state, + }) + } + } + + fn call( + &mut self, + stage: FrameStage, + result: Result<(), E>, + ) -> Result<(), ScheduleError> { + result.map_err(|source| { + self.state = SchedulerState::Faulted; + ScheduleError::Runtime { stage, source } + }) + } +} + +#[cfg(test)] +#[path = "tests/frame.rs"] +mod tests; diff --git a/crates/skate-core/src/player/input_phase/air_output.rs b/crates/skate-core/src/player/input_phase/air_output.rs new file mode 100644 index 00000000..0dc33db3 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/air_output.rs @@ -0,0 +1,37 @@ +//! Completed Air publication shared by physical state and graph consumers. +use super::types::RawVector; +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct AirOutputFields { + ///KnownAir Fill82D36880, retained template values outside states that write them. + pub trajectory_apex_0: RawVector, + pub collision_position_16: RawVector, + pub landing_normal_32: RawVector, + pub selector_vector_48: RawVector, + pub trajectory_position_64: RawVector, + pub landing_heading_80: RawVector, + pub selector_com_position_96: RawVector, + pub jump_velocity_delta_112: RawVector, + pub launch_velocity_128: RawVector, + pub time_in_state_176: f32, + pub collision_time_180: f32, + pub collision_normal_speed_188: f32, + pub time_to_apex_196: f32, + pub trajectory_index_220: i32, + pub selected_trajectory_240: [RawVector; 4], + pub trajectory_plane_samples_336: [f32; 25], + pub known_air_valid_437: u8, + pub launched_442: u8, + pub flag_444: u8, + /// Air324 high bits used by IsHandPlanting82BA55A8. + pub handplant_flags_324: u32, + pub vector_160: RawVector, + pub scalar_184: f32, + pub landing_normal_144: RawVector, + pub jump_height_200: f32, + pub reached_apex_436: u8, + pub flag_441: u8, + pub flag_446: u8, + pub flag_447: u8, + pub flag_448: u8, + pub use_air_reckoning_452: u8, +} diff --git a/crates/skate-core/src/player/input_phase/mod.rs b/crates/skate-core/src/player/input_phase/mod.rs new file mode 100644 index 00000000..04ae05b4 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/mod.rs @@ -0,0 +1,21 @@ +//! Skate 3 TU3 `PhysicalPlayerHiLOD::Input` (`0x82DB4048`). +//! +//! The phase is split into direct field publication and an ordered runtime. +//! Separate native subsystems remain mandatory service calls; none have a +//! fallback implementation here. + +mod publication; +mod motion_math; +mod runtime; +mod types; +mod air_output; +mod pose_output; + +pub use runtime::{InputContinuation, InputPhaseError, InputPhaseServices, process_input, start_input, finish_input}; +pub use types::*; +pub use air_output::AirOutputFields; +pub use pose_output::{AnimationOutputFields, ScoringOutputFields, SkeletonOutputFields}; + +#[cfg(test)] +mod tests; + diff --git a/crates/skate-core/src/player/input_phase/motion_math.rs b/crates/skate-core/src/player/input_phase/motion_math.rs new file mode 100644 index 00000000..5ed810cd --- /dev/null +++ b/crates/skate-core/src/player/input_phase/motion_math.rs @@ -0,0 +1,58 @@ +//! Inlined physical input calculations from TU3 82DB4048. +//! Xenon estimates use the shared, explicitly approximation-derived model. +use super::types::{GroundHistoryRequest, GroundHistoryResult, PrepareJumpRequest, RawVector}; +use crate::physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::{dot3, reciprocal_estimate}, +}; + +///82DB5184..51D4: retain displacement separately from the damped velocity. +pub fn ground_history(request: GroundHistoryRequest) -> GroundHistoryResult { + let old = request.previous_position.map(f32::from_bits); + let current = request.current_position.map(f32::from_bits); + let previous = request.previous_filtered_delta.map(f32::from_bits); + let delta: [f32; 4] = std::array::from_fn(|i| current[i] - old[i]); + let timestep = f32::from_bits(request.timestep_bits); + let mut reciprocal = reciprocal_estimate(timestep); + for _ in 0..2 { + let error = (-reciprocal).mul_add(timestep, 1.0); + reciprocal = reciprocal.mul_add(error, reciprocal); + } + //Literal82072818, then8231A844 minus that coefficient. + let retention = f32::from_bits(0x3f73_3333); + let change = 1.0 - retention; + GroundHistoryResult { + delta: delta.map(f32::to_bits), + filtered_delta: std::array::from_fn(|i| { + let scaled = reciprocal * (delta[i] * change); + previous[i].mul_add(retention, scaled).to_bits() + }), + } +} + +///82DB53F0..5508 integrates the gravity component along the board velocity. +///The PrepareJump flag's direct velocity copy is handled by the caller. +pub fn prepared_jump_velocity(request: PrepareJumpRequest) -> RawVector { + let velocity = request.skateboard_vector.map(f32::from_bits); + let previous = request.previous_velocity.map(f32::from_bits); + let squared = dot3(velocity, velocity); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + //Initializer82F826F8 broadcasts82181A88 into830BD350. + let direction = if length > f32::from_bits(0x3586_37bd) { + velocity.map(|v| v * inverse) + } else { + [0.0; 4] + }; + let down_component = f32::from_bits(direction[1].to_bits() ^ 0x8000_0000); + let acceleration = down_component * f32::from_bits(0x411c_cccd); //822F8BD4 + std::array::from_fn(|i| { + (direction[i] * acceleration) + .mul_add(f32::from_bits(0x3c88_8889), previous[i]) //820849C8 + .to_bits() + }) +} diff --git a/crates/skate-core/src/player/input_phase/pose_output.rs b/crates/skate-core/src/player/input_phase/pose_output.rs new file mode 100644 index 00000000..a6e98097 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/pose_output.rs @@ -0,0 +1,40 @@ +//! Completed pose observations; original PhysOut template constructors. +use super::types::RawMatrix; + +/// Skeleton reset82DE3A28 explicitly clears these scalars and flags. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SkeletonOutputFields { + pub hips_right_angle_496: f32, + pub hips_up_angle_500: f32, + pub scalar_544: f32, + pub no_support_time_548: f32, + pub time_until_teleport_576: f32, + pub response_strength_580: f32, + pub extra_weight_584: f32, + pub response_change_588: f32, + pub flag_597: u8, + pub over_599: u8, + pub flag_600: u8, + pub flag_601: u8, + pub teleport_pending_604: u8, + pub response_changed_605: u8, + pub anim_to_world_11920: RawMatrix, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnimationOutputFields { + pub collision_time_144: f32, + pub profile_148: u32, +} +impl Default for AnimationOutputFields { + fn default() -> Self { + // Animation82DE3F38 stores FLT_MAX at144 and zero at148. + Self { collision_time_144: f32::MAX, profile_148: 0 } + } +} + +/// Scoring2 reset82DE4468 clears the conditioner capability mask at204. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ScoringOutputFields { + pub capabilities_204: u32, +} diff --git a/crates/skate-core/src/player/input_phase/publication.rs b/crates/skate-core/src/player/input_phase/publication.rs new file mode 100644 index 00000000..70155032 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/publication.rs @@ -0,0 +1,340 @@ +use crate::input::animation_packet::{self, ProcessedPacketFields}; + +use super::types::{ + AnimationInputPacket, NativeReferenceBase, NativeRelativeReference, PhysicalPlayerInput, + PlayerInputState, ProcessedPhysicsInput, RawMatrix, +}; + +pub(crate) fn publish_player_flags(player: &mut PlayerInputState, out: &mut ProcessedPhysicsInput) { + transfer_bit(&mut out.flags_2472, 14, player.flags_1296, 27); + transfer_bit(&mut out.flags_2488, 26, player.flags_1296, 20); + out.vectors_880_896_912_928_944[3] = player.queued_vector_1280; + transfer_bit(&mut out.flags_2488, 22, player.flags_1296, 18); + transfer_bit(&mut out.flags_2480, 16, player.flags_1296, 17); + player.queued_vector_1280 = [0; 4]; + player.flags_1296 &= !(1 << 20); +} + +pub(crate) fn publish_force_braking( + player: &PlayerInputState, + physical: &PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + out: &mut ProcessedPhysicsInput, +) { + let force = packet.force_braking_10796 != 0 + && (player.state_count_1312 as i32) > 5 + && physical.skateboard.scalar_160 < f32::from_bits(0x3dcc_cccd) + && physical.state.state_16 == 100 + && (physical.collision.wheel_count_0 as i32) > 2; + replace_bool(&mut out.flags_2476, 24, force); +} + +pub(crate) fn publish_collision_prefix( + player: &PlayerInputState, + physical: &PhysicalPlayerInput, + out: &mut ProcessedPhysicsInput, +) { + replace_byte(&mut out.flags_2484, 26, physical.collision.flag_215); + replace_byte(&mut out.flags_2484, 22, physical.collision.flag_216); + out.vectors_720_784_800_816_832_864[5] = player.manager_1852_vector_176; +} + +pub(crate) fn publish_animation_packet( + packet: &AnimationInputPacket<'_>, + out: &mut ProcessedPhysicsInput, +) { + let mut fields = ProcessedPacketFields { + flags_2468: out.flags_2468, + flags_2476: out.flags_2476, + timestep: out.timestep_2604, + scalar_2668: out.scalar_2668, + vector_1520: out.vector_1520, + matrix_1536: out.matrix_1536, + byte_1600: out.byte_1600, + truck_tightness: out.truck_tightness_2760, + scalar_2764: out.scalar_2764, + }; + animation_packet::publish(packet.publication, &mut fields); + out.flags_2468 = fields.flags_2468; + out.flags_2476 = fields.flags_2476; + out.timestep_2604 = fields.timestep; + out.scalar_2668 = fields.scalar_2668; + out.vector_1520 = fields.vector_1520; + out.matrix_1536 = fields.matrix_1536; + out.byte_1600 = fields.byte_1600; + out.truck_tightness_2760 = fields.truck_tightness; + out.scalar_2764 = fields.scalar_2764; +} + +pub(crate) fn select_surface( + player: &PlayerInputState, + packet: &AnimationInputPacket<'_>, + surface_default_mode: u32, + out: &mut ProcessedPhysicsInput, +) -> Result<(), u32> { + let index = packet.state_variant_10928; + let Some(variant) = player.state_variants_1408.get(index as usize) else { + out.state_variant_index_2528 = index; + return Err(index); + }; + out.state_variant_ref_2548 = Some(NativeRelativeReference { + base: NativeReferenceBase::Player, + offset: 0x580 + 0x10 * index as u16, + }); + out.state_variant_index_2528 = index; + + let requested = if variant.surface_override_enabled_60 != 0 { + match surface_default_mode { + 1..=3 => 1, + 5 => 3, + 13 => 2, + value => value, + } + } else { + surface_default_mode + }; + select_surface_mode(requested, out); + Ok(()) +} + +fn select_surface_mode(requested: u32, out: &mut ProcessedPhysicsInput) { + let mode = match requested { + 1..=5 => requested, + 6 => 3, + _ => 1, + }; + let (primary, secondary) = match mode { + 1 => (24, 104), + 2 => (40, 116), + 3 => (56, 128), + 4 => (72, 140), + 5 => (88, 152), + _ => unreachable!("surface mode normalization is exhaustive"), + }; + out.surface_mode_2540 = mode; + out.surface_primary_ref_2544 = Some(NativeRelativeReference { + base: NativeReferenceBase::SurfaceSelector, + offset: primary, + }); + out.surface_secondary_ref_2552 = Some(NativeRelativeReference { + base: NativeReferenceBase::SurfaceSelector, + offset: secondary, + }); +} + +pub(crate) fn publish_external_physics( + player: &mut PlayerInputState, + packet: &AnimationInputPacket<'_>, + out: &mut ProcessedPhysicsInput, +) { + if packet.use_external_physics_10688 != 0 { + out.flags_2472 |= 1 << 29; + out.external_physics_1616 + .copy_from(packet.external_physics_10512); + if out.external_physics_1616.flags & (1 << 26) != 0 { + player + .external_physics_cache_1008 + .copy_from(packet.external_physics_10512); + } else { + out.external_physics_1616.vectors[5..9] + .copy_from_slice(&player.external_physics_cache_1008.vectors[5..9]); + out.external_physics_1616.vectors[9] = player.external_physics_cache_1008.vectors[9]; + transfer_bit( + &mut out.external_physics_1616.flags, + 27, + player.external_physics_cache_1008.flags, + 27, + ); + out.external_physics_1616.flags |= 1 << 26; + } + } + replace_byte(&mut out.flags_2472, 28, packet.external_physics_flag_10689); +} + +pub(crate) fn publish_state_prefix( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + captured_state: u32, + out: &mut ProcessedPhysicsInput, +) { + transfer_bit(&mut out.flags_2468, 13, player.flags_1296, 31); + let has_probe_or_animation_flag = + out.flags_2468 & (1 << 2) != 0 || out.probe_1792.byte_104 != 0; + replace_bool(&mut out.flags_2468, 18, has_probe_or_animation_flag); + player.frames_since_teleport_1328 = if player.flags_1296 & (1 << 29) != 0 { + 0 + } else { + player.frames_since_teleport_1328.wrapping_add(1) + }; + out.frames_since_teleport_2584 = player.frames_since_teleport_1328; + player.flags_1296 &= !(1 << 29); + out.state_count_2564 = player.state_count_1312; + out.state_timer_2664 = player.state_timer_1344; + out.update_count_2568 = player.update_count_1316; + replace_byte(&mut out.flags_2472, 12, packet.flag_10373); + replace_byte(&mut out.flags_2468, 0, packet.flag_10786); + replace_byte(&mut out.flags_2472, 31, packet.flag_10787); + out.state_2508 = captured_state; + out.state_2504 = captured_state; + out.category_2512 = physical.state.category_12; + replace_byte(&mut out.flags_2472, 30, physical.state.flag_61); + replace_byte(&mut out.flags_2472, 18, physical.state.flag_69); + replace_byte(&mut out.flags_2480, 31, physical.state.flag_74); + out.grind_words_2532_2536 = physical.grinds.words_136_140; + out.category_2516 = physical.state.category_12; + out.player_state_value_2520 = player.state_value_1336; +} + +pub(crate) fn publish_physical_outputs( + physical: &PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + out: &mut ProcessedPhysicsInput, +) { + out.vectors_400_416[0] = physical.skateboard.vector_80; + out.vectors_400_416[1] = physical.skateboard.vector_96; + out.scalar_2736 = physical.skateboard.scalar_172; + out.scalar_2612 = physical.skateboard.scalar_168; + out.scalar_2656 = physical.skateboard.scalar_164; + out.scalar_2652 = physical.skateboard.scalar_160; + out.vectors_720_784_800_816_832_864[0] = physical.skateboard.vector_64; + out.vectors_720_784_800_816_832_864[1] = physical.skateboard.vector_128; + out.vectors_720_784_800_816_832_864[2] = physical.skateboard.vector_144; + + out.vectors_624_640_656_672[0] = packet.vector_10816; + out.vectors_624_640_656_672[1] = packet.vector_10832; + transfer_bit(&mut out.flags_2476, 6, packet.flags_10932, 31); + transfer_bit(&mut out.flags_2476, 5, packet.flags_10932, 30); + transfer_bit(&mut out.flags_2476, 4, packet.flags_10932, 29); + out.scalar_2696 = packet.scalar_10912; + out.vectors_624_640_656_672[2] = packet.vector_10848; + out.scalar_2700 = packet.scalar_10916; + out.vectors_624_640_656_672[3] = packet.vector_10864; + replace_byte(&mut out.flags_2480, 0, packet.flag_10369); + out.vectors_880_896_912_928_944[0] = packet.vector_10880; + out.vectors_880_896_912_928_944[1] = packet.vector_10896; + transfer_bit(&mut out.flags_2488, 24, packet.flags_10932, 22); + + out.vectors_544_560_592_608[2] = physical.reckoning.vector_64; + out.vectors_880_896_912_928_944[4] = physical.reckoning.vector_144; + replace_byte(&mut out.flags_2488, 21, physical.reckoning.flag_164); + out.vectors_544_560_592_608[3] = if physical.air.use_air_reckoning_452 != 0 { + physical.air.vector_160 + } else { + physical.reckoning.vector_16 + }; + out.vectors_544_560_592_608[0] = physical.reckoning.vector_96; + out.vectors_544_560_592_608[1] = physical.board_reckoning_side_176; + out.vectors_464_480_496_512_528[0] = physical.ground.vector_96; + out.vectors_464_480_496_512_528[4] = physical.ground.vector_64; + out.vectors_720_784_800_816_832_864[4] = physical.ground.vector_128; + replace_byte(&mut out.flags_2476, 27, physical.ground.flag_317); + replace_byte(&mut out.flags_2476, 23, physical.ground.flag_318); + + out.air_scalar_2772 = physical.air.scalar_184; + replace_byte(&mut out.flags_2476, 26, physical.air.flag_441); + replace_byte(&mut out.flags_2476, 0, physical.air.flag_446); + replace_byte(&mut out.flags_2480, 25, physical.air.flag_447); + replace_byte(&mut out.flags_2480, 24, physical.air.flag_448); + transfer_bit( + &mut out.flags_2480, + 30, + physical.component_1832_word_1876, + 28, + ); + replace_byte(&mut out.flags_2468, 9, physical.grinds.flag_318); + replace_byte(&mut out.flags_2468, 8, physical.grinds.flag_322); + + replace_byte(&mut out.flags_2468, 15, physical.collision.flag_3478); + replace_byte(&mut out.flags_2468, 16, physical.collision.flag_3475); + replace_byte(&mut out.flags_2468, 17, physical.collision.flag_3472); + out.wheel_count_2556 = physical.collision.wheel_count_0; + replace_byte(&mut out.flags_2468, 14, physical.collision.flag_3477); + out.collision_scalar_2924 = physical.collision.scalar_28; + replace_byte(&mut out.flags_2488, 30, physical.collision.flag_3481); + replace_byte(&mut out.flags_2472, 11, physical.physics.flag_32); + out.vectors_720_784_800_816_832_864[3] = physical.physics.vector_16; + replace_byte(&mut out.flags_2472, 9, physical.skeleton.flag_597); + replace_byte(&mut out.flags_2480, 10, physical.skeleton.flag_600); + replace_byte(&mut out.flags_2480, 9, physical.skeleton.flag_601); + out.filtered_state_2524 = physical.filtered_state_0; + + replace_byte(&mut out.flags_2476, 21, physical.off_board.flag_304); + replace_bool(&mut out.flags_2476, 17, physical.off_board.scalar_32 <= 0.0); + replace_byte(&mut out.flags_2480, 15, physical.off_board.flag_311); + replace_byte(&mut out.flags_2476, 16, physical.off_board.flag_315); + replace_byte(&mut out.flags_2476, 8, physical.off_board.flag_316); + out.off_board_scalar_2832 = physical.off_board.scalar_32; + replace_byte(&mut out.flags_2480, 1, physical.off_board.flag_318); + replace_byte(&mut out.flags_2484, 23, physical.off_board.flag_314); + out.vectors_880_896_912_928_944[2] = physical.off_board.vector_64; + replace_byte(&mut out.flags_2484, 16, physical.off_board.flag_328); + publish_wheel_contacts(physical, out); +} + +fn publish_wheel_contacts(physical: &PhysicalPlayerInput, out: &mut ProcessedPhysicsInput) { + let wheel = physical.collision.wheel_contact_3296_3299; + let outer_tail = wheel[2] != 0 || wheel[3] != 0; + let outer_nose = wheel[0] != 0 || wheel[1] != 0; + let left_pair = wheel[2] != 0 || wheel[0] != 0; + let right_pair = wheel[3] != 0 || wheel[1] != 0; + let switched = out.flags_2468 & (1 << 20) != 0; + replace_bool( + &mut out.flags_2472, + 27, + if switched { outer_nose } else { outer_tail }, + ); + replace_bool( + &mut out.flags_2472, + 26, + if switched { outer_tail } else { outer_nose }, + ); + replace_bool( + &mut out.flags_2472, + 25, + if switched { right_pair } else { left_pair }, + ); + replace_bool( + &mut out.flags_2472, + 24, + if switched { left_pair } else { right_pair }, + ); +} + +pub(crate) fn effective_animation_transform(source: RawMatrix, flags_2476: u32) -> RawMatrix { + let mut result = source; + if flags_2476 & (1 << 2) != 0 { + for row in [0, 2] { + for lane in &mut result[row] { + *lane = (-f32::from_bits(*lane)).to_bits(); + } + } + } + result +} + +pub(crate) fn publish_line_tests(player: &PlayerInputState, out: &mut ProcessedPhysicsInput) { + out.line_tests_960_1008_1056 = [ + player.left_line_test_1536, + player.right_line_test_1584, + player.hips_line_test_1488, + ]; + if out.line_tests_960_1008_1056[0].valid != 0 { + out.left_surface_2596 = out.line_tests_960_1008_1056[0].surface; + } + if out.line_tests_960_1008_1056[1].valid != 0 { + out.right_surface_2600 = out.line_tests_960_1008_1056[1].surface; + } +} + +pub(crate) fn replace_byte(word: &mut u32, bit: u32, source: u8) { + *word = (*word & !(1 << bit)) | (u32::from(source & 1) << bit); +} + +pub(crate) fn replace_bool(word: &mut u32, bit: u32, source: bool) { + *word = (*word & !(1 << bit)) | (u32::from(source) << bit); +} + +pub(crate) fn transfer_bit(word: &mut u32, bit: u32, source: u32, source_bit: u32) { + *word = (*word & !(1 << bit)) | (((source >> source_bit) & 1) << bit); +} diff --git a/crates/skate-core/src/player/input_phase/runtime.rs b/crates/skate-core/src/player/input_phase/runtime.rs new file mode 100644 index 00000000..f7bbdf0a --- /dev/null +++ b/crates/skate-core/src/player/input_phase/runtime.rs @@ -0,0 +1,368 @@ +use super::publication::{ + effective_animation_transform, publish_animation_packet, publish_collision_prefix, + publish_external_physics, publish_force_braking, publish_line_tests, publish_physical_outputs, + publish_player_flags, publish_state_prefix, replace_bool, replace_byte, select_surface, + transfer_bit, +}; +use super::types::{ + AnimationInputPacket, GroundHistoryRequest, PhysicalPlayerInput, PlayerInputState, + PrepareJumpRequest, ProcessedPhysicsInput, RawVector, +}; + +const FIXED_FRAME_STEP: f32 = f32::from_bits(0x3c88_8889); + +/// Mandatory boundaries called by TU3 `PhysicalPlayerHiLOD::Input`. +/// +/// Inlined motion calculations execute directly in this module. Services own +/// only separate physical subsystems and host data access. +pub trait InputPhaseServices { + type Error; + + fn update_pre_input_manager_82d81610( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + ) -> Result<(), Self::Error>; + fn reset_processed_input_82bf9ef0( + &mut self, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; + fn actor_query_slot_56(&mut self) -> Result; + fn actor_query_slot_44(&mut self) -> Result; + fn reset_player_probe_82d7a330( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + ) -> Result<(), Self::Error>; + fn check_teleport_82db88c8( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; + fn actor_input_available_slot_4(&mut self) -> Result; + fn transition_action_825903c8(&mut self) -> Result; + fn calculate_ground_position_82c02840( + &mut self, + physical: &PhysicalPlayerInput, + ) -> Result; + fn prepare_board_toolkit_82c013f0( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; + fn process_skeleton_82bd8918( + &mut self, + packet: &AnimationInputPacket<'_>, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; + fn update_grind_manager_82d8a828( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error>; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InputPhaseError { + Service(E), + /// Native dereferences a null +2548 pointer when this packet value is >4. + InvalidStateVariant(u32), +} + +/// Pre-reset observations retained by82DB4048 across CheckTeleport. +#[derive(Debug)] +pub struct InputContinuation { + captured_state: u32, + captured_category: u32, +} + +/// Complete recovered TU3 PhysicalPlayerHiLOD::Input82DB4048. +/// The split also lets a host release subsystem borrows at CheckTeleport. +pub fn process_input( + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result<(), InputPhaseError> { + let continuation = start_input(player, physical, packet, output, services)?; + finish_input(continuation, player, physical, packet, output, services) +} + +///82DB4048 through CheckTeleport82DB42DC. Complete the physical reset +///continuation before calling finish_input; captured fields must survive it. +pub fn start_input( + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result> { + let captured_state = physical.state.state_16; + player.manager_1856_counter_320 = native_nonnegative_decrement(player.manager_1856_counter_320); + services + .update_pre_input_manager_82d81610(player, physical) + .map_err(InputPhaseError::Service)?; + player.manager_1852_flag_256 = 0; + let captured_category = physical.state.category_12; + services + .reset_processed_input_82bf9ef0(output) + .map_err(InputPhaseError::Service)?; + + publish_player_flags(player, output); + output.actor_query_2948 = services + .actor_query_slot_56() + .map_err(InputPhaseError::Service)?; + output.actor_query_2952 = services + .actor_query_slot_44() + .map_err(InputPhaseError::Service)?; + publish_force_braking(player, physical, packet, output); + output.probe_1792 = player.probe; + services + .reset_player_probe_82d7a330(player, physical) + .map_err(InputPhaseError::Service)?; + publish_collision_prefix(player, physical, output); + publish_animation_packet(packet, output); + + output.state_identifier_2496 = physical.state.identifier_8; + services + .check_teleport_82db88c8(player, physical, output) + .map_err(InputPhaseError::Service)?; + Ok(InputContinuation { captured_state, captured_category }) +} + +///82DB42F0 through82DB5570, after any reset/normal-setup continuation. +pub fn finish_input( + continuation: InputContinuation, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + packet: &AnimationInputPacket<'_>, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result<(), InputPhaseError> { + let InputContinuation { captured_state, captured_category } = continuation; + transfer_bit(&mut output.flags_2472, 10, player.flags_1296, 29); + publish_transition(packet, output, services)?; + update_signed_ground_time(player, physical, output); + update_time_on_ground(player, captured_category, output); + + select_surface(player, packet, physical.surface_default_mode, output) + .map_err(InputPhaseError::InvalidStateVariant)?; + replace_byte(&mut output.flags_2476, 2, packet.flag_10370); + publish_external_physics(player, packet, output); + publish_state_prefix(player, physical, packet, captured_state, output); + publish_physical_outputs(physical, packet, output); + update_timers(player, physical, output); + update_ground_position(player, physical, captured_category, output, services)?; + + output.effective_anim_transform_192 = + effective_animation_transform(physical.skeleton.anim_to_world_11920, output.flags_2476); + update_ground_history(player, captured_category, output); + publish_ground_history(player, output); + services + .prepare_board_toolkit_82c013f0(player, physical, output) + .map_err(InputPhaseError::Service)?; + publish_line_tests(player, output); + services + .process_skeleton_82bd8918(packet, physical, output) + .map_err(InputPhaseError::Service)?; + publish_post_skeleton(player, physical, output); + services + .update_grind_manager_82d8a828(player, physical, output) + .map_err(InputPhaseError::Service) +} + +fn publish_transition( + packet: &AnimationInputPacket<'_>, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result<(), InputPhaseError> { + if !services + .actor_input_available_slot_4() + .map_err(InputPhaseError::Service)? + { + return Ok(()); + } + output.transition_2636 = if packet.suppress_transition_10376 != 0 { + 0.0 + } else { + let value = services + .transition_action_825903c8() + .map_err(InputPhaseError::Service)?; + let lower = xenon_fsel(-1.0 - value, -1.0, value); + xenon_fsel(1.0 - lower, lower, 1.0) + }; + Ok(()) +} + +fn update_signed_ground_time( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, +) { + let previous = player.signed_ground_time_1356; + player.signed_ground_time_1356 = if physical.state.signed_ground_step_84 != 0 { + xenon_fsel(previous, previous, 0.0) + FIXED_FRAME_STEP + } else { + xenon_fsel(previous, 0.0, previous) - FIXED_FRAME_STEP + }; + output.signed_ground_time_2756 = player.signed_ground_time_1356; +} + +fn update_time_on_ground( + player: &mut PlayerInputState, + captured_category: u32, + output: &mut ProcessedPhysicsInput, +) { + if captured_category == 100 { + output.flags_2472 |= 1 << 13; + player.grounded_frames_1300 = player.grounded_frames_1300.wrapping_add(1); + player.time_on_ground_1352 += output.timestep_2604; + replace_bool( + &mut player.flags_1296, + 31, + (player.grounded_frames_1300 as i32) > 6, + ); + } else { + player.time_on_ground_1352 = 0.0; + player.grounded_frames_1300 = 0; + } + output.time_on_ground_2752 = player.time_on_ground_1352; +} + +fn update_timers( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, +) { + if physical.state.state_16 == 104 { + player.skitch_timer_1376 = 2.0; + player.skitch_value_1372 = physical.state.skitch_value_40; + } + if player.skitch_timer_1376 > 0.0 { + player.skitch_timer_1376 -= output.timestep_2604; + output.skitch_value_2592 = player.skitch_value_1372; + } + if physical.ground.scalar_292 > 0.0 { + player.ground_timer_1380 = physical.ground.scalar_292; + } + if player.ground_timer_1380 > 0.0 { + player.ground_timer_1380 -= output.timestep_2604; + output.ground_timer_2848 = player.ground_timer_1380; + output.skitch_value_2592 = player.skitch_value_1372; + } + if physical.ground.scalar_296 > 0.0 { + player.secondary_ground_timer_1384 = physical.ground.scalar_296; + } + if player.secondary_ground_timer_1384 > 0.0 { + player.secondary_ground_timer_1384 -= output.timestep_2604; + output.secondary_ground_timer_2852 = player.secondary_ground_timer_1384; + } +} + +fn update_ground_position( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + captured_category: u32, + output: &mut ProcessedPhysicsInput, + services: &mut S, +) -> Result<(), InputPhaseError> { + if captured_category == 500 { + player.current_ground_position_1216 = physical.skeleton.anim_to_world_11920[3]; + player.time_off_board_1368 += FIXED_FRAME_STEP; + } else { + player.current_ground_position_1216 = services + .calculate_ground_position_82c02840(physical) + .map_err(InputPhaseError::Service)?; + player.time_off_board_1368 = 0.0; + } + output.time_off_board_2836 = player.time_off_board_1368; + Ok(()) +} + +fn update_ground_history( + player: &mut PlayerInputState, + captured_category: u32, + output: &ProcessedPhysicsInput, +) { + if captured_category != 200 && captured_category != 600 && player.ground_history_frames_1304 > 0 + { + let previous = player.previous_ground_position_1200; + player.previous_ground_position_1200 = player.current_ground_position_1216; + if output.frames_since_teleport_2584 > 1 { + let result = super::motion_math::ground_history(GroundHistoryRequest { + previous_position: previous, + current_position: player.current_ground_position_1216, + previous_filtered_delta: player.damped_ground_delta_1248, + timestep_bits: output.timestep_2604.to_bits(), + }); + player.ground_delta_1232 = result.delta; + player.damped_ground_delta_1248 = result.filtered_delta; + } + } +} + +fn publish_ground_history(player: &PlayerInputState, output: &mut ProcessedPhysicsInput) { + output.vectors_464_480_496_512_528[1] = player.previous_ground_position_1200; + output.vectors_464_480_496_512_528[2] = player.current_ground_position_1216; + output.vectors_464_480_496_512_528[3] = player.damped_ground_delta_1248; +} + +fn publish_post_skeleton( + player: &mut PlayerInputState, + physical: &PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, +) { + if output.flags_2472 & (1 << 21) != 0 { + replace_byte(&mut output.flags_2472, 21, physical.state.flag_66); + } + let skitch_or_ground_window = player.skitch_timer_1376 > 0.0 + || (output.flags_2476 & (1 << 22) != 0 && physical.ground.scalar_276 >= 0.0); + replace_bool(&mut output.flags_2476, 3, skitch_or_ground_window); + player.time_since_last_input_1348 = if output.flags_2472 & (1 << 23) != 0 { + player.time_since_last_input_1348 + output.timestep_2604 + } else { + 0.0 + }; + output.time_since_last_input_2748 = player.time_since_last_input_1348; + + if output.flags_2468 & (1 << 21) != 0 { + player.prepared_jump_velocity_1184 = output.vectors_400_416[1]; + } else { + player.prepared_jump_velocity_1184 = + super::motion_math::prepared_jump_velocity(PrepareJumpRequest { + skateboard_vector: output.vectors_400_416[1], + previous_velocity: player.prepared_jump_velocity_1184, + }); + } + + player.spin_same_direction_frames_1324 = + if output.spin_input_2672 * player.previous_spin_input_1360 <= 0.0 { + 0 + } else { + player.spin_same_direction_frames_1324.wrapping_add(1) + }; + player.previous_spin_input_1360 = output.spin_input_2672; + output.spin_same_direction_frames_2580 = player.spin_same_direction_frames_1324; + output.prepared_jump_704 = player.prepared_jump_velocity_1184; + output.crouch_delta_2780 = output.crouch_2776 - player.previous_crouch_1364; + player.previous_crouch_1364 = output.crouch_2776; +} + +fn native_nonnegative_decrement(value: u32) -> u32 { + let decremented = value.wrapping_sub(1); + if decremented.overflowing_add(0x8000_0000).1 { + 0 + } else { + decremented + } +} + +fn xenon_fsel(test: f32, nonnegative: f32, negative: f32) -> f32 { + if test >= -0.0 { nonnegative } else { negative } +} + diff --git a/crates/skate-core/src/player/input_phase/tests.rs b/crates/skate-core/src/player/input_phase/tests.rs new file mode 100644 index 00000000..9e7b9e48 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/tests.rs @@ -0,0 +1,460 @@ +use crate::animation::output::actor_packet::ExternalPhysicsInput; +use crate::input::animation_packet::AnimationPacketFields; + +use super::publication::{ + effective_animation_transform, publish_external_physics, publish_physical_outputs, + select_surface, +}; +use super::*; + +fn external(flags: u32) -> ExternalPhysicsInput { + ExternalPhysicsInput { + vectors: [[0; 4]; 10], + flags, + } +} + +fn animation_publication() -> AnimationPacketFields { + AnimationPacketFields { + stance_byte: 0, + timestep: 0.25, + scalar_10388: 0.0, + flags_10375_10496_10784: [0; 3], + vector_10480: [0; 4], + matrix_10704: [[0; 4]; 4], + byte_10768: 0, + truck_tightness: 0.0, + scalar_10792: 0.0, + flag_10371: 0, + } +} + +fn packet<'a>( + publication: &'a AnimationPacketFields, + external_physics: &'a ExternalPhysicsInput, +) -> AnimationInputPacket<'a> { + AnimationInputPacket { + publication, + external_physics_10512: external_physics, + flag_10369: 0, + flag_10370: 0, + flag_10373: 0, + suppress_transition_10376: 0, + use_external_physics_10688: 0, + external_physics_flag_10689: 0, + flag_10786: 0, + flag_10787: 0, + force_braking_10796: 0, + vector_10816: [0; 4], + vector_10832: [0; 4], + vector_10848: [0; 4], + vector_10864: [0; 4], + vector_10880: [0; 4], + vector_10896: [0; 4], + scalar_10912: 0.0, + scalar_10916: 0.0, + state_variant_10928: 0, + flags_10932: 0, + } +} + +#[derive(Default)] +struct Services { + calls: Vec<&'static str>, + manager_state_change: Option<(u32, u32)>, + actor_input_available: bool, +} + +impl InputPhaseServices for Services { + type Error = (); + + fn update_pre_input_manager_82d81610( + &mut self, + _player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + ) -> Result<(), Self::Error> { + self.calls.push("manager"); + if let Some((state, category)) = self.manager_state_change { + physical.state.state_16 = state; + physical.state.category_12 = category; + } + Ok(()) + } + + fn reset_processed_input_82bf9ef0( + &mut self, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("reset"); + *output = ProcessedPhysicsInput::default(); + Ok(()) + } + + fn actor_query_slot_56(&mut self) -> Result { + self.calls.push("actor56"); + Ok(56) + } + + fn actor_query_slot_44(&mut self) -> Result { + self.calls.push("actor44"); + Ok(44) + } + + fn reset_player_probe_82d7a330( + &mut self, + player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + ) -> Result<(), Self::Error> { + self.calls.push("probe_reset"); + player.probe.byte_104 = 0; + Ok(()) + } + + fn check_teleport_82db88c8( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + _output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("teleport"); + Ok(()) + } + + fn actor_input_available_slot_4(&mut self) -> Result { + self.calls.push("actor_input"); + Ok(self.actor_input_available) + } + + fn transition_action_825903c8(&mut self) -> Result { + self.calls.push("transition"); + Ok(2.0) + } + + fn calculate_ground_position_82c02840( + &mut self, + _physical: &PhysicalPlayerInput, + ) -> Result { + self.calls.push("ground_position"); + Ok([2.0f32, 4.0, 6.0, 0.0].map(f32::to_bits)) + } + + fn prepare_board_toolkit_82c013f0( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + _output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("board_toolkit"); + Ok(()) + } + + fn process_skeleton_82bd8918( + &mut self, + _packet: &AnimationInputPacket<'_>, + _physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("skeleton"); + output.flags_2472 |= (1 << 21) | (1 << 23); + output.flags_2476 |= 1 << 22; + output.spin_input_2672 = 2.0; + output.crouch_2776 = 3.0; + Ok(()) + } + + fn update_grind_manager_82d8a828( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + _output: &mut ProcessedPhysicsInput, + ) -> Result<(), Self::Error> { + self.calls.push("grind"); + Ok(()) + } +} + +#[test] +fn complete_phase_keeps_native_service_order_and_persistent_counters() { + let publication = animation_publication(); + let external_physics = external(0); + let packet = packet(&publication, &external_physics); + let mut player = PlayerInputState { + manager_1856_counter_320: 2, + manager_1852_flag_256: 9, + grounded_frames_1300: 6, + update_count_1316: 42, + frames_since_teleport_1328: 2, + ground_history_frames_1304: 1, + time_since_last_input_1348: 1.0, + time_on_ground_1352: 1.0, + previous_spin_input_1360: 1.0, + previous_crouch_1364: 1.0, + previous_ground_position_1200: [1.0f32, 2.0, 3.0, 0.0].map(f32::to_bits), + damped_ground_delta_1248: [0; 4], + probe: ProbeFields { + byte_104: 7, + ..ProbeFields::default() + }, + left_line_test_1536: LineTestFields { + surface: 123, + valid: 2, + ..LineTestFields::default() + }, + ..PlayerInputState::default() + }; + let mut physical = PhysicalPlayerInput::default(); + physical.state.category_12 = 100; + physical.state.state_16 = 104; + physical.state.skitch_value_40 = 77; + physical.state.signed_ground_step_84 = 1; + physical.surface_default_mode = 1; + physical.ground.scalar_276 = 0.0; + physical.ground.scalar_292 = 0.5; + physical.ground.scalar_296 = 0.75; + let mut output = ProcessedPhysicsInput::default(); + let mut services = Services { + actor_input_available: true, + ..Services::default() + }; + + process_input( + &mut player, + &mut physical, + &packet, + &mut output, + &mut services, + ) + .unwrap(); + + assert_eq!( + services.calls, + [ + "manager", + "reset", + "actor56", + "actor44", + "probe_reset", + "teleport", + "actor_input", + "transition", + "ground_position", + "board_toolkit", + "skeleton", + "grind", + ] + ); + assert_eq!(player.manager_1856_counter_320, 1); + assert_eq!(player.manager_1852_flag_256, 0); + assert_eq!(output.actor_query_2948, 56); + assert_eq!(output.actor_query_2952, 44); + assert_eq!(output.probe_1792.byte_104, 7); + assert_eq!(player.probe.byte_104, 0); + assert_eq!(player.grounded_frames_1300, 7); + assert_eq!(player.update_count_1316, 42); + assert_eq!(output.update_count_2568, 42); + assert_eq!(output.time_on_ground_2752, 1.25); + assert_ne!(output.flags_2468 & (1 << 13), 0); + assert_eq!(output.transition_2636, 1.0); + assert_eq!(player.skitch_timer_1376, 1.75); + assert_eq!(output.skitch_value_2592, 77); + assert_eq!(output.ground_timer_2848, 0.25); + assert_eq!(output.secondary_ground_timer_2852, 0.5); + assert_eq!( + output.vectors_464_480_496_512_528[1].map(f32::from_bits), + [2.0, 4.0, 6.0, 0.0] + ); + assert_eq!( + output.vectors_464_480_496_512_528[2].map(f32::from_bits), + [2.0, 4.0, 6.0, 0.0] + ); + for (actual, expected) in output.vectors_464_480_496_512_528[3] + .map(f32::from_bits) + .into_iter() + .zip([0.2, 0.4, 0.6, 0.0]) + { + assert!((actual - expected).abs() < 1e-6); + } + assert_eq!( + player.ground_delta_1232.map(f32::from_bits), + [1.0, 2.0, 3.0, 0.0] + ); + assert_eq!(output.time_since_last_input_2748, 1.25); + assert_eq!(output.prepared_jump_704.map(f32::from_bits), [0.0; 4]); + assert_eq!(output.spin_same_direction_frames_2580, 1); + assert_eq!(output.crouch_delta_2780, 2.0); + assert_eq!(output.left_surface_2596, 123); +} + +#[test] +fn manager_call_precedes_category_capture_but_state_number_is_already_captured() { + let publication = animation_publication(); + let external_physics = external(0); + let packet = packet(&publication, &external_physics); + let mut player = PlayerInputState::default(); + let mut physical = PhysicalPlayerInput::default(); + physical.state.state_16 = 11; + physical.state.category_12 = 100; + physical.surface_default_mode = 1; + physical.skeleton.anim_to_world_11920[3] = [7, 8, 9, 10]; + let mut output = ProcessedPhysicsInput::default(); + let mut services = Services { + manager_state_change: Some((22, 500)), + ..Services::default() + }; + + process_input( + &mut player, + &mut physical, + &packet, + &mut output, + &mut services, + ) + .unwrap(); + + assert_eq!(output.state_2504, 11); + assert_eq!(output.state_2508, 11); + assert_eq!(output.category_2512, 500); + assert_eq!(output.category_2516, 500); + assert_eq!(player.current_ground_position_1216, [7, 8, 9, 10]); + assert!(!services.calls.contains(&"ground_position")); + assert_eq!(player.time_off_board_1368.to_bits(), 0x3c88_8889); +} + +#[test] +fn physical_publication_uses_low_flag_bits_and_stance_wheel_permutation() { + let publication = animation_publication(); + let external_physics = external(0); + let mut packet = packet(&publication, &external_physics); + packet.flags_10932 = (1 << 31) | (1 << 29) | (1 << 22); + packet.flag_10369 = 2; + let mut physical = PhysicalPlayerInput::default(); + physical.ground.flag_317 = 3; + physical.air.flag_446 = 2; + physical.off_board.scalar_32 = f32::NAN; + physical.collision.wheel_contact_3296_3299 = [1, 0, 0, 0]; + let mut output = ProcessedPhysicsInput::default(); + output.flags_2468 &= !(1 << 20); + + publish_physical_outputs(&physical, &packet, &mut output); + assert_ne!(output.flags_2476 & (1 << 6), 0); + assert_eq!(output.flags_2476 & (1 << 5), 0); + assert_ne!(output.flags_2476 & (1 << 4), 0); + assert_ne!(output.flags_2488 & (1 << 24), 0); + assert_eq!(output.flags_2480 & 1, 0); + assert_ne!(output.flags_2476 & (1 << 27), 0); + assert_eq!(output.flags_2476 & 1, 0); + assert_eq!(output.flags_2476 & (1 << 17), 0); + assert_eq!(output.flags_2472 & (1 << 27), 0); + assert_ne!(output.flags_2472 & (1 << 26), 0); + assert_ne!(output.flags_2472 & (1 << 25), 0); + assert_eq!(output.flags_2472 & (1 << 24), 0); + + output.flags_2468 |= 1 << 20; + publish_physical_outputs(&physical, &packet, &mut output); + assert_ne!(output.flags_2472 & (1 << 27), 0); + assert_eq!(output.flags_2472 & (1 << 26), 0); + assert_eq!(output.flags_2472 & (1 << 25), 0); + assert_ne!(output.flags_2472 & (1 << 24), 0); +} + +#[test] +fn external_physics_fallback_uses_cached_trajectory_and_preserves_packet_high_bits() { + let publication = animation_publication(); + let mut incoming = external((1 << 31) | (1 << 25)); + incoming.vectors = [[0x100; 4]; 10]; + let mut packet = packet(&publication, &incoming); + packet.use_external_physics_10688 = 1; + packet.external_physics_flag_10689 = 3; + let mut player = PlayerInputState::default(); + for index in 5..10 { + player.external_physics_cache_1008.vectors[index] = [index as u32; 4]; + } + player.external_physics_cache_1008.flags = 1 << 27; + let mut output = ProcessedPhysicsInput::default(); + output.external_physics_1616.flags = 0x1234; + + publish_external_physics(&mut player, &packet, &mut output); + + assert_ne!(output.flags_2472 & (1 << 29), 0); + assert_ne!(output.flags_2472 & (1 << 28), 0); + assert_eq!(output.external_physics_1616.vectors[4], [0x100; 4]); + assert_eq!(output.external_physics_1616.vectors[5], [5; 4]); + assert_eq!(output.external_physics_1616.vectors[9], [9; 4]); + assert_ne!(output.external_physics_1616.flags & (1 << 31), 0); + assert_ne!(output.external_physics_1616.flags & (1 << 27), 0); + assert_ne!(output.external_physics_1616.flags & (1 << 26), 0); + assert_eq!(output.external_physics_1616.flags & 0x01ff_ffff, 0x1234); +} + +#[test] +fn surface_selector_and_effective_transform_match_recovered_switches() { + let publication = animation_publication(); + let external_physics = external(0); + let mut packet = packet(&publication, &external_physics); + let mut player = PlayerInputState::default(); + player.state_variants_1408[2].surface_override_enabled_60 = 1; + packet.state_variant_10928 = 2; + let mut output = ProcessedPhysicsInput::default(); + + select_surface(&player, &packet, 13, &mut output).unwrap(); + assert_eq!(output.surface_mode_2540, 2); + assert_eq!(output.state_variant_ref_2548.unwrap().offset, 0x5a0); + assert_eq!(output.surface_primary_ref_2544.unwrap().offset, 40); + assert_eq!(output.surface_secondary_ref_2552.unwrap().offset, 116); + + player.state_variants_1408[2].surface_override_enabled_60 = 0; + select_surface(&player, &packet, 6, &mut output).unwrap(); + assert_eq!(output.surface_mode_2540, 3); + packet.state_variant_10928 = 5; + assert_eq!(select_surface(&player, &packet, 1, &mut output), Err(5)); + + let matrix = [ + [ + 1.0f32.to_bits(), + (-2.0f32).to_bits(), + 0.0f32.to_bits(), + 4.0f32.to_bits(), + ], + [5.0f32.to_bits(); 4], + [ + 6.0f32.to_bits(), + 7.0f32.to_bits(), + 8.0f32.to_bits(), + 9.0f32.to_bits(), + ], + [10.0f32.to_bits(); 4], + ]; + let flipped = effective_animation_transform(matrix, 1 << 2); + assert_eq!(flipped[0][0], (-1.0f32).to_bits()); + assert_eq!(flipped[0][1], 2.0f32.to_bits()); + assert_eq!(flipped[1], matrix[1]); + assert_eq!(flipped[2][0], (-6.0f32).to_bits()); + assert_eq!(flipped[3], matrix[3]); +} + +#[test] +fn physical_vector_publication_uses_the_restored_native_offsets() { + let publication = animation_publication(); + let external = external(0); + let packet = packet(&publication, &external); + let mut physical = PhysicalPlayerInput::default(); + physical.board_reckoning_side_176 = [176; 4]; + physical.reckoning.vector_16 = [16; 4]; + physical.reckoning.vector_64 = [64; 4]; + physical.reckoning.vector_96 = [96; 4]; + physical.physics.vector_16 = [116; 4]; + physical.off_board.vector_64 = [164; 4]; + physical.air.vector_160 = [160; 4]; + let mut output = ProcessedPhysicsInput::default(); + publish_physical_outputs(&physical, &packet, &mut output); + assert_eq!( + output.vectors_544_560_592_608, + [[96; 4], [176; 4], [64; 4], [16; 4]] + ); + assert_eq!(output.vectors_720_784_800_816_832_864[3], [116; 4]); + assert_eq!(output.vectors_880_896_912_928_944[2], [164; 4]); + physical.air.use_air_reckoning_452 = 1; + publish_physical_outputs(&physical, &packet, &mut output); + assert_eq!(output.vectors_544_560_592_608[3], [160; 4]); +} + diff --git a/crates/skate-core/src/player/input_phase/types.rs b/crates/skate-core/src/player/input_phase/types.rs new file mode 100644 index 00000000..06e9bd99 --- /dev/null +++ b/crates/skate-core/src/player/input_phase/types.rs @@ -0,0 +1,473 @@ +use super::air_output::AirOutputFields; +use super::pose_output::{AnimationOutputFields, ScoringOutputFields, SkeletonOutputFields}; +use crate::animation::output::actor_packet::ExternalPhysicsInput; +use crate::input::animation_packet::AnimationPacketFields; + +pub type RawVector = [u32; 4]; +pub type RawMatrix = [[u32; 4]; 4]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ProbeFields { + pub vectors_16_32_48: [RawVector; 3], + pub words_64_68: [u32; 2], + pub bytes_72_73: [u8; 2], + pub vector_80: RawVector, + pub words_96_100: [u32; 2], + pub byte_104: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LineTestFields { + pub position: RawVector, + pub normal: RawVector, + pub surface: u32, + /// The native byte is copied in full; conditionals test it for nonzero. + pub valid: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StateVariantFields { + /// Byte +60 of the object referenced by the variant block's +4 pointer. + pub surface_override_enabled_60: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +/// Reset82DE53F0 copies the template initialized by82DE3BE8: +/// every represented word and byte in this subset initially equals zero. +pub struct CurrentStateFields { + pub identifier_8: u32, + pub category_12: u32, + pub state_16: u32, + pub surface_height_32: f32, + pub skitch_value_40: u32, + pub flag_61: u8, + pub flag_66: u8, + pub flag_69: u8, + pub flag_74: u8, + pub signed_ground_step_84: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SkateboardMotionFields { + pub vector_64: RawVector, + pub vector_80: RawVector, + pub vector_96: RawVector, + pub vector_128: RawVector, + pub vector_144: RawVector, + pub scalar_160: f32, + pub scalar_164: f32, + pub scalar_168: f32, + pub scalar_172: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SystemReckoningFields { + /// Physical COM velocity from Skeleton16176 (82BE1D84/94). + /// Restored r28 at82DB49B0 is16, not128. + pub vector_16: RawVector, + pub vector_64: RawVector, + pub vector_96: RawVector, + pub vector_144: RawVector, + pub flag_164: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GroundOutputFields { + pub vector_64: RawVector, + pub vector_96: RawVector, + pub vector_128: RawVector, + pub scalar_276: f32, + pub scalar_292: f32, + pub scalar_296: f32, + pub flag_317: u8, + pub flag_318: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CollisionOutputFields { + pub wheel_count_0: u32, + pub scalar_28: f32, + pub predicted_position_64: RawVector, + pub flag_215: u8, + pub flag_216: u8, + pub wheel_contact_3296_3299: [u8; 4], + pub flag_3472: u8, + pub flag_3475: u8, + pub flag_3477: u8, + pub flag_3478: u8, + pub flag_3479: u8, + pub flag_3480: u8, + pub flag_3481: u8, + pub flag_3483: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PhysicsOutputFields { + pub flag_32: u8, + /// Physics+16. This native record is only48bytes long. + pub vector_16: RawVector, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GrindOutputFields { + pub words_136_140: [u32; 2], + pub flag_318: u8, + pub flag_322: u8, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct OffBoardOutputFields { + pub scalar_32: f32, + pub flag_304: u8, + pub flag_311: u8, + pub flag_314: u8, + pub flag_315: u8, + pub flag_316: u8, + /// OffBoard reset82DE4138 clears this. LandingOnDeckManager Fill82D79420 + /// publishes manager260 && !manager258; only Biped/LandingOnDeck state + /// outputs call that producer. Ordinary Ground retains the reset value. + pub hippy_hurdling_317: u8, + pub flag_318: u8, + pub dropping_board_322: u8, + pub flag_319: u8, + pub retrieving_board_323: u8, + pub flag_328: u8, + pub vector_64: RawVector, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PhysicalPlayerInput { + /// SkateboardReckoning+176; restored r29 at82DB4994 is176. + pub board_reckoning_side_176: RawVector, + pub skateboard: SkateboardMotionFields, + pub air: AirOutputFields, + pub physics: PhysicsOutputFields, + pub grinds: GrindOutputFields, + pub skeleton: SkeletonOutputFields, + pub animation: AnimationOutputFields, + pub scoring: ScoringOutputFields, + /// Present only when Teleporting702 publishes a ready reset target. + pub teleport_output: Option, + pub collision: CollisionOutputFields, + pub state: CurrentStateFields, + pub ground: GroundOutputFields, + pub reckoning: SystemReckoningFields, + pub filtered_state_0: u32, + pub off_board: OffBoardOutputFields, + /// `player+1896 -> +0 -> +24 -> +16`, before the variant override. + pub surface_default_mode: u32, + /// Player component +1832, word +1876; source bit 28 is consumed. + pub component_1832_word_1876: u32, +} + +pub struct AnimationInputPacket<'a> { + pub publication: &'a AnimationPacketFields, + pub external_physics_10512: &'a ExternalPhysicsInput, + pub flag_10369: u8, + pub flag_10370: u8, + pub flag_10373: u8, + pub suppress_transition_10376: u8, + pub use_external_physics_10688: u8, + pub external_physics_flag_10689: u8, + pub flag_10786: u8, + pub flag_10787: u8, + pub force_braking_10796: u8, + pub vector_10816: RawVector, + pub vector_10832: RawVector, + pub vector_10848: RawVector, + pub vector_10864: RawVector, + pub vector_10880: RawVector, + pub vector_10896: RawVector, + pub scalar_10912: f32, + pub scalar_10916: f32, + pub state_variant_10928: u32, + pub flags_10932: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NativeReferenceBase { + Player, + SurfaceSelector, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NativeRelativeReference { + pub base: NativeReferenceBase, + pub offset: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlayerInputState { + pub manager_1856_counter_320: u32, + pub manager_1852_flag_256: u8, + pub manager_1852_vector_176: RawVector, + pub flags_1296: u32, + pub grounded_frames_1300: u32, + pub state_count_1312: u32, + pub update_count_1316: u32, + pub spin_same_direction_frames_1324: u32, + pub frames_since_teleport_1328: u32, + /// ProcessOutput82DB78A8 keeps a dismount request alive for three outputs. + pub dismount_request_frames_1332: u32, + pub state_value_1336: u32, + pub state_timer_1344: f32, + pub time_since_last_input_1348: f32, + pub time_on_ground_1352: f32, + pub signed_ground_time_1356: f32, + pub previous_spin_input_1360: f32, + pub previous_crouch_1364: f32, + pub time_off_board_1368: f32, + pub skitch_value_1372: u32, + pub skitch_timer_1376: f32, + pub ground_timer_1380: f32, + pub secondary_ground_timer_1384: f32, + pub probe: ProbeFields, + pub queued_vector_1280: RawVector, + pub external_physics_cache_1008: ExternalPhysicsInput, + pub prepared_jump_velocity_1184: RawVector, + pub previous_ground_position_1200: RawVector, + pub current_ground_position_1216: RawVector, + pub ground_delta_1232: RawVector, + pub damped_ground_delta_1248: RawVector, + pub state_variants_1408: [StateVariantFields; 5], + pub hips_line_test_1488: LineTestFields, + pub left_line_test_1536: LineTestFields, + pub right_line_test_1584: LineTestFields, + /// Number at player +1304 used to gate the ground-history filter. + pub ground_history_frames_1304: i32, +} + +impl Default for PlayerInputState { + fn default() -> Self { + Self { + manager_1856_counter_320: 0, + manager_1852_flag_256: 0, + manager_1852_vector_176: [0; 4], + flags_1296: 0, + grounded_frames_1300: 0, + state_count_1312: 0, + update_count_1316: 0, + spin_same_direction_frames_1324: 0, + frames_since_teleport_1328: 0, + dismount_request_frames_1332: 0, + state_value_1336: 0, + state_timer_1344: 0.0, + time_since_last_input_1348: 0.0, + time_on_ground_1352: 0.0, + signed_ground_time_1356: 0.0, + previous_spin_input_1360: 0.0, + previous_crouch_1364: 0.0, + time_off_board_1368: 0.0, + skitch_value_1372: 0, + skitch_timer_1376: 0.0, + ground_timer_1380: 0.0, + secondary_ground_timer_1384: 0.0, + probe: ProbeFields::default(), + queued_vector_1280: [0; 4], + external_physics_cache_1008: empty_external_physics(), + prepared_jump_velocity_1184: [0; 4], + previous_ground_position_1200: [0; 4], + current_ground_position_1216: [0; 4], + ground_delta_1232: [0; 4], + damped_ground_delta_1248: [0; 4], + state_variants_1408: [StateVariantFields::default(); 5], + hips_line_test_1488: LineTestFields::default(), + left_line_test_1536: LineTestFields::default(), + right_line_test_1584: LineTestFields::default(), + ground_history_frames_1304: 0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProcessedPhysicsInput { + pub effective_anim_transform_192: RawMatrix, + pub vectors_400_416: [RawVector; 2], + pub vectors_464_480_496_512_528: [RawVector; 5], + pub vectors_544_560_592_608: [RawVector; 4], + pub vectors_624_640_656_672: [RawVector; 4], + pub prepared_jump_704: RawVector, + ///Skeleton::ProcessData82BD8E70..8EA8, after ordered animation attributes. + pub collision_pose_error_736: RawVector, + pub animation_com_to_deck_752: RawVector, + pub animation_com_to_deck_delta_768: RawVector, + pub vectors_720_784_800_816_832_864: [RawVector; 6], + pub vectors_880_896_912_928_944: [RawVector; 5], + pub line_tests_960_1008_1056: [LineTestFields; 3], + pub vector_1520: RawVector, + pub matrix_1536: RawMatrix, + pub byte_1600: u8, + pub external_physics_1616: ExternalPhysicsInput, + pub probe_1792: ProbeFields, + 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 state_identifier_2496: u32, + pub state_2504: u32, + pub state_2508: u32, + pub category_2512: u32, + pub category_2516: u32, + pub player_state_value_2520: u32, + pub filtered_state_2524: u32, + pub state_variant_index_2528: u32, + pub grind_words_2532_2536: [u32; 2], + pub surface_mode_2540: u32, + pub surface_primary_ref_2544: Option, + pub state_variant_ref_2548: Option, + pub surface_secondary_ref_2552: Option, + pub wheel_count_2556: u32, + pub state_count_2564: u32, + pub update_count_2568: u32, + pub spin_same_direction_frames_2580: u32, + pub frames_since_teleport_2584: u32, + pub skitch_value_2592: u32, + pub left_surface_2596: u32, + pub right_surface_2600: u32, + pub timestep_2604: f32, + pub scalar_2612: f32, + pub transition_2636: f32, + ///Skeleton82BDD008: BodySpin plus the actual grind-air adjustment result. + ///Read only with flags2484bit15; Reset82BFA394 clears it each input tick. + pub grind_adjusted_body_spin_2644: f32, + ///Reset82BFA2D0 writes original literal822F8B40, -9.8. + pub gravity_2648: f32, + pub scalar_2652: f32, + pub scalar_2656: f32, + pub state_timer_2664: f32, + pub scalar_2668: f32, + /// Written by Skeleton::ProcessData before the post-skeleton suffix. + pub spin_input_2672: f32, + pub scalar_2696: f32, + pub scalar_2700: f32, + pub scalar_2736: f32, + pub time_since_last_input_2748: f32, + pub time_on_ground_2752: f32, + pub signed_ground_time_2756: f32, + pub truck_tightness_2760: f32, + pub scalar_2764: f32, + ///Current minus preceding authored board forward-axis Y,82BD8E20. + pub board_at_y_delta_2768: f32, + pub air_scalar_2772: f32, + /// Written by Skeleton::ProcessData before crouch-delta publication. + pub crouch_2776: f32, + pub crouch_delta_2780: f32, + pub off_board_scalar_2832: f32, + pub time_off_board_2836: f32, + pub ground_timer_2848: f32, + pub secondary_ground_timer_2852: f32, + pub collision_scalar_2924: f32, + pub actor_query_2948: u32, + pub actor_query_2952: u32, +} + +impl Default for ProcessedPhysicsInput { + fn default() -> Self { + Self { + effective_anim_transform_192: [[0; 4]; 4], + vectors_400_416: [[0; 4]; 2], + vectors_464_480_496_512_528: [[0; 4]; 5], + vectors_544_560_592_608: [[0; 4]; 4], + vectors_624_640_656_672: [[0; 4]; 4], + prepared_jump_704: [0; 4], + collision_pose_error_736: [0; 4], + animation_com_to_deck_752: [0; 4], + animation_com_to_deck_delta_768: [0; 4], + vectors_720_784_800_816_832_864: [[0; 4]; 6], + vectors_880_896_912_928_944: [[0; 4]; 5], + line_tests_960_1008_1056: [LineTestFields::default(); 3], + vector_1520: [0; 4], + matrix_1536: [[0; 4]; 4], + byte_1600: 0, + external_physics_1616: empty_external_physics(), + probe_1792: ProbeFields::default(), + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + flags_2488: 0, + state_identifier_2496: 0, + state_2504: 0, + state_2508: 0, + category_2512: 0, + category_2516: 0, + player_state_value_2520: 0, + filtered_state_2524: 0, + state_variant_index_2528: 0, + grind_words_2532_2536: [0; 2], + surface_mode_2540: 0, + surface_primary_ref_2544: None, + state_variant_ref_2548: None, + surface_secondary_ref_2552: None, + wheel_count_2556: 0, + state_count_2564: 0, + update_count_2568: 0, + spin_same_direction_frames_2580: 0, + frames_since_teleport_2584: 0, + skitch_value_2592: 0, + left_surface_2596: 0, + right_surface_2600: 0, + timestep_2604: 0.0, + scalar_2612: 0.0, + transition_2636: 0.0, + grind_adjusted_body_spin_2644: 0.0, + gravity_2648: 0.0, + scalar_2652: 0.0, + scalar_2656: 0.0, + state_timer_2664: 0.0, + scalar_2668: 0.0, + spin_input_2672: 0.0, + scalar_2696: 0.0, + scalar_2700: 0.0, + scalar_2736: 0.0, + time_since_last_input_2748: 0.0, + time_on_ground_2752: 0.0, + signed_ground_time_2756: 0.0, + truck_tightness_2760: 0.0, + scalar_2764: 0.0, + board_at_y_delta_2768: 0.0, + air_scalar_2772: 0.0, + crouch_2776: 0.0, + crouch_delta_2780: 0.0, + off_board_scalar_2832: 0.0, + time_off_board_2836: 0.0, + ground_timer_2848: 0.0, + secondary_ground_timer_2852: 0.0, + collision_scalar_2924: 0.0, + actor_query_2948: 0, + actor_query_2952: 0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundHistoryRequest { + pub previous_position: RawVector, + pub current_position: RawVector, + pub previous_filtered_delta: RawVector, + /// Processed +2604. The native block also consumes constants at + /// `0x82072818` and `0x8231A844`; the service owns those exact values. + pub timestep_bits: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundHistoryResult { + pub delta: RawVector, + pub filtered_delta: RawVector, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PrepareJumpRequest { + pub skateboard_vector: RawVector, + pub previous_velocity: RawVector, +} + +pub(crate) fn empty_external_physics() -> ExternalPhysicsInput { + ExternalPhysicsInput { + vectors: [[0; 4]; 10], + flags: 0, + } +} diff --git a/crates/skate-core/src/player/lifecycle.rs b/crates/skate-core/src/player/lifecycle.rs new file mode 100644 index 00000000..05033b17 --- /dev/null +++ b/crates/skate-core/src/player/lifecycle.rs @@ -0,0 +1,229 @@ +//! Physical-player state changes recovered from TU3 `0x82DB8540`. +//! +//! The selected state objects remain owned by `PhysicalPlayer`; changing state +//! selects one of those objects, rather than constructing a replacement. + +use super::state::{PhysicalStateId, UnknownPhysicalState}; + +/// The state object selected from one of `PhysicalPlayer`'s owned pointers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StateBinding { + pub state: PhysicalStateId, + pub owner_offset: u32, +} + +impl StateBinding { + pub const fn new(state: PhysicalStateId) -> Self { + Self { + state, + owner_offset: state.native_owner_offset(), + } + } +} + +/// State-change fields owned directly by `PhysicalPlayer`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlayerStateChangeFields { + /// Player+1312. `SetPhysicsState` clears it before publishing state history. + pub word_1312: u32, + /// Player+1336. Updated to the previous category only across a category edge. + pub previous_category_latch_1336: u32, + /// Player+1344. `SetPhysicsState` clears it on every accepted transition. + pub scalar_1344: f32, +} + +/// The exact `ProcessedPhysIn` fields read or written by `0x82DB8540`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProcessedStateChangeFields { + /// +2480; bits 7 and 8 choose free-board controller mode on off-board entry. + pub flags_2480: u32, + /// +2500; requested state. + pub requested_state_2500: u32, + /// +2504; type reported by the old state's vtable slot +28. + pub previous_state_2504: u32, + /// +2508; current concrete state published before old-state Exit. + pub current_state_2508: u32, + /// +2512; category of `current_state_2508`. + pub current_category_2512: u32, + /// +2516; category of `previous_state_2504`. + pub previous_category_2516: u32, + /// +2520; copy of Player+1336 after its conditional update. + pub previous_category_latch_2520: u32, + /// +2564; reset before the old state's Exit call. + pub word_2564: u32, + /// +2664; reset before the old state's Exit call. + pub scalar_2664: f32, +} + +/// Fields at `PhysicalPlayer+1844`'s `SkateboardController`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SkateboardControllerFields { + /// Controller+444. Its wider meaning is unresolved; every mode change here + /// resets it with a 32-bit zero. + pub word_444: u32, + /// Controller+448. Values used by this path are stopped=0, held=1, free=2. + pub state_448: u32, + /// Controller+452, identified by `StopController` as the system-on byte. + pub system_on_452: bool, +} + +/// Complete mutable data touched by the state-change function itself. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StateChangeData { + pub player: PlayerStateChangeFields, + pub processed: ProcessedStateChangeFields, + pub skateboard_controller: SkateboardControllerFields, +} + +/// Board and drive operations called by the controller branch. Their complete +/// implementations are `HoldSkateboard` `0x82D75370` and +/// `LetGoOfSkateboard` `0x82D75440`; this lifecycle never replaces them. +pub trait SkateboardControllerActions { + fn hold_skateboard(&mut self); + fn let_go_of_skateboard(&mut self); +} + +/// Context supplied to an owned state's Enter or Exit implementation. +/// `active` makes the pointer-switch boundary observable without exposing a +/// native pointer in engine-independent code. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StateCall { + pub state: StateBinding, + pub active: StateBinding, +} + +/// Calls corresponding to the recovered state vtable slots used here. +pub trait PhysicalStateCalls { + /// Vtable +28 on the currently selected state. + fn get_type(&mut self, state: StateBinding) -> PhysicalStateId; + /// Vtable +20 on the old state. + fn exit(&mut self, call: StateCall); + /// Vtable +16 on the newly selected state. + fn enter(&mut self, call: StateCall); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PhysicalPlayerStateLifecycle { + active: StateBinding, +} + +impl PhysicalPlayerStateLifecycle { + pub const fn new(initial_state: PhysicalStateId) -> Self { + Self { + active: StateBinding::new(initial_state), + } + } + + pub const fn active(&self) -> StateBinding { + self.active + } + + /// Port of `PhysicalPlayerHiLOD::SetPhysicsState` `0x82DB8540`. + /// + /// Unknown numeric IDs are rejected before any callbacks or field writes. + /// The native default branches exit and re-enter the existing pointer for + /// such values, but no recovered caller contract establishes them as valid + /// states; making that case an error prevents a silent false transition. + pub fn set_physics_state( + &mut self, + requested_raw: u32, + data: &mut StateChangeData, + states: &mut S, + controller_actions: &mut C, + ) -> Result + where + S: PhysicalStateCalls, + C: SkateboardControllerActions, + { + let requested = PhysicalStateId::try_from(requested_raw)?; + + prepare_skateboard_controller( + requested, + &mut data.skateboard_controller, + data.processed.flags_2480, + controller_actions, + ); + + data.player.word_1312 = 0; + data.player.scalar_1344 = 0.0; + data.processed.scalar_2664 = 0.0; + data.processed.word_2564 = 0; + + let previous = states.get_type(self.active); + let previous_category = previous.category(); + let current_category = requested.category(); + data.processed.previous_state_2504 = previous as u32; + data.processed.previous_category_2516 = previous_category; + data.processed.requested_state_2500 = requested as u32; + data.processed.current_state_2508 = requested as u32; + data.processed.current_category_2512 = current_category; + if current_category != previous_category { + data.player.previous_category_latch_1336 = previous_category; + } + data.processed.previous_category_latch_2520 = data.player.previous_category_latch_1336; + + states.exit(StateCall { + state: self.active, + active: self.active, + }); + self.active = StateBinding::new(requested); + states.enter(StateCall { + state: self.active, + active: self.active, + }); + Ok(self.active) + } +} + +fn prepare_skateboard_controller( + requested: PhysicalStateId, + controller: &mut SkateboardControllerFields, + processed_flags_2480: u32, + actions: &mut C, +) { + if !matches!( + requested, + PhysicalStateId::BipedGround | PhysicalStateId::BipedAir | PhysicalStateId::OffBoardPushing + ) { + stop_skateboard_controller(controller, actions); + return; + } + + if controller.system_on_452 { + return; + } + + controller.word_444 = 0; + if processed_flags_2480 & (0x80 | 0x100) != 0 { + if controller.state_448 != 2 { + actions.let_go_of_skateboard(); + controller.state_448 = 2; + } + } else if controller.state_448 != 1 { + actions.hold_skateboard(); + controller.state_448 = 1; + } + controller.system_on_452 = true; +} + +/// `StopController` `0x82D75EA0`, called directly by `SetPhysicsState` for +/// every requested state outside 500..=502. +fn stop_skateboard_controller( + controller: &mut SkateboardControllerFields, + actions: &mut C, +) { + if !controller.system_on_452 { + return; + } + let previous_state = controller.state_448; + controller.word_444 = 0; + if previous_state != 0 { + actions.let_go_of_skateboard(); + controller.state_448 = 0; + } + controller.system_on_452 = false; +} + +#[cfg(test)] +#[path = "tests/lifecycle.rs"] +mod tests; diff --git a/crates/skate-core/src/player/logic.rs b/crates/skate-core/src/player/logic.rs new file mode 100644 index 00000000..cfd0a829 --- /dev/null +++ b/crates/skate-core/src/player/logic.rs @@ -0,0 +1,96 @@ +//! TU3 PhysicalPlayer Logic phase (`0x82DB5FA8`). + +use super::{ + lifecycle::{ + PhysicalPlayerStateLifecycle, PhysicalStateCalls, SkateboardControllerActions, + StateBinding, StateChangeData, + }, + selector::{StateSelectionInput, StateSelector}, + state::UnknownPhysicalState, +}; + +/// Player+1876 staging data cleared at the end of every Logic phase. +#[derive(Clone, Debug, PartialEq)] +pub struct PhysicsOutputStaging { + /// Bytes +20 through +53. + pub bytes_20_through_53: [u8; 34], + /// Floats +56 through +188. + pub scalars_56_through_188: [f32; 34], + /// Word +200. + pub word_200: u32, +} + +impl PhysicsOutputStaging { + pub fn clear_like_82db6000(&mut self) { + self.word_200 = 0; + self.bytes_20_through_53.fill(0); + self.scalars_56_through_188.fill(0.0); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LogicResult { + pub previous: StateBinding, + pub selected: StateBinding, + pub changed: bool, +} + +/// State owned by the exact transition portion of PhysicalPlayer's Logic phase. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhysicalPlayerLogic { + pub selector: StateSelector, + pub lifecycle: PhysicalPlayerStateLifecycle, + /// PhysicalPlayer+1304; native writes 100 only when the concrete state changes. + pub frames_since_jump_1304: u32, +} + +impl PhysicalPlayerLogic { + pub fn new(initial_state: super::state::PhysicalStateId) -> Self { + Self { + selector: StateSelector::default(), + lifecycle: PhysicalPlayerStateLifecycle::new(initial_state), + frames_since_jump_1304: 0, + } + } + + /// Calls current-state GetType, calculates the suggested state, performs + /// SetPhysicsState only on an edge, then clears the output staging block. + pub fn run( + &mut self, + selection_input: &StateSelectionInput, + state_change_data: &mut StateChangeData, + output_staging: &mut PhysicsOutputStaging, + states: &mut S, + controller_actions: &mut C, + ) -> Result + where + S: PhysicalStateCalls, + C: SkateboardControllerActions, + { + let previous = self.lifecycle.active(); + let previous_type = states.get_type(previous); + let suggested = self.selector.calculate(previous_type, selection_input); + let changed = suggested != previous_type; + let selected = if changed { + self.frames_since_jump_1304 = 100; + self.lifecycle.set_physics_state( + suggested as u32, + state_change_data, + states, + controller_actions, + )? + } else { + previous + }; + output_staging.clear_like_82db6000(); + Ok(LogicResult { + previous, + selected, + changed, + }) + } +} + +#[cfg(test)] +#[path = "tests/logic.rs"] +mod tests; diff --git a/crates/skate-core/src/player/mod.rs b/crates/skate-core/src/player/mod.rs new file mode 100644 index 00000000..b6e3c738 --- /dev/null +++ b/crates/skate-core/src/player/mod.rs @@ -0,0 +1,24 @@ +//! Playable-skater orchestration recovered from the TU3 player and world loops. +//! +//! State behavior lives in the riding, air, animation and physics modules. This +//! module owns the one ordering contract that connects those systems. + +pub mod adjust; +pub mod frame; +pub mod input_phase; +pub mod lifecycle; +pub mod logic; +pub mod post_input; +pub mod post_output; +pub mod post_state; +pub mod pre_state; +pub mod selector; +pub mod slide_state; +pub mod state; +pub mod state_phase; +pub mod wipeout; +pub mod wipeout_state; +pub mod offboard; + +pub mod conditioner_capabilities; +pub mod teleport_state; diff --git a/crates/skate-core/src/player/offboard/board_possession.rs b/crates/skate-core/src/player/offboard/board_possession.rs new file mode 100644 index 00000000..14b245ad --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession.rs @@ -0,0 +1,59 @@ +//! Original TU3 SkateboardController. Lifecycle state448 remains in the existing +//! SkateboardControllerFields; this module owns only its additional data. +pub mod manager; +pub mod math; +pub mod lifecycle; +pub mod motion; +pub type Vector = [f32;4]; +pub type Frame = [Vector;4]; +pub const IDENTITY: Frame = [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[0.;4]]; +use crate::player::lifecycle::SkateboardControllerFields; + +#[derive(Clone, Copy, Debug)] +pub struct Retrieval { + pub initial_0: Frame, pub target_64: Frame, pub current_128: Frame, + pub elapsed_192: f32, pub duration_196: f32, pub progress_200: f32, pub weight_204: f32, +} +impl Default for Retrieval { + fn default()->Self {Self {initial_0:IDENTITY,target_64:IDENTITY,current_128:IDENTITY, + elapsed_192:0.,duration_196:0.,progress_200:0.,weight_204:0.}} +} +#[derive(Clone, Copy, Debug)] +pub struct HandDrive { + /// Native child frame224/288, parent frame256/320. + pub child: Frame, pub parent: Frame, + /// Linear then angular spring/maxvelocity,damping,strength,type. + pub dynamics: [[u32;4];2], +} +impl Default for HandDrive { + fn default()->Self {Self {child:IDENTITY,parent:IDENTITY,dynamics:[[0,0,0,2];2]}} +} +#[derive(Clone, Debug)] +pub struct State {pub retrieval:Retrieval, pub hands:[HandDrive;2], pub selected_hand_424:u32} +impl Default for State { + fn default()->Self {Self {retrieval:Retrieval::default(),hands:[HandDrive::default();2],selected_hand_424:2}} +} +#[derive(Clone, Copy, Debug)] +pub struct Processed { + pub board_frame_64:Frame, pub player_frame_192:Frame, + pub position_592:Vector, pub velocity_912:Vector, pub direction_400:Vector, + pub hide_direction_464:Vector, pub flags_2476:u32,pub flags_2480:u32,pub flags_2488:u32, +} +#[derive(Clone, Copy, Debug)] +pub struct Fill { + pub angle_36:f32,pub angle_40:f32, + pub held_311:bool,pub free_312:bool,pub returning_313:bool, + pub hiding_321:bool,pub flag_322:bool,pub flag_323:bool,pub flag_324:bool, +} +/// Complete82D76D20 numerical/byte publication, given actual82BE3170 bone11. +pub fn fill(fields:&SkateboardControllerFields,state:&State,p:&Processed,bone11:Frame)->Fill { + let frame=p.player_frame_192; + let on_axis=math::madd(frame[1],math::dot(math::sub(bone11[3],frame[3]),frame[1]),frame[3]); + let position=if fields.state_448==3 {state.retrieval.initial_0[3]}else{p.board_frame_64[3]}; + let delta=math::sub(position,on_axis); + let angle=math::quadrant_angle(math::dot(delta,frame[0]),math::dot(delta,frame[2])); + let s=fields.state_448;let bits=p.flags_2476; + Fill {angle_36:angle,angle_40:0.,held_311:s==1,free_312:matches!(s,2|3|4), + returning_313:s==4,hiding_321:s==3,flag_322:bits&0x200!=0, + flag_323:bits&0x400!=0||s==4,flag_324:bits&0x400!=0||(bits&0x800!=0&&s==4)} +} diff --git a/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs b/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs new file mode 100644 index 00000000..d3aed35f --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/lifecycle.rs @@ -0,0 +1,132 @@ +use super::{*,math::*}; +use crate::point_graph::PointGraph; +pub struct Settings { + pub hide_distance:f32,pub hide_offset:f32,pub return_distance:f32,pub mounted_return_distance:f32, + pub mounting_time:f32,pub retrieval_time:PointGraph<8>,pub retrieval_weight:PointGraph<8>, + pub throw_pitch:f32,pub throw_velocity:PointGraph<8>, + pub throw_target_pitch:f32,pub throw_pitch_scalar:f32,pub throw_roll_scalar:f32,pub throw_yaw_scalar:f32, +} +/// Actual completed physical and evaluated skeleton observations. +pub struct Observation { + pub processed:Processed,pub board_collision_flags_872:u32,pub board_state_840:u32, + pub hand_contacts:[bool;2],pub physical_hand_positions:[Vector;2], + pub animation_board_frame_12624:Frame,pub animation_hand_frames:[Frame;2], + pub attachment_frame_0:Frame, +} +#[derive(Clone,Copy,Debug)] +pub struct Alignment {pub first_1008:Vector,pub second_1024:Vector,pub factor_1040:f32,pub flag_1044:bool} +/// Each call mutates the actual board/solver owner. No observation defaults. +pub trait Effects { + fn enable_animation_soft(&mut self); + fn enable_animation_angular_only(&mut self); + fn disable_animation(&mut self); + fn disable_linear_drive(&mut self); + ///82C03AF8 clears8384 bit7, executes091F8/090C0, enables selected volumes. + fn standard_board(&mut self); + ///7549C..54D0 sets8384 bit7, deck drag literal416FFFFF,09160, enables volumes. + fn released_board(&mut self); + fn collision_volumes(&mut self,enabled:bool); + fn clear_alignment(&mut self); + fn alignment(&mut self,value:Alignment); + fn velocity(&mut self,value:Vector); + fn position(&mut self,value:Vector); + fn hook_frame(&mut self,value:Frame); + fn target_position_velocity(&mut self,value:Vector); + fn torque(&mut self,value:Vector); +} +impl State { + ///75370 writes held state BEFORE selecting/arming the hand and drive frames. + pub fn hold(&mut self,fields:&mut SkateboardControllerFields,o:&Observation,e:&mut impl Effects){ + e.enable_animation_soft();e.standard_board();e.collision_volumes(true); + fields.state_448=1; + self.selected_hand_424=(!o.processed.flags_2476>>2)&1; + self.hands[self.selected_hand_424 as usize].dynamics= + [[0x4395ffff,0,0x468c9fff,2];2]; + self.update_drive_frames(o); + } + pub fn disable_hand(&mut self){ + if self.selected_hand_424!=2{ + self.hands[self.selected_hand_424 as usize].dynamics=[[0,0,0,2];2]; + self.selected_hand_424=2; + } + } + ///75440 does not itself change448. Caller transitions after all actions. + pub fn let_go(&mut self,fields:&mut SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + e.disable_animation();e.released_board();e.collision_volumes(true);e.clear_alignment(); + self.disable_hand(); + if fields.state_448==4{e.velocity([0.;4]);} + fields.word_444=0; + if o.processed.flags_2476&0x1000!=0{ + fields.word_444=8;e.velocity(motion::throw_velocity(&o.processed,s)); + } + } + pub fn hide(&mut self,o:&Observation,e:&mut impl Effects){ + e.collision_volumes(false);e.disable_linear_drive();e.clear_alignment();self.disable_hand(); + self.retrieval=Retrieval::default();self.retrieval.initial_0=o.processed.board_frame_64; + self.retrieval.elapsed_192=0.;e.enable_animation_angular_only(); + } + pub fn retrieve(&mut self,fields:&SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + e.collision_volumes(false);e.clear_alignment();e.enable_animation_angular_only(); + let old=self.retrieval.initial_0[3];self.retrieval=Retrieval::default(); + self.retrieval.initial_0=o.processed.board_frame_64; + if fields.state_448==3{self.retrieval.initial_0[3]=old;} + self.retrieval.target_64=o.attachment_frame_0; + let mut delta=sub(self.retrieval.initial_0[3],self.retrieval.target_64[3]); + let mut distance=length(delta);let tiny=f32::from_bits(0x3a83126f); + if !(distance>=tiny){distance=tiny;delta=scale(o.processed.player_frame_192[2],tiny);} + let mounting=o.processed.flags_2480&0x80000!=0; + let maximum=if mounting{s.mounted_return_distance}else{s.return_distance}; + if distance>maximum||fields.state_448==3{ + self.retrieval.initial_0[3]=madd(scale(delta,maximum),reciprocal(distance,2),self.retrieval.target_64[3]); + e.position(self.retrieval.initial_0[3]);distance=maximum; + } + self.retrieval.duration_196=s.retrieval_time.evaluate(distance);self.retrieval.elapsed_192=0.; + if mounting{ + let d=self.retrieval.duration_196; + self.retrieval.duration_196=if s.mounting_time-d>=0.{d}else{s.mounting_time}; + } + } + ///75F00. Fields are borrowed from the single existing controller owner. + pub fn update_state(&mut self,f:&mut SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + let p=&o.processed; + match f.state_448{ + 1=>{ + let free_allowed=p.flags_2480&0x80000==0&&p.flags_2488&0x8000000==0; + if p.flags_2476&0x2000!=0||(free_allowed&&o.board_collision_flags_872&0x4000000!=0){ + f.word_444=0;self.let_go(f,o,s,e);f.state_448=2; + }else{ + let hand=if self.selected_hand_424==0{0}else{1}; + e.alignment(Alignment { + first_1008:unit(sub(o.physical_hand_positions[hand],p.board_frame_64[3]),[0.;4]), + second_1024:unit(sub(p.position_592,p.board_frame_64[3]),[0.;4]), + factor_1040:f32::from_bits(0x3f4ccccd),flag_1044:true}); + } + } + 2=>{ + let delta=sub(p.board_frame_64[3],p.position_592); + let distant=dot(delta,delta)>s.hide_distance*s.hide_distance; + if p.flags_2480&0x80000!=0||p.flags_2476&0x800!=0{ + f.word_444=0;self.retrieve(f,o,s,e);f.state_448=4; + }else if distant||o.board_state_840==6{ + f.word_444=0;self.hide(o,e);f.state_448=3; + } + } + 3=>if p.flags_2480&0x80000!=0||p.flags_2476&0x800!=0{ + f.word_444=0;self.retrieve(f,o,s,e);f.state_448=4; + }, + 4=>{ + let hand=((!p.flags_2476>>2)&1)as usize; + if p.flags_2480&0x80000==0&&o.hand_contacts[hand]{ + f.word_444=0;self.let_go(f,o,s,e);f.state_448=2; + }else if !(self.retrieval.progress_200<1.){ + e.velocity([0.;4]);f.word_444=0;self.hold(f,o,e);f.state_448=1; + } + } + 5=>if o.board_collision_flags_872&0x4000000!=0{ + f.word_444=0;self.let_go(f,o,s,e);f.state_448=2; + }else{e.alignment(Alignment{first_1008:p.board_frame_64[2],second_1024:p.board_frame_64[2], + factor_1040:f32::from_bits(0x3f733333),flag_1044:false});}, + _=>{} + } + } +} diff --git a/crates/skate-core/src/player/offboard/board_possession/manager.rs b/crates/skate-core/src/player/offboard/board_possession/manager.rs new file mode 100644 index 00000000..f5b0a331 --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/manager.rs @@ -0,0 +1,38 @@ +//! Ground state56's hand manager, distinct from SkateboardController448. +use super::Vector; +#[derive(Clone, Copy, Debug)] +pub struct Hand { + pub vectors_0_to_64:[Vector;5],pub word_80:u32,pub flag_84:bool, + pub scalars_96_100:[f32;2],pub flags_104_to_107:[bool;4], +} +impl Default for Hand { + fn default()->Self {Self {vectors_0_to_64:[[0.;4],[0.;4],[0.,1.,0.,0.],[0.;4],[0.;4]], + word_80:0,flag_84:false,scalars_96_100:[0.;2],flags_104_to_107:[false;4]}} +} +#[derive(Clone, Debug)] +pub struct State { + pub hands:[Hand;2],pub vectors_224_to_272:[Vector;4],pub scalars_288_to_296:[f32;3], + pub word_300:u32,pub flags_304_to_307:[bool;4],pub words_308_to_316:[u32;3], +} +impl Default for State { + fn default()->Self {Self {hands:[Hand::default();2],vectors_224_to_272:[[0.;4];4], + scalars_288_to_296:[0.;3],word_300:2,flags_304_to_307:[false;4],words_308_to_316:[0;3]}} +} +/// Actual skeleton16432 object's reset fields; all other fields survive. +pub struct SkeletonReset<'a> { + pub enabled_464:&'a mut bool,pub values_468:&'a mut [f32;4], + pub values_484:&'a mut [f32;4],pub words_500:&'a mut [u32;4], +} +impl State { + ///82D77168 writes only the represented fields, not pointer fields320 onward + ///or unwritten gaps88..95 in each hand record. + pub fn reset(&mut self) {*self=Self::default();} + ///82D770D8 preserves hand state across500/501/502 and always publishes304. + pub fn enter(&mut self,previous_2504:u32,current_2508:u32,skeleton:SkeletonReset<'_>) { + if !matches!(previous_2504,500|501|502) { + self.reset();*skeleton.values_468=[0.;4];*skeleton.values_484=[0.;4]; + *skeleton.words_500=[0;4];*skeleton.enabled_464=false; + } + self.flags_304_to_307[0]=current_2508==502; + } +} diff --git a/crates/skate-core/src/player/offboard/board_possession/math.rs b/crates/skate-core/src/player/offboard/board_possession/math.rs new file mode 100644 index 00000000..30730732 --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/math.rs @@ -0,0 +1,46 @@ +//! VMX arithmetic shared by the possession stages. Four stored lanes, xyz dot. +use super::{Vector,Frame}; +pub fn dot(a:Vector,b:Vector)->f32{a[0]*b[0]+a[1]*b[1]+a[2]*b[2]} +pub fn sub(a:Vector,b:Vector)->Vector{std::array::from_fn(|i|a[i]-b[i])} +pub fn scale(a:Vector,s:f32)->Vector{a.map(|x|x*s)} +pub fn madd(a:Vector,s:f32,b:Vector)->Vector{std::array::from_fn(|i|a[i].mul_add(s,b[i]))} +pub 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]),(-a[3]).mul_add(b[3],a[3]*b[3])]} +fn inverse_sqrt(s:f32)->f32{ + let mut r=crate::physics::reciprocal_sqrt::estimate(s); + for _ in 0..2 {r=(r*0.5).mul_add((-s).mul_add(r*r,1.),r);}r +} +pub fn length(a:Vector)->f32{let s=dot(a,a);let n=s*inverse_sqrt(s);if s==0.{0.}else{n}} +pub fn unit(a:Vector,fallback:Vector)->Vector{ + let s=dot(a,a);let r=inverse_sqrt(s);let n=if s==0.{0.}else{s*r}; + if n>f32::from_bits(0x358637bd){scale(a,r)}else{fallback} +} +pub fn reciprocal(x:f32,steps:usize)->f32{ + let mut r=crate::physics::native_arithmetic::reciprocal_estimate(x); + for _ in 0..steps{r=r.mul_add((-r).mul_add(x,1.),r);}r +} +///76D20 differs from atan2 at zero, including its signed-zero half-pi result. +pub fn quadrant_angle(y:f32,x:f32)->f32{ + let basic=crate::input::angle::atan(y.mul_add(reciprocal(x,1),0.)); + let sign=y.to_bits()&0x80000000; + let result=if 0.>x{f32::from_bits(0x40490fdb|sign)+basic}else{basic}; + if x==0.{f32::from_bits(0x3fc90fdb|sign)}else{result} +} +pub fn transform_direction(frame:Frame,v:Vector)->Vector{ + madd(frame[2],v[2],madd(frame[1],v[1],scale(frame[0],v[0]))) +} +pub fn projected_angle(a:Vector,b:Vector,axis:Vector)->f32{ + if !(dot(a,a)*dot(b,b)>f32::from_bits(0x37800000)){return 0.;} + let a=sub(a,scale(axis,dot(axis,a)));let b=sub(b,scale(axis,dot(axis,b))); + let aa=dot(a,a);let bb=dot(b,b);let gate=f32::from_bits(0x38d1b717); + if !(aa>gate&&bb>gate){return 0.;} + let one=|x:f32|{let r=crate::physics::reciprocal_sqrt::estimate(x);(r*0.5).mul_add((-x).mul_add(r*r,1.),r)}; + let a=scale(a,one(aa));let b=scale(b,one(bb)); + let angle=crate::trigonometry::acos(dot(a,b).max(-1.).min(1.)); + if dot(cross(a,b),axis)<0.{f32::from_bits(0x40c90fdb)-angle}else{angle} +} +pub fn wrap(angle:f32)->f32{ + let t=angle*f32::from_bits(0x3e22f983);let fraction=t-t.floor(); + (fraction-if fraction>0.5{1.}else{0.})*f32::from_bits(0x40c90fdb) +} diff --git a/crates/skate-core/src/player/offboard/board_possession/motion.rs b/crates/skate-core/src/player/offboard/board_possession/motion.rs new file mode 100644 index 00000000..af0badaf --- /dev/null +++ b/crates/skate-core/src/player/offboard/board_possession/motion.rs @@ -0,0 +1,79 @@ +use super::{*,math::*,lifecycle::*}; +const DT:f32=f32::from_bits(0x3c888889); +const RADIANS:f32=f32::from_bits(0x3c8efa35); +impl State { + ///751D8: inverse board rotation and inverse translation are evaluated + ///separately before composing the selected hand. Preserve z/y/x order. + pub fn update_drive_frames(&mut self,o:&Observation){ + let hand=if self.selected_hand_424==0{0}else{1}; + let a=o.animation_board_frame_12624;let b=o.animation_hand_frames[hand]; + let inverse:Frame=std::array::from_fn(|column|if column<3 { + [a[0][column],a[1][column],a[2][column],0.] + }else{[0.;4]}); + let negative=sub([0.;4],a[3]); + let translation=madd(inverse[0],negative[0], + madd(inverse[1],negative[1],scale(inverse[2],negative[2]))); + let mut parent=IDENTITY; + for i in 0..3{parent[i]=transform_direction(inverse,b[i]);} + parent[3]=madd(inverse[2],b[3][2],madd(inverse[1],b[3][1],madd(inverse[0],b[3][0],translation))); + self.hands[self.selected_hand_424 as usize].child=IDENTITY; + self.hands[self.selected_hand_424 as usize].parent=parent; + } + /// Controller block82DB6150..61B0, after physical state's update callback. + pub fn update(&mut self,f:&mut SkateboardControllerFields,o:&Observation,s:&Settings,e:&mut impl Effects){ + if !f.system_on_452{return;} + self.update_state(f,o,s,e); + match f.state_448{ + 1=>self.update_drive_frames(o), + 2=>if (f.word_444 as i32)>0{ + f.word_444=f.word_444.wrapping_sub(1); + for torque in throw_torques(&o.processed,s){e.torque(torque);} + }, + 3=>{ + self.retrieval.elapsed_192+=DT;self.retrieval.progress_200=0.5;self.retrieval.weight_204=0.5; + self.retrieval.target_64=self.retrieval.initial_0; + self.retrieval.target_64[3]=madd(o.processed.hide_direction_464,s.hide_offset,o.processed.player_frame_192[3]); + self.retrieval.current_128=self.retrieval.target_64; + e.hook_frame(self.retrieval.current_128);e.position(self.retrieval.current_128[3]);e.velocity([0.;4]); + } + 4=>{ + self.retrieval.elapsed_192+=DT; + let fraction=self.retrieval.elapsed_192/self.retrieval.duration_196; + self.retrieval.progress_200=if 1.-fraction>=0.{fraction}else{1.}; + self.retrieval.weight_204=s.retrieval_weight.evaluate(self.retrieval.progress_200); + self.retrieval.target_64=o.attachment_frame_0; + self.retrieval.current_128=crate::animation::foot_ik::interpolate_affine( + &self.retrieval.initial_0,&self.retrieval.target_64,self.retrieval.weight_204); + e.hook_frame(self.retrieval.current_128);e.target_position_velocity(self.retrieval.current_128[3]); + } + _=>{} + } + } +} +///764F0 retains even the initial zero-yaw construction and all fused products. +pub fn throw_velocity(p:&Processed,s:&Settings)->Vector{ + let (sin,cos)=crate::trigonometry::sin_cos(0.*RADIANS); + let yaw=[[cos,0.,-sin,cos],[0.,1.,0.,0.],[sin,0.,cos,sin],[0.;4]]; + let (sin,cos)=crate::trigonometry::sin_cos(-s.throw_pitch*RADIANS); + let local=transform_direction(yaw,[0.,-sin,cos,0.]); + let direction=transform_direction(p.player_frame_192,local); + let speed=length(p.velocity_912); + scale(direction,speed+s.throw_velocity.evaluate(speed)) +} +///767D0: yaw, pitch, roll torque outputs in source append order. +pub fn throw_torques(p:&Processed,s:&Settings)->[Vector;3]{ + let mut flat_board=p.board_frame_64[2];flat_board[1]=0.; + let mut flat_direction=p.direction_400;flat_direction[1]=0.; + let heading=unit(flat_direction,unit(flat_board,[0.,0.,1.,0.])); + let up=[0.,1.,0.,0.];let right=cross(up,heading); + let basis=[right,up,heading,[0.;4]]; + let (sin,cos)=crate::trigonometry::sin_cos(-s.throw_target_pitch*RADIANS); + let target_right=transform_direction(basis,[1.,0.,0.,1.]); + let target_up=transform_direction(basis,[0.,cos,sin,0.]); + let target_forward=transform_direction(basis,[0.,-sin,cos,0.]); + let mut forward=p.board_frame_64[2];let mut right=p.board_frame_64[0]; + if 0.>dot(forward,target_forward){forward=forward.map(|v|-v);right=right.map(|v|-v);} + [scale(scale(target_up,wrap(projected_angle(forward,target_forward,target_up))),s.throw_yaw_scalar), + scale(scale(target_right,wrap(projected_angle(forward,target_forward,target_right))),s.throw_pitch_scalar), + scale(scale(target_forward,wrap(projected_angle(right,target_right,target_forward))),s.throw_roll_scalar)] +} diff --git a/crates/skate-core/src/player/offboard/cadence.rs b/crates/skate-core/src/player/offboard/cadence.rs new file mode 100644 index 00000000..146b391f --- /dev/null +++ b/crates/skate-core/src/player/offboard/cadence.rs @@ -0,0 +1,303 @@ +//! Biped cadence producer82D80720 and phase helpers82D7AA18..82D7AFD0. +//! Original TU3, SHA256431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! Embed this state once in Biped; callers supply actual movement/animation input. +//! PC floating-point arithmetic is not proven bit-exact Xenon arithmetic. + +const DT: f32 = f32::from_bits(0x3c888889); +const EPSILON: f32 = f32::from_bits(0x3a83126f); +type Vector = [f32; 3]; + +/// Retained Biped720..736; duration is unwritten by constructor82D7AFD8. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BipedPhase { + pub phase: f32, + pub rate: f32, + pub target: f32, + pub duration: Option, + pub forward_target: bool, +} + +impl Default for BipedPhase { + fn default() -> Self { + Self { + phase: 0.0, + rate: 0.0, + target: -1.0, + duration: None, + forward_target: false, + } + } +} + +/// This is part of the physical Biped owner, not a MotionGraph phase clock. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BipedCadence { + pub phase: BipedPhase, + /// Original enum716. Values above every threshold retain the previous value. + pub locomotion_index: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CadenceThresholds(pub [f32; 4]); + +impl CadenceThresholds { + ///82D7AFD8: metrics come from actual walk/run/sprint AnimTransZ attributes. + pub fn from_clip_speeds(walk: f32, run: f32, sprint: f32) -> Self { + Self([ + f32::from_bits(0x3c23d70a), + (run + walk) * 0.5, + (sprint + run) * 0.5, + f32::from_bits(0x501502f9), + ]) + } +} + +/// Offsets identify original Biped or job-input fields until their upstream +/// physical names are recovered. No field may be filled from a guessed proxy. +#[derive(Clone, Copy, Debug)] +pub struct CadenceInput { + pub motion_512: Vector, + pub motion_reference_272: Vector, + pub reject_axis_400: Vector, + pub reject_enabled_708: bool, + pub up_144: Vector, + pub frame_rows_0_16_32: [Vector; 3], + pub frame_position_48: Vector, + pub animation_motion_224: Vector, + pub requested_duration_288: f32, + pub requested_phase_296: f32, + pub suppress_adjustment_353: bool, + pub contact_flags_176: u32, + pub contact_point_96: Vector, +} + +impl BipedCadence { + ///82D80720. Called after movement stage82D80548, before common publication. + pub fn update(&mut self, input: &CadenceInput, thresholds: CadenceThresholds) { + let mut motion = subtract(input.motion_512, input.motion_reference_272); + if input.reject_enabled_708 { + motion = reject(motion, input.reject_axis_400); + } + let mut magnitude = length(reject(motion, input.up_144)); + for (index, threshold) in thresholds.0.into_iter().enumerate() { + if magnitude <= threshold { + self.locomotion_index = index as u32; + break; + } + } + if !(input.requested_phase_296 < 0.0) { + self.phase + .request(input.requested_phase_296, input.requested_duration_288); + } else if self.locomotion_index == 0 { + if !(self.phase.target >= 0.0) { + self.phase.stop(); + } + } else { + let animation = input.animation_motion_224; + let horizontal = [animation[0], 0.0, animation[2]]; + let horizontal_length = length(horizontal); + if horizontal_length > EPSILON { + //82D80A58..AA8 transposes rows, then accumulates x, y, z. + let local = input.frame_rows_0_16_32.map(|row| { + row[2].mul_add(motion[2], row[1].mul_add(motion[1], row[0] * motion[0])) + }); + let projection = (1.0 / horizontal_length) * dot(local, horizontal); + magnitude = square_root(local[1].mul_add(local[1], projection * projection)); + } + let animation_length = length(animation); + self.phase.rate = if animation_length < EPSILON { + 0.0 + } else { + magnitude / animation_length + }; + self.phase.target = -1.0; + if !input.reject_enabled_708 + && !input.suppress_adjustment_353 + && input.contact_flags_176 & 0x180 != 0 + { + let time = (length(subtract(input.contact_point_96, input.frame_position_48)) + - 0.2) + / magnitude; + let lower = self.phase.rate * 0.4; + let upper = self.phase.rate * 2.5; + if time > f32::from_bits(0x3c088889) { + if input.contact_flags_176 & 0x100 != 0 { + let target = if self.phase.phase > 0.37 && self.phase.phase <= 0.87 { + 0.9 + } else { + 0.4 + }; + self.phase.adjust_targets(target, target, time, upper); + } else { + self.phase.adjust_contact(time, lower, upper); + } + } + } + } + self.phase.advance(); + } +} + +impl BipedPhase { + fn request(&mut self, target: f32, mut duration: f32) { + if duration > 0.1 { + duration -= 0.1; + } + if self.target == target && self.duration == Some(duration) { + return; + } + let delta = target - self.phase; + self.target = target; + self.duration = Some(duration); + self.forward_target = true; + self.rate = if delta < 0.0 { + (delta + 1.0) / duration + } else { + delta / duration + }; + } + + fn stop(&mut self) { + let mut target = 0.0; + let mut best = f32::from_bits(0x7149f2ca); + for candidate in [0.0, 0.5] { + let raw = candidate - self.phase; + let wrapped = wrap(0.0, raw + 1.0, 1.0); + let distance = if raw.abs() < wrapped.abs() { + raw.abs() + } else { + wrapped.abs() + }; + if distance < best { + target = candidate; + best = distance; + } + } + self.forward_target = false; + self.duration = Some(0.2); + self.target = target; + let distance = (target - self.phase).abs(); + self.rate = select(distance - (1.0 - distance), 1.0 - distance, distance) * 5.0; + } + + ///82D7AD90: this fixed retail phase step is also used by Biped Air. + pub fn advance(&mut self) { + if self.target < 0.0 { + let next = self.rate.mul_add(DT, self.phase); + self.phase = next - next.floor(); + } else if self.forward_target { + if self.phase == self.target { + return; + } + let goal = if self.target < self.phase { + self.target + 1.0 + } else { + self.target + }; + let old = self.phase; + let next = self.rate.mul_add(DT, old); + self.phase = if old < goal && next >= goal { + self.target + } else { + next - next.floor() + }; + } else { + let old = self.phase; + let mut next = old + wrap(-0.5, self.target - old, 0.5); + let step = self.rate * DT; + if old > next + step { + next = old - step; + } else if old < next - step { + next = old + step; + } + self.phase = wrap(0.0, next, 1.0); + } + } + + ///82D7AB48, with82D7AA18's two candidate rates when neither wraps to1. + fn adjust_targets(&mut self, a: f32, b: f32, time: f32, upper: f32) { + let da = wrap(0.0, a - self.phase, 1.0); + let db = wrap(0.0, b - self.phase, 1.0); + self.rate = if da >= 1.0 { + clamp(db / time, 0.0, upper) + } else if db >= 1.0 { + clamp(da / time, 0.0, upper) + } else { + let inverse = 1.0 / time; + let a = da * inverse; + let b = db * inverse; + select_rate(a, b, 0.0, upper, select(a - b, b, a)) + }; + } + + ///82D7AC30 selects the legal contact target closest to the current rate. + fn adjust_contact(&mut self, time: f32, lower: f32, upper: f32) { + let inverse = 1.0 / time; + let a = wrap(0.0, 0.45 - self.phase, 1.0) * inverse; + let b = wrap(0.0, 0.95 - self.phase, 1.0) * inverse; + let preferred = if (self.rate - a).abs() < (self.rate - b).abs() { + a + } else { + b + }; + self.rate = select_rate(a, b, lower, upper, preferred); + } +} + +fn select_rate(a: f32, b: f32, lower: f32, upper: f32, both: f32) -> f32 { + if a >= lower && a <= upper { + if b >= lower && b <= upper { both } else { a } + } else if b >= lower && b <= upper { + b + } else { + let da = if a < lower { lower - a } else { a - upper }; + let db = if b < lower { lower - b } else { b - upper }; + clamp(if da < db { a } else { b }, lower, upper) + } +} + +///82D7AF18: closed endpoints and signed floor count, not rem_euclid. +fn wrap(lower: f32, value: f32, upper: f32) -> f32 { + let count = if value < lower { + ((upper - value) / (upper - lower)).floor() as i32 + } else if value > upper { + (((value - lower) / (upper - lower)).floor() as i32).wrapping_neg() + } else { + 0 + }; + clamp((upper - lower).mul_add(count as f32, value), lower, upper) +} + +fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +fn clamp(value: f32, lower: f32, upper: f32) -> f32 { + let value = select(lower - value, lower, value); + select(upper - value, value, upper) +} +fn subtract(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +fn dot(a: Vector, b: Vector) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn reject(vector: Vector, axis: Vector) -> Vector { + let projection = dot(axis, vector); + std::array::from_fn(|i| vector[i] - axis[i] * projection) +} +fn length(vector: Vector) -> f32 { + square_root(dot(vector, vector)) +} +fn square_root(squared: f32) -> f32 { + // Independent PC seed. Preserve both original refinement steps and zero select. + let mut inverse = squared.sqrt().recip(); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + let result = squared * inverse; + if squared == 0.0 { 0.0 } else { result } +} + +#[cfg(test)] +#[path = "cadence/tests.rs"] +mod tests; diff --git a/crates/skate-core/src/player/offboard/cadence/tests.rs b/crates/skate-core/src/player/offboard/cadence/tests.rs new file mode 100644 index 00000000..41f9222b --- /dev/null +++ b/crates/skate-core/src/player/offboard/cadence/tests.rs @@ -0,0 +1,180 @@ +use super::*; + +fn close(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 2.0e-6, "{actual} != {expected}"); +} +fn input() -> CadenceInput { + CadenceInput { + motion_512: [0.0, 0.0, 1.0], + motion_reference_272: [0.0; 3], + reject_axis_400: [0.0, 0.0, 1.0], + reject_enabled_708: false, + up_144: [0.0, 1.0, 0.0], + frame_rows_0_16_32: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + frame_position_48: [0.0; 3], + animation_motion_224: [0.0, 0.0, 1.0], + requested_duration_288: 0.0, + requested_phase_296: -1.0, + suppress_adjustment_353: false, + contact_flags_176: 0, + contact_point_96: [0.0, 0.0, 1.2], + } +} +fn thresholds() -> CadenceThresholds { + CadenceThresholds::from_clip_speeds(2.0, 4.0, 6.0) +} + +#[test] +fn stock_initialization_and_clip_thresholds() { + let c = BipedCadence::default(); + assert_eq!(c.phase.duration, None); + assert_eq!(c.phase.target, -1.0); + assert_eq!(thresholds().0, [0.01, 3.0, 5.0, 1.0e10]); +} + +#[test] +fn moving_rate_uses_physical_motion_relative_to_animation() { + let mut c = BipedCadence::default(); + let mut i = input(); + i.motion_512[2] = 4.0; + i.motion_reference_272[2] = 1.0; + i.animation_motion_224[2] = 2.0; + c.update(&i, thresholds()); + assert_eq!(c.locomotion_index, 1); // Exact threshold chooses lower bucket. + close(c.phase.rate, 1.5); + close(c.phase.phase, 0.025); +} + +#[test] +fn movement_beyond_every_threshold_retains_previous_locomotion() { + let mut c = BipedCadence { + locomotion_index: 3, + ..Default::default() + }; + c.update(&input(), CadenceThresholds([0.0; 4])); + assert_eq!(c.locomotion_index, 3); +} + +#[test] +fn local_motion_keeps_vertical_component_for_cadence() { + let mut c = BipedCadence::default(); + let mut i = input(); + i.motion_512 = [2.0, 3.0, 0.0]; + i.frame_rows_0_16_32 = [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]; + c.update(&i, thresholds()); + close(c.phase.rate, 13.0f32.sqrt()); + assert_eq!(c.locomotion_index, 1); +} + +#[test] +fn contact_rejection_can_stop_locomotion_without_overwriting_animation() { + let mut c = BipedCadence::default(); + c.phase.phase = 0.25; + let mut i = input(); + i.reject_enabled_708 = true; + c.update(&i, thresholds()); + assert_eq!(c.locomotion_index, 0); + assert_eq!(c.phase.target, 0.0); // Equidistant stop targets prefer first. + assert_eq!(c.phase.duration, Some(0.2)); + close(c.phase.rate, 1.25); + close(c.phase.phase, 0.25 - 1.25 / 60.0); +} + +#[test] +fn repeated_explicit_target_does_not_recompute_rate_each_tick() { + let mut c = BipedCadence::default(); + c.phase.phase = 0.9; + let mut i = input(); + i.requested_phase_296 = 0.1; + i.requested_duration_288 = 0.3; + c.update(&i, thresholds()); + let rate = c.phase.rate; + close(rate, 1.0); + c.update(&i, thresholds()); + assert_eq!(c.phase.rate, rate); + close(c.phase.phase, 0.9 + 2.0 / 60.0); +} + +#[test] +fn forward_snap_preserves_one_endpoint_and_free_motion_wraps() { + let mut phase = BipedPhase { + phase: 0.99, + rate: 2.0, + target: 1.0, + forward_target: true, + ..Default::default() + }; + phase.advance(); + assert_eq!(phase.phase, 1.0); + phase.advance(); + assert_eq!(phase.phase, 1.0); + phase.target = -1.0; + phase.advance(); + close(phase.phase, 2.0 / 60.0); +} + +#[test] +fn closed_wrap_endpoints_are_not_euclidean_remainder() { + assert_eq!(wrap(0.0, 1.0, 1.0), 1.0); + assert_eq!(wrap(0.0, -1.0, 1.0), 1.0); + assert_eq!(wrap(0.0, 2.0, 1.0), 0.0); + assert_eq!(wrap(-0.5, 0.5, 0.5), 0.5); + assert_eq!(wrap(-0.5, -0.5, 0.5), -0.5); +} + +#[test] +fn nearest_target_can_advance_backwards_across_zero() { + let mut phase = BipedPhase { + phase: 0.01, + rate: 0.3, + target: 0.99, + ..Default::default() + }; + phase.advance(); + close(phase.phase, 0.005); +} + +#[test] +fn obstacle_target_interval_preserves_authored_boundary_ties() { + for (phase, expected_rate) in [(0.37, 0.03), (0.4, 0.5), (0.87, 0.03), (0.88, 0.52)] { + let mut c = BipedCadence::default(); + c.phase.phase = phase; + let mut i = input(); + i.contact_flags_176 = 0x100; + c.update(&i, thresholds()); + close(c.phase.rate, expected_rate); + assert_eq!(c.phase.target, -1.0); + } +} + +#[test] +fn obstacle_contact_chooses_rate_near_current_and_suppression_skips_it() { + let mut c = BipedCadence::default(); + let mut i = input(); + i.contact_flags_176 = 0x80; + i.contact_point_96[2] = 0.7; // Half second to contact after0.2 clearance. + c.update(&i, thresholds()); + close(c.phase.rate, 0.9); + i.suppress_adjustment_353 = true; + c.update(&i, thresholds()); + close(c.phase.rate, 1.0); +} + +#[test] +fn rate_selection_uses_inclusive_limits_and_clamps_nearest_candidate() { + assert_eq!(select_rate(1.0, 2.0, 1.0, 2.0, 2.0), 2.0); + assert_eq!(select_rate(0.0, 2.0, 1.0, 2.0, 0.0), 2.0); + assert_eq!(select_rate(0.0, 4.0, 1.0, 3.0, 0.0), 3.0); // Equal distances prefer B. + assert_eq!(select_rate(0.5, 5.0, 1.0, 3.0, 0.0), 1.0); +} + +#[test] +fn no_animation_motion_gives_zero_rate_without_fabricated_clip_speed() { + let mut c = BipedCadence::default(); + let mut i = input(); + i.animation_motion_224 = [0.0; 3]; + c.update(&i, thresholds()); + assert_eq!(c.phase.rate, 0.0); + assert_eq!(c.phase.phase, 0.0); + assert_eq!(c.locomotion_index, 1); +} diff --git a/crates/skate-core/src/player/offboard/contact_correction.rs b/crates/skate-core/src/player/offboard/contact_correction.rs new file mode 100644 index 00000000..4a35fcd9 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_correction.rs @@ -0,0 +1,91 @@ +//! Biped contact correction82D7C9E0, called before slide/movement/cadence. +//! Input displacement vectors come from actual skeleton collision output. +use crate::physics::{native_arithmetic, reciprocal_sqrt::estimate}; +type Vector = [f32; 4]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ContactCorrection { + /// Biped708, copied by common ProcessOutput into OffBoard333. + pub active: bool, + /// Biped400, copied into OffBoard288 even when inactive; retain old value. + pub direction: Vector, + /// Biped384, recomputed every ground update. + pub displacement: Vector, +} + +#[derive(Clone, Copy, Debug)] +pub struct Input { + /// Ground job192/208 from skeleton16288/16304. + pub collision_displacements: [Vector; 2], + pub projection_axis_416: Vector, + pub up_axis_16: Vector, +} + +impl ContactCorrection { + pub fn update(&mut self, input: Input) { + self.displacement = [0.0; 4]; + let [a, b] = input.collision_displacements; + let square_a = dot(a, a); + let square_b = dot(b, b); + //The second vector wins equality and unordered comparison. + let selected = if square_a > square_b { a } else { b }; + let maximum = select(square_a - square_b, square_a, square_b); + self.active = false; + //82D7CA7C ble skips the branch only for ordered <=. + if !(maximum <= f32::from_bits(0x38d1_b717)) { + let inverse = inverse_length(dot(selected, selected)); + self.direction = selected.map(|value| value * inverse); + self.active = true; + let length = magnitude(maximum); + let excess = length - f32::from_bits(0x3d4c_cccd); + let excess = select(excess, excess, 0.0); + let displacement = self.direction.map(|value| (value * excess) * 0.5); + self.displacement = clamp_length( + reject(displacement, input.projection_axis_416), + f32::from_bits(0x3dcc_cccd), + ); + } + self.displacement = reject(self.displacement, input.up_axis_16); + } +} + +fn dot(a: Vector, b: Vector) -> f32 { + native_arithmetic::dot3(a, b) +} +fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +fn reject(vector: Vector, axis: Vector) -> Vector { + let projection = dot(axis, vector); + std::array::from_fn(|i| vector[i] - axis[i] * projection) +} +pub(super) fn inverse_length(squared: f32) -> f32 { + let mut inverse = estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + inverse +} +pub(super) fn magnitude(squared: f32) -> f32 { + let length = squared * inverse_length(squared); + if squared == 0.0 { 0.0 } else { length } +} + +/// Original82BD3D90, preserving four lanes and two reciprocal refinements. +pub(super) fn clamp_length(vector: Vector, maximum: f32) -> Vector { + let length = magnitude(dot(vector, vector)); + if !(length >= f32::from_bits(0x3780_0000)) { + return vector; + } + let bounded = select(maximum - length, length, maximum); + let mut inverse = native_arithmetic::reciprocal_estimate(length); + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(length, 1.0), inverse); + } + vector.map(|value| (value * bounded) * inverse) +} + +#[cfg(test)] +#[path = "contact_correction/tests.rs"] +mod tests; + diff --git a/crates/skate-core/src/player/offboard/contact_correction/tests.rs b/crates/skate-core/src/player/offboard/contact_correction/tests.rs new file mode 100644 index 00000000..c2b402a8 --- /dev/null +++ b/crates/skate-core/src/player/offboard/contact_correction/tests.rs @@ -0,0 +1,51 @@ +use super::*; +fn state() -> ContactCorrection { + //Explicit synthetic prior state; constructor/reset ownership is separate. + ContactCorrection { active: true, direction: [0.0, 0.0, 1.0, 0.0], displacement: [1.0; 4] } +} +fn input(a: Vector, b: Vector) -> Input { + Input { collision_displacements: [a, b], projection_axis_416: [0.0; 4], up_axis_16: [0.0, 1.0, 0.0, 0.0] } +} +fn close(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}"); +} + +#[test] +fn inactive_contact_clears_displacement_but_retains_previous_direction() { + let mut state = state(); + state.update(input([0.0; 4], [0.001, 0.0, 0.0, 0.0])); + assert!(!state.active); + assert_eq!(state.direction, [0.0, 0.0, 1.0, 0.0]); + assert_eq!(state.displacement, [0.0; 4]); +} + +#[test] +fn equal_contacts_choose_second_and_apply_clearance_before_half_correction() { + let mut state = state(); + state.update(input([0.15, 0.0, 0.0, 0.0], [0.0, 0.0, -0.15, 0.0])); + assert!(state.active); + close(state.direction[2], -1.0); + close(state.displacement[2], -0.05); + close(state.displacement[0], 0.0); +} + +#[test] +fn large_correction_is_limited_then_projected_off_current_up_axis() { + let mut state = state(); + state.update(input([3.0, 4.0, 0.0, 0.0], [0.0; 4])); + close(state.direction[0], 0.6); + close(state.direction[1], 0.8); + close(state.displacement[0], 0.06); + close(state.displacement[1], 0.0); +} + +#[test] +fn projection_axis_removes_response_without_disabling_contact_observation() { + let mut state = state(); + let mut i = input([0.0, 0.0, 0.2, 0.0], [0.0; 4]); + i.projection_axis_416 = [0.0, 0.0, 1.0, 0.0]; + state.update(i); + assert!(state.active); + close(state.direction[2], 1.0); + assert_eq!(state.displacement, [0.0; 4]); +} diff --git a/crates/skate-core/src/player/offboard/controller.rs b/crates/skate-core/src/player/offboard/controller.rs new file mode 100644 index 00000000..a6ce1c3c --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller.rs @@ -0,0 +1,87 @@ +//! Canonical Biped owner and original ground dispatcher82D7C818. +//! Constructor82D7AFD8/reset82D7B1C0; S3 SHA256 +//!431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +mod output; +mod placement; +mod state; +mod step; +pub(super) use output::build_frame; +pub use placement::PlacementInput; +#[cfg(test)] +mod tests; +use super::{movement_intent, movement_velocity}; +use crate::point_graph::PointGraph; +pub use output::GroundResult; +pub use state::{ClipMetric, State}; +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; + +/// Validated stock physics_biped curves, supplied by the data boundary. +pub struct Settings { + pub movement_intent: movement_intent::Settings, + pub movement_velocity: movement_velocity::Settings, + pub slide_vs_slope: PointGraph<8>, + pub slide_vs_speed: PointGraph<8>, +} + +/// Actual448-byte ground-job content as typed values. Ground-state geometry/ +/// processed controls and delayed scene query consumption happen before this job. +/// No default implementation: a caller must supply its actual observations. +#[derive(Clone, Copy, Debug)] +pub struct GroundJob { + pub contact_position: Vector, //0 + pub contact_normal: Vector, //16 + pub support_frame: Frame, //32..80 + pub target_position: Vector, //96 + pub target_normal: Vector, //112 + pub edge_position: Vector, //128 + pub edge_normal: Vector, //144 + pub flags: u32, //176 + pub support_id: u32, //180 + pub collision_displacements: [Vector; 2], //192/208 + pub animation_motion: Vector, //224 + pub animation_velocity: Vector, //240 + pub desired_direction: Vector, //256 + pub animation_position: Vector, //272 + pub requested_duration: f32, //288 + pub mirrored: bool, //292 + pub requested_phase: f32, //296 + pub override_duration: f32, //300 + pub animation_directed: bool, //304 + pub movement: f32, //308 + pub steering: f32, //312 + pub sprint_pressed: bool, //316 + pub suppress_lean: bool, //317 + pub suppress_minimum: bool, //318 + pub target_frame_present: bool, //352 + pub edge_active: bool, //353 + pub target_frame: Frame, //368..416 + pub ignore_obstacle: bool, //432 +} + +pub struct Controller { + pub state: State, + pub settings: Settings, +} +impl Controller { + /// Metrics are actual OffBoard walk/run/sprint AnimTransZ query results. + /// None means the original query returned absent, whose native speed is0. + pub fn new(settings: Settings, metrics: [Option; 3]) -> Self { + Self { + state: State::new(metrics), + settings, + } + } + /// Original Reset preserves cadence and positional-correction vectors. + /// Physical Enter placement is a separate lifecycle operation. + pub fn reset(&mut self) { + self.state.reset(); + } + pub fn place(&mut self, input: PlacementInput) { + self.state.place(input); + } + pub fn step_ground(&mut self, job: &GroundJob) -> GroundResult { + step::update(&mut self.state, &self.settings, job); + output::export(&self.state) + } +} diff --git a/crates/skate-core/src/player/offboard/controller/output.rs b/crates/skate-core/src/player/offboard/controller/output.rs new file mode 100644 index 00000000..54f31e6b --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/output.rs @@ -0,0 +1,66 @@ +//! Original result exporter82D80F48 and frame helper82D2CDC0. +use super::state::{IDENTITY, ZERO}; +use super::{Frame, State, Vector}; +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundResult { + pub physical_frame: Frame, //result0 <- Biped64 + pub animation_frame: Frame, //64 <-128 + pub surface_frame: Frame, //128 <-82D2CDC0(axis416,forward32) + pub velocity: Vector, //192 <-512 + pub position: Vector, //208 <-368 + pub angular_velocity: f32, //224 <-688 + pub alternate: bool, //228 <-709 + pub sliding: bool, //229 <-710 +} +pub(super) fn export(s: &State) -> GroundResult { + GroundResult { + physical_frame: s.motion.published_frame_64, + animation_frame: s.frame_output.frame, + surface_frame: build_frame(s.surface.spring_normal, s.motion.frame_0[2]), + velocity: s.frame_output.velocity, + position: s.position_368, + angular_velocity: s.motion.angular_velocity_688, + alternate: s.alternate_709, + sliding: s.sliding.active_710, + } +} +fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +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]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn invsqrt(q: f32) -> f32 { + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.0), r); + } + r +} +fn normalize(v: Vector) -> Vector { + let r = invsqrt(dot(v, v)); + v.map(|x| x * r) +} +pub(crate) fn build_frame(up: Vector, forward: Vector) -> Frame { + let right = cross(up, forward); + let q = dot(right, right); + let raw_length = q * invsqrt(q); + let length = if q == 0.0 { 0.0 } else { raw_length }; + //830BD300 initialized82F82690 from821647E0, bits37800000. + if !(length > f32::from_bits(0x3780_0000)) { + return IDENTITY; + } + let mut inverse = 1.0 / length; + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(length, 1.0), inverse); + } + let right = right.map(|x| x * inverse); + let up = normalize(up); + let forward = normalize(cross(right, up)); + [right, up, forward, ZERO] +} diff --git a/crates/skate-core/src/player/offboard/controller/placement.rs b/crates/skate-core/src/player/offboard/controller/placement.rs new file mode 100644 index 00000000..e8b62ad4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/placement.rs @@ -0,0 +1,61 @@ +//! Original82D7B7A0, called with real GroundEnter frame/velocity/body position. +use super::state::ZERO; +use super::{Frame, State, Vector}; +#[derive(Clone, Copy, Debug)] +pub struct PlacementInput { + pub frame: Frame, //entry0..48 + pub velocity: Vector, //entry64 + pub body_position: Vector, //entry80, actual skeleton body position + pub current_state: u32, //processed2508 + pub previous_state: u32, //processed2504 + pub previous_frame: Frame, //processed192..240 +} +impl State { + pub fn place(&mut self, i: PlacementInput) { + if i.current_state == 500 { + self.reset(); + let delta = sub(self.correction_target_592, i.previous_frame[3]); + let eligible = i.previous_state == 501 + && !(dot(i.frame[2], delta) <= 0.0) + && !(dot(i.frame[2], i.previous_frame[2]) <= f32::from_bits(0x3f66_6666)); + if eligible { + let difference = sub(i.previous_frame[3], self.correction_target_592); + let amount = dot(i.previous_frame[2], difference); + self.motion.correction_576 = i.previous_frame[2].map(|v| v * amount); + self.motion.correction_enabled_711 = true; + } else { + self.clear_entry_correction(); + } + } else if i.current_state == 501 { + self.clear_entry_correction(); + } + self.motion.frame_0 = i.frame; + self.motion.published_frame_64 = i.frame; + self.frame_output.frame = i.frame; + self.position_368 = i.body_position; + self.motion.velocity_480 = i.velocity; + self.motion.speed_704 = length(i.velocity); + self.surface.spring_normal = i.frame[1]; + self.surface.spring_delta = ZERO; + } + fn clear_entry_correction(&mut self) { + self.motion.correction_576 = ZERO; + self.correction_target_592 = ZERO; + self.motion.correction_enabled_711 = false; + } +} +fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +fn sub(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +fn length(v: Vector) -> f32 { + let q = dot(v, v); + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.0), r); + } + let n = q * r; + if q == 0.0 { 0.0 } else { n } +} diff --git a/crates/skate-core/src/player/offboard/controller/state.rs b/crates/skate-core/src/player/offboard/controller/state.rs new file mode 100644 index 00000000..c009e5fe --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/state.rs @@ -0,0 +1,132 @@ +use super::super::{ + cadence::{BipedCadence, CadenceThresholds}, + contact_correction::ContactCorrection, + ground_motion::GroundMotionState, + movement_intent, + position_output::FrameOutput, + slide::{Sliding, SpecialMode}, + surface_frame, +}; +use super::{Frame, Vector}; +pub(super) const ZERO: Vector = [0.0; 4]; +pub(super) const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +pub(super) const IDENTITY: Frame = [[1.0, 0.0, 0.0, 0.0], UP, [0.0, 0.0, 1.0, 0.0], ZERO]; +#[derive(Clone, Copy, Debug)] +pub struct ClipMetric { + pub translation_z: f32, + pub end_time: f32, +} +impl ClipMetric { + fn speed(self) -> f32 { + (-1.0 / self.end_time) * self.translation_z + } +} + +/// Each persistent original field has one owner. Per-stage packets in step.rs +/// are temporary and copy updated shared fields back before the next producer. +#[derive(Clone, Debug)] +pub struct State { + pub motion: GroundMotionState, + pub intent: movement_intent::State, + pub surface: surface_frame::State, + pub contact: ContactCorrection, + pub special: SpecialMode, + pub sliding: Sliding, + pub frame_output: FrameOutput, + pub cadence: BipedCadence, + pub thresholds: CadenceThresholds, + pub position_368: Vector, + pub correction_target_592: Vector, + pub forward_delta_692: f32, + pub right_delta_696: f32, + pub velocity_override_remaining_772: f32, + pub alternate_709: bool, +} +impl State { + pub fn new(metrics: [Option; 3]) -> Self { + let speeds = metrics.map(|m| m.map(ClipMetric::speed).unwrap_or(0.0)); + Self { + motion: reset_motion(ZERO), + intent: movement_intent::State::default(), + surface: surface_frame::State::default(), + contact: ContactCorrection { + active: false, + direction: ZERO, + displacement: ZERO, + }, + special: SpecialMode { + enabled_714: false, + elapsed_788: 0.0, + }, + sliding: Sliding { + velocity_528: ZERO, + active_710: false, + }, + frame_output: FrameOutput { + frame: IDENTITY, + velocity: ZERO, + }, + cadence: BipedCadence::default(), + thresholds: CadenceThresholds::from_clip_speeds(speeds[0], speeds[1], speeds[2]), + position_368: ZERO, + correction_target_592: ZERO, + forward_delta_692: 0.0, + right_delta_696: 0.0, + velocity_override_remaining_772: -1.0, + alternate_709: false, + } + } + ///82D7B1C0 deliberately leaves phase716..736 and vectors576/592 alone. + pub fn reset(&mut self) { + self.motion = reset_motion(self.motion.correction_576); + self.intent = movement_intent::State::default(); + self.surface = surface_frame::State::default(); + self.contact = ContactCorrection { + active: false, + direction: ZERO, + displacement: ZERO, + }; + self.special = SpecialMode { + enabled_714: false, + elapsed_788: 0.0, + }; + self.sliding = Sliding { + velocity_528: ZERO, + active_710: false, + }; + self.frame_output = FrameOutput { + frame: IDENTITY, + velocity: ZERO, + }; + self.position_368 = ZERO; + self.forward_delta_692 = 0.0; + self.right_delta_696 = 0.0; + self.velocity_override_remaining_772 = -1.0; + self.alternate_709 = false; + } +} +fn reset_motion(correction_576: Vector) -> GroundMotionState { + GroundMotionState { + frame_0: IDENTITY, + published_frame_64: IDENTITY, + previous_support_frame_192: IDENTITY, + support_velocity_256: ZERO, + predicted_support_velocity_272: ZERO, + previous_support_velocity_288: ZERO, + support_acceleration_304: ZERO, + filtered_local_acceleration_320: ZERO, + support_yaw_336: 0.0, + previous_support_yaw_340: 0.0, + predicted_support_yaw_344: 0.0, + support_speed_348: 0.0, + support_id_352: 0, + velocity_480: ZERO, + correction_576, + target_frame_608: IDENTITY, + angular_velocity_688: 0.0, + speed_704: 0.0, + correction_enabled_711: false, + support_velocity_removed_715: false, + target_scale_784: -1.0, + } +} diff --git a/crates/skate-core/src/player/offboard/controller/step.rs b/crates/skate-core/src/player/offboard/controller/step.rs new file mode 100644 index 00000000..04ff06e3 --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/step.rs @@ -0,0 +1,194 @@ +//!82D7C818 ordering. Every shared writeback precedes its next reader. +use super::super::{ + cadence, contact_correction, ground_branch, ground_motion, movement_intent, movement_velocity, + position_output, slide, surface_frame, +}; +use super::{GroundJob, Settings, State, Vector}; +fn xyz(v: Vector) -> [f32; 3] { + [v[0], v[1], v[2]] +} +pub(super) fn update(s: &mut State, c: &Settings, j: &GroundJob) { + s.intent.speed = 0.0; + s.intent.steering = 0.0; + s.special + .update(j.movement, j.flags, s.motion.frame_0[2][1]); + s.contact.update(contact_correction::Input { + collision_displacements: j.collision_displacements, + projection_axis_416: s.surface.spring_normal, + up_axis_16: s.motion.frame_0[1], + }); + s.sliding.update( + slide::Input { + special_mode_714: s.special.enabled_714, + surface_normal_560: s.surface.source_normal, + movement_velocity_480: s.motion.velocity_480, + contact_direction_400: s.contact.active.then_some(s.contact.direction), + }, + &c.slide_vs_slope, + &c.slide_vs_speed, + ); + s.intent.update( + &c.movement_intent, + &movement_intent::Input { + flags: j.flags, + suppress_minimum: j.suppress_minimum, + magnitude: j.movement, + steering: j.steering, + sprint_pressed: j.sprint_pressed, + edge_active: j.edge_active, + ignore_obstacle: j.ignore_obstacle, + direction: j.desired_direction, + edge_tangent: j.target_frame[2], + edge_point: j.target_frame[3], + right: s.motion.frame_0[0], + up: s.motion.frame_0[1], + forward: s.motion.frame_0[2], + position: s.motion.frame_0[3], + obstacle: s.contact.active, + obstacle_normal: s.contact.direction, + sliding: s.sliding.active_710, + slide_velocity: s.sliding.velocity_528, + }, + ); + s.alternate_709 = ground_branch::select_alternate(ground_branch::BranchInput { + movement_velocity_480: s.motion.velocity_480, + output_velocity_512: s.frame_output.velocity, + right_0: s.motion.frame_0[0], + position_48: s.motion.frame_0[3], + contact_point_96: j.target_position, + contact_flags_176: j.flags, + suppressed_317: j.suppress_lean, + }); + if s.alternate_709 { + let forward = s.motion.frame_0[2]; + ground_branch::integrate_alternate( + &mut s.motion.velocity_480, + &mut s.motion.speed_704, + &mut s.motion.frame_0[3], + &mut s.frame_output, + forward, + s.surface.final_up, + s.surface.spring_normal, + ); + return; + } + velocity(s, c, j); + s.surface.update_surface(&surface_frame::SurfaceInput { + flags: j.flags, + normal: j.contact_normal, + origin: j.contact_position, + edge_point: j.edge_position, + edge_normal: j.edge_normal, + }); + s.surface.update_spring(&surface_frame::SpringInput { + velocity: s.motion.velocity_480, + up: s.motion.frame_0[1], + right: s.motion.frame_0[0], + forward: s.motion.frame_0[2], + spring_right: s.frame_output.frame[0], + spring_forward: s.frame_output.frame[2], + right_delta: s.right_delta_696, + forward_delta: s.forward_delta_692, + suppress_lean: j.suppress_lean, + }); + ground_motion::update( + &mut s.motion, + &ground_motion::GroundMotionInput { + contact_position_0: j.contact_position, + contact_frame_32: j.support_frame, + contact_target_96: j.target_position, + contact_normal_112: j.target_normal, + contact_flags_176: j.flags, + contact_id_180: j.support_id, + animation_motion_224: j.animation_motion, + animation_motion_240: j.animation_velocity, + requested_duration_288: j.requested_duration, + mirrored_292: j.mirrored, + animation_directed_304: j.animation_directed, + target_frame_present_352: j.target_frame_present, + target_frame_368: j.target_frame, + reference_frame_128: s.frame_output.frame, + contact_displacement_384: s.contact.displacement, + velocity_addition_528: s.sliding.velocity_528, + desired_up_544: s.surface.surface_normal, + correction_target_592: s.correction_target_592, + obstacle_target_672: s.intent.edge_target, + obstacle_enabled_713: s.intent.edge_aligned, + }, + ); + s.frame_output.update( + s.motion.frame_0[2], + s.surface.final_up, + s.surface.spring_normal, + s.motion.frame_0[3], + ); + position_output::update_position( + &mut s.position_368, + position_output::PositionInput { + previous_origin_112: s.motion.published_frame_64[3], + override_origin_592: s.correction_target_592, + use_override_711: s.motion.correction_enabled_711, + projection_axis_416: s.surface.spring_normal, + contact_flags_176: j.flags, + contact_origin_0: j.contact_position, + contact_origin_96: j.target_position, + frame_position_176: s.frame_output.frame[3], + animation_position_272: j.animation_position, + }, + ); + s.cadence.update( + &cadence::CadenceInput { + motion_512: xyz(s.frame_output.velocity), + motion_reference_272: xyz(s.motion.predicted_support_velocity_272), + reject_axis_400: xyz(s.contact.direction), + reject_enabled_708: s.contact.active, + up_144: xyz(s.frame_output.frame[1]), + frame_rows_0_16_32: [ + xyz(s.motion.frame_0[0]), + xyz(s.motion.frame_0[1]), + xyz(s.motion.frame_0[2]), + ], + frame_position_48: xyz(s.motion.frame_0[3]), + animation_motion_224: xyz(j.animation_motion), + requested_duration_288: j.requested_duration, + requested_phase_296: j.requested_phase, + suppress_adjustment_353: j.edge_active, + contact_flags_176: j.flags, + contact_point_96: xyz(j.target_position), + }, + s.thresholds, + ); +} +fn velocity(s: &mut State, c: &Settings, j: &GroundJob) { + let mut state = movement_velocity::State { + velocity: s.motion.velocity_480, + turn: s.motion.angular_velocity_688, + forward_delta: s.forward_delta_692, + right_delta: s.right_delta_696, + speed: s.motion.speed_704, + override_remaining: s.velocity_override_remaining_772, + }; + state.update( + &c.movement_velocity, + &movement_velocity::Input { + forward: s.motion.frame_0[2], + right: s.motion.frame_0[0], + plane_normal: s.surface.surface_normal, + desired_speed: s.intent.speed, + steering: s.intent.steering, + slope_mode: s.special.enabled_714, + obstacle: s.contact.active, + obstacle_normal: s.contact.direction, + override_gate: j.requested_phase, + override_duration: j.override_duration, + override_velocity: j.animation_velocity, + flags: j.flags, + }, + ); + s.motion.velocity_480 = state.velocity; + s.motion.angular_velocity_688 = state.turn; + s.motion.speed_704 = state.speed; + s.forward_delta_692 = state.forward_delta; + s.right_delta_696 = state.right_delta; + s.velocity_override_remaining_772 = state.override_remaining; +} diff --git a/crates/skate-core/src/player/offboard/controller/tests.rs b/crates/skate-core/src/player/offboard/controller/tests.rs new file mode 100644 index 00000000..58c7f9fe --- /dev/null +++ b/crates/skate-core/src/player/offboard/controller/tests.rs @@ -0,0 +1,190 @@ +use super::state::{IDENTITY, UP, ZERO}; +use super::*; +fn metrics() -> [Option; 3] { + [ + Some(ClipMetric { + translation_z: -2.0, + end_time: 1.0, + }), + Some(ClipMetric { + translation_z: -4.0, + end_time: 1.0, + }), + Some(ClipMetric { + translation_z: -8.0, + end_time: 1.0, + }), + ] +} +fn graph(value: f32) -> PointGraph { + PointGraph { + x: std::array::from_fn(|i| i as f32), + y: [value; N], + } +} +fn settings() -> Settings { + Settings { + movement_intent: movement_intent::Settings { + sprint_speed: graph(2.0), + normal_speed: graph(1.0), + sprint_blend: graph(0.0), + sprint_time_cap: 1.0, + slide_steering: graph(0.0), + }, + movement_velocity: movement_velocity::Settings { + slope_speed_scalar: graph(1.0), + slope_mode_speed: graph(1.0), + turn_vs_speed: graph(0.0), + turn_delta_vs_speed: graph(1.0), + }, + slide_vs_slope: graph(0.0), + slide_vs_speed: graph(0.0), + } +} +fn job() -> GroundJob { + GroundJob { + contact_position: ZERO, + contact_normal: UP, + support_frame: IDENTITY, + target_position: ZERO, + target_normal: UP, + edge_position: ZERO, + edge_normal: UP, + flags: 1, + support_id: 0, + collision_displacements: [ZERO; 2], + animation_motion: [0.0, 0.0, 1.0, 0.0], + animation_velocity: ZERO, + desired_direction: [0.0, 0.0, 1.0, 0.0], + animation_position: ZERO, + requested_duration: 1.0, + mirrored: false, + requested_phase: -1.0, + override_duration: 1.0, + animation_directed: false, + movement: 1.0, + steering: 0.0, + sprint_pressed: false, + suppress_lean: false, + suppress_minimum: false, + target_frame_present: false, + edge_active: false, + target_frame: IDENTITY, + ignore_obstacle: false, + } +} +#[test] +fn constructor_uses_clip_metrics_and_original_initial_fields() { + let s = State::new(metrics()); + assert_eq!( + s.thresholds.0, + [0.01, 3.0, 6.0, f32::from_bits(0x5015_02f9)] + ); + assert_eq!(s.motion.frame_0, IDENTITY); + assert_eq!(s.surface.surface_normal, UP); + assert_eq!(s.velocity_override_remaining_772, -1.0); + assert_eq!(s.motion.target_scale_784, -1.0); + assert_eq!(s.cadence.phase.duration, None); +} +#[test] +fn absent_clip_queries_have_original_zero_speed() { + let s = State::new([None; 3]); + assert_eq!(s.thresholds.0[1], 0.0); + assert_eq!(s.thresholds.0[2], 0.0); +} +#[test] +fn reset_preserves_phase_and_correction_vectors_only() { + let mut s = State::new(metrics()); + s.cadence.phase.phase = 0.75; + s.cadence.phase.duration = Some(2.0); + s.motion.correction_576 = [1.0; 4]; + s.correction_target_592 = [2.0; 4]; + s.motion.correction_enabled_711 = true; + s.motion.velocity_480 = [3.0; 4]; + s.intent.speed = 9.0; + s.reset(); + assert_eq!(s.cadence.phase.phase, 0.75); + assert_eq!(s.cadence.phase.duration, Some(2.0)); + assert_eq!(s.motion.correction_576, [1.0; 4]); + assert_eq!(s.correction_target_592, [2.0; 4]); + assert!(!s.motion.correction_enabled_711); + assert_eq!(s.motion.velocity_480, ZERO); + assert_eq!(s.intent.speed, 0.0); +} +fn placement(current: u32, previous: u32) -> PlacementInput { + PlacementInput { + frame: IDENTITY, + velocity: [3.0, 0.0, 4.0, 0.0], + body_position: [1.0, 2.0, 3.0, 0.0], + current_state: current, + previous_state: previous, + previous_frame: IDENTITY, + } +} +#[test] +fn landing_preserves_eligible_correction_from_air_target() { + let mut s = State::new(metrics()); + s.correction_target_592 = [0.0, 0.0, 2.0, 0.0]; + s.place(placement(500, 501)); + assert!(s.motion.correction_enabled_711); + assert_eq!(s.motion.correction_576, [0.0, 0.0, -2.0, 0.0]); + assert_eq!(s.position_368, [1.0, 2.0, 3.0, 0.0]); + assert_eq!(s.motion.speed_704, 5.0); +} +#[test] +fn air_placement_clears_correction_without_resetting_movement_timers() { + let mut s = State::new(metrics()); + s.intent.sprint_time = 4.0; + s.correction_target_592 = [1.0; 4]; + s.motion.correction_enabled_711 = true; + s.place(placement(501, 500)); + assert_eq!(s.intent.sprint_time, 4.0); + assert_eq!(s.correction_target_592, ZERO); + assert!(!s.motion.correction_enabled_711); +} +#[test] +fn other_placement_keeps_existing_correction() { + let mut s = State::new(metrics()); + s.motion.correction_576 = [1.0; 4]; + s.motion.correction_enabled_711 = true; + s.place(placement(600, 500)); + assert_eq!(s.motion.correction_576, [1.0; 4]); + assert!(s.motion.correction_enabled_711); +} +#[test] +fn full_normal_dispatch_moves_canonical_velocity_and_publishes() { + let mut c = Controller::new(settings(), metrics()); + let result = c.step_ground(&job()); + assert!(!result.alternate); + assert!(c.state.motion.velocity_480[2] > 0.0); + assert!(result.physical_frame[3][2] > 0.0); + assert_eq!(result.velocity, c.state.frame_output.velocity); + assert_eq!(result.position, c.state.position_368); +} +#[test] +fn alternate_dispatch_skips_cadence_and_position_but_exports() { + let mut c = Controller::new(settings(), metrics()); + let mut j = job(); + j.flags = 2; + j.target_position = [0.0, 0.0, 1.0, 0.0]; + c.state.motion.velocity_480 = [0.0, 5.0, 0.0, 0.0]; + c.state.frame_output.velocity = [0.0, 5.0, 0.0, 0.0]; + c.state.position_368 = [9.0; 4]; + c.state.cadence.phase.phase = 0.75; + let r = c.step_ground(&j); + assert!(r.alternate); + assert_eq!(c.state.position_368, [9.0; 4]); + assert_eq!(c.state.cadence.phase.phase, 0.75); + assert!(c.state.motion.velocity_480[1] < 5.0); +} +#[test] +fn exporter_degenerate_frame_is_identity() { + assert_eq!(build_frame(UP, UP), IDENTITY); + let f = build_frame([0.0, 2.0, 0.0, 0.0], [0.0, 0.0, 3.0, 0.0]); + for axis in 0..3 { + for lane in 0..3 { + assert!((f[axis][lane] - IDENTITY[axis][lane]).abs() < 1e-6); + } + } + assert_eq!(f[3], ZERO); +} diff --git a/crates/skate-core/src/player/offboard/ground_branch.rs b/crates/skate-core/src/player/offboard/ground_branch.rs new file mode 100644 index 00000000..66d84860 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_branch.rs @@ -0,0 +1,61 @@ +//! Original82D7D868 branch selector and82D7E1A8 alternate integration. +use super::{contact_correction::{inverse_length, magnitude}, position_output::FrameOutput}; +use crate::physics::native_arithmetic::dot3; +type Vector = [f32; 4]; +const DT: f32 = f32::from_bits(0x3c88_8889); + +pub struct BranchInput { + pub movement_velocity_480: Vector, + pub output_velocity_512: Vector, + pub right_0: Vector, + pub position_48: Vector, + pub contact_point_96: Vector, + pub contact_flags_176: u32, + pub suppressed_317: bool, +} +/// Recomputed Biped709. This selects a ground-controller branch, not a physical state. +pub fn select_alternate(input: BranchInput) -> bool { + let velocity = if input.movement_velocity_480[1] > input.output_velocity_512[1] { + input.output_velocity_512 + } else { input.movement_velocity_480 }; + if input.suppressed_317 || input.contact_flags_176 & 2 == 0 { return false; } + let delta: Vector = std::array::from_fn(|i| input.contact_point_96[i] - input.position_48[i]); + if dot3(delta, delta) <= f32::from_bits(0x3b23_d70b) { return false; } + // Native permuted multiply/subtract computes delta cross right. + let right = input.right_0; + let normal = [ + (-delta[2]).mul_add(right[1], delta[1] * right[2]), + (-delta[0]).mul_add(right[2], delta[2] * right[0]), + (-delta[1]).mul_add(right[0], delta[0] * right[1]), + (-delta[3]).mul_add(right[3], delta[3] * right[3]), + ]; + let square = dot3(normal, normal); + let inverse = inverse_length(square); + let unit = if magnitude(square) > f32::from_bits(0x3586_37bd) { + normal.map(|v| v * inverse) + } else { [0.0; 4] }; + dot3(velocity, unit) > 4.0 +} + +///82D7E1A8 writes480/704/48 and then calls82D80360. The caller skips +///82D80548 and cadence on this branch, but still publishes82D80F48 afterward. +pub fn integrate_alternate( + velocity_480: &mut Vector, + speed_704: &mut f32, + position_48: &mut Vector, + output: &mut FrameOutput, + forward_32: Vector, + frame_up_448: Vector, + projection_axis_416: Vector, +) { + // Original830BD330 initializer82F826B0 stores [0,-9.8,0,0], using + //82165A10 zero and822F8B40 C11CCCCD. It is not the splatted830BD400. + let gravity: Vector = [0.0, f32::from_bits(0xc11c_cccd), 0.0, 0.0]; + *velocity_480 = std::array::from_fn(|i| gravity[i].mul_add(DT, velocity_480[i])); + *speed_704 = magnitude(dot3(*velocity_480, *velocity_480)); + *position_48 = std::array::from_fn(|i| velocity_480[i].mul_add(DT, position_48[i])); + output.update(forward_32, frame_up_448, projection_axis_416, *position_48); +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_branch/tests.rs b/crates/skate-core/src/player/offboard/ground_branch/tests.rs new file mode 100644 index 00000000..1d2ef33b --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_branch/tests.rs @@ -0,0 +1,44 @@ +use super::*; +fn input() -> BranchInput { + BranchInput { movement_velocity_480:[0.0,5.0,0.0,0.0], output_velocity_512:[0.0,6.0,0.0,0.0], + right_0:[1.0,0.0,0.0,0.0], position_48:[0.0;4],contact_point_96:[0.0,0.0,1.0,0.0], + contact_flags_176:2,suppressed_317:false } +} +#[test] +fn both_velocities_must_clear_projected_threshold() { + assert!(select_alternate(input())); + let mut i=input(); i.output_velocity_512[1]=4.0; + assert!(!select_alternate(i)); + let mut i=input(); i.movement_velocity_480[1]=4.0; + assert!(!select_alternate(i)); +} +#[test] +fn contact_distance_suppression_and_contact_bit_gate_branch() { + let mut i=input(); i.contact_point_96=[0.0,0.0,0.04,0.0]; + assert!(!select_alternate(i)); + let mut i=input(); i.suppressed_317=true; + assert!(!select_alternate(i)); + let mut i=input(); i.contact_flags_176=1; + assert!(!select_alternate(i)); + let mut i=input(); i.contact_point_96=[1.0,0.0,0.0,0.0]; + assert!(!select_alternate(i)); +} +#[test] +fn reversed_contact_direction_reverses_velocity_requirement() { + let mut i=input(); i.contact_point_96[2]=-1.0; + assert!(!select_alternate(i)); + let mut i=input(); i.contact_point_96[2]=-1.0; + i.movement_velocity_480[1]=-5.0; i.output_velocity_512[1]=-6.0; + assert!(select_alternate(i)); +} +#[test] +fn alternate_integrates_gravity_before_position_and_frame_output() { + let mut velocity=[1.0,0.0,0.0,0.0]; let mut speed=0.0; let mut position=[0.0;4]; + let right=[1.0,0.0,0.0,0.0]; let up=[0.0,1.0,0.0,0.0]; let forward=[0.0,0.0,1.0,0.0]; + let mut output=FrameOutput {frame:[right,up,forward,position],velocity:[0.0;4]}; + integrate_alternate(&mut velocity,&mut speed,&mut position,&mut output,forward,up,up); + assert_eq!(velocity[1],f32::from_bits(0xc11c_cccd)*DT); + assert_eq!(position[1],velocity[1]*DT); + assert!(speed>1.0); + for i in 0..3 { assert!((output.frame[3][i]-position[i]).abs()<1e-6); } +} diff --git a/crates/skate-core/src/player/offboard/ground_entry.rs b/crates/skate-core/src/player/offboard/ground_entry.rs new file mode 100644 index 00000000..94e40b48 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_entry.rs @@ -0,0 +1,86 @@ +//! Numerical BipedGround Enter82D30808 and local reset fields82D30BD0. +//! The game lifecycle also resets toolkit/contact data, initializes the controller, +//! enters board manager and skeleton mode4, and resets trajectory helpers in order. +mod math; +use math::*; +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; + +/// Ground physical-state fields, separate from the Biped controller. +#[derive(Clone, Debug)] +pub struct State { + pub frame_80: Frame, + pub flags_144_to_150: [bool; 7], + pub counter_152: u32, + pub counter_156: u32, + pub elapsed_160: f32, + pub distance_164: f32, + pub elapsed_168: f32, + pub angle_172: f32, + pub angular_velocity_176: f32, + pub duration_180: f32, +} +impl Default for State { + fn default() -> Self { + Self { frame_80:IDENTITY,flags_144_to_150:[false;7],counter_152:0,counter_156:0, + elapsed_160:0.0,distance_164:f32::from_bits(0x5015_02f9),elapsed_168:0.0, + angle_172:0.0,angular_velocity_176:0.0,duration_180:0.0 } + } +} +pub struct Input { + /// Actual82BE3650 animation-to-world frame, before changing skeleton mode. + pub animation_frame: Frame, + pub processed_velocity_608: Vector, + pub processed_flags_2484: u32, + pub processed_flags_2476: u32, + pub requested_angle_2936: f32, + pub requested_duration_2896: f32, + pub previous_state_2504: u32, + pub previous_frame_up_208: Vector, + /// Actual skeleton15872; feeds Biped initialization input80. + pub body_position_15872: Vector, +} +pub struct Placement { + pub frame: Frame, + pub planar_velocity: Vector, + pub body_position: Vector, +} +impl State { + /// Call following toolkit/contact reset; pass returned fields to82D7B7A0. + pub fn enter(&mut self, input: &Input) -> Placement { + *self=Self::default(); + self.flags_144_to_150[4]=input.previous_state_2504==502; + let up=input.animation_frame[1]; + let velocity=input.processed_velocity_608; + let planar=sub(velocity,scale(up,dot(up,velocity))); + let mut frame=input.animation_frame; + if input.processed_flags_2484 & 0x4000 != 0 { + self.flags_144_to_150[6]=true; + self.duration_180=input.requested_duration_2896; + let mut angle=input.requested_angle_2936; + if input.processed_flags_2476 & 4 != 0 { angle *= -1.0; } + let direction=if length(planar)=0.0) { + original += if original>0.0 { -TAU } else { TAU }; + } + self.angular_velocity_176=(self.angle_172+original)/self.duration_180; + } + self.frame_80=frame; + // Source sets149 after board-manager entry and before skeleton mode4. + self.flags_144_to_150[5]=true; + if input.previous_state_2504==501 { + self.flags_144_to_150[1]=f32::from_bits(0x3f35_c28f)>input.previous_frame_up_208[1]; + } + Placement {frame,planar_velocity:planar,body_position:input.body_position_15872} + } +} +#[cfg(test)] +mod tests; + diff --git a/crates/skate-core/src/player/offboard/ground_entry/math.rs b/crates/skate-core/src/player/offboard/ground_entry/math.rs new file mode 100644 index 00000000..ee05ce92 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_entry/math.rs @@ -0,0 +1,40 @@ +//! Original82C1E220 quaternion vector rotation and8296EC98 signed angle. +use super::{Frame,Vector}; +pub(super) const TAU:f32=f32::from_bits(0x40c9_0fdb); +pub(super) const IDENTITY:Frame=[[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]]; +pub(super) fn dot(a:Vector,b:Vector)->f32 { crate::physics::native_arithmetic::dot3(a,b) } +pub(super) fn scale(v:Vector,s:f32)->Vector { v.map(|x|x*s) } +pub(super) fn sub(a:Vector,b:Vector)->Vector { std::array::from_fn(|i|a[i]-b[i]) } +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]),(-a[3]).mul_add(b[3],a[3]*b[3])] +} +fn inverse_length(q:f32,refinements:usize)->f32 { + let mut r=crate::physics::reciprocal_sqrt::estimate(q); + for _ in 0..refinements { r=(r*0.5).mul_add((-q).mul_add(r*r,1.0),r); } + r +} +pub(super) fn length(v:Vector)->f32 { + let q=dot(v,v);let l=q*inverse_length(q,2);if q==0.0 {0.0} else {l} +} +pub(super) fn wrap(angle:f32)->f32 { + let turns=angle*f32::from_bits(0x3e22_f983); + let fraction=turns-turns.floor(); + (fraction-if fraction>0.5 {1.0} else {0.0})*TAU +} +pub(super) fn signed_angle(a:Vector,b:Vector,axis:Vector)->f32 { + let qa=dot(a,a);let qb=dot(b,b); + if !(qa>f32::from_bits(0x38d1_b717)) || !(qb>f32::from_bits(0x38d1_b717)) { return 0.0; } + let a=scale(a,inverse_length(qa,1));let b=scale(b,inverse_length(qb,1)); + let cosine=dot(a,b).max(-1.0).min(1.0); + let angle=crate::trigonometry::acos(cosine); + if dot(cross(a,b),axis)<0.0 {TAU-angle} else {angle} +} +pub(super) fn rotate(v:Vector,axis:Vector,angle:f32)->Vector { + let (s,c)=crate::trigonometry::sin_cos(angle*0.5); + let mut q=scale(axis,s);q[3]=c; + let first=cross(q,v); + let intermediate=std::array::from_fn(|i|v[i].mul_add(c,first[i])); + let second=cross(q,intermediate); + std::array::from_fn(|i|second[i].mul_add(2.0,v[i])) +} diff --git a/crates/skate-core/src/player/offboard/ground_entry/tests.rs b/crates/skate-core/src/player/offboard/ground_entry/tests.rs new file mode 100644 index 00000000..814363b9 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_entry/tests.rs @@ -0,0 +1,42 @@ +use super::*; +fn input()->Input { + Input {animation_frame:IDENTITY,processed_velocity_608:[1.0,2.0,3.0,0.0],processed_flags_2484:0, + processed_flags_2476:0,requested_angle_2936:0.0,requested_duration_2896:1.0,previous_state_2504:0, + previous_frame_up_208:[0.0,1.0,0.0,0.0],body_position_15872:[0.0,1.0,0.0,0.0]} +} +#[test] +fn ordinary_entry_uses_actual_animation_frame_and_rejects_vertical_velocity() { + let mut state=State::default();let mut i=input();i.animation_frame[3]=[7.0,8.0,9.0,0.0]; + let placement=state.enter(&i); + assert_eq!(placement.frame,i.animation_frame); + assert_eq!(placement.planar_velocity,[1.0,0.0,3.0,0.0]); + assert_eq!(placement.body_position,i.body_position_15872); + assert!(state.flags_144_to_150[5]); + assert!(!state.flags_144_to_150[6]); +} +#[test] +fn entry_resets_previous_flags_and_records_only_matching_previous_state() { + let mut state=State::default();let mut i=input();i.previous_state_2504=502; + state.enter(&i);assert!(state.flags_144_to_150[4]); + i.previous_state_2504=501;i.previous_frame_up_208[1]=0.7; + state.enter(&i);assert!(state.flags_144_to_150[1]);assert!(!state.flags_144_to_150[4]); + i.previous_frame_up_208[1]=f32::from_bits(0x3f35_c28f); + state.enter(&i);assert!(!state.flags_144_to_150[1]); +} +#[test] +fn directed_entry_aligns_frame_to_velocity_and_preserves_translation() { + let mut state=State::default();let mut i=input();i.processed_flags_2484=0x4000; + i.processed_velocity_608=[3.0,2.0,0.0,0.0];i.animation_frame[3]=[7.0,8.0,9.0,0.0]; + let p=state.enter(&i); + assert!(state.flags_144_to_150[6]);assert!((p.frame[2][0]-1.0).abs()<1e-6); + assert_eq!(p.frame[3],i.animation_frame[3]); +} +#[test] +fn low_speed_uses_animation_forward_and_mirroring_reverses_requested_turn() { + let mut state=State::default();let mut i=input();i.processed_flags_2484=0x4000; + i.processed_velocity_608=[0.0;4];i.requested_angle_2936=0.5; + state.enter(&i);let normal=state.angular_velocity_176; + i.processed_flags_2476=4;state.enter(&i); + assert!((state.angular_velocity_176+normal).abs()<1e-5); + assert!(normal>0.0); +} diff --git a/crates/skate-core/src/player/offboard/ground_input.rs b/crates/skate-core/src/player/offboard/ground_input.rs new file mode 100644 index 00000000..797b602f --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_input.rs @@ -0,0 +1,71 @@ +//! Original TU3 ground control input82D310F8; S3 SHA256 +//!431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! Pure calculation: the existing BipedGround STATE owns the inputs and outputs. +//! Stock curves must be present and validated at the data boundary. + +mod math; +#[cfg(test)] +mod tests; + +use crate::point_graph::PointGraph; +use math::{cross, dot, inverse_frame, length, normalize, signed_angle}; + +#[derive(Clone, Copy, Debug)] +pub struct GroundInput { + pub processed_flags_2472: u32, + pub processed_direct_2684: f32, + pub processed_direct_2680: f32, + pub processed_stick_2692: f32, + pub processed_stick_2688: f32, + pub processed_scale_2912: f32, + pub processed_scale_2908: f32, + /// Forward row of the actual Biped frame, state112. + pub frame_forward_112: [f32; 3], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +/// Ground STATE offsets:708/712 become job-input308/312 (job base400). +/// These are not Biped controller708, which is a separate correction boolean. +pub struct GroundInputOutput { + pub state_708: f32, + pub state_712: f32, + /// Original untransformed input, NOT its coordinates in the flattened frame. + pub state_656: [f32; 4], +} + +///82D310F8. Curve A key DF759B46440F16E9; curve B key2DD95B399BAE313E +/// (TurnVsStickAngle). Leading PointNegGraphData8 bounds are not inputs here. +pub fn calculate( + input: &GroundInput, + curve_a: &PointGraph<8>, + turn_vs_stick_angle: &PointGraph<8>, +) -> GroundInputOutput { + if input.processed_flags_2472 & 0x1000_0000 != 0 { + return GroundInputOutput { + state_708: input.processed_direct_2684, + state_712: input.processed_direct_2680, + state_656: [0.0; 4], + }; + } + let raw = [input.processed_stick_2692, 0.0, input.processed_stick_2688]; + let up = [0.0, 1.0, 0.0]; + let mut right = [1.0, 0.0, 0.0]; + let mut forward = [0.0, 0.0, 1.0]; + // Signed comparison: negative vertical does not receive an identity fallback. + if dot(input.frame_forward_112, up) < f32::from_bits(0x3f7f_be77) { + right = normalize(cross(up, input.frame_forward_112)); + forward = normalize(cross(right, up)); + } + let local = inverse_frame(right, up, forward, raw); + let angle = signed_angle(local[0], -local[2]) * f32::from_bits(0x3ea2_f983); + // Scalar fsel accepts either signed zero; unordered input selects -1. + let sign = if angle >= 0.0 { 1.0 } else { -1.0 }; + let absolute_angle = angle * sign; + let magnitude = length(local); + GroundInputOutput { + state_708: (curve_a.evaluate(absolute_angle) * magnitude) * input.processed_scale_2912, + state_712: ((turn_vs_stick_angle.evaluate(absolute_angle) * magnitude) * sign) + * input.processed_scale_2908, + state_656: [raw[0], raw[1], raw[2], 0.0], + } +} diff --git a/crates/skate-core/src/player/offboard/ground_input/math.rs b/crates/skate-core/src/player/offboard/ground_input/math.rs new file mode 100644 index 00000000..732797bc --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_input/math.rs @@ -0,0 +1,133 @@ +//! Independently derived from original82D310F8 and82473B98. +//! Ordinary PC reciprocal/sqrt seeds retain original refinements; they are not +//! claimed to reproduce Xenon estimate instructions bit for bit. + +type Vector = [f32; 3]; +const HALF_PI: f32 = f32::from_bits(0x3fc9_0fdb); +const PI: f32 = f32::from_bits(0x4049_0fdb); + +pub(super) fn dot(a: Vector, b: Vector) -> f32 { + // PC dot3, without a hardware VMX sum-rounding parity claim. + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} + +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]), + ] +} + +fn inverse_sqrt(q: f32) -> f32 { + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + let square = r * r; + let half = r * 0.5; + let error = (-q).mul_add(square, 1.0); + r = half.mul_add(error, r); + } + r +} + +pub(super) fn normalize(v: Vector) -> Vector { + let r = inverse_sqrt(dot(v, v)); + v.map(|value| value * r) +} + +pub(super) fn length(v: Vector) -> f32 { + let q = dot(v, v); + let result = q * inverse_sqrt(q); + if q == 0.0 { 0.0 } else { result } +} + +pub(super) fn inverse_frame(right: Vector, up: Vector, forward: Vector, v: Vector) -> Vector { + let c0 = cross(up, forward); + let c1 = cross(forward, right); + let c2 = cross(right, up); + let determinant = dot(right, c0); + let mut reciprocal = 1.0 / determinant; + for _ in 0..2 { + reciprocal = reciprocal.mul_add((-determinant).mul_add(reciprocal, 1.0), reciprocal); + } + // Scale inverse columns before multiplying and accumulating input components. + let c = [c0, c1, c2]; + std::array::from_fn(|i| { + let x = c[i][0] * reciprocal; + let y = c[i][1] * reciprocal; + let z = c[i][2] * reciprocal; + z.mul_add(v[2], y.mul_add(v[1], x * v[0])) + }) +} + +pub(super) fn signed_angle(x: f32, y: f32) -> f32 { + let seed = 1.0 / y; + let reciprocal = seed.mul_add((-y).mul_add(seed, 1.0), seed); + let mut angle = rational_atan(x.mul_add(reciprocal, 0.0)); + if y < 0.0 { + angle += PI.copysign(x); + } + if y == 0.0 { + angle = HALF_PI.copysign(x); + } + angle +} + +fn reciprocal_two(d: f32) -> f32 { + let seed = 1.0 / d; + let first = seed.mul_add((-d).mul_add(seed, 1.0), seed); + let second = first.mul_add((-d).mul_add(first, 1.0), first); + // Original compares the FIRST refinement before selecting the second. + if first.is_nan() { seed } else { second } +} + +///82473B98: range reduction, rational polynomial, strict thresholds and signs. +pub(super) fn rational_atan(original: f32) -> f32 { + let absolute = original.abs(); + let invert = absolute > 1.0; + let reciprocal = reciprocal_two(absolute); + let x = if invert { reciprocal } else { absolute }; + let base_offset = if invert { HALF_PI } else { 0.0 }; + let reduced_offset = if invert { + f32::from_bits(0x3f86_0a92) + } else { + f32::from_bits(0x3f06_0a92) + }; + let denominator = x + f32::from_bits(0x3fdd_b3d7); + let numerator = x.mul_add(f32::from_bits(0x3f3b_67af), x) - 1.0; + let transformed = numerator * reciprocal_two(denominator); + let reduce = x > f32::from_bits(0x3e89_30a3); + let t = if reduce { transformed } else { x }; + let offset = if reduce { reduced_offset } else { base_offset }; + let q = t * t; + let mut p = q.mul_add(f32::from_bits(0xbf56_6bd7), f32::from_bits(0xc107_e9fb)); + p = q.mul_add(p, f32::from_bits(0xc1a4_0bfe)); + p = q.mul_add(p, f32::from_bits(0xc15b_0533)); + p *= q; + let mut d = q + f32::from_bits(0x4170_624f); + d = q.mul_add(d, f32::from_bits(0x426e_5052)); + d = q.mul_add(d, f32::from_bits(0x42ac_5090)); + d = q.mul_add(d, f32::from_bits(0x4224_43e6)); + let correction = p * reciprocal_two(d); + let polynomial = t.mul_add(correction, t); + let mut result = if t.abs() < f32::from_bits(0x3980_0000) { + t + } else { + polynomial + }; + if invert { + result = -result; + } + result += offset; + if original < 0.0 { + result = -result; + } + let huge = f32::from_bits(0x7e80_0000); + if original > huge { + result = HALF_PI; + } + if original < -huge { + result = -HALF_PI; + } + result +} diff --git a/crates/skate-core/src/player/offboard/ground_input/tests.rs b/crates/skate-core/src/player/offboard/ground_input/tests.rs new file mode 100644 index 00000000..16f83113 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_input/tests.rs @@ -0,0 +1,185 @@ +use super::*; + +fn input() -> GroundInput { + GroundInput { + processed_flags_2472: 0, + processed_direct_2684: 9.0, + processed_direct_2680: -7.0, + processed_stick_2692: 0.0, + processed_stick_2688: 1.0, + processed_scale_2912: 1.0, + processed_scale_2908: 1.0, + frame_forward_112: [0.0, 0.0, 1.0], + } +} + +fn constant(value: f32) -> PointGraph<8> { + PointGraph { + x: [0.0, 0.1, 0.2, 0.3, 0.4, 0.6, 0.8, 1.0], + y: [value; 8], + } +} + +fn angle_curve() -> PointGraph<8> { + let x = [0.0, 0.1, 0.2, 0.3, 0.4, 0.6, 0.8, 1.0]; + PointGraph { x, y: x } +} + +fn near(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 2.0e-6, "{actual} != {expected}"); +} + +#[test] +fn direct_flag_bypasses_invalid_geometry_and_scaling() { + let mut i = input(); + i.processed_flags_2472 = 0x1000_0000; + i.frame_forward_112 = [f32::NAN; 3]; + i.processed_scale_2912 = f32::NAN; + i.processed_stick_2692 = f32::NAN; + let output = calculate(&i, &constant(f32::NAN), &constant(f32::NAN)); + assert_eq!( + output, + GroundInputOutput { + state_708: 9.0, + state_712: -7.0, + state_656: [0.0; 4] + } + ); +} + +#[test] +fn other_flag_does_not_select_direct_input() { + let mut i = input(); + i.processed_flags_2472 = 0x0800_0000; + near(calculate(&i, &constant(2.0), &constant(1.0)).state_708, 2.0); +} + +#[test] +fn camera_relative_quadrants_and_output_raw_vector() { + let mut i = input(); + i.processed_stick_2692 = 0.6; + i.processed_stick_2688 = 0.8; + let right = calculate(&i, &constant(1.0), &constant(2.0)); + near(right.state_708, 1.0); + near(right.state_712, 2.0); + assert_eq!(right.state_656, [0.6, 0.0, 0.8, 0.0]); + i.processed_stick_2692 = -0.6; + near( + calculate(&i, &constant(1.0), &constant(2.0)).state_712, + -2.0, + ); +} + +#[test] +fn slope_is_flattened_before_input_transform() { + let base = calculate(&input(), &angle_curve(), &constant(1.0)); + let mut i = input(); + i.frame_forward_112 = [0.0, 0.6, 0.8]; + let slope = calculate(&i, &angle_curve(), &constant(1.0)); + near(slope.state_708, base.state_708); + near(slope.state_712, base.state_712); +} + +#[test] +fn rotated_frame_changes_angle_but_not_stored_input() { + let mut i = input(); + i.frame_forward_112 = [1.0, 0.0, 0.0]; + let result = calculate(&i, &angle_curve(), &constant(1.0)); + near(result.state_708, 0.5); + near(result.state_712, -1.0); + assert_eq!(result.state_656, [0.0, 0.0, 1.0, 0.0]); +} + +#[test] +fn threshold_equality_uses_identity_and_below_uses_frame() { + let mut i = input(); + i.frame_forward_112 = [0.1, f32::from_bits(0x3f7f_be77), 0.0]; + near(calculate(&i, &angle_curve(), &constant(1.0)).state_708, 1.0); + i.frame_forward_112[1] = f32::from_bits(0x3f7f_be76); + near(calculate(&i, &angle_curve(), &constant(1.0)).state_708, 0.5); +} + +#[test] +fn positive_vertical_identity_but_negative_vertical_has_no_invented_guard() { + let mut i = input(); + i.frame_forward_112 = [0.0, 1.0, 0.0]; + near(calculate(&i, &constant(1.0), &constant(1.0)).state_708, 1.0); + i.frame_forward_112[1] = -1.0; + assert!( + calculate(&i, &constant(1.0), &constant(1.0)) + .state_708 + .is_nan() + ); +} + +#[test] +fn zero_stick_is_zero_despite_intermediate_reciprocal_nan() { + let mut i = input(); + i.processed_stick_2688 = 0.0; + let result = calculate(&i, &constant(3.0), &constant(4.0)); + assert_eq!(result.state_708, 0.0); + assert_eq!(result.state_712, 0.0); +} + +#[test] +fn magnitude_curve_and_processed_scales_all_contribute() { + let mut i = input(); + i.processed_stick_2692 = -3.0; + i.processed_stick_2688 = 4.0; + i.processed_scale_2912 = 2.0; + i.processed_scale_2908 = 3.0; + let result = calculate(&i, &constant(0.5), &constant(2.0)); + near(result.state_708, 5.0); + near(result.state_712, -30.0); +} + +#[test] +fn shared_curve_exact_endpoints_knots_and_unordered_input() { + let curve = PointGraph { + x: [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0], + y: [2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 14.0, 18.0], + }; + for index in 0..8 { + assert_eq!(curve.evaluate(curve.x[index]), curve.y[index]); + } + assert_eq!(curve.evaluate(-1.0), 2.0); + assert_eq!(curve.evaluate(2.0), 18.0); + assert_eq!(curve.evaluate(f32::NAN), 18.0); + assert_eq!(curve.evaluate(0.0625), 3.0); +} + +#[test] +fn original_angle_tracks_finite_atan_across_reduction_boundaries() { + for boundary in [ + f32::from_bits(0x3980_0000), + f32::from_bits(0x3e89_30a3), + 1.0, + ] { + for bits in [ + boundary.to_bits() - 1, + boundary.to_bits(), + boundary.to_bits() + 1, + ] { + let value = f32::from_bits(bits); + near(math::rational_atan(value), value.atan()); + near(math::rational_atan(-value), (-value).atan()); + } + } + for value in [0.0_f32, 0.1, 0.5, 2.0, 10.0, 1.0e30] { + near(math::rational_atan(value), value.atan()); + } +} + +#[test] +fn axis_override_preserves_x_sign_even_when_both_inputs_are_zero() { + assert_eq!(math::signed_angle(0.0, 0.0).to_bits(), 0x3fc9_0fdb); + assert_eq!(math::signed_angle(-0.0, -0.0).to_bits(), 0xbfc9_0fdb); + near( + math::signed_angle(1.0, -1.0), + 3.0 * std::f32::consts::FRAC_PI_4, + ); + near( + math::signed_angle(-1.0, -1.0), + -3.0 * std::f32::consts::FRAC_PI_4, + ); +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle.rs b/crates/skate-core/src/player/offboard/ground_lifecycle.rs new file mode 100644 index 00000000..f560198f --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle.rs @@ -0,0 +1,24 @@ +//! BipedGround Exit82D30CC0, PostPhysics82D32AA0 and FillPhysOut82D32D38. +//! Borrows canonical Ground state; source writes only the returned packet fields. +mod collision; +mod exit; +mod post; +mod publication; +#[cfg(test)] +mod tests; +use super::ground_entry::Vector; +pub use collision::{CollisionInput, CollisionSettings, check_collision}; +pub use exit::{ExitServices, exit}; +pub use post::{PostInput, post_physics}; +pub use publication::{Publication, PublicationInput, publish}; +fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +fn length(v: Vector) -> f32 { + let q = dot(v, v); + let mut r = crate::physics::reciprocal_sqrt::estimate(q); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.0), r); + } + if q == 0.0 { 0.0 } else { q * r } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/collision.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/collision.rs new file mode 100644 index 00000000..89355697 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/collision.rs @@ -0,0 +1,66 @@ +//! Complete82D90148, borrowing the existing shared Wipeout request owner. +use super::dot; +use crate::player::{ + offboard::ground_entry::Vector, + wipeout::{self, Requests}, +}; +pub struct CollisionSettings { + ///physics_wipeout/default layout96..120. + pub vehicle_scalar: f32, + pub vehicle_contact: f32, + pub maximum_displacement: f32, + pub maximum_arm_contact: f32, + pub maximum_body_contact: f32, + pub minimum_speed: f32, + pub maximum_squash: f32, + ///Actual attribute472174920C68FBE3. Required when processed2480bit7. + pub special_scalar: f32, +} +pub struct CollisionInput<'a> { + pub shared: &'a wipeout::Frame, + pub skeleton_velocity_16336: Vector, + pub contact_flag_4072: bool, + pub contact_force_4056: f32, +} +pub fn check_collision(requests: &mut Requests, s: &CollisionSettings, i: &CollisionInput<'_>) { + requests.mode = 4; + if dot(i.skeleton_velocity_16336, i.skeleton_velocity_16336) + <= s.minimum_speed * s.minimum_speed + { + return; + } + let requested = if i.shared.flags_2480 & 0x80 != 0 { + s.special_scalar + } else { + 1.0 + }; + //The source loads vehicle_scalar whenever the contact flag is present, + //even when vehicle_contact comparison does not emit reason7. + let vehicle = if i.contact_flag_4072 { + if i.contact_force_4056 > s.vehicle_contact { + requests.request(7, 0.0); + } + s.vehicle_scalar + } else { + 1.0 + }; + let scalar = if requested - vehicle >= 0.0 { + vehicle + } else { + requested + }; + if i.shared.maximum_pose_error > scalar * s.maximum_squash { + requests.request(18, 0.0); + } else { + let limit = s.maximum_displacement * scalar; + if dot(i.shared.pose_error, i.shared.pose_error) > limit * limit { + requests.request(1, 0.0); + } else if wipeout::regional_force( + i.shared, + s.maximum_body_contact * scalar, + s.maximum_arm_contact * vehicle, + ) { + requests.request(0, 0.0); + } + } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/exit.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/exit.rs new file mode 100644 index 00000000..96150099 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/exit.rs @@ -0,0 +1,20 @@ +//!82D30CC0 lifecycle ordering. Toolkit refresh is a real prerequisite service. +pub trait ExitServices { + ///Run complete original82D81610 against the actual toolkit/contact owner. + ///Its active-contact branch must not be replaced by a reset/no-op. + fn refresh_toolkit_contacts(&mut self); + ///After refresh, zero actual toolkit320 and30452/30456/30460/30464/30468. + fn clear_toolkit_exit_fields(&mut self); + fn ground_geometry_active(&self) -> bool; + ///Actual Groundstate76 geometry service virtual12 release. + fn release_ground_geometry(&mut self); + fn clear_ground_geometry_active(&mut self); +} +pub fn exit(services: &mut impl ExitServices) { + services.refresh_toolkit_contacts(); + services.clear_toolkit_exit_fields(); + if services.ground_geometry_active() { + services.release_ground_geometry(); + services.clear_ground_geometry_active(); + } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/post.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/post.rs new file mode 100644 index 00000000..1b51e1d5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/post.rs @@ -0,0 +1,54 @@ +use super::{CollisionInput, CollisionSettings, check_collision, dot, length}; +use crate::player::{ + offboard::ground_entry::{State, Vector}, + wipeout::Requests, +}; +pub struct PostInput<'a> { + pub collision: CollisionInput<'a>, + pub processed_flags_2484: u32, + pub processed_velocity_608: Vector, + pub skeleton_displacement_16288: Vector, + pub skeleton_displacement_16304: Vector, + pub ground_kind_356: u32, +} +pub fn post_physics( + state: &mut State, + requests: &mut Requests, + settings: &CollisionSettings, + input: &PostInput<'_>, +) { + check_collision(requests, settings, &input.collision); + if input.processed_flags_2484 & 0x400 != 0 { + if state.flags_144_to_150[6] && state.flags_144_to_150[2] { + requests.request(31, 0.0); + } + state.elapsed_168 = 0.0; + if state.flags_144_to_150[2] + || state.angle_172 > f32::from_bits(0x3fc9_0fdb) + || matches!(input.ground_kind_356, 2 | 4) + || 0.5 > state.frame_80[1][1] + { + requests.request(31, 0.0); + } else if !(dot( + input.processed_velocity_608, + input.skeleton_displacement_16304, + ) >= length(input.processed_velocity_608) * f32::from_bits(0xbca3_d70a)) + { + requests.request(32, 0.0); + } + } else { + let a = dot(input.skeleton_displacement_16304, state.frame_80[1]).abs(); + let b = dot(input.skeleton_displacement_16288, state.frame_80[1]).abs(); + if a > f32::from_bits(0x3eb3_3333) || b > f32::from_bits(0x3e19_999a) { + state.elapsed_168 += f32::from_bits(0x3c88_8889); + } else { + state.elapsed_168 = 0.0; + } + if !(state.elapsed_168 < f32::from_bits(0x3d4c_ccce)) { + requests.request(32, 0.0); + } + } + if state.flags_144_to_150[1] { + requests.request(28, 0.0); + } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/publication.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/publication.rs new file mode 100644 index 00000000..a4bf751d --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/publication.rs @@ -0,0 +1,65 @@ +use super::length; +use crate::player::offboard::ground_entry::{State, Vector}; +pub struct PublicationInput { + pub ground_flags_752_to_754: [bool; 3], + pub contact_flags_368: u32, + pub contact_position_192: Vector, + pub query_position_816: Vector, + pub processed_flags_2476: u32, + pub ground_kind_356: u32, + pub ground_scalar_360: f32, + pub motion_vector_1040: Vector, + pub motion_up_864: Vector, + ///Actual hand manager flags105/106/107 and217/218/219. + pub hand_flags: [[bool; 3]; 2], +} +///Only fields written by82D32D38/82D785F8; caller preserves all other output. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Publication { + pub physics_counter_36: u32, + pub physics_counter_40: u32, + pub physics_flag_86: bool, + pub offboard_flag_304: bool, + pub offboard_kind_88: u32, + pub offboard_scalar_112: f32, + pub offboard_flag_329: bool, + pub offboard_flag_330: bool, + pub offboard_distance_116: f32, + pub offboard_flag_334: bool, + pub offboard_scalar_32: f32, + pub offboard_flag_328: bool, + pub animation_vector_144: Vector, + pub animation_flag_164: bool, + pub offboard_hand_flags_306_307: [bool; 2], +} +pub fn publish(state: &State, i: &PublicationInput) -> Publication { + let near = i.ground_flags_752_to_754[1] || !(state.distance_164 >= f32::from_bits(0x3daa_aaab)); + let blocked = state.flags_144_to_150[3] || i.contact_flags_368 & 8 != 0; + let distance = if i.ground_flags_752_to_754[0] && i.contact_flags_368 & 1 != 0 { + length(std::array::from_fn(|n| { + i.contact_position_192[n] - i.query_position_816[n] + })) + } else { + f32::from_bits(0x5015_02f9) + }; + let projection = super::dot(i.motion_up_864, i.motion_vector_1040); + Publication { + physics_counter_36: state.counter_152, + physics_counter_40: state.counter_156, + physics_flag_86: i.processed_flags_2476 & 0x0040_0000 != 0 && !state.flags_144_to_150[0], + offboard_flag_304: state.flags_144_to_150[0], + offboard_kind_88: i.ground_kind_356, + offboard_scalar_112: i.ground_scalar_360, + offboard_flag_329: blocked, + offboard_flag_330: near && !blocked, + offboard_distance_116: distance, + offboard_flag_334: i.ground_flags_752_to_754[2], + offboard_scalar_32: if state.flags_144_to_150[2] { 1.0 } else { 0.0 }, + offboard_flag_328: state.flags_144_to_150[2], + animation_vector_144: std::array::from_fn(|n| { + i.motion_vector_1040[n] - i.motion_up_864[n] * projection + }), + animation_flag_164: true, + offboard_hand_flags_306_307: i.hand_flags.map(|f| (f[0] && !f[1]) || f[2]), + } +} diff --git a/crates/skate-core/src/player/offboard/ground_lifecycle/tests.rs b/crates/skate-core/src/player/offboard/ground_lifecycle/tests.rs new file mode 100644 index 00000000..61fcd5d4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_lifecycle/tests.rs @@ -0,0 +1,215 @@ +use super::*; +use crate::physics::skeleton_animation_record::IDENTITY; +use crate::player::{ + offboard::ground_entry::State, + wipeout::{Frame, Requests}, +}; +fn input() -> Frame { + Frame { + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + category: 100, + timestep: 1.0 / 60.0, + time_on_ground: 1.0, + speed: 0.0, + animation_up: [0.0, 1.0, 0.0, 0.0], + landing_angle: 0.0, + deck_velocity: [0.0; 4], + com_velocity: [0.0; 4], + jump_fix_frames: 1000, + deck: IDENTITY, + input_board: IDENTITY, + world_to_animation: IDENTITY, + closing_velocity: [0.0; 4], + board_material_flags: 0, + board_contact: true, + wheel_contact: true, + board_contact_normal: [0.0, 1.0, 0.0, 0.0], + opposing_contact: 0.0, + regions_force: [0.0; 8], + maximum_skater_force: 0.0, + vehicle_force: 0.0, + group_8: false, + conflicting: false, + compliant: false, + highest_normal: [0.0, 1.0, 0.0, 0.0], + pose_error: [0.0; 4], + maximum_pose_error: 0.0, + flip_active: false, + flip_requested_speed: 0.0, + system_up_y: 1.0, + grind_selected: false, + grind_normal_valid: false, + grind_normal: [0.0, 1.0, 0.0, 0.0], + } +} +fn settings() -> CollisionSettings { + CollisionSettings { + vehicle_scalar: 0.35, + vehicle_contact: 6.0, + maximum_displacement: 0.3, + maximum_arm_contact: 20.0, + maximum_body_contact: 20.0, + minimum_speed: 0.0, + maximum_squash: 0.6, + special_scalar: 1.0, + } +} +fn collision(f: &Frame) -> CollisionInput<'_> { + CollisionInput { + shared: f, + skeleton_velocity_16336: [1.0, 0.0, 0.0, 0.0], + contact_flag_4072: false, + contact_force_4056: 0.0, + } +} +fn post(f: &Frame) -> PostInput<'_> { + PostInput { + collision: collision(f), + processed_flags_2484: 0, + processed_velocity_608: [0.0; 4], + skeleton_displacement_16288: [0.0; 4], + skeleton_displacement_16304: [0.0; 4], + ground_kind_356: 0, + } +} +#[test] +fn duplicate_ground_triggers_increment_count_and_keep_shared_requests() { + let f = input(); + let mut p = post(&f); + p.processed_flags_2484 = 0x400; + let mut s = State::default(); + s.flags_144_to_150[6] = true; + s.flags_144_to_150[2] = true; + s.flags_144_to_150[1] = true; + let mut q = Requests::new(); + q.request(3, 2.0); + post_physics(&mut s, &mut q, &settings(), &p); + assert_eq!(q.count, 4); + assert!(q.reasons[31] && q.reasons[28] && q.reasons[3]); + assert_eq!(q.values[3], 2.0); + assert_eq!(q.mode, 4); +} +#[test] +fn displacement_requires_persistent_ticks_then_clears_when_contact_settles() { + let f = input(); + let mut p = post(&f); + p.skeleton_displacement_16304 = [0.0, 0.5, 0.0, 0.0]; + let mut s = State::default(); + let mut q = Requests::new(); + for _ in 0..2 { + post_physics(&mut s, &mut q, &settings(), &p); + } + assert_eq!(q.count, 0); + post_physics(&mut s, &mut q, &settings(), &p); + assert!(q.reasons[32]); + p.skeleton_displacement_16304 = [0.0; 4]; + post_physics(&mut s, &mut q, &settings(), &p); + assert_eq!(s.elapsed_168, 0.0); +} +#[test] +fn opposite_displacement_triggers_only_beyond_speed_scaled_threshold() { + let f = input(); + let mut p = post(&f); + p.processed_flags_2484 = 0x400; + p.processed_velocity_608 = [2.0, 0.0, 0.0, 0.0]; + let mut s = State::default(); + let mut q = Requests::new(); + p.skeleton_displacement_16304 = [-0.02, 0.0, 0.0, 0.0]; + post_physics(&mut s, &mut q, &settings(), &p); + assert_eq!(q.count, 0); + p.skeleton_displacement_16304[0] = -0.03; + post_physics(&mut s, &mut q, &settings(), &p); + assert!(q.reasons[32]); +} +#[test] +fn vehicle_scalar_applies_even_below_vehicle_request_force() { + let mut f = input(); + f.maximum_pose_error = 0.3; + let mut i = collision(&f); + i.contact_flag_4072 = true; + i.contact_force_4056 = 0.0; + let mut q = Requests::new(); + check_collision(&mut q, &settings(), &i); + assert!(q.reasons[18]); + assert!(!q.reasons[7]); +} +#[test] +fn collision_priority_selects_squash_before_displacement_and_force() { + let mut f = input(); + f.maximum_pose_error = 1.0; + f.pose_error = [1.0, 0.0, 0.0, 0.0]; + f.regions_force = [100.0; 8]; + let mut q = Requests::new(); + check_collision(&mut q, &settings(), &collision(&f)); + assert!(q.reasons[18]); + assert!(!q.reasons[1] && !q.reasons[0]); +} +fn publication() -> PublicationInput { + PublicationInput { + ground_flags_752_to_754: [false; 3], + contact_flags_368: 0, + contact_position_192: [3.0, 0.0, 4.0, 0.0], + query_position_816: [0.0; 4], + processed_flags_2476: 0, + ground_kind_356: 4, + ground_scalar_360: 0.7, + motion_vector_1040: [2.0, 3.0, 4.0, 5.0], + motion_up_864: [0.0, 1.0, 0.0, 2.0], + hand_flags: [[true, false, false], [true, true, false]], + } +} +#[test] +fn output_gates_distance_and_preserves_projection_w_lane() { + let mut s = State::default(); + s.distance_164 = 0.0; + let mut i = publication(); + let o = publish(&s, &i); + assert_eq!(o.offboard_distance_116, 1e10); + assert!(o.offboard_flag_330); + assert_eq!(o.animation_vector_144, [2.0, 0.0, 4.0, -1.0]); + assert_eq!(o.offboard_hand_flags_306_307, [true, false]); + i.ground_flags_752_to_754[0] = true; + i.contact_flags_368 = 9; + let o = publish(&s, &i); + assert_eq!(o.offboard_distance_116, 5.0); + assert!(o.offboard_flag_329); + assert!(!o.offboard_flag_330); +} +#[test] +fn exit_calls_real_refresh_before_clearing_and_releasing() { + struct Service { + calls: Vec, + active: bool, + } + impl ExitServices for Service { + fn refresh_toolkit_contacts(&mut self) { + self.calls.push(1) + } + fn clear_toolkit_exit_fields(&mut self) { + self.calls.push(2) + } + fn ground_geometry_active(&self) -> bool { + self.active + } + fn release_ground_geometry(&mut self) { + self.calls.push(3) + } + fn clear_ground_geometry_active(&mut self) { + self.calls.push(4); + self.active = false + } + } + let mut service = Service { + calls: vec![], + active: true, + }; + exit(&mut service); + assert_eq!(service.calls, [1, 2, 3, 4]); + service.calls.clear(); + exit(&mut service); + assert_eq!(service.calls, [1, 2]); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion.rs b/crates/skate-core/src/player/offboard/ground_motion.rs new file mode 100644 index 00000000..94028070 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion.rs @@ -0,0 +1,90 @@ +//! Original TU3 Biped movement82D7EDC8 and leaves800C0/80EB0/80CE0. +//! S3 SHA256431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +//! The controller owns these fields once; this stage makes no collision queries. + +mod approach; +mod math; +mod publication; +mod support; +#[cfg(test)] +mod tests; + +pub type Vector = [f32; 4]; +pub type Frame = [Vector; 4]; +use math::*; + +/// Only fields written by this stage. Integrate these into the one Biped owner; +/// velocity480, correction576/711 and frame0 must not become stale shadow copies. +#[derive(Clone, Debug)] +pub struct GroundMotionState { + pub frame_0: Frame, + pub published_frame_64: Frame, + pub previous_support_frame_192: Frame, + pub support_velocity_256: Vector, + pub predicted_support_velocity_272: Vector, + pub previous_support_velocity_288: Vector, + pub support_acceleration_304: Vector, + pub filtered_local_acceleration_320: Vector, + pub support_yaw_336: f32, + pub previous_support_yaw_340: f32, + pub predicted_support_yaw_344: f32, + pub support_speed_348: f32, + pub support_id_352: u32, + pub velocity_480: Vector, + pub correction_576: Vector, + pub target_frame_608: Frame, + pub angular_velocity_688: f32, + pub speed_704: f32, + pub correction_enabled_711: bool, + pub support_velocity_removed_715: bool, + pub target_scale_784: f32, +} + +/// Actual job values and external-controller fields, never query placeholders. +#[derive(Clone, Copy, Debug)] +pub struct GroundMotionInput { + pub contact_position_0: Vector, + pub contact_frame_32: Frame, + pub contact_target_96: Vector, + pub contact_normal_112: Vector, + pub contact_flags_176: u32, + pub contact_id_180: u32, + pub animation_motion_224: Vector, + pub animation_motion_240: Vector, + pub requested_duration_288: f32, + pub mirrored_292: bool, + pub animation_directed_304: bool, + pub target_frame_present_352: bool, + pub target_frame_368: Frame, + /// Externally produced Biped frame128..176; read by800C0 and angle limiting. + pub reference_frame_128: Frame, + /// Contact correction producer384 (not the job's frame384). + pub contact_displacement_384: Vector, + pub velocity_addition_528: Vector, + pub desired_up_544: Vector, + pub correction_target_592: Vector, + pub obstacle_target_672: Vector, + pub obstacle_enabled_713: bool, +} + +///82D7EDC8. Order: support history, approach, displacement/heading, publication. +pub fn update(state: &mut GroundMotionState, input: &GroundMotionInput) { + let support_delta = support::update(state, input); + let old_position = state.frame_0[3]; + state.frame_0[3] = ZERO; + let (step, height) = approach::calculate(state, input, old_position, support_delta); + let displacement = add(ZERO, step); + let displacement = add(displacement, height); + let displacement = add(displacement, input.contact_displacement_384); + let displacement = madd(input.velocity_addition_528, DT, displacement); + let displacement = madd(state.predicted_support_velocity_272, DT, displacement); + let angle = (state.angular_velocity_688 + state.predicted_support_yaw_344) * DT; + let axis = state.frame_0[1]; + state.frame_0 = state.frame_0.map(|row| rotate(row, axis, angle)); + state.frame_0[0] = normalize(cross(input.desired_up_544, state.frame_0[2])); + state.frame_0[1] = input.desired_up_544; + state.frame_0[2] = normalize(cross(state.frame_0[0], input.desired_up_544)); + state.frame_0 = crate::physics::skeleton_root::orthonormalize(state.frame_0); + state.frame_0[3] = add(old_position, displacement); + publication::update(state, displacement); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/approach.rs b/crates/skate-core/src/player/offboard/ground_motion/approach.rs new file mode 100644 index 00000000..ee36f67d --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/approach.rs @@ -0,0 +1,120 @@ +//!82D7F458..FDD0 animation-directed movement and ground/obstacle approach. +use super::math::*; +use super::{Frame, GroundMotionInput, GroundMotionState, Vector}; +use crate::trigonometry; + +///82D80EB0. No state change when the job has no target frame. +fn refresh_target(s: &mut GroundMotionState, i: &GroundMotionInput) { + if i.target_frame_present_352 { + s.target_frame_608 = i.target_frame_368; + s.target_frame_608[3] = madd( + i.target_frame_368[0], + f32::from_bits(0x3e4c_cccd), + i.target_frame_368[3], + ); + } +} + +pub(super) fn calculate( + s: &mut GroundMotionState, + i: &GroundMotionInput, + position: Vector, + delta: Frame, +) -> (Vector, Vector) { + let mut step = scale(s.velocity_480, DT); + let mut height = ZERO; + if i.animation_directed_304 { + let animation_speed = length(i.animation_motion_224); + let stationary = animation_speed < EPSILON; + let mut difference = sub(s.target_frame_608[3], position); + let mut distance = length(difference); + if !(s.target_scale_784 >= 0.0) { + refresh_target(s, i); + difference = sub(s.target_frame_608[3], position); + distance = length(difference); + s.target_scale_784 = distance + / if stationary { + i.requested_duration_288 + } else { + animation_speed + }; + } + step = ZERO; + if !(distance <= EPSILON) { + let mut step_length = s.target_scale_784 * DT; + if !stationary { + step_length = length(i.animation_motion_240) * step_length; + } + step = scale(scale(difference, reciprocal(distance)), step_length); + } + let sine = clamp_signed(dot( + cross(s.target_frame_608[0], s.frame_0[2]), + s.frame_0[1], + )); + let angle = trigonometry::asin(sine); + let denominator = if stationary { + i.requested_duration_288 * s.target_scale_784 + } else { + s.target_scale_784 * animation_speed + }; + s.angular_velocity_688 = 0.0; + if !(denominator <= EPSILON) { + s.angular_velocity_688 = ((1.0 - distance / denominator) * angle) * RATE; + } + s.target_frame_608 = compose(s.target_frame_608, delta); + return (step, height); + } + s.target_scale_784 = -1.0; + refresh_target(s, i); + if i.contact_flags_176 & 2 == 0 && !i.obstacle_enabled_713 { + if i.contact_flags_176 & 1 != 0 { + height = height_adjustment(s, i, position); + } + return (step, height); + } + let mut target = if i.obstacle_enabled_713 { + i.obstacle_target_672 + } else { + let side = normalize_safe(cross(UP, s.frame_0[2]), s.frame_0[0]); + let point = if s.correction_enabled_711 { + i.correction_target_592 + } else { + i.contact_target_96 + }; + sub(point, scale(side, dot(sub(point, position), side))) + }; + let difference = sub(target, position); + if dot(difference, difference) < f32::from_bits(0x3780_0000) + || !(dot(difference, s.velocity_480) >= -0.5) + { + let predicted = madd(scale(s.velocity_480, DT), 2.0, position); + target = [predicted[0], target[1], predicted[2], predicted[3]]; + } + let difference = sub(target, position); + let budget = length(step); + let distance = length(difference); + if !(distance <= budget) { + step = scale(normalize_safe(difference, ZERO), budget); + } else { + let normal = if i.obstacle_enabled_713 { + i.target_frame_368[1] + } else { + i.contact_normal_112 + }; + let angle = f32::from_bits(0x4234_0000) * f32::from_bits(0x3c8e_fa35); + let limited = limit_angle(normal, i.reference_frame_128[1], angle); + let normal = normalize_safe(limited, ZERO); + let tangent = sub(s.velocity_480, scale(normal, dot(s.velocity_480, normal))); + step = madd(normalize_safe(tangent, ZERO), budget - distance, difference); + } + if !(budget >= f32::from_bits(0x3c23_d70a)) { + height = height_adjustment(s, i, position); + } + (step, height) +} +fn height_adjustment(s: &GroundMotionState, i: &GroundMotionInput, position: Vector) -> Vector { + scale( + s.frame_0[1], + dot(sub(i.contact_position_0, position), s.frame_0[1]) * f32::from_bits(0x3d4c_cccd), + ) +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/math.rs b/crates/skate-core/src/player/offboard/ground_motion/math.rs new file mode 100644 index 00000000..45417ef4 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/math.rs @@ -0,0 +1,130 @@ +//! Original VMX operation trees expressed with ordinary PC floating-point math. +//! PC seeds and dot sums are not a bit-exact Xenon emulation claim. +use super::{Frame, Vector}; +use crate::trigonometry; +pub(super) const ZERO: Vector = [0.0; 4]; +pub(super) const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +pub(super) const IDENTITY: Frame = [[1.0, 0.0, 0.0, 0.0], UP, [0.0, 0.0, 1.0, 0.0], ZERO]; +pub(super) const DT: f32 = f32::from_bits(0x3c88_8889); +pub(super) const RATE: f32 = f32::from_bits(0x426f_ffff); +pub(super) const EPSILON: f32 = f32::from_bits(0x3a83_126f); +const NORMAL_EPSILON: f32 = f32::from_bits(0x3586_37bd); +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 dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +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]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn inverse_sqrt(q: f32) -> f32 { + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + let square = r * r; + let half = r * 0.5; + r = half.mul_add((-q).mul_add(square, 1.0), r); + } + r +} +pub(super) fn reciprocal(d: f32) -> f32 { + let mut r = 1.0 / d; + for _ in 0..2 { + r = r.mul_add((-d).mul_add(r, 1.0), r); + } + r +} +pub(super) fn length(v: Vector) -> f32 { + let q = dot(v, v); + let x = q * inverse_sqrt(q); + if q == 0.0 { 0.0 } else { x } +} +pub(super) fn normalize(v: Vector) -> Vector { + scale(v, inverse_sqrt(dot(v, v))) +} +pub(super) fn normalize_safe(v: Vector, fallback: Vector) -> Vector { + let q = dot(v, v); + let r = inverse_sqrt(q); + let n = scale(v, r); + let len = if q == 0.0 { 0.0 } else { q * r }; + if len > NORMAL_EPSILON { n } else { fallback } +} +pub(super) fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +pub(super) fn clamp_signed(v: f32) -> f32 { + let low = select(-1.0 - v, -1.0, v); + select(1.0 - low, low, 1.0) +} +pub(super) fn transform_vector(v: Vector, f: Frame) -> Vector { + madd(f[2], v[2], madd(f[1], v[1], scale(f[0], v[0]))) +} +pub(super) fn transform_point(v: Vector, f: Frame) -> Vector { + madd(f[2], v[2], madd(f[1], v[1], madd(f[0], v[0], f[3]))) +} +pub(super) fn compose(a: Frame, b: Frame) -> Frame { + [ + transform_vector(a[0], b), + transform_vector(a[1], b), + transform_vector(a[2], b), + transform_point(a[3], b), + ] +} +pub(super) fn inverse_rigid(f: Frame) -> Frame { + crate::physics::skeleton_root::inverse_rigid(&f) +} + +///82D7FEE0..FFCC Rodrigues columns, including original repeated X in W. +pub(super) fn rotate(v: Vector, axis: Vector, angle: f32) -> Vector { + let [x, y, z, _] = axis; + 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], + ]; + transform_vector(v, [columns[0], columns[1], columns[2], ZERO]) +} + +/// Original82BD3E78, independently checked against its full raw body. +pub(super) fn limit_angle(target: Vector, from: Vector, maximum: f32) -> Vector { + let a = normalize_safe(target, ZERO); + let b = normalize_safe(from, ZERO); + let axis = cross(a, b); + let cosine = dot(a, b).max(-1.0).min(1.0); + let angle = trigonometry::acos(cosine); + let tau = f32::from_bits(0x40c9_0fdb); + let turns = angle.mul_add(f32::from_bits(0x3e22_f983), 0.5).floor(); + let closest = (-turns).mul_add(tau, angle); + if closest.abs() < maximum || dot(axis, axis) < f32::from_bits(0x3780_0000) { + return target; + } + let axis = normalize(axis); + let (s, c) = trigonometry::sin_cos(-maximum * 0.5); + let mut q = scale(axis, s); + q[3] = c; //82BD4130 vrlimi inserts cosine in the quaternion W lane. + let first = cross(q, from); + let intermediate = madd(from, c, first); + let second = cross(q, intermediate); + scale(madd(second, 2.0, from), length(target)) +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/publication.rs b/crates/skate-core/src/player/offboard/ground_motion/publication.rs new file mode 100644 index 00000000..3b3bb0c5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/publication.rs @@ -0,0 +1,28 @@ +//!82D80CE0: published frame copy and retained positional correction decay. +use super::math::*; +use super::{GroundMotionState, Vector}; +pub(super) fn update(s: &mut GroundMotionState, displacement: Vector) { + s.published_frame_64 = s.frame_0; + if !s.correction_enabled_711 { + return; + } + let magnitude = length(s.correction_576); + if magnitude <= f32::from_bits(0x3c23_d70a) { + s.correction_576 = ZERO; + s.correction_enabled_711 = false; + } else { + let unit = scale(s.correction_576, 1.0 / magnitude); + let proportional = magnitude * f32::from_bits(0xbd4c_cccd); + let minimum = f32::from_bits(0xbba3_d70a); + let decay = select(minimum - proportional, proportional, minimum); + let projected = dot(displacement, unit); + let amount = select(projected - decay, decay, projected); + if amount > -magnitude { + s.correction_576 = madd(unit, amount, s.correction_576); + } else { + s.correction_576 = ZERO; + s.correction_enabled_711 = false; + } + } + s.published_frame_64[3] = sub(s.published_frame_64[3], s.correction_576); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/support.rs b/crates/skate-core/src/player/offboard/ground_motion/support.rs new file mode 100644 index 00000000..11a8da82 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/support.rs @@ -0,0 +1,101 @@ +//!82D7EEAC..F454 moving support history and800C0 output filtering. +use super::math::*; +use super::{Frame, GroundMotionInput, GroundMotionState}; +use crate::trigonometry; + +pub(super) fn update(s: &mut GroundMotionState, i: &GroundMotionInput) -> Frame { + let mut delta = IDENTITY; + if i.contact_flags_176 & 1 == 0 { + return delta; + } + if s.support_id_352 != i.contact_id_180 { + if s.support_velocity_removed_715 { + s.velocity_480 = add(s.velocity_480, s.predicted_support_velocity_272); + s.speed_704 = length(s.velocity_480); + } + s.support_yaw_336 = 0.0; + s.previous_support_yaw_340 = 0.0; + s.predicted_support_yaw_344 = 0.0; + s.support_acceleration_304 = ZERO; + s.predicted_support_velocity_272 = ZERO; + s.support_velocity_256 = ZERO; + s.support_id_352 = i.contact_id_180; + s.support_velocity_removed_715 = false; + } else { + delta = compose( + inverse_rigid(s.previous_support_frame_192), + i.contact_frame_32, + ); + let moved = transform_point(s.frame_0[3], delta); + let velocity = scale(sub(moved, s.frame_0[3]), reciprocal(DT)); + let acceleration = madd( + sub(velocity, s.support_velocity_256), + reciprocal(DT), + s.support_acceleration_304, + ); + s.support_acceleration_304 = scale(acceleration, f32::from_bits(0x3f73_3333)); + s.support_velocity_256 = velocity; + s.predicted_support_velocity_272 = + add(velocity, sub(velocity, s.previous_support_velocity_288)); + s.previous_support_velocity_288 = velocity; + let rotated = transform_vector(s.frame_0[2], delta); + let flat = sub(rotated, scale(UP, dot(rotated, UP))); + let direction = normalize_safe(flat, ZERO); + let sine = clamp_signed(dot(cross(s.frame_0[2], direction), UP)); + let yaw = trigonometry::asin(sine) * RATE; + s.support_yaw_336 = yaw; + s.predicted_support_yaw_344 = yaw.mul_add(2.0, -s.previous_support_yaw_340); + s.previous_support_yaw_340 = yaw; + if dot( + s.predicted_support_velocity_272, + s.predicted_support_velocity_272, + ) > EPSILON + && !s.support_velocity_removed_715 + { + s.velocity_480 = add(s.velocity_480, s.predicted_support_velocity_272.map(|v| -v)); + s.support_velocity_removed_715 = true; + s.speed_704 = length(s.velocity_480); + } + } + filter(s, i); + s.previous_support_frame_192 = i.contact_frame_32; + delta +} + +///82D800C0: reference-frame local acceleration, mirrored X/Z, cap then filter. +fn filter(s: &mut GroundMotionState, i: &GroundMotionInput) { + let f = i.reference_frame_128; + let columns = [ + [f[0][0], f[1][0], f[2][0], 0.0], + [f[0][1], f[1][1], f[2][1], 0.0], + [f[0][2], f[1][2], f[2][2], 0.0], + ZERO, + ]; + let mut local = transform_vector(s.support_acceleration_304, columns); + local[1] = 0.0; + if i.mirrored_292 { + local[0] *= -1.0; + local[2] *= -1.0; + } + let mut request = scale(local, f32::from_bits(0x3ccc_cccd)); + let magnitude = length(request); + if !(magnitude <= 1.0) { + request = scale(request, 1.0 / magnitude); + } + s.filtered_local_acceleration_320 = madd( + request, + f32::from_bits(0x3d23_d70a), + scale( + s.filtered_local_acceleration_320, + f32::from_bits(0x3f75_c28f), + ), + ); + let speed = length([ + s.support_velocity_256[0], + 0.0, + s.support_velocity_256[2], + s.support_velocity_256[0], + ]) * f32::from_bits(0x3d4c_cccd); + let nonnegative = select(-speed, 0.0, speed); + s.support_speed_348 = select(1.0 - nonnegative, nonnegative, 1.0); +} diff --git a/crates/skate-core/src/player/offboard/ground_motion/tests.rs b/crates/skate-core/src/player/offboard/ground_motion/tests.rs new file mode 100644 index 00000000..db597f57 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_motion/tests.rs @@ -0,0 +1,270 @@ +use super::math::{DT, IDENTITY, UP, ZERO}; +use super::*; +fn state() -> GroundMotionState { + GroundMotionState { + frame_0: IDENTITY, + published_frame_64: IDENTITY, + previous_support_frame_192: IDENTITY, + support_velocity_256: ZERO, + predicted_support_velocity_272: ZERO, + previous_support_velocity_288: ZERO, + support_acceleration_304: ZERO, + filtered_local_acceleration_320: ZERO, + support_yaw_336: 0.0, + previous_support_yaw_340: 0.0, + predicted_support_yaw_344: 0.0, + support_speed_348: 0.0, + support_id_352: 1, + velocity_480: ZERO, + correction_576: ZERO, + target_frame_608: IDENTITY, + angular_velocity_688: 0.0, + speed_704: 0.0, + correction_enabled_711: false, + support_velocity_removed_715: false, + target_scale_784: -1.0, + } +} +fn input() -> GroundMotionInput { + GroundMotionInput { + contact_position_0: ZERO, + contact_frame_32: IDENTITY, + contact_target_96: ZERO, + contact_normal_112: UP, + contact_flags_176: 0, + contact_id_180: 1, + animation_motion_224: ZERO, + animation_motion_240: ZERO, + requested_duration_288: 1.0, + mirrored_292: false, + animation_directed_304: false, + target_frame_present_352: false, + target_frame_368: IDENTITY, + reference_frame_128: IDENTITY, + contact_displacement_384: ZERO, + velocity_addition_528: ZERO, + desired_up_544: UP, + correction_target_592: ZERO, + obstacle_target_672: ZERO, + obstacle_enabled_713: false, + } +} +fn near(a: f32, b: f32) { + assert!((a - b).abs() < 1.0e-5, "{a} != {b}"); +} +#[test] +fn free_step_combines_all_actual_displacements() { + let mut s = state(); + let mut i = input(); + s.frame_0[3] = [10.0, 2.0, 3.0, 0.0]; + s.velocity_480 = [6.0, 0.0, 0.0, 0.0]; + s.predicted_support_velocity_272 = [0.0, 0.0, 3.0, 0.0]; + i.velocity_addition_528 = [0.0, 12.0, 0.0, 0.0]; + i.contact_displacement_384 = [0.5, 0.0, 0.0, 0.0]; + update(&mut s, &i); + near(s.frame_0[3][0], 10.6); + near(s.frame_0[3][1], 2.2); + near(s.frame_0[3][2], 3.05); + assert_eq!(s.frame_0, s.published_frame_64); +} +#[test] +fn absent_contact_preserves_support_history() { + let mut s = state(); + let i = input(); + s.support_velocity_256 = [1.0; 4]; + s.support_id_352 = 9; + support::update(&mut s, &i); + assert_eq!(s.support_velocity_256, [1.0; 4]); + assert_eq!(s.support_id_352, 9); +} +#[test] +fn new_support_restores_removed_velocity_and_resets_derivatives() { + let mut s = state(); + let mut i = input(); + i.contact_flags_176 = 1; + i.contact_id_180 = 2; + s.support_velocity_removed_715 = true; + s.predicted_support_velocity_272 = [3.0, 0.0, 0.0, 0.0]; + s.velocity_480 = [1.0, 0.0, 0.0, 0.0]; + s.predicted_support_yaw_344 = 5.0; + support::update(&mut s, &i); + assert_eq!(s.support_id_352, 2); + near(s.velocity_480[0], 4.0); + near(s.speed_704, 4.0); + assert!(!s.support_velocity_removed_715); + assert_eq!(s.predicted_support_velocity_272, ZERO); + assert_eq!(s.predicted_support_yaw_344, 0.0); +} +#[test] +fn translated_support_predicts_velocity_and_removes_it_once() { + let mut s = state(); + let mut i = input(); + i.contact_flags_176 = 1; + i.contact_frame_32[3][0] = DT; + s.velocity_480 = [10.0, 0.0, 0.0, 0.0]; + support::update(&mut s, &i); + near(s.support_velocity_256[0], 1.0); + near(s.predicted_support_velocity_272[0], 2.0); + near(s.velocity_480[0], 8.0); + assert!(s.support_velocity_removed_715); + i.contact_frame_32[3][0] = 2.0 * DT; + support::update(&mut s, &i); + near(s.predicted_support_velocity_272[0], 1.0); + near(s.velocity_480[0], 8.0); +} +#[test] +fn rotated_support_produces_signed_yaw_history() { + let mut s = state(); + let mut i = input(); + i.contact_flags_176 = 1; + let angle = 0.1_f32; + let (sin, cos) = angle.sin_cos(); + i.contact_frame_32[0] = [cos, 0.0, -sin, 0.0]; + i.contact_frame_32[2] = [sin, 0.0, cos, 0.0]; + support::update(&mut s, &i); + near(s.support_yaw_336, angle * math::RATE); + near(s.predicted_support_yaw_344, 2.0 * s.support_yaw_336); +} +#[test] +fn mirrored_support_acceleration_reverses_x_and_z_filter() { + let mut a = state(); + let mut b = state(); + let mut i = input(); + i.contact_flags_176 = 1; + i.contact_frame_32[3] = [DT, 0.0, DT, 0.0]; + support::update(&mut a, &i); + i.mirrored_292 = true; + support::update(&mut b, &i); + near( + a.filtered_local_acceleration_320[0], + -b.filtered_local_acceleration_320[0], + ); + near( + a.filtered_local_acceleration_320[2], + -b.filtered_local_acceleration_320[2], + ); + assert_eq!(a.filtered_local_acceleration_320[1], 0.0); +} +#[test] +fn stationary_animation_reaches_target_at_requested_duration() { + let mut s = state(); + let mut i = input(); + i.animation_directed_304 = true; + i.requested_duration_288 = 2.0; + s.target_frame_608[3] = [0.0, 0.0, 10.0, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(s.target_scale_784, 5.0); + near(step[2], 5.0 * DT); +} +#[test] +fn moving_animation_uses_current_motion_length_and_retains_scale() { + let mut s = state(); + let mut i = input(); + i.animation_directed_304 = true; + i.animation_motion_224 = [0.0, 0.0, 2.0, 0.0]; + i.animation_motion_240 = [0.0, 0.0, 3.0, 0.0]; + s.target_frame_608[3] = [0.0, 0.0, 10.0, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(s.target_scale_784, 5.0); + near(step[2], 15.0 * DT); + i.animation_motion_224[2] = 4.0; + approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(s.target_scale_784, 5.0); +} +#[test] +fn target_refresh_uses_right_offset_and_applies_support_delta_after_step() { + let mut s = state(); + let mut i = input(); + i.animation_directed_304 = true; + i.target_frame_present_352 = true; + i.target_frame_368[3] = [0.0, 0.0, 3.0, 0.0]; + let mut delta = IDENTITY; + delta[3] = [2.0, 0.0, 0.0, 0.0]; + approach::calculate(&mut s, &i, ZERO, delta); + near(s.target_frame_608[3][0], 2.2); + near(s.target_frame_608[3][2], 3.0); +} +#[test] +fn contact_height_adjustment_is_five_percent_along_current_up() { + let mut s = state(); + let mut i = input(); + i.contact_flags_176 = 1; + i.contact_position_0 = [0.0, 2.0, 0.0, 0.0]; + let (_, height) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(height[1], 0.1); +} +#[test] +fn obstacle_approach_caps_step_to_velocity_budget() { + let mut s = state(); + let mut i = input(); + s.velocity_480 = [0.0, 0.0, 6.0, 0.0]; + i.obstacle_enabled_713 = true; + i.obstacle_target_672 = [0.0, 0.0, 10.0, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(step[2], 0.1); +} +#[test] +fn nearby_target_uses_remaining_tangent_budget() { + let mut s = state(); + let mut i = input(); + s.velocity_480 = [0.0, 0.0, 6.0, 0.0]; + i.obstacle_enabled_713 = true; + i.obstacle_target_672 = [0.0, 0.0, 0.05, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(step[2], 0.1); +} +#[test] +fn contact_target_lateral_component_is_removed() { + let mut s = state(); + let mut i = input(); + s.velocity_480 = [0.0, 0.0, 6.0, 0.0]; + i.contact_flags_176 = 2; + i.contact_target_96 = [9.0, 0.0, 1.0, 0.0]; + let (step, _) = approach::calculate(&mut s, &i, ZERO, IDENTITY); + near(step[0], 0.0); + near(step[2], 0.1); +} +#[test] +fn publication_decays_correction_without_moving_physical_frame() { + let mut s = state(); + s.correction_enabled_711 = true; + s.correction_576 = [1.0, 0.0, 0.0, 0.0]; + publication::update(&mut s, ZERO); + near(s.correction_576[0], 0.95); + near(s.published_frame_64[3][0], -0.95); + near(s.frame_0[3][0], 0.0); + assert!(s.correction_enabled_711); +} +#[test] +fn publication_threshold_and_large_opposing_step_clear_correction() { + let mut s = state(); + s.correction_enabled_711 = true; + s.correction_576 = [0.01, 0.0, 0.0, 0.0]; + publication::update(&mut s, ZERO); + assert!(!s.correction_enabled_711); + s.correction_enabled_711 = true; + s.correction_576 = [1.0, 0.0, 0.0, 0.0]; + publication::update(&mut s, [-2.0, 0.0, 0.0, 0.0]); + assert_eq!(s.correction_576, ZERO); + assert!(!s.correction_enabled_711); +} +#[test] +fn limiter_preserves_parallel_target_and_caps_oblique_normal() { + assert_eq!(math::limit_angle(UP, UP, 0.5), UP); + let v = math::limit_angle([1.0, 0.0, 0.0, 0.0], UP, std::f32::consts::FRAC_PI_4); + near(v[0], std::f32::consts::FRAC_1_SQRT_2); + near(v[1], std::f32::consts::FRAC_1_SQRT_2); +} + +#[test] +fn limiter_keeps_quaternion_w_lane_fused_residuals() { + let limit = 0.73; + let from = [0.0, 1.0, 0.0, 0.37]; + let got = math::limit_angle([1.0, 0.0, 0.0, 0.0], from, limit); + let (_, c) = crate::trigonometry::sin_cos(-limit * 0.5); + let first_w = (-c).mul_add(from[3], c * from[3]); + let middle_w = from[3].mul_add(c, first_w); + let second_w = (-c).mul_add(middle_w, c * middle_w); + let expected = second_w.mul_add(2.0, from[3]); + assert_eq!(got[3].to_bits(), expected.to_bits()); +} diff --git a/crates/skate-core/src/player/offboard/ground_query.rs b/crates/skate-core/src/player/offboard/ground_query.rs new file mode 100644 index 00000000..75846bfb --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query.rs @@ -0,0 +1,118 @@ +//! Original TU3 Biped ground geometry:82D32808,82C20728,82C20C08,82D31620. +//! Scene enumeration/collision is supplied explicitly by the canonical host. +//! The host must consume the previous Sync query during the next PreUpdate. +mod consume; +mod geometry; +mod math; +mod packet; +use crate::math::Vector3; +pub use consume::{consume_geometry, interpret_hits}; +pub use geometry::{closest_point, edge_search, select_edge}; +pub use packet::prepare_packet; +#[derive(Clone, Copy, Debug)] +pub struct Frame { + pub right: Vector3, + pub up: Vector3, + pub forward: Vector3, + pub position: Vector3, +} +impl Frame { + pub const IDENTITY: Self = Self { + right: Vector3::new(1., 0., 0.), + up: Vector3::new(0., 1., 0.), + forward: Vector3::new(0., 0., 1.), + position: Vector3::ZERO, + }; +} +#[derive(Clone, Copy, Debug)] +pub struct QueryContext { + pub selection_flags_2948: u32, + pub matching_id_2952: i32, +} +#[derive(Clone, Copy, Debug)] +pub struct Edge { + pub start: Vector3, + pub end: Vector3, +} +#[derive(Clone, Copy, Debug)] +pub struct EdgeSearch { + pub min: Vector3, + pub max: Vector3, + pub frame: Frame, + pub context: QueryContext, + pub narrow_forward: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct EdgeSelection { + pub edge: Edge, + pub closest: Vector3, +} +#[derive(Clone, Copy, Debug)] +pub struct Line { + pub start: Vector3, + pub end: Vector3, + pub radius: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct GroundQueryPacket { + pub context: QueryContext, + pub center: Vector3, + pub up: Vector3, + pub tangent: Vector3, + pub lines: [Line; 7], +} +impl GroundQueryPacket { + ///82764AB0: Ground pool,21888 pool, and21892 only on island.flags==3. + pub const SOURCE_POOL_MASK: u8 = 7; + ///8276D510 adds no facing rejection; triangle winding rules still apply. + pub const FACING_FLAGS: u8 = 3; + pub const MESH_EXCLUSION_MASK: u32 = 0; + pub const MATERIAL_EXCLUSION_MASK: [u32; 4] = [0; 4]; +} +#[derive(Clone, Copy, Debug)] +pub struct LineHit { + pub position: Vector3, + pub face_normal: Vector3, + pub fraction: f32, + pub packed_surface: u16, +} +///Complete fields read by82D31620; not the unrelated general grind classifier. +#[derive(Clone, Copy, Debug)] +pub struct GroundGeometry { + pub frame: Frame, + pub kind: u32, + pub flag26: bool, + pub flag27: bool, + pub flag28: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct ConsumeInput { + pub frame_80: Frame, + pub contact_position_192: Vector3, + pub contact_flags_368: u32, + pub reach_364: f32, + pub previous_input_up_416: Vector3, +} +#[derive(Clone, Copy, Debug)] +pub struct GroundAdjustment { + pub state_752: bool, + pub state_753: bool, + pub state_754: bool, + pub frame_768: Frame, + pub input_up_416: Vector3, +} +///The runtime implements actual authored edge/scene-body queries, never cached +///observations. Provider ordering, cap40, transforms and context filtering are +///specified in OFFBOARD_BIPED_GROUND_QUERY_HANDOFF.md. +pub trait GroundQueryScene { + type Error; + fn edge_candidates(&mut self, search: &EdgeSearch) -> Result, Self::Error>; + ///Return actual seven query results. Preserve source pool ordering, group + ///matching, fatness0, decoded face normals and strict nearest-hit ties. + fn query_lines( + &mut self, + packet: &GroundQueryPacket, + ) -> Result<[Option; 7], Self::Error>; +} +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_query/consume.rs b/crates/skate-core/src/player/offboard/ground_query/consume.rs new file mode 100644 index 00000000..87c31c9f --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/consume.rs @@ -0,0 +1,129 @@ +use super::{ + ConsumeInput, Frame, GroundAdjustment, GroundGeometry, GroundQueryPacket, LineHit, math::*, +}; +use crate::math::Vector3; +///82C20C08 fields directly consumed by82D31620; calls82C21558/82C1E170. +pub fn interpret_hits(packet: &GroundQueryPacket, hits: [Option; 7]) -> GroundGeometry { + let h = |i: usize| hits[i].is_some(); + let mut side = scale( + cross(Vector3::new(0., 1., 0.), packet.tangent), + inverse_root(dot( + cross(Vector3::new(0., 1., 0.), packet.tangent), + cross(Vector3::new(0., 1., 0.), packet.tangent), + )), + ); + let kind = if (h(0) && h(1)) || h(4) { + 3 + } else if h(0) { + if hits[2].is_some_and(|v| !(v.fraction >= 0.65)) { + 2 + } else { + 1 + } + } else if h(1) { + side = scale(side, -1.); + if hits[3].is_some_and(|v| !(v.fraction >= 0.65)) { + 2 + } else { + 1 + } + } else { + 0 + }; + let flag28 = hits[0].is_some_and(|v| v.packed_surface & 0x0f80 == 0x0400) + || hits[1].is_some_and(|v| v.packed_surface & 0x0f80 == 0x0400); + let (flag26, flag27) = if let Some(hit) = hits[6] { + let delta = sub(hit.position, packet.center); + let d = dot(hit.face_normal, safe_unit(packet.tangent, Vector3::ZERO)); + let rejected = if d > 0. { + sub(hit.face_normal, scale(packet.tangent, d)) + } else { + hit.face_normal + }; + let normal = safe_unit(rejected, Vector3::ZERO); + ( + !(delta.y > -1.), + 0.9 > normal.y && dot(horizontal(delta), normal) > 0., + ) + } else { + (true, false) + }; + GroundGeometry { + frame: Frame { + right: side, + up: packet.up, + forward: packet.tangent, + position: packet.center, + }, + kind, + flag26, + flag27, + flag28, + } +} +///82D31620 including its PreUpdate resets82D30EAC..82D30F0C. None means the +///previous query was not pending; contact fallback still executes. +pub fn consume_geometry(input: ConsumeInput, geometry: Option) -> GroundAdjustment { + let mut out = GroundAdjustment { + state_752: false, + state_753: false, + state_754: false, + frame_768: Frame::IDENTITY, + input_up_416: input.previous_input_up_416, + }; + let mut nearby = false; + if let Some(g) = geometry { + let delta = sub(g.frame.position, input.frame_80.position); + let dh = horizontal(delta); + nearby = 0.3 > delta.y.abs() + && f32::from_bits(0x3d23_d70b) > dot(dh, dh) + && dot(g.frame.forward, input.frame_80.forward).abs() > 0.9; + if !g.flag28 && input.reach_364 > length(dh) + 0.7 { + out.state_752 = g.kind == 2 && g.frame.up.y > 0.98; + out.state_754 = g.flag26 || g.flag27; + } + if g.kind == 0 || g.kind == 1 { + out.state_753 = !(dot(delta, g.frame.right).abs() > 0.06) + && !(input.contact_flags_368 & 0x10 != 0 && input.contact_flags_368 & 0x20 != 0); + } + if out.state_752 || out.state_753 { + out.frame_768 = g.frame; + } + } + if !out.state_753 && input.contact_flags_368 & 1 != 0 && input.contact_flags_368 & 0x38 == 0 { + let h = dot( + sub(input.frame_80.position, input.contact_position_192), + input.frame_80.up, + ); + // Native bge/ble leave unordered values on the accepting path. + if !(h >= 0.06) && !(h <= -0.02) { + out.state_753 = true; + out.frame_768 = input.frame_80; + out.frame_768.position = input.contact_position_192; + } + } + if nearby || out.state_753 { + let x = horizontal(out.frame_768.right); + let x_length = length(x); + if !(x_length <= 0.001) { + let x = scale(x, reciprocal(x_length)); + let z = sub( + out.frame_768.forward, + scale(x, dot(out.frame_768.forward, x)), + ); + let z_length = length(z); + if !(z_length <= 0.001) { + let z = scale(z, reciprocal(z_length)); + out.frame_768.right = x; + out.frame_768.forward = z; + out.frame_768.up = cross(z, x); + } + } + if 0. > out.frame_768.up.y { + out.frame_768.up = scale(out.frame_768.up, -1.); + out.frame_768.right = scale(out.frame_768.right, -1.); + } + out.input_up_416 = out.frame_768.up; + } + out +} diff --git a/crates/skate-core/src/player/offboard/ground_query/geometry.rs b/crates/skate-core/src/player/offboard/ground_query/geometry.rs new file mode 100644 index 00000000..ffca39ae --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/geometry.rs @@ -0,0 +1,62 @@ +use super::{Edge, EdgeSearch, EdgeSelection, Frame, QueryContext, math::*}; +use crate::math::Vector3; +/// 82D32168..321C0 and82D32808. Inputs are the live Biped frame and state1040. +pub fn edge_search( + frame: Frame, + context: QueryContext, + state_vector_1040: Vector3, + processed_2488: u32, +) -> EdgeSearch { + let narrow_forward = + processed_2488 & 0x0800_0000 != 0 || !(dot(state_vector_1040, state_vector_1040) >= 1.); + let center = if narrow_forward { + madd(frame.forward, 0.75, frame.position) + } else { + frame.position + }; + let extent = madd( + abs(frame.forward), + 0.75, + madd(abs(frame.up), 0.2, scale(abs(frame.right), 0.5)), + ); + EdgeSearch { + min: sub(center, extent), + max: add(center, extent), + frame, + context, + narrow_forward, + } +} +///82E09D28: finite-segment closest point, including the tiny-edge retain branch. +pub fn closest_point(point: Vector3, edge: Edge) -> Vector3 { + let mut d = sub(edge.end, edge.start); + let l = length(d); + if l > f32::from_bits(0x3780_0000) { + d = scale(d, reciprocal(l)); + } + let t = dot(d, sub(point, edge.start)).min(l).max(0.); + madd(d, t, edge.start) +} +///82D32808: input candidates MUST retain provider order and its shared cap40. +///Both distances must improve; this is not a nearest-distance sort. +pub fn select_edge(search: EdgeSearch, candidates: &[Edge]) -> Option { + let mut best_vertical = 0.2; + let mut best_horizontal = 1.5; + let mut selected = None; + for &edge in candidates.iter().take(40) { + let closest = closest_point(search.frame.position, edge); + let delta = sub(closest, search.frame.position); + if search.narrow_forward && 0. > dot(search.frame.forward, delta) { + continue; + } + let vertical = dot(search.frame.up, delta); + let horizontal_distance = length(sub(delta, scale(search.frame.up, vertical))); + // Native scalar bge rejects ordered >=; unordered does not take bge. + if !(vertical.abs() >= best_vertical) && !(horizontal_distance >= best_horizontal) { + best_vertical = vertical.abs(); + best_horizontal = horizontal_distance; + selected = Some(EdgeSelection { edge, closest }); + } + } + selected +} diff --git a/crates/skate-core/src/player/offboard/ground_query/math.rs b/crates/skate-core/src/player/offboard/ground_query/math.rs new file mode 100644 index 00000000..3513f39b --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/math.rs @@ -0,0 +1,63 @@ +use crate::{math::Vector3, physics::native_arithmetic}; +pub(super) fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +pub(super) fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(super) fn scale(a: Vector3, k: f32) -> Vector3 { + Vector3::new(a.x * k, a.y * k, a.z * k) +} +pub(super) fn madd(a: Vector3, k: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(k, b.x), + a.y.mul_add(k, b.y), + a.z.mul_add(k, b.z), + ) +} +pub(super) fn dot(a: Vector3, b: Vector3) -> f32 { + a.x * b.x + a.y * b.y + a.z * b.z +} +pub(super) fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ) +} +pub(super) fn horizontal(a: Vector3) -> Vector3 { + Vector3::new(a.x, 0., a.z) +} +pub(super) fn abs(a: Vector3) -> Vector3 { + Vector3::new(a.x.abs(), a.y.abs(), a.z.abs()) +} +// Independent PC estimate, retaining the two TU3 refinement steps; not bit-exact Xenon. +pub(super) fn inverse_root(q: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_square_root_estimate(q); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.), r); + } + r +} +pub(super) fn length(a: Vector3) -> f32 { + let q = dot(a, a); + let l = q * inverse_root(q); + if q == 0. { 0. } else { l } +} +pub(super) fn reciprocal(x: f32) -> f32 { + let mut r = native_arithmetic::reciprocal_estimate(x); + for _ in 0..2 { + r = r.mul_add((-x).mul_add(r, 1.), r); + } + r +} +pub(super) fn safe_unit(a: Vector3, fallback: Vector3) -> Vector3 { + let q = dot(a, a); + let r = inverse_root(q); + let l = if q == 0. { 0. } else { q * r }; + if l > f32::from_bits(0x358637bd) { + scale(a, r) + } else { + fallback + } +} diff --git a/crates/skate-core/src/player/offboard/ground_query/packet.rs b/crates/skate-core/src/player/offboard/ground_query/packet.rs new file mode 100644 index 00000000..21922db5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/packet.rs @@ -0,0 +1,56 @@ +use super::{EdgeSelection, Frame, GroundQueryPacket, Line, QueryContext, math::*}; +use crate::math::Vector3; +///82D321D0..32320 then82C20728: all seven Biped lines, in source order. +pub fn prepare_packet( + frame: Frame, + context: QueryContext, + selected: EdgeSelection, + deck_half_wheelbase: f32, +) -> Option { + let edge = selected.edge; + let closest = selected.closest; + let reverse = safe_unit(sub(edge.start, edge.end), frame.right); + let forward = safe_unit( + sub(frame.forward, scale(reverse, dot(reverse, frame.forward))), + frame.forward, + ); + let seventh_start = madd(forward, 0.2, closest); + let delta = sub(edge.end, edge.start); + let raw_up = cross(delta, cross(Vector3::new(0., 1., 0.), delta)); + let up = safe_unit(raw_up, raw_up); + let tangent = safe_unit(delta, delta); + if f32::from_bits(0x3727_c5ac) > length(raw_up) { + return None; + } + let projection = madd(tangent, dot(sub(closest, edge.start), tangent), edge.start); + let center = sub(closest, sub(closest, projection)); + let side = cross(up, tangent); + let short_up = scale(up, 0.04); + let near = scale(side, 0.09); + let far = scale(side, deck_half_wheelbase); + let far_up = scale(up, deck_half_wheelbase * f32::from_bits(0x3f87_ae14)); + let line = |c: Vector3, d: Vector3, radius| Line { + start: add(c, d), + end: sub(c, d), + radius, + }; + Some(GroundQueryPacket { + context, + center, + up, + tangent, + lines: [ + line(add(center, near), short_up, 0.), + line(sub(center, near), short_up, 0.), + line(add(center, far), far_up, 0.), + line(sub(center, far), far_up, 0.), + line(center, short_up, 0.), + line(add(center, scale(up, 0.09)), near, 0.001), + Line { + start: seventh_start, + end: add(seventh_start, Vector3::new(0., -6., 0.)), + radius: 0., + }, + ], + }) +} diff --git a/crates/skate-core/src/player/offboard/ground_query/tests.rs b/crates/skate-core/src/player/offboard/ground_query/tests.rs new file mode 100644 index 00000000..badea94e --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_query/tests.rs @@ -0,0 +1,138 @@ +use super::*; +fn v(x: f32, y: f32, z: f32) -> Vector3 { + Vector3::new(x, y, z) +} +fn context() -> QueryContext { + QueryContext { + selection_flags_2948: 0, + matching_id_2952: -1, + } +} +fn input() -> ConsumeInput { + ConsumeInput { + frame_80: Frame::IDENTITY, + contact_position_192: Vector3::ZERO, + contact_flags_368: 0, + reach_364: 1., + previous_input_up_416: v(0.1, 0.9, 0.), + } +} +fn hit(position: Vector3) -> LineHit { + LineHit { + position, + face_normal: v(0., 1., 0.), + fraction: 0.5, + packed_surface: 0, + } +} +#[test] +fn edge_selection_requires_both_distances_to_improve_in_order() { + let search = edge_search(Frame::IDENTITY, context(), v(0., 0., 2.), 0); + let edges = + [v(0.5, 0.1, 0.), v(0.1, 0.15, 0.), v(0.6, 0.05, 0.)].map(|p| Edge { start: p, end: p }); + let selected = select_edge(search, &edges).unwrap(); + assert_eq!(selected.closest, edges[0].start); + let reversed = select_edge(search, &[edges[1], edges[0]]).unwrap(); + assert_eq!(reversed.closest, edges[1].start); +} +#[test] +fn biped_packet_has_seventh_downward_line_and_rounded_sixth_line() { + let selected = EdgeSelection { + edge: Edge { + start: v(0., 0., -1.), + end: v(0., 0., 1.), + }, + closest: Vector3::ZERO, + }; + let packet = prepare_packet(Frame::IDENTITY, context(), selected, 0.25).unwrap(); + assert_eq!(packet.lines[0].start, v(0.09, 0.04, 0.)); + assert_eq!(packet.lines[5].radius, 0.001); + assert_eq!(packet.lines[6].start, v(0., 0., 0.2)); + assert_eq!(packet.lines[6].end, v(0., -6., 0.2)); + assert_eq!(GroundQueryPacket::SOURCE_POOL_MASK, 7); +} +#[test] +fn no_query_preserves_existing_contact_up_but_contact_fallback_runs() { + let i = input(); + let out = consume_geometry(i, None); + assert!(!out.state_753); + assert_eq!(out.input_up_416, i.previous_input_up_416); + let out = consume_geometry( + ConsumeInput { + contact_flags_368: 1, + ..i + }, + None, + ); + assert!(out.state_753); + assert_eq!(out.input_up_416, v(0., 1., 0.)); + let out = consume_geometry( + ConsumeInput { + contact_flags_368: 9, + ..i + }, + None, + ); + assert!(!out.state_753); +} +#[test] +fn reach_and_material_flag_gate_step_without_gating_side_contact() { + let g = GroundGeometry { + frame: Frame::IDENTITY, + kind: 2, + flag26: true, + flag27: false, + flag28: false, + }; + let out = consume_geometry( + ConsumeInput { + reach_364: 0.7, + ..input() + }, + Some(g), + ); + assert!(!out.state_752); + assert!(!out.state_754); + let out = consume_geometry(input(), Some(g)); + assert!(out.state_752 && out.state_754); + let out = consume_geometry(input(), Some(GroundGeometry { flag28: true, ..g })); + assert!(!out.state_752); + let out = consume_geometry( + input(), + Some(GroundGeometry { + kind: 1, + flag28: true, + ..g + }), + ); + assert!(out.state_753); +} +#[test] +fn seventh_hit_height_and_first_hit_material_reach_consumed_flags() { + let packet = prepare_packet( + Frame::IDENTITY, + context(), + EdgeSelection { + edge: Edge { + start: v(0., 0., -1.), + end: v(0., 0., 1.), + }, + closest: Vector3::ZERO, + }, + 0.25, + ) + .unwrap(); + let mut hits = [None; 7]; + hits[0] = Some(LineHit { + packed_surface: 0x400, + ..hit(Vector3::ZERO) + }); + hits[2] = Some(hit(Vector3::ZERO)); + hits[6] = Some(hit(v(0., -1., 0.))); + let g = interpret_hits(&packet, hits); + assert_eq!(g.kind, 2); + assert!(g.flag28 && g.flag26); + assert!(!g.flag27); + hits[6] = Some(hit(v(0., -0.9, 0.))); + assert!(!interpret_hits(&packet, hits).flag26); +} diff --git a/crates/skate-core/src/player/offboard/ground_reckoning.rs b/crates/skate-core/src/player/offboard/ground_reckoning.rs new file mode 100644 index 00000000..d5c34b60 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_reckoning.rs @@ -0,0 +1,101 @@ +//! Original Reckoning::UpdateBiped82D8E3E0, using the existing shared owners. +//! 82D8E458 explicitly clears velocity; riding acceleration/damping is absent. +//! This adapter follows the shared XYZ orientation representation; all input +//! frame/filter math uses four lanes and returns complete computed vectors. +use crate::{ + air::{ + body_spin::{self, BodySpinState}, + reckoning::AirState, + }, + math::Vector3, + point_graph::PointGraph, + riding::{ground_orientation::GroundOrientation, reckoning_frames::ReckoningFrames}, +}; +type Vector = [f32; 4]; +pub struct Settings<'a> { + pub ground_normal_smoothing: [f32; 4], + pub tilt_vs_rotation: &'a PointGraph<8>, + pub tilt_vs_slope: &'a PointGraph<8>, +} +pub struct Input { + /// Actual current reckoning1152, supplied as all four original lanes. + pub previous_up: Vector, + pub requested_up: Vector, + pub requested_forward: Vector, + pub blend: f32, + pub reverse_stance: bool, + pub enable_body_spin_input: bool, + pub physical_body_spin_2812: f32, +} +/// Four-lane publications let the canonical owner retain every computed lane. +/// No separate persistent orientation state is introduced here. +pub struct Output { + pub dynamic_up_1136: Vector, + pub up_1152: Vector, + pub target_1168: Vector, + pub velocity_1184: Vector, + pub ground_normal_1216: Vector, +} +pub fn update( + orientation: &mut GroundOrientation, + frames: &mut ReckoningFrames, + body_spin: &mut BodySpinState, + air: &mut AirState, + settings: Settings<'_>, + input: Input, +) -> Output { + orientation.dynamic_up = xyz(input.previous_up); + frames.target_lean_angle = 0.0; + air.secondary_lean_angle = 0.0; + orientation.up_velocity = Vector3::ZERO; + let ground = orientation + .ground_filter + .update(settings.ground_normal_smoothing, input.requested_up); + orientation.ground_normal = xyz(ground); + frames.heading = input.requested_forward; + let retained = 1.0 - input.blend; + let candidate: Vector = std::array::from_fn(|i| { + input.previous_up[i].mul_add(input.blend, input.requested_up[i] * retained) + }); + let square = crate::physics::native_arithmetic::dot3(candidate, candidate); + let inverse = super::contact_correction::inverse_length(square); + let length = if square == 0.0 { 0.0 } else { square * inverse }; + let up = if length > f32::from_bits(0x3586_37bd) { + candidate.map(|v| v * inverse) + } else { + input.previous_up + }; + orientation.up = xyz(up); + orientation.target = xyz(up); + 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); + frames.calculate_tilt( + input.reverse_stance, + settings.tilt_vs_rotation, + settings.tilt_vs_slope, + ); + body_spin::update_ground( + body_spin, + if input.enable_body_spin_input { + input.physical_body_spin_2812 + } else { + 0.0 + }, + ); + Output { + dynamic_up_1136: input.previous_up, + up_1152: up, + target_1168: up, + velocity_1184: [0.0; 4], + ground_normal_1216: ground, + } +} +fn xyz(v: Vector) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_reckoning/tests.rs b/crates/skate-core/src/player/offboard/ground_reckoning/tests.rs new file mode 100644 index 00000000..3caf5425 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_reckoning/tests.rs @@ -0,0 +1,98 @@ +use super::*; +use crate::air::ground_normal::GroundNormalFilter; + +fn orientation() -> GroundOrientation { + let up = [0., 1., 0., 0.]; + GroundOrientation { + dynamic_up: xyz(up), + up: xyz(up), + target: xyz(up), + up_velocity: Vector3::new(7., 8., 9.), + ground_normal: xyz(up), + ground_blend: 0.75, + ground_filter: GroundNormalFilter::initialized([1., 0., 0., 1.], up), + slow_filter: GroundNormalFilter::initialized([0.2, 0.1, 0.05, 0.3], up), + fast_filter: GroundNormalFilter::initialized([0.7, 0.2, 0.1, 0.8], up), + } +} + +fn run(orientation: &mut GroundOrientation, input: Input) -> (Output, BodySpinState, AirState) { + let curve = PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [0.; 8], + }; + let mut frames = ReckoningFrames::new(); + let mut spin = BodySpinState::new(); + let mut air = AirState::new(); + air.spin_angle = 2.; + air.secondary_lean_angle = 3.; + let output = update( + orientation, + &mut frames, + &mut spin, + &mut air, + Settings { + ground_normal_smoothing: [1., 0., 0., 1.], + tilt_vs_rotation: &curve, + tilt_vs_slope: &curve, + }, + input, + ); + (output, spin, air) +} + +fn input() -> Input { + Input { + previous_up: [0., 1., 0., 2.], + requested_up: [0., 0., 1., 4.], + requested_forward: [1., 0., 0., 0.], + blend: 0.5, + reverse_stance: false, + enable_body_spin_input: true, + physical_body_spin_2812: 0.25, + } +} + +#[test] +fn blend_preserves_fourth_lane_and_updates_history_without_resetting_other_state() { + let mut state = orientation(); + let controls = state.slow_filter.words()[..4].to_vec(); + let (out, spin, air) = run(&mut state, input()); + assert!((out.up_1152[1] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.00001); + assert!((out.up_1152[3] - 3. * std::f32::consts::SQRT_2).abs() < 0.00001); + assert_eq!(out.dynamic_up_1136, [0., 1., 0., 2.]); + assert_eq!(out.velocity_1184, [0.; 4]); + assert_eq!(state.up_velocity, Vector3::ZERO); + assert_eq!(state.ground_blend, 0.75); + assert_eq!(&state.slow_filter.words()[..4], controls.as_slice()); + assert_eq!( + &state.slow_filter.words()[4..8], + &out.up_1152.map(f32::to_bits) + ); + assert_eq!( + &state.fast_filter.words()[8..12], + &out.up_1152.map(f32::to_bits) + ); + assert!( + state.slow_filter.words()[12..16] + .iter() + .any(|&word| word != 0) + ); + assert_eq!(f32::from_bits(spin.words()[30]), 0.25); + assert_eq!(air.secondary_lean_angle, 0.); + assert_eq!(air.spin_angle, 2.); +} + +#[test] +fn cancellation_retains_previous_up_and_disabled_spin_still_advances_history() { + let mut state = orientation(); + let mut input = input(); + input.requested_up = [0., -1., 0., -2.]; + input.enable_body_spin_input = false; + let previous = input.previous_up; + let (out, spin, _) = run(&mut state, input); + assert_eq!(out.up_1152, previous); + assert_eq!(out.target_1168, previous); + assert_eq!(f32::from_bits(spin.words()[30]), 0.); + assert_eq!(spin.words()[42], 1); +} diff --git a/crates/skate-core/src/player/offboard/ground_sync.rs b/crates/skate-core/src/player/offboard/ground_sync.rs new file mode 100644 index 00000000..557048f3 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_sync.rs @@ -0,0 +1,217 @@ +//! TU3 BipedGround Sync82D31DC0, air decision82D32338 and board update82D324B0. +//! Call only after the Biped job completes. Geometry submitted here is consumed +//! by the next PreUpdate; this function never fabricates collision observations. +mod math; +use math::*; +use super::{ground_entry::{Frame, State, Vector}, ground_query}; + +#[derive(Clone, Copy, Debug)] +pub struct CompletedMotion { + pub contact_position_192: Vector, + pub contact_flags_368: u32, + pub physical_frame_848: Frame, + pub animation_frame_912: Frame, + pub vector_976: Vector, + pub vector_992: Vector, + pub vector_1008: Vector, + pub vector_1040: Vector, + pub vector_1056: Vector, + pub launch_1076: bool, + pub flag_1077: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct Processed { + pub frame_192: Frame, + pub position_592: Vector, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub flags_2488: u32, + pub elapsed_2664: f32, + pub value_2852: f32, + pub query_context: ground_query::QueryContext, +} +/// Actual state40 settings. Values are loaded attributes, with no inferred defaults. +#[derive(Clone, Copy, Debug)] +pub struct BoardSettings { + pub extent_0: Vector, + pub extent_16: Vector, + pub offset_32: Vector, + pub angle_436: f32, + pub angle_440: f32, + pub margin_444: f32, + pub angle_452: f32, + pub angle_456: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Bounds { pub frame: Frame, pub extents: Vector } +#[derive(Clone, Copy, Debug)] +pub struct BoardLimits { pub margin: f32, pub angle_a: f32, pub angle_b: f32 } +///82D2DCC0's seven vectors are all overwritten by Sync before82D811C8. +#[derive(Clone, Copy, Debug)] +pub struct ToolkitInput(pub [Vector; 7]); + +/// Canonical physical owners implement these operations against live state. +/// Associated values carry the actual Biped launch packet and queried candidate; +/// they are not boolean observations supplied in advance of their producers. +pub trait Services: ground_query::GroundQueryScene { + type Launch; + type Candidate; + fn processed(&self) -> Processed; + ///82D2DB98 initialization then82D7BA78 with final argument false. + fn prepare_biped_launch(&mut self) -> Self::Launch; + fn skeleton_point_10960(&self) -> Vector; + /// Store point at launch32, then execute82D6CA58 against BipedAir. + fn launch_air(&mut self, launch: Self::Launch, point: Vector); + fn update_skeleton(&mut self, frame: Frame, vector_1056: Vector); + fn board_settings(&self) -> BoardSettings; + fn board_flags_12836(&self) -> u8; + ///82D2CF68 initialized candidate,82D4D150 mode0 and initially zero key. + fn probe_board(&mut self, position: Vector) -> Option; + fn candidate_key_188(&self, candidate: &Self::Candidate) -> [u32; 2]; + /// Actual82BE3220 bone23 transform's position48. + fn bone_23_position(&mut self) -> Vector; + ///82E08DB8, including candidate projection and82E08EE8 geometry tests. + fn classify_board(&mut self, candidate: &Self::Candidate, bone: Vector, + bounds: Bounds, limits: BoardLimits) -> bool; + ///82D74200 mode4. This runs even when probe/classification fails. + fn query_board(&mut self, position: Vector, bounds: Bounds, limits: BoardLimits); + fn bind_board(&mut self, key: [u32; 2]); + /// Board action virtual44, with live manager12828 after binding. + fn commit_bound_board(&mut self); + /// Board action virtual24 with original processed frame and live manager fields. + fn commit_free_board(&mut self, frame: Frame); + ///82D749D0 followed by clearing manager12780. + fn reset_board(&mut self); + fn update_toolkit(&mut self, input: ToolkitInput); + fn deck_half_wheelbase(&self) -> f32; + /// Submit all seven real collision lines; retain until next PreUpdate consumes. + fn submit_ground_query(&mut self, packet: ground_query::GroundQueryPacket); +} + +///82D32338: special contact/input flags suppress only the explicit jump request. +/// Loss of contact and the controller's launch byte still trigger an air launch. +pub fn wants_air(state: &State, motion: &CompletedMotion, p: &Processed) -> bool { + let mut launch = motion.contact_flags_368 & 1 == 0 + && p.elapsed_2664 > f32::from_bits(0x3d4c_cccd); + launch |= motion.launch_1076; + if motion.contact_flags_368 & 8 == 0 && p.flags_2480 & 0x0002_0180 == 0 + && !state.flags_144_to_150[6] { + launch |= p.flags_2476 & 0x0008_0000 != 0; + } + launch +} + +///82D2E250 with argument7=false: retain forward.w while flattening only y. +pub fn board_bounds(frame: Frame, offset: Vector, extents: Vector) -> Bounds { + let mut forward = frame[2]; forward[1] = 0.0; + let forward = normalize_or(forward, [0.0,0.0,1.0,0.0]); + let up = [0.0,1.0,0.0,0.0]; + let right = cross(up, forward); + let position = madd(up, offset[1], madd(forward, offset[2]+extents[2], frame[3])); + Bounds { frame: [right, up, forward, position], extents } +} + +///82D324B0. The caller clears144; a failed probe deliberately preserves that byte. +pub fn update_board(state: &mut State, services: &mut S) { + let settings = services.board_settings(); + let original_frame = services.processed().frame_192; + let bounds = board_bounds(original_frame, settings.offset_32, settings.extent_16); + let position = services.processed().position_592; + if services.board_flags_12836() & 0x40 != 0 { + if let Some(candidate) = services.probe_board(position) { + // The probe may change live processed/settings state. + let settings = services.board_settings(); + let frame = services.processed().frame_192; + let inner = board_bounds(frame, settings.offset_32, + scale(settings.extent_0, f32::from_bits(0x3f73_3333))); + let settings = services.board_settings(); + let limits = limits(settings.margin_444, settings.angle_452, settings.angle_436); + let bone = services.bone_23_position(); + state.flags_144_to_150[0] = services.classify_board(&candidate, bone, inner, limits); + if state.flags_144_to_150[0] { + let key = services.candidate_key_188(&candidate); + state.counter_152 = key[0]; state.counter_156 = key[1]; + } + } + } + let settings = services.board_settings(); + services.query_board(position, bounds, + limits(settings.margin_444, settings.angle_456, settings.angle_440)); + if state.flags_144_to_150[0] { + services.bind_board([state.counter_152, state.counter_156]); + services.commit_bound_board(); + } else { services.commit_free_board(original_frame); } +} +fn limits(margin: f32, a: f32, b: f32) -> BoardLimits { + let radians = f32::from_bits(0x3c8e_fa35); + BoardLimits {margin, angle_a:a*radians, angle_b:b*radians} +} + +/// Full Sync order; returns whether a new geometry packet was submitted. +/// Scene failure is propagated after the already completed physical updates. +pub fn sync(state: &mut State, motion: &CompletedMotion, + services: &mut S) -> Result { + state.flags_144_to_150[2] = wants_air(state, motion, &services.processed()); + if state.flags_144_to_150[2] { + let launch = services.prepare_biped_launch(); + let point = services.skeleton_point_10960(); + let frame = motion.animation_frame_912; + let point = madd(frame[2], point[2], madd(frame[1], point[1], madd(frame[0], point[0], frame[3]))); + services.launch_air(launch, point); + } + state.flags_144_to_150[0] = false; + state.flags_144_to_150[3] = motion.flag_1077; + state.frame_80 = motion.physical_frame_848; + if motion.contact_flags_368 & 1 != 0 { + let up = state.frame_80[1]; + let distance = dot(sub(motion.contact_position_192, state.frame_80[3]), up); + if !(distance <= 0.0) {state.frame_80[3] = madd(up, distance, state.frame_80[3]);} + } + let mut animation = motion.animation_frame_912; + if state.flags_144_to_150[6] { + state.duration_180 -= f32::from_bits(0x3c88_8889); + if !(state.duration_180 > 0.0) { + state.duration_180 = 0.0; state.flags_144_to_150[6] = false; + } + state.angle_172 *= f32::from_bits(0x3f66_6666); + let angle = (-state.angular_velocity_176).mul_add(state.duration_180, state.angle_172); + let (sin,cos) = crate::trigonometry::sin_cos(angle); + let forward = madd([sin,0.0,cos,sin], animation[2][2], + madd([0.0,1.0,0.0,0.0], animation[2][1], + scale([cos,0.0,-sin,cos], animation[2][0]))); + // Native branch has no zero fallback on either cross normalization. + let right = normalize_unchecked(cross(animation[1], forward)); + animation[0] = right; + animation[1] = normalize_unchecked(cross(forward, right)); + animation[2] = forward; + } + services.update_skeleton(animation, motion.vector_1056); + let p = services.processed(); + if p.flags_2484 & 0x2000_0000 == 0 && p.flags_2480 & 0x0004_0000 == 0 + && !(p.value_2852 > 0.0) { + if p.flags_2476 & 0x0040_0000 != 0 { + if !state.flags_144_to_150[4] { update_board(state, services); } + else if !(p.elapsed_2664 <= 0.5) {state.flags_144_to_150[4] = false;} + } else {services.reset_board();} + } + services.update_toolkit(ToolkitInput([state.frame_80[3], motion.vector_1008, + motion.vector_992, motion.vector_976, motion.vector_1040, animation[1], animation[0]])); + let p = services.processed(); + let frame = query_frame(state.frame_80); + let search = ground_query::edge_search(frame, p.query_context, xyz(motion.vector_1040), p.flags_2488); + let candidates = services.edge_candidates(&search)?; + if let Some(edge) = ground_query::select_edge(search, &candidates) { + if let Some(packet) = ground_query::prepare_packet(frame, p.query_context, edge, + services.deck_half_wheelbase()) { + services.submit_ground_query(packet); + return Ok(true); + } + } + Ok(false) +} +fn xyz(v: Vector) -> crate::math::Vector3 { crate::math::Vector3::new(v[0],v[1],v[2]) } +fn query_frame(f: Frame) -> ground_query::Frame { + ground_query::Frame {right:xyz(f[0]),up:xyz(f[1]),forward:xyz(f[2]),position:xyz(f[3])} +} +#[cfg(test)] mod tests; diff --git a/crates/skate-core/src/player/offboard/ground_sync/math.rs b/crates/skate-core/src/player/offboard/ground_sync/math.rs new file mode 100644 index 00000000..5c260e4f --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_sync/math.rs @@ -0,0 +1,21 @@ +use super::Vector; +pub(super) fn dot(a: Vector, b: Vector) -> f32 { a[0]*b[0]+a[1]*b[1]+a[2]*b[2] } +pub(super) fn scale(v: Vector, s: f32) -> Vector { v.map(|x|x*s) } +pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i|a[i]-b[i]) } +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::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]),(-a[3]).mul_add(b[3],a[3]*b[3])] +} +fn inverse(sq: f32) -> f32 { + // PC reciprocal-square-root seed; two native Newton refinements retained. + let mut r = crate::physics::reciprocal_sqrt::estimate(sq); + for _ in 0..2 {r = (r*0.5).mul_add((-sq).mul_add(r*r,1.0),r);} + r +} +pub(super) fn normalize_unchecked(v: Vector) -> Vector {scale(v,inverse(dot(v,v)))} +pub(super) fn normalize_or(v: Vector, fallback: Vector) -> Vector { + let sq=dot(v,v); let r=inverse(sq); + let length=if sq==0.0 {0.0}else{sq*r}; + if length>f32::from_bits(0x3586_37bd) {scale(v,r)} else {fallback} +} diff --git a/crates/skate-core/src/player/offboard/ground_sync/tests.rs b/crates/skate-core/src/player/offboard/ground_sync/tests.rs new file mode 100644 index 00000000..7f608e83 --- /dev/null +++ b/crates/skate-core/src/player/offboard/ground_sync/tests.rs @@ -0,0 +1,130 @@ +use super::*; +const FRAME: Frame = [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[0.,0.,0.,1.]]; +fn motion() -> CompletedMotion { + CompletedMotion {contact_position_192:[0.,2.,0.,1.],contact_flags_368:1, + physical_frame_848:FRAME,animation_frame_912:FRAME,vector_976:[1.;4], + vector_992:[2.;4],vector_1008:[3.;4],vector_1040:[0.;4],vector_1056:[4.;4], + launch_1076:false,flag_1077:true} +} +fn processed() -> Processed { + Processed {frame_192:FRAME,position_592:[0.;4],flags_2476:0,flags_2480:0, + flags_2484:0,flags_2488:0,elapsed_2664:0.,value_2852:0., + query_context:ground_query::QueryContext{selection_flags_2948:0,matching_id_2952:0}} +} +#[test] +fn explicit_jump_suppression_does_not_suppress_controller_launch() { + let mut state=State::default(); let mut m=motion(); let mut p=processed(); + p.flags_2476=0x80000; + assert!(wants_air(&state,&m,&p)); + for mask in [0x20000,0x80,0x100] { + p.flags_2480=mask; assert!(!wants_air(&state,&m,&p)); + m.launch_1076=true; assert!(wants_air(&state,&m,&p)); m.launch_1076=false; + } + p.flags_2480=0; state.flags_144_to_150[6]=true; + assert!(!wants_air(&state,&m,&p)); + m.contact_flags_368=0; p.elapsed_2664=0.05; + assert!(!wants_air(&state,&m,&p)); + p.elapsed_2664=f32::from_bits(0.05f32.to_bits()+1); + assert!(wants_air(&state,&m,&p)); +} +#[test] +fn board_bounds_flattens_y_but_retains_scaled_w() { + let mut frame=FRAME; frame[2]=[0.,9.,2.,6.]; + let b=board_bounds(frame,[99.,3.,4.,55.],[1.,2.,5.,8.]); + assert!((b.frame[2][3]-3.).abs()<1e-5); + assert!((b.frame[3][3]-28.).abs()<1e-4); + assert_eq!(b.frame[3][1],3.); + assert_eq!(b.extents,[1.,2.,5.,8.]); + frame[2]=[0.,7.,0.,8.]; + assert_eq!(board_bounds(frame,[0.;4],[0.;4]).frame[2],[0.,0.,1.,0.]); +} +struct Recorder { + p: Processed, events:Vec<&'static str>, point:Vector, bone_frame:Frame, + toolkit:Option, found:bool, accepted:bool, edge:bool, +} +impl Recorder { + fn new()->Self {Self{p:processed(),events:vec![],point:[0.;4],bone_frame:FRAME, + toolkit:None,found:false,accepted:false,edge:false}} +} +impl ground_query::GroundQueryScene for Recorder { + type Error=(); + fn edge_candidates(&mut self,s:&ground_query::EdgeSearch)->Result,()> { + self.events.push("edges"); assert!(s.narrow_forward); + Ok(if self.edge {vec![ground_query::Edge { + start:crate::math::Vector3::new(-0.2,0.,0.), + end:crate::math::Vector3::new(0.2,0.,0.)}]}else{vec![]}) + } + fn query_lines(&mut self,_:&ground_query::GroundQueryPacket)->Result<[Option;7],()> { + panic!("Sync must not consume its newly submitted queries") + } +} +impl Services for Recorder { + type Launch=(); type Candidate=[u32;2]; + fn processed(&self)->Processed{self.p} + fn prepare_biped_launch(&mut self){self.events.push("prepare");self.point=[2.,3.,4.,90.];} + fn skeleton_point_10960(&self)->Vector{self.point} + fn launch_air(&mut self,_:(),point:Vector){self.events.push("air");assert_eq!(point,[2.,3.,4.,1.]);} + fn update_skeleton(&mut self,frame:Frame,_:Vector){ + self.events.push("skeleton");self.bone_frame=frame; + // Exercises the required fresh processed read after this callback. + self.p.flags_2476=0x400000; + } + fn board_settings(&self)->BoardSettings{ + BoardSettings{extent_0:[2.;4],extent_16:[3.;4],offset_32:[0.;4], + angle_436:10.,angle_440:20.,margin_444:0.2,angle_452:30.,angle_456:40.} + } + fn board_flags_12836(&self)->u8{0x40} + fn probe_board(&mut self,_:Vector)->Option<[u32;2]>{self.events.push("probe");self.found.then_some([7,11])} + fn candidate_key_188(&self,c:&[u32;2])->[u32;2]{*c} + fn bone_23_position(&mut self)->Vector{self.events.push("bone23");[0.;4]} + fn classify_board(&mut self,_:&[u32;2],_:Vector,b:Bounds,l:BoardLimits)->bool{ + self.events.push("classify");assert_eq!(b.extents,[1.9;4]); + assert!((l.angle_a-30.*f32::from_bits(0x3c8efa35)).abs()<1e-6);self.accepted + } + fn query_board(&mut self,_:Vector,b:Bounds,l:BoardLimits){ + self.events.push("board_query");assert_eq!(b.extents,[3.;4]); + assert!((l.angle_a-40.*f32::from_bits(0x3c8efa35)).abs()<1e-6); + } + fn bind_board(&mut self,key:[u32;2]){self.events.push("bind");assert_eq!(key,[7,11]);} + fn commit_bound_board(&mut self){self.events.push("bound");} + fn commit_free_board(&mut self,_:Frame){self.events.push("free");} + fn reset_board(&mut self){self.events.push("reset");} + fn update_toolkit(&mut self,p:ToolkitInput){self.events.push("toolkit");self.toolkit=Some(p);} + fn deck_half_wheelbase(&self)->f32{0.3} + fn submit_ground_query(&mut self,_:ground_query::GroundQueryPacket){self.events.push("submit");} +} +#[test] +fn launch_then_skeleton_then_board_then_toolkit_then_edges() { + let mut s=State::default();let mut m=motion();m.launch_1076=true; + let mut r=Recorder::new();r.found=true;r.accepted=true; + assert_eq!(sync(&mut s,&m,&mut r),Ok(false)); + assert_eq!(r.events,["prepare","air","skeleton","probe","bone23","classify","board_query","bind","bound","toolkit","edges"]); + assert_eq!(s.frame_80[3],[0.,2.,0.,1.]); + assert_eq!([s.counter_152,s.counter_156],[7,11]); + assert!(s.flags_144_to_150[0]&&s.flags_144_to_150[2]&&s.flags_144_to_150[3]); + assert_eq!(r.toolkit.unwrap().0,[s.frame_80[3],[3.;4],[2.;4],[1.;4],[0.;4],FRAME[1],FRAME[0]]); +} +#[test] +fn failed_probe_still_updates_board_and_uses_free_action() { + let mut s=State::default();let mut r=Recorder::new(); + sync(&mut s,&motion(),&mut r).unwrap(); + assert_eq!(r.events,["skeleton","probe","board_query","free","toolkit","edges"]); +} +#[test] +fn last_spin_tick_still_rotates_and_landing_gate_clears_without_query() { + let mut s=State::default();s.flags_144_to_150[6]=true;s.flags_144_to_150[4]=true; + s.duration_180=0.001;s.angle_172=1.;s.angular_velocity_176=99.; + let mut r=Recorder::new();r.p.elapsed_2664=0.6; + sync(&mut s,&motion(),&mut r).unwrap(); + assert!(!s.flags_144_to_150[6]);assert!(!s.flags_144_to_150[4]); + assert_eq!(s.duration_180,0.);assert_eq!(s.angle_172,0.9); + assert!(r.bone_frame[2][0]>0.7); + assert_eq!(r.events,["skeleton","toolkit","edges"]); +} +#[test] +fn selected_edge_submits_next_frame_packet_without_consuming_it() { + let mut s=State::default();let mut r=Recorder::new();r.edge=true; + let mut m=motion();m.contact_position_192=FRAME[3]; + assert_eq!(sync(&mut s,&m,&mut r),Ok(true)); + assert_eq!(&r.events[r.events.len()-3..],["toolkit","edges","submit"]); +} diff --git a/crates/skate-core/src/player/offboard/mod.rs b/crates/skate-core/src/player/offboard/mod.rs new file mode 100644 index 00000000..0d7cf129 --- /dev/null +++ b/crates/skate-core/src/player/offboard/mod.rs @@ -0,0 +1,16 @@ +//! Recovered off-board and shared recovery behavior. +pub mod twist_lean; +pub mod cadence; +pub mod contact_correction; +pub mod ground_input; +pub mod ground_branch; +pub mod position_output; +pub mod slide; +pub mod movement_intent; +pub mod movement_velocity; +pub mod surface_frame; +pub mod ground_motion; +pub mod controller; +pub mod ground_entry; +pub mod ground_query; +pub mod pose_adjust; diff --git a/crates/skate-core/src/player/offboard/movement_intent.rs b/crates/skate-core/src/player/offboard/movement_intent.rs new file mode 100644 index 00000000..a837340c --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_intent.rs @@ -0,0 +1,134 @@ +//! Biped movement producer82D7CF50, steering merge82D7D5A0 and slide82D7D608. +//! Original TU3; state/input offsets are documented at the host boundary. +use crate::point_graph::PointGraph; +mod math; +use math::*; +pub type Vector = [f32; 4]; + +pub struct Settings { + /// Biped320 <- physics_biped350, x+16/y+32. + pub sprint_speed: PointGraph<4>, + /// Biped5D0 <- physics_biped90, x+16/y+80. + pub normal_speed: PointGraph<16>, + /// Biped3F0 <- physics_biped1C0, x+16/y+48; header+8 is cap. + pub sprint_blend: PointGraph<8>, + pub sprint_time_cap: f32, + /// Biped440 <- physics_biped300, x+16/y+48. + pub slide_steering: PointGraph<8>, +} + +/// Retained outputs and timers; native Reset82D7B1C0 initializes these to zero. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct State { + pub speed: f32, //756 + pub steering: f32, //760 + pub sprint_time: f32, //764 + pub sprint_grace: f32, //768 + pub secondary_speed: f32, //776 + pub original_steering: f32, //780 + pub obstacle_centered: bool, //712 + pub edge_aligned: bool, //713 + pub edge_target: Vector, //672 +} + +pub struct Input { + pub flags: u32, //input176 + pub suppress_minimum: bool, //input318 + pub magnitude: f32, //input308 + pub steering: f32, //input312 + pub sprint_pressed: bool, //input316 + pub edge_active: bool, //input353; also disables sprint + pub ignore_obstacle: bool, //input432 + pub direction: Vector, //input256 + pub edge_tangent: Vector, //input400 + pub edge_point: Vector, //input416 + pub right: Vector, //Biped0 + pub up: Vector, //Biped16 + pub forward: Vector, //Biped32 + pub position: Vector, //Biped48 + pub obstacle: bool, //Biped708 + pub obstacle_normal: Vector, //Biped400 + pub sliding: bool, //Biped710 + pub slide_velocity: Vector, //Biped528 +} + +impl State { + /// Once per original physical tick; source uses literal1/60, not graph dt. + pub fn update(&mut self, settings: &Settings, input: &Input) { + let minimum = if !input.suppress_minimum && input.flags & 8 != 0 { 0.5 } else { 0.0 }; + let magnitude = select(minimum - input.magnitude, minimum, input.magnitude); + let dt = f32::from_bits(0x3c88_8889); + self.sprint_grace = if input.sprint_pressed { f32::from_bits(0x3e99_999a) } else { self.sprint_grace - dt }; + let sprint = (self.sprint_grace > 0.0 || input.sprint_pressed) && !input.edge_active; + self.sprint_time = clamp(self.sprint_time + if sprint { dt } else { -dt }, 0.0, settings.sprint_time_cap); + let fast = settings.sprint_speed.evaluate(magnitude); + let normal = settings.normal_speed.evaluate(magnitude); + let blend = if sprint { settings.sprint_blend.evaluate(self.sprint_time) } else { 0.0 }; + self.speed = (1.0 - blend).mul_add(normal, blend * fast); + self.secondary_speed = 0.0; + self.steering = input.steering; + self.original_steering = input.steering; + self.edge_aligned = false; + if input.obstacle && !input.ignore_obstacle && !(self.speed <= 0.0) { + let opposite = scale(input.obstacle_normal, -1.0); + let desired = wrap_angle(signed_angle(opposite, input.direction, [0.0, 1.0, 0.0, 0.0])); + let degrees = if self.obstacle_centered { 55.0 } else { 25.0 }; + self.obstacle_centered = degrees * f32::from_bits(0x3c8e_fa35) > desired.abs(); + let facing = wrap_angle(signed_angle(opposite, input.forward, [0.0, 1.0, 0.0, 0.0])); + if !(facing.abs() >= HALF_PI) { + let correction = if self.obstacle_centered { + (facing * f32::from_bits(0xbf22_f983)) * 0.5 + } else { + (-facing).mul_add(f32::from_bits(0x3f22_f983), if desired <= 0.0 { -1.0 } else { 1.0 }) * 0.5 + }; + self.merge_steering(clamp(correction, -1.0, 1.0)); + if self.obstacle_centered { self.speed = 0.0; } + } + } else { + self.obstacle_centered = false; + if input.sliding { + self.apply_slide(settings, input); + } else if input.edge_active && !(self.speed <= 0.0) { + let direction = if input.magnitude == 0.0 { input.forward } else { input.direction }; + let tangent = if dot(input.edge_tangent, direction) >= 0.0 { input.edge_tangent } else { scale(input.edge_tangent, -1.0) }; + self.edge_target = madd(tangent, 0.5, input.edge_point); + let delta = sub(self.edge_target, input.position); + if !(dot(horizontal(direction), horizontal(delta)) <= f32::from_bits(0x3f66_6666)) { + self.edge_aligned = true; + let normalized = normalize_or(delta, input.forward); + self.steering = clamp(dot(normalized, input.right) * 0.5, -1.0, 1.0); + } + } + } + } + + ///82D7D5A0: opposite signs replace; same signs retain larger magnitude. + fn merge_steering(&mut self, correction: f32) { + if !(self.steering * correction >= 0.0) { + self.steering = correction; + } else { + let sign = if correction <= 0.0 { -1.0 } else { 1.0 }; + let old = self.steering * sign; + let new = correction * sign; + self.steering = select(old - new, old, new) * sign; + } + } + + fn apply_slide(&mut self, settings: &Settings, input: &Input) { + let angle = wrap_angle(projected_angle(input.slide_velocity, input.forward, input.up)); + let original = self.steering; + self.speed = (1.0 - original.abs()) * self.speed; + let sign = select(angle, 1.0, -1.0); + if !(original * sign <= 0.0) { + let limit = 45.0 * f32::from_bits(0x3c8e_fa35); + let weight = (-angle).mul_add(sign, limit) / limit; + self.steering = clamp(weight, 0.0, 1.0) * original; + } + let correction = settings.slide_steering.evaluate(sign * (angle * f32::from_bits(0x3ea2_f983))); + self.steering = clamp((-correction).mul_add(sign, self.steering), -1.0, 1.0); + if !(angle.abs() >= HALF_PI) { self.speed += length(input.slide_velocity); } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/movement_intent/math.rs b/crates/skate-core/src/player/offboard/movement_intent/math.rs new file mode 100644 index 00000000..d34f9c83 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_intent/math.rs @@ -0,0 +1,56 @@ +//! Address-audited scalar forms; host sqrt seed plus original refinements. +//! Ordinary PC arithmetic is not bit-exact Xenon emulation. +use super::Vector; +pub(super) const HALF_PI: f32 = f32::from_bits(0x3fc9_0fdb); +const EPSILON: f32 = f32::from_bits(0x3586_37bd); //82F826F8 ->830BD350 +pub(super) fn select(test: f32, yes: f32, no: f32) -> f32 { if test >= 0.0 { yes } else { no } } +pub(super) fn clamp(value: f32, low: f32, high: f32) -> f32 { + let lower = select(low - value, low, value); + select(high - lower, lower, high) +} +pub(super) fn dot(a: Vector, b: Vector) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } +pub(super) fn scale(a: Vector, s: f32) -> Vector { a.map(|v| v * s) } +pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i] - b[i]) } +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i| a[i].mul_add(s, b[i])) } +fn inverse_squared(value: f32, refinements: usize) -> f32 { + let mut r = crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..refinements { + let error = (-value).mul_add(r * r, 1.0); + r = (r * 0.5).mul_add(error, r); + } + r +} +pub(super) fn length(v: Vector) -> f32 { + let squared = dot(v, v); + let value = squared * inverse_squared(squared, 2); + if squared == 0.0 { 0.0 } else { value } +} +pub(super) fn normalize_or(v: Vector, fallback: Vector) -> Vector { + let squared = dot(v, v); + let r = inverse_squared(squared, 2); + let length = if squared == 0.0 { 0.0 } else { squared * r }; + if length > EPSILON { scale(v, r) } else { fallback } +} +///8269A4F0 permute [x,zero,x,x], then vrlimi mask2 restores Z. +///W is X, not zero: preserve the native lane even though the caller dots XYZ. +pub(super) fn horizontal(v: Vector) -> Vector { normalize_or([v[0], 0.0, v[2], v[0]], [0.0; 4]) } +pub(super) fn wrap_angle(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c9_0fdb) +} +pub(super) fn signed_angle(a: Vector, b: Vector, axis: Vector) -> f32 { + let aa = dot(a, a); + let bb = dot(b, b); + let threshold = f32::from_bits(0x38d1_b717); + if !(aa > threshold && bb > threshold) { return 0.0; } + let a = scale(a, inverse_squared(aa, 1)); + let b = scale(b, inverse_squared(bb, 1)); + let angle = crate::trigonometry::acos(dot(a, b).max(-1.0).min(1.0)); + let cross = [(-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])]; + if dot(cross, axis) < 0.0 { f32::from_bits(0x40c9_0fdb) - angle } else { angle } +} +pub(super) fn projected_angle(a: Vector, b: Vector, axis: Vector) -> f32 { + if !(dot(a, a) * dot(b, b) > f32::from_bits(0x3780_0000)) { return 0.0; } + signed_angle(sub(a, scale(axis, dot(axis, a))), sub(b, scale(axis, dot(axis, b))), axis) +} diff --git a/crates/skate-core/src/player/offboard/movement_intent/tests.rs b/crates/skate-core/src/player/offboard/movement_intent/tests.rs new file mode 100644 index 00000000..61c09ec8 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_intent/tests.rs @@ -0,0 +1,95 @@ +use super::*; + +fn constant(value: f32) -> PointGraph { + PointGraph { x: std::array::from_fn(|i| i as f32), y: [value; N] } +} +fn settings() -> Settings { + Settings { sprint_speed: constant(8.0), normal_speed: constant(4.0), sprint_blend: constant(1.0), sprint_time_cap: 2.0, slide_steering: constant(0.0) } +} +fn input() -> Input { + Input { + flags: 0, suppress_minimum: false, magnitude: 1.0, steering: 0.0, + sprint_pressed: false, edge_active: false, ignore_obstacle: false, + direction: [0.0, 0.0, 1.0, 0.0], edge_tangent: [0.0, 0.0, 1.0, 0.0], edge_point: [0.0; 4], + right: [1.0, 0.0, 0.0, 0.0], up: [0.0, 1.0, 0.0, 0.0], forward: [0.0, 0.0, 1.0, 0.0], position: [0.0; 4], + obstacle: false, obstacle_normal: [0.0, 0.0, -1.0, 0.0], sliding: false, slide_velocity: [0.0; 4], + } +} +#[test] +fn sprint_grace_survives_release_but_edge_disables_blend() { + let mut state = State::default(); + let mut input = input(); + input.sprint_pressed = true; + state.update(&settings(), &input); + assert_eq!(state.speed, 8.0); + input.sprint_pressed = false; + state.update(&settings(), &input); + assert_eq!(state.speed, 8.0); + assert!(state.sprint_grace > 0.0); + input.edge_active = true; + state.update(&settings(), &input); + assert_eq!(state.speed, 4.0); + assert!(state.sprint_time > 0.0); +} +#[test] +fn centered_obstacle_stops_speed_and_has_priority_over_slide() { + let mut state = State::default(); + let mut input = input(); + input.obstacle = true; + input.sliding = true; + input.slide_velocity = [0.0, 0.0, 3.0, 0.0]; + state.update(&settings(), &input); + assert!(state.obstacle_centered); + assert_eq!(state.speed, 0.0); + input.ignore_obstacle = true; + state.update(&settings(), &input); + assert!(!state.obstacle_centered); + assert!((state.speed - 7.0).abs() < 0.00001); +} +#[test] +fn steering_merge_replaces_opposition_and_preserves_stronger_same_sign() { + let mut state = State::default(); + state.steering = 0.8; + state.merge_steering(0.2); + assert_eq!(state.steering, 0.8); + state.merge_steering(-0.1); + assert_eq!(state.steering, -0.1); + state.merge_steering(-0.6); + assert_eq!(state.steering, -0.6); +} +#[test] +fn edge_tangent_flips_before_target_and_flag_resets_next_tick() { + let mut state = State::default(); + let mut input = input(); + input.edge_active = true; + input.edge_tangent = [0.0, 0.0, -1.0, 0.0]; + input.edge_point = [0.2, 0.0, 1.0, 0.0]; + state.update(&settings(), &input); + assert_eq!(state.edge_target, [0.2, 0.0, 1.5, 0.0]); + assert!(state.edge_aligned); + assert!(state.steering > 0.0); + input.edge_active = false; + state.update(&settings(), &input); + assert!(!state.edge_aligned); + assert_eq!(state.edge_target, [0.2, 0.0, 1.5, 0.0]); +} +#[test] +fn angle_degeneracy_and_half_turn_boundary_are_retained() { + assert_eq!(signed_angle([0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]), 0.0); + assert_eq!(normalize_or([0.0; 4], [0.0, 0.0, 1.0, 0.0]), [0.0, 0.0, 1.0, 0.0]); + let pi = f32::from_bits(0x4049_0fdb); + assert!((wrap_angle(pi) - pi).abs() < 0.000001); + assert!(wrap_angle(pi + 0.1) < 0.0); +} + +#[test] +fn edge_target_preserves_native_fourth_lane() { + let mut state = State::default(); + let mut input = input(); + input.edge_active = true; + input.edge_tangent[3] = 2.0; + input.edge_point[3] = 3.0; + state.update(&settings(), &input); + assert_eq!(state.edge_target[3], 4.0); + assert_eq!(horizontal([1.0, 4.0, 0.0, 9.0]), [1.0, 0.0, 0.0, 1.0]); +} diff --git a/crates/skate-core/src/player/offboard/movement_velocity.rs b/crates/skate-core/src/player/offboard/movement_velocity.rs new file mode 100644 index 00000000..c1e03666 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_velocity.rs @@ -0,0 +1,115 @@ +//! Original Biped velocity/turn producer82D7D9B0, TU3. +//! Call after movement intent; velocity/turn limits are per physical update. +use crate::point_graph::PointGraph; +mod math; +use math::*; +pub type Vector = [f32; 4]; + +pub struct Settings { + /// Biped0x490 <- layout0x2B0 Hash_31309236050A8F09. + pub slope_speed_scalar: PointGraph<8>, + /// Biped0x4E0 <- attribute Hash_CE45C724B30F9134. + pub slope_mode_speed: PointGraph<8>, + /// Biped0x530 <- layout0x120 TurnVsSpeed. + pub turn_vs_speed: PointGraph<8>, + /// Biped0x580 <- layout0x170 TurnDeltaVsSpeed. + pub turn_delta_vs_speed: PointGraph<8>, +} + +/// Actual Biped fields, decimal offsets. Reset82D7B1C0 sets timer=-1, others0. +#[derive(Clone, Debug, PartialEq)] +pub struct State { + pub velocity: Vector, //480 + pub turn: f32, //688 + pub forward_delta: f32, //692 + pub right_delta: f32, //696 + pub speed: f32, //704 + pub override_remaining: f32, //772 +} +impl Default for State { + fn default() -> Self { + Self { velocity: [0.0; 4], turn: 0.0, forward_delta: 0.0, right_delta: 0.0, speed: 0.0, override_remaining: -1.0 } + } +} +pub struct Input { + pub forward: Vector, //Biped32 + pub right: Vector, //Biped0 + pub plane_normal: Vector, //Biped544 + pub desired_speed: f32, //Biped756 + pub steering: f32, //Biped760 + pub slope_mode: bool, //Biped714 + pub obstacle: bool, //Biped708 + pub obstacle_normal: Vector, //Biped400 + pub override_gate: f32, //input296 + pub override_duration: f32, //input300 + pub override_velocity: Vector, //input240 + pub flags: u32, //input176 +} + +impl State { + pub fn update(&mut self, settings: &Settings, input: &Input) { + let dt = f32::from_bits(0x3c88_8889); + let radians = f32::from_bits(0x3c8e_fa35); + let projected = sub(input.forward, scale(input.plane_normal, dot(input.forward, input.plane_normal))); + let direction = normalize_or(projected, input.forward); + //82D7DB08..DB94 inlines the same recovered asin polynomial. + let slope_degrees = crate::trigonometry::asin(direction[1]) * f32::from_bits(0x4265_2ee1); + let target_speed = if input.slope_mode { + settings.slope_mode_speed.evaluate(slope_degrees.abs()) + } else { + let mut target = settings.slope_speed_scalar.evaluate(slope_degrees) * input.desired_speed; + if !(input.desired_speed <= 0.5) && !(target >= self.speed) { + let gravity_delta = if self.velocity[1] > 0.0 { + (f32::from_bits(0xc11c_cccd) * dt) * select(-direction[1], 0.0, direction[1]) + } else { 0.0 }; + let delta = target - self.speed; + let limited_delta = select(delta - -2.0, delta, -2.0) * dt; + target = self.speed + select(gravity_delta - limited_delta, limited_delta, gravity_delta); + } + target + }; + let original = self.velocity; + let mut target = madd(original, 0.0, scale(direction, target_speed)); + let turn_error = (settings.turn_vs_speed.evaluate(self.speed) * input.steering).mul_add(radians, -self.turn); + let mut maximum_delta = if input.slope_mode { 0.5 } else { f32::from_bits(0x3e4c_cccd) }; + if !(input.override_gate < 0.0) && !input.slope_mode { + let first = self.override_remaining < 0.0; + if first { self.override_remaining = input.override_duration; } + let ratio = if input.override_duration < f32::from_bits(0x3a83_126f) { 0.0 } else { self.override_remaining / input.override_duration }; + let amount = ratio * 1.25; + let mut blend = select(1.0 - amount, amount, 1.0); + if !first && !(length(input.override_velocity) <= length(original) * f32::from_bits(0x3f8c_cccd)) { + blend = select(blend - 0.5, 0.5, blend); + } + target = madd(original, blend, scale(input.override_velocity, 1.0 - blend)); + maximum_delta = 100.0; + if input.flags & 8 != 0 { + let speed = length(target); + if !(speed >= f32::from_bits(0x3c23_d70a)) { + target = scale(direction, 1.0); + } else if !(speed >= 1.0) { + target = scale(target, 1.0 / speed); + } + } + let remaining = self.override_remaining - dt; + self.override_remaining = select(-remaining, 0.0, remaining); + } else { + self.override_remaining = -1.0; + } + let turn_delta = settings.turn_delta_vs_speed.evaluate(self.speed) * radians; + let mut velocity = limit_delta(target, original, maximum_delta); + self.turn += clamp(turn_error, -turn_delta, turn_delta); + if input.obstacle { + let opposite = scale(input.obstacle_normal, -1.0); + target = remove_positive(target, opposite); + velocity = remove_positive(velocity, opposite); + } + self.right_delta = dot(sub(velocity, original), input.right); + self.forward_delta = dot(sub(target, original), input.forward); + self.velocity = velocity; + self.speed = length(velocity); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/movement_velocity/math.rs b/crates/skate-core/src/player/offboard/movement_velocity/math.rs new file mode 100644 index 00000000..6be7e536 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_velocity/math.rs @@ -0,0 +1,41 @@ +//!82BD41B0 delta bound and82C1E170 one-sided projection, original raw audited. +use super::Vector; +pub(super) fn select(test: f32, yes: f32, no: f32) -> f32 { if test >= 0.0 { yes } else { no } } +pub(super) fn clamp(value: f32, low: f32, high: f32) -> f32 { + let lower = select(low - value, low, value); + select(high - lower, lower, high) +} +pub(super) fn dot(a: Vector, b: Vector) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } +pub(super) fn scale(a: Vector, s: f32) -> Vector { a.map(|v| v * s) } +pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i] - b[i]) } +pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i| a[i].mul_add(s, b[i])) } +fn inverse_squared(value: f32) -> f32 { + //PC seed is not bit-exact Xenon; retain both original refinement stages. + let mut r = crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..2 { + let error = (-value).mul_add(r * r, 1.0); + r = (r * 0.5).mul_add(error, r); + } + r +} +pub(super) fn length(v: Vector) -> f32 { + let squared = dot(v, v); + let value = squared * inverse_squared(squared); + if squared == 0.0 { 0.0 } else { value } +} +pub(super) fn normalize_or(v: Vector, fallback: Vector) -> Vector { + let squared = dot(v, v); + let r = inverse_squared(squared); + let magnitude = if squared == 0.0 { 0.0 } else { squared * r }; + if magnitude > f32::from_bits(0x3586_37bd) { scale(v, r) } else { fallback } +} +pub(super) fn limit_delta(target: Vector, current: Vector, maximum: f32) -> Vector { + let delta = sub(target, current); + let magnitude = length(delta); + if maximum >= magnitude { target } else { madd(delta, maximum / magnitude, current) } +} +pub(super) fn remove_positive(value: Vector, direction: Vector) -> Vector { + let projection = dot(value, normalize_or(direction, [0.0; 4])); + //Native subtracts original direction, NOT the normalized direction. + if projection > 0.0 { sub(value, scale(direction, projection)) } else { value } +} diff --git a/crates/skate-core/src/player/offboard/movement_velocity/tests.rs b/crates/skate-core/src/player/offboard/movement_velocity/tests.rs new file mode 100644 index 00000000..e0aa7a05 --- /dev/null +++ b/crates/skate-core/src/player/offboard/movement_velocity/tests.rs @@ -0,0 +1,95 @@ +use super::*; +fn curve(value: f32) -> PointGraph<8> { PointGraph { x: std::array::from_fn(|i| i as f32), y: [value; 8] } } +fn settings() -> Settings { + Settings { slope_speed_scalar: curve(1.0), slope_mode_speed: curve(3.0), turn_vs_speed: curve(90.0), turn_delta_vs_speed: curve(15.0) } +} +fn input() -> Input { + Input { + forward: [0.0, 0.0, 1.0, 0.0], right: [1.0, 0.0, 0.0, 0.0], plane_normal: [0.0, 1.0, 0.0, 0.0], desired_speed: 5.0, steering: 1.0, + slope_mode: false, obstacle: false, obstacle_normal: [0.0, 0.0, -1.0, 0.0], override_gate: -1.0, override_duration: 1.0, + override_velocity: [0.0; 4], flags: 0, + } +} +#[test] +fn desired_forward_delta_differs_from_limited_velocity() { + let mut state = State::default(); + state.update(&settings(), &input()); + assert_eq!(state.forward_delta, 5.0); + assert!((state.velocity[2] - 0.2).abs() < 0.000001); + assert_eq!(state.right_delta, 0.0); + assert_eq!(state.turn, 15.0 * f32::from_bits(0x3c8e_fa35)); + assert_eq!(state.override_remaining, -1.0); +} +#[test] +fn slope_mode_uses_its_own_speed_and_ignores_override() { + let mut state = State::default(); + let mut input = input(); + input.slope_mode = true; + input.override_gate = 0.0; + input.override_velocity = [20.0, 0.0, 0.0, 0.0]; + state.update(&settings(), &input); + assert_eq!(state.forward_delta, 3.0); + assert!((state.speed - 0.5).abs() < 0.000001); + assert_eq!(state.override_remaining, -1.0); +} +#[test] +fn override_first_tick_retains_old_velocity_then_fast_target_reduces_blend() { + let mut state = State::default(); + let mut input = input(); + input.override_gate = 0.0; + input.override_velocity = [0.0, 0.0, 10.0, 0.0]; + state.update(&settings(), &input); + assert_eq!(state.velocity, [0.0; 4]); + assert!(state.override_remaining < 1.0); + state.update(&settings(), &input); + assert_eq!(state.velocity, [0.0, 0.0, 5.0, 0.0]); +} +#[test] +fn minimum_motion_override_uses_projected_forward() { + let mut state = State::default(); + let mut input = input(); + input.override_gate = 0.0; + input.flags = 8; + state.update(&settings(), &input); + assert_eq!(state.velocity, [0.0, 0.0, 1.0, 0.0]); +} +#[test] +fn obstacle_projects_desired_and_limited_velocity_independently() { + let mut state = State::default(); + let mut input = input(); + input.obstacle = true; + state.update(&settings(), &input); + assert_eq!(state.velocity, [0.0; 4]); + assert_eq!(state.forward_delta, 0.0); +} +#[test] +fn nonunit_projection_subtracts_original_direction_and_zero_is_safe() { + assert_eq!(remove_positive([2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0]), [-2.0, 0.0, 0.0, 0.0]); + assert_eq!(remove_positive([-2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0]), [-2.0, 0.0, 0.0, 0.0]); + assert_eq!(remove_positive([2.0, 0.0, 0.0, 0.0], [0.0; 4]), [2.0, 0.0, 0.0, 0.0]); + assert_eq!(limit_delta([0.0; 4], [0.0; 4], 0.2), [0.0; 4]); +} + +#[test] +fn upward_slowdown_retains_gravity_even_beyond_two_per_second_bound() { + let mut state = State { velocity: [0.0, 6.0, 8.0, 0.0], speed: 10.0, ..State::default() }; + let mut input = input(); + input.forward = [0.0, 0.6, 0.8, 0.0]; + input.plane_normal = [1.0, 0.0, 0.0, 0.0]; + input.desired_speed = 1.0; + state.update(&settings(), &input); + let expected = 10.0 + (f32::from_bits(0xc11c_cccd) * f32::from_bits(0x3c88_8889)) * 0.6; + assert!((state.speed - expected).abs() < 0.00001); + assert!(state.speed < 10.0 - 2.0 * f32::from_bits(0x3c88_8889)); +} + +#[test] +fn vector_delta_limits_xyz_and_preserves_scaled_w() { + let mut state = State::default(); + let mut input = input(); + input.forward[3] = 2.0; + state.update(&settings(), &input); + assert!((state.speed - 0.2).abs() < 0.000001); + assert!((state.velocity[3] - 0.4).abs() < 0.000001); + assert_eq!(remove_positive([2.0, 0.0, 0.0, 7.0], [2.0, 0.0, 0.0, 3.0]), [-2.0, 0.0, 0.0, 1.0]); +} diff --git a/crates/skate-core/src/player/offboard/pose_adjust.rs b/crates/skate-core/src/player/offboard/pose_adjust.rs new file mode 100644 index 00000000..2a1736ac --- /dev/null +++ b/crates/skate-core/src/player/offboard/pose_adjust.rs @@ -0,0 +1,40 @@ +//! Skeleton::UpdateOffboardAdjust82BD8F10 and SkeletonIK82BF1A78. +//! Both transforms are current animation globals, before physical bone offsets. +use crate::physics::skeleton_animation_record::AnimationPartTransform as Transform; +/// Flags2476 bit2 selects left when set, right when clear. +pub fn selected_hand(flags_2476: u32) -> usize { + usize::from(flags_2476 & 4 == 0) +} +/// Actual hand composed with the rigid inverse of the board-parented hand. +/// Native inverse is a transpose, not a general scale/shear matrix inverse. +pub fn adjustment(actual: &Transform, reparented: &Transform) -> Transform { + let inverse: [[f32; 4]; 3] = std::array::from_fn(|column| { + [ + reparented[0][column], + reparented[1][column], + reparented[2][column], + 0., + ] + }); + //82BF1B18/40/50: inverse translation accumulates Z, then Y, then X. + let translation: [f32; 4] = std::array::from_fn(|lane| { + let z = (0. - reparented[3][2]) * inverse[2][lane]; + let yz = (0. - reparented[3][1]).mul_add(inverse[1][lane], z); + (0. - reparented[3][0]).mul_add(inverse[0][lane], yz) + }); + std::array::from_fn(|column| { + std::array::from_fn(|lane| { + if column < 3 { + let x = inverse[column][0] * actual[0][lane]; + let y = inverse[column][1].mul_add(actual[1][lane], x); + inverse[column][2].mul_add(actual[2][lane], y) + } else { + let x = translation[0].mul_add(actual[0][lane], actual[3][lane]); + let y = translation[1].mul_add(actual[1][lane], x); + translation[2].mul_add(actual[2][lane], y) + } + }) + }) +} +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/pose_adjust/tests.rs b/crates/skate-core/src/player/offboard/pose_adjust/tests.rs new file mode 100644 index 00000000..552e5405 --- /dev/null +++ b/crates/skate-core/src/player/offboard/pose_adjust/tests.rs @@ -0,0 +1,25 @@ +use super::*; +use crate::physics::skeleton_animation_record::IDENTITY; +#[test] +fn mapped_hand_choice_and_relative_translation() { + assert_eq!(selected_hand(4), 0); + assert_eq!(selected_hand(0), 1); + let mut actual = IDENTITY; + actual[3] = [7., 8., 9., 0.]; + let mut reparented = IDENTITY; + reparented[3] = [2., 3., 4., 0.]; + let result = adjustment(&actual, &reparented); + assert_eq!(result[3], [5., 5., 5., 0.]); + assert_eq!(result[..3], IDENTITY[..3]); +} +#[test] +fn relative_rotated_hand_maps_reparented_position_to_actual_position() { + let mut target = IDENTITY; + target[0] = [0., 1., 0., 0.]; + target[1] = [-1., 0., 0., 0.]; + target[3] = [2., 3., 0., 0.]; + let result = adjustment(&IDENTITY, &target); + assert_eq!(result[0], [0., -1., 0., 0.]); + assert_eq!(result[1], [1., 0., 0., 0.]); + assert_eq!(result[3], [-3., 2., 0., 0.]); +} diff --git a/crates/skate-core/src/player/offboard/position_output.rs b/crates/skate-core/src/player/offboard/position_output.rs new file mode 100644 index 00000000..85340f56 --- /dev/null +++ b/crates/skate-core/src/player/offboard/position_output.rs @@ -0,0 +1,109 @@ +//! Biped frame/position output stages82D80360 and82D80548. +//! The retained state is part of the actual Biped owner; no inferred initialization. +use crate::physics::{native_arithmetic::dot3, reciprocal_sqrt::estimate, skeleton_root::orthonormalize}; +type Vector = [f32; 4]; +type Matrix = [Vector; 4]; +const DT: f32 = f32::from_bits(0x3c88_8889); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameOutput { + /// Biped128..176. Common cadence reads this frame's up vector144. + pub frame: Matrix, + /// Biped512. Common physical publication copies this to OffBoard64. + pub velocity: Vector, +} + +impl FrameOutput { + ///82D80360 after movement, before82D80548 and cadence82D80720. + pub fn update( + &mut self, + forward_32: Vector, + frame_up_448: Vector, + projection_axis_416: Vector, + position_48: Vector, + ) { + let right = normalize(cross(frame_up_448, forward_32)); + let forward = normalize(cross(right, projection_axis_416)); + self.frame = orthonormalize([right, frame_up_448, forward, self.frame[3]]); + let mut change: Vector = std::array::from_fn(|i| (position_48[i] - self.frame[3][i]) * 60.0 - self.velocity[i]); + change[1] = select(change[1] - -1.0, change[1], -1.0); + self.velocity = std::array::from_fn(|i| self.velocity[i] + change[i]); + self.frame[3] = std::array::from_fn(|i| self.velocity[i].mul_add(DT, self.frame[3][i])); + } +} + +#[derive(Clone, Copy, Debug)] +pub struct PositionInput { + pub previous_origin_112: Vector, + pub override_origin_592: Vector, + pub use_override_711: bool, + pub projection_axis_416: Vector, + pub contact_flags_176: u32, + pub contact_origin_0: Vector, + pub contact_origin_96: Vector, + pub frame_position_176: Vector, + pub animation_position_272: Vector, +} + +///82D80548 updates retained Biped368 using actual ground-job contact/pose data. +pub fn update_position(position_368: &mut Vector, input: PositionInput) { + let up = input.projection_axis_416; + let mut origin = input.previous_origin_112; + if input.use_override_711 { + origin = input.override_origin_592; + } else if input.contact_flags_176 & 1 != 0 { + if dot3(subtract(input.contact_origin_0, origin), up) > 0.0 { + origin = input.contact_origin_0; + } + } + if input.contact_flags_176 & 2 != 0 { + let contact_height = dot3(subtract(input.contact_origin_96, origin), up); + let frame_height = dot3(subtract(input.frame_position_176, origin), up); + let height = select(contact_height - frame_height, contact_height, frame_height); + let height = select(height - 0.3, 0.3, height); + //82D80638 ble: unordered takes the update branch. + if !(height <= 0.0) { + origin = std::array::from_fn(|i| up[i].mul_add(height, origin[i])); + } + } + let delta = subtract(input.animation_position_272, origin); + let projection = dot3(up, delta); + let planar: Vector = std::array::from_fn(|i| delta[i] - up[i] * projection); + let target: Vector = std::array::from_fn(|i| up[i].mul_add(0.95, origin[i] + planar[i])); + let delta = subtract(target, *position_368); + let height = dot3(delta, up); + let lower = select(-0.1 - height, -0.1, height); + let bounded = select(0.1 - lower, lower, 0.1); + let correction = bounded - height; + *position_368 = std::array::from_fn(|i| { + position_368[i] + up[i].mul_add(correction, delta[i]) + }); +} + +fn subtract(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +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]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn normalize(vector: Vector) -> Vector { + let squared = dot3(vector, vector); + let mut inverse = estimate(squared); + for _ in 0..2 { + inverse = (inverse * 0.5).mul_add((-squared).mul_add(inverse * inverse, 1.0), inverse); + } + vector.map(|value| value * inverse) +} + +#[cfg(test)] +#[path = "position_output/tests.rs"] +mod tests; + diff --git a/crates/skate-core/src/player/offboard/position_output/tests.rs b/crates/skate-core/src/player/offboard/position_output/tests.rs new file mode 100644 index 00000000..b3c6109f --- /dev/null +++ b/crates/skate-core/src/player/offboard/position_output/tests.rs @@ -0,0 +1,61 @@ +use super::*; +const UP: Vector = [0.0, 1.0, 0.0, 0.0]; +const FORWARD: Vector = [0.0, 0.0, 1.0, 0.0]; +fn close(a: f32, b: f32) { assert!((a-b).abs() < 2e-6, "{a} != {b}"); } +fn frame() -> FrameOutput { + FrameOutput { frame: [[1.0,0.0,0.0,0.0], UP, FORWARD, [0.0;4]], velocity: [0.0;4] } +} +fn input() -> PositionInput { + PositionInput { previous_origin_112:[0.0;4], override_origin_592:[0.0;4], use_override_711:false, + projection_axis_416:UP, contact_flags_176:0, contact_origin_0:[0.0;4], + contact_origin_96:[0.0;4], frame_position_176:[0.0;4], animation_position_272:[2.0,0.0,3.0,0.0] } +} + +#[test] +fn frame_tracks_horizontal_motion_but_limits_downward_velocity_change() { + let mut state = frame(); + state.update(FORWARD, UP, UP, [1.0,-1.0,2.0,0.0]); + close(state.velocity[0],60.0); + close(state.velocity[1],-1.0); + close(state.velocity[2],120.0); + close(state.frame[3][0],1.0); + close(state.frame[3][1],-1.0/60.0); + state.update(FORWARD, UP, UP, [1.0,-1.0,2.0,0.0]); + close(state.velocity[1],-2.0); + close(state.frame[3][1],-3.0/60.0); +} + +#[test] +fn upward_frame_velocity_is_not_limited_and_basis_is_normalized() { + let mut state = frame(); + state.update([0.0,0.0,3.0,0.0], UP, UP, [0.0,1.0,0.0,0.0]); + close(state.velocity[1],60.0); + close(state.frame[3][1],1.0); + close(state.frame[0][0], 1.0); + close(state.frame[2][2], 1.0); +} + +#[test] +fn position_follows_planar_animation_and_limits_vertical_change_per_update() { + let mut position = [0.0;4]; + update_position(&mut position,input()); + assert_eq!(position[0],2.0); + assert_eq!(position[2],3.0); + close(position[1],0.1); + update_position(&mut position,input()); + close(position[1],0.2); +} + +#[test] +fn alternate_origin_precedes_first_contact_and_second_contact_is_capped() { + let mut position = [0.0,0.95,0.0,0.0]; + let mut i=input(); + i.contact_flags_176=3; + i.contact_origin_0=[0.0,10.0,0.0,0.0]; + i.contact_origin_96=[0.0,20.0,0.0,0.0]; + i.use_override_711=true; + i.override_origin_592=[0.0,-0.3,0.0,0.0]; + update_position(&mut position,i); + close(position[1],0.95); //override-.3 then second-contact+.3 then offset+.95. +} + diff --git a/crates/skate-core/src/player/offboard/reset_animation.rs b/crates/skate-core/src/player/offboard/reset_animation.rs new file mode 100644 index 00000000..087a4c95 --- /dev/null +++ b/crates/skate-core/src/player/offboard/reset_animation.rs @@ -0,0 +1,48 @@ +//! Original TU3 reset field writes. Borrow the canonical animation/MG owners. +///82B97308 ->82B972A8. This is the original stance enum comparison, not XOR +///of two guessed booleans; all original integer cases remain distinguishable. +pub fn given_stance( + original_stance: u32, + requested: &mut u32, + flags: &mut u32, + mirrored: &mut bool, +) { + if (original_stance != 1 || *requested != 0) && (original_stance != 0 || *requested != 1) { + *flags |= 0xc000_0000; + *mirrored = original_stance != 0; + } else { + *flags &= 0x3fff_ffff; + *mirrored = original_stance != 1; + } + *requested = 0; +} +///82B98050's two raw rlwinm masks; unrelated stance/request fields survive. +pub fn skater_flags(flags: &mut u32, mirrored: &mut bool, word_15320: &mut u32) { + *flags &= 0x0ff7_ffff; + *mirrored = false; + *word_15320 = 0; +} +///FullMG5840..5920 inclusive, borrowed as its21 published scalar/flag words. +///Only the recovered writes occur. In particular5912/5916 survive. +pub fn motion_output(words: &mut [u32; 21], gesture: &mut u32, gesture_flags: &mut [u8; 4]) { + for index in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 17] { + words[index] = 0; + } + words[11] &= 0x7fff_ffff; + words[13] &= 0x1fff_ffff; + words[15] &= 0x1fff_ffff; + words[16] &= 0x3fff_ffff; + words[20] = (words[20] & 0x01fb_ffff) | 0x0200_0000; + *gesture = 37; + gesture_flags[0] = 0; + gesture_flags[1] = 0; +} +#[cfg(test)] +mod tests; +///8258F360 writes four meaningful request words and clears last word bit31. +///The native last word's lower31 bits originate in uninitialized stack data; +///they are not assigned a fabricated deterministic payload by this API. +pub fn motion_request(words_2664_2676: &mut [u32; 4], active_2680: &mut bool) { + *words_2664_2676 = [0, 0x3e4c_cccd, 0, 0]; + *active_2680 = false; +} diff --git a/crates/skate-core/src/player/offboard/reset_animation/tests.rs b/crates/skate-core/src/player/offboard/reset_animation/tests.rs new file mode 100644 index 00000000..4d84b597 --- /dev/null +++ b/crates/skate-core/src/player/offboard/reset_animation/tests.rs @@ -0,0 +1,32 @@ +use super::*; +#[test] +fn reset_is_selective_not_full_default() { + let mut words = [u32::MAX; 21]; + let mut gesture = 8; + let mut bytes = [9; 4]; + motion_output(&mut words, &mut gesture, &mut bytes); + assert_eq!(words[18], u32::MAX); + assert_eq!(words[19], u32::MAX); + assert_eq!(words[11], 0x7fffffff); + assert_eq!(words[20], 0x03fbffff); + assert_eq!(gesture, 37); + assert_eq!(bytes, [0, 0, 9, 9]); +} +#[test] +fn given_stance_retains_lower_flags_and_consumes_request() { + for (original, request, mirror, upper) in [ + (0, 0, false, 3), + (0, 1, true, 0), + (1, 0, false, 0), + (1, 1, true, 3), + (2, 0, true, 3), + ] { + let mut request = request; + let mut flags = 0x1234; + let mut mirrored = false; + given_stance(original, &mut request, &mut flags, &mut mirrored); + assert_eq!(mirrored, mirror); + assert_eq!(flags, (upper << 30) | 0x1234); + assert_eq!(request, 0); + } +} diff --git a/crates/skate-core/src/player/offboard/slide.rs b/crates/skate-core/src/player/offboard/slide.rs new file mode 100644 index 00000000..eb2d941b --- /dev/null +++ b/crates/skate-core/src/player/offboard/slide.rs @@ -0,0 +1,92 @@ +//! Biped special-mode latch82D7C8E8 and sliding velocity82D7CC18. +use super::contact_correction::{clamp_length, inverse_length, magnitude}; +use crate::{physics::native_arithmetic::dot3, point_graph::PointGraph}; +type Vector = [f32; 4]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpecialMode { + pub enabled_714: bool, + pub elapsed_788: f32, +} +impl SpecialMode { + pub fn update(&mut self, move_input_308: f32, contact_flags_176: u32, forward_y: f32) { + if self.enabled_714 { + self.elapsed_788 += f32::from_bits(0x3c88_8889); + if !(move_input_308 >= 0.5) + || (!(self.elapsed_788 <= 0.5) && contact_flags_176 & 0x200 == 0) + { + self.enabled_714 = false; + } + } else if !(self.elapsed_788 <= 0.0) { + if f32::from_bits(0x3e19_999a) > forward_y { + self.elapsed_788 = 0.0; + } + } else if contact_flags_176 & 0x200 != 0 { + self.elapsed_788 = 0.0; + self.enabled_714 = true; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Sliding { + pub velocity_528: Vector, + pub active_710: bool, +} +#[derive(Clone, Copy, Debug)] +pub struct Input { + pub special_mode_714: bool, + pub surface_normal_560: Vector, + pub movement_velocity_480: Vector, + pub contact_direction_400: Option, +} +impl Sliding { + pub fn update(&mut self, input: Input, slide_vs_slope: &PointGraph<8>, slide_vs_speed: &PointGraph<8>) { + //82D7CC44 returns without changing either retained field in this mode. + if input.special_mode_714 { return; } + let down = [0.0, -1.0, 0.0, 0.0]; + let normal = input.surface_normal_560; + let projection = dot3(normal, down); + let downhill: Vector = std::array::from_fn(|i| down[i] - normal[i] * projection); + let tilt = -downhill[1]; + let lower = select(-tilt, 0.0, tilt); + let slope = select(1.0 - lower, lower, 1.0); + let uphill = safe_unit(downhill).map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)); + let speed = dot3(input.movement_velocity_480, uphill); + let factor = -(slide_vs_slope.evaluate(slope) * slide_vs_speed.evaluate(speed)); + //82F82588 initializes830BD400 by splatting822F8B40 (-9.8). + let acceleration = clamp_length(downhill.map(|v| v * (f32::from_bits(0xc11c_cccd) * factor)), 5.0); + self.velocity_528 = std::array::from_fn(|i| { + acceleration[i].mul_add(f32::from_bits(0x3d4c_cccd), self.velocity_528[i] * 0.95) + }); + let threshold = f32::from_bits(0x3c23_d70b); + if threshold > dot3(acceleration, acceleration) { + self.velocity_528 = self.velocity_528.map(|v| v * 0.9); + } + if let Some(direction) = input.contact_direction_400 { + self.velocity_528 = reject_positive(self.velocity_528, direction.map(|v| -v)); + } + self.active_710 = dot3(self.velocity_528, self.velocity_528) > threshold; + } +} +fn select(test: f32, positive: f32, negative: f32) -> f32 { + if test >= 0.0 { positive } else { negative } +} +fn safe_unit(vector: Vector) -> Vector { + let square = dot3(vector, vector); + let inverse = inverse_length(square); + if magnitude(square) > f32::from_bits(0x3586_37bd) { + vector.map(|v| v * inverse) + } else { [0.0; 4] } +} +///82C1E170: project against safe unit, subtract the original supplied direction. +fn reject_positive(vector: Vector, direction: Vector) -> Vector { + let projection = dot3(vector, safe_unit(direction)); + if projection > 0.0 { + std::array::from_fn(|i| vector[i] - direction[i] * projection) + } else { vector } +} + +#[cfg(test)] +#[path = "slide/tests.rs"] +mod tests; diff --git a/crates/skate-core/src/player/offboard/slide/tests.rs b/crates/skate-core/src/player/offboard/slide/tests.rs new file mode 100644 index 00000000..806207d8 --- /dev/null +++ b/crates/skate-core/src/player/offboard/slide/tests.rs @@ -0,0 +1,56 @@ +use super::*; +fn constant(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 input() -> Input { + Input { special_mode_714:false, surface_normal_560:[0.0,1.0,0.0,0.0], + movement_velocity_480:[0.0;4], contact_direction_400:None } +} +fn close(a:f32,b:f32) { assert!((a-b).abs()<1e-6,"{a} != {b}"); } + +#[test] +fn special_mode_retains_elapsed_until_low_heading_allows_reentry() { + let mut mode=SpecialMode {enabled_714:false,elapsed_788:0.0}; + mode.update(1.0,0x200,0.0); + assert!(mode.enabled_714); + mode.update(0.0,0,0.5); + assert!(!mode.enabled_714); + assert!(mode.elapsed_788>0.0); + mode.update(1.0,0x200,0.5); + assert!(!mode.enabled_714); + mode.update(1.0,0x200,0.0); + assert_eq!(mode.elapsed_788,0.0); + assert!(!mode.enabled_714); + mode.update(1.0,0x200,0.0); + assert!(mode.enabled_714); +} + +#[test] +fn special_mode_skips_both_slide_fields() { + let mut slide=Sliding {velocity_528:[1.0,2.0,3.0,0.0],active_710:true}; + let prior=slide; + let mut i=input(); i.special_mode_714=true; + slide.update(i,&constant(1.0),&constant(1.0)); + assert_eq!(slide,prior); +} + +#[test] +fn flat_surface_decays_old_slide_without_downhill_force() { + let mut slide=Sliding {velocity_528:[1.0,0.0,0.0,0.0],active_710:true}; + slide.update(input(),&constant(1.0),&constant(1.0)); + close(slide.velocity_528[0],0.95*0.9); + assert!(slide.active_710); +} + +#[test] +fn slope_force_follows_surface_and_contact_removes_into_wall_component() { + let mut i=input(); i.surface_normal_560=[0.0,0.8,0.6,0.0]; + let mut slide=Sliding {velocity_528:[0.0;4],active_710:false}; + slide.update(i,&constant(1.0),&constant(1.0)); + assert!(slide.velocity_528[1]<0.0); + assert!(slide.velocity_528[2]>0.0); + assert!(slide.active_710); + i.contact_direction_400=Some([0.0,0.0,-1.0,0.0]); + slide.update(i,&constant(1.0),&constant(1.0)); + close(slide.velocity_528[2],0.0); +} diff --git a/crates/skate-core/src/player/offboard/surface_frame.rs b/crates/skate-core/src/player/offboard/surface_frame.rs new file mode 100644 index 00000000..07e908c5 --- /dev/null +++ b/crates/skate-core/src/player/offboard/surface_frame.rs @@ -0,0 +1,85 @@ +//! Original Biped82D7E2A8 surface normal and82D7E840 spring/lean stages. +//! Only these stages' written fields are retained here; all vectors keep VMX w. +mod math; +use math::*; +pub type Vector = [f32; 4]; +const UP: Vector = [0.0, 1.0, 0.0, 0.0]; + +#[derive(Clone, Debug, PartialEq)] +pub struct State { + pub surface_normal: Vector, //544 + pub source_normal: Vector, //560 + pub spring_normal: Vector, //416 + pub spring_delta: Vector, //432 + pub final_up: Vector, //448 + pub lean: Vector, //464 +} +impl Default for State { + fn default() -> Self { + Self { surface_normal: UP, source_normal: UP, spring_normal: UP, spring_delta: [0.0; 4], final_up: UP, lean: [0.0; 4] } + } +} +pub struct SurfaceInput { + pub flags: u32, //input176 + pub normal: Vector, //input16 + pub origin: Vector, //input0 + pub edge_point: Vector, //input128 + pub edge_normal: Vector, //input144 +} +pub struct SpringInput { + pub velocity: Vector, //Biped480 + pub up: Vector, //Biped16 + pub right: Vector, //Biped0 + pub forward: Vector, //Biped32 + pub spring_right: Vector, //Biped128 + pub spring_forward: Vector, //Biped160 + pub right_delta: f32, //Biped696 + pub forward_delta: f32, //Biped692 + pub suppress_lean: bool, //input317 +} +impl State { + pub fn update_surface(&mut self, input: &SurfaceInput) { + if input.flags & 1 == 0 { return; } + self.source_normal = input.normal; + let mut target = input.normal; + if input.flags & 4 != 0 { + let delta = sub(input.edge_point, input.origin); + let right = normalize_or(cross(UP, delta), [0.0; 4]); + let candidate = normalize_or(cross(delta, right), input.normal); + let limit = 45.0 * f32::from_bits(0x3c8e_fa35); + let first = clamp_angle(candidate, input.normal, limit); + let second = clamp_angle(input.edge_normal, input.normal, limit); + let lateral = scale(right, dot(right, input.normal)); + target = normalize_or(sub(input.normal, lateral), UP); + if first[1] > target[1] { target = first; } + if second[1] > target[1] { target = second; } + let remaining = clamp(1.0 - dot(lateral, lateral), 0.0, 1.0); + target = madd(target, square_root(remaining), lateral); + } + let angle = wrap_angle(unsigned_angle(self.surface_normal, target)).abs(); + if angle < f32::from_bits(0x3f49_0fdb) || target[1] > f32::from_bits(0x3f35_c28f) { + self.surface_normal = normalize_or(madd(self.surface_normal, 0.5, scale(target, 0.5)), self.surface_normal); + } + } + + pub fn update_spring(&mut self, input: &SpringInput) { + let blend = clamp(length(input.velocity) * 0.125, 0.0, 1.0); + let candidate = madd(self.surface_normal, blend, scale(UP, 1.0 - blend)); + let limited = clamp_angle(candidate, self.surface_normal, 36.0 * f32::from_bits(0x3c8e_fa35)); + let target = normalize_or(limited, input.up); + let desired_delta = scale(sub(target, self.spring_normal), f32::from_bits(0x3e4c_cccd)); + let desired_delta = clamp_axis(desired_delta, input.spring_right, f32::from_bits(0xbccc_cccd), f32::from_bits(0x3ccc_cccd)); + let acceleration = sub(desired_delta, self.spring_delta); + let acceleration = clamp_axis(acceleration, input.spring_right, f32::from_bits(0xbbc4_9ba6), f32::from_bits(0x3bc4_9ba6)); + let acceleration = clamp_axis(acceleration, input.spring_forward, f32::from_bits(0xbd4c_cccd), f32::from_bits(0x3d4c_cccd)); + self.spring_delta = add(self.spring_delta, acceleration); + self.spring_normal = normalize_or(add(self.spring_normal, self.spring_delta), self.spring_normal); + let lean_target = if input.suppress_lean { [0.0; 4] } else { + madd(input.forward, input.forward_delta * f32::from_bits(0x3d23_d70a), scale(input.right, input.right_delta * f32::from_bits(0x3f33_3333))) + }; + self.lean = madd(lean_target, f32::from_bits(0x3d4c_cccd), scale(self.lean, f32::from_bits(0x3f73_3333))); + self.final_up = normalize_or(add(self.lean, self.spring_normal), self.spring_normal); + } +} +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/offboard/surface_frame/math.rs b/crates/skate-core/src/player/offboard/surface_frame/math.rs new file mode 100644 index 00000000..c0c15c7c --- /dev/null +++ b/crates/skate-core/src/player/offboard/surface_frame/math.rs @@ -0,0 +1,54 @@ +//! Original82BD3E78/8296EBB0 and inlined VMX arithmetic. +use super::Vector; +pub(super) fn select(test: f32, yes: f32, no: f32) -> f32 { if test >= 0.0 { yes } else { no } } +pub(super) fn clamp(v: f32, low: f32, high: f32) -> f32 { let v = select(low-v, low, v); select(high-v, v, high) } +pub(super) fn dot(a: Vector, b: Vector) -> f32 { a[0]*b[0]+a[1]*b[1]+a[2]*b[2] } +pub(super) fn scale(v: Vector, s: f32) -> Vector { v.map(|x| x*s) } +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 madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i| a[i].mul_add(s,b[i])) } +pub(super) fn cross(a: Vector, b: Vector) -> Vector { + //vpermwi0x63 preserves lane w; its fused subtraction is retained too. + [(-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])] +} +fn inverse(value: f32, steps: usize) -> f32 { + //Ordinary host seed, not a claim of bit-exact Xenon estimate emulation. + let mut r = crate::physics::reciprocal_sqrt::estimate(value); + for _ in 0..steps { let e=(-value).mul_add(r*r,1.0); r=(r*0.5).mul_add(e,r); } + r +} +pub(super) fn square_root(value: f32) -> f32 { let result=value*inverse(value,2); if value==0.0 {0.0} else {result} } +pub(super) fn length(v: Vector) -> f32 { square_root(dot(v,v)) } +pub(super) fn normalize_or(v: Vector, fallback: Vector) -> Vector { + let sq=dot(v,v); let r=inverse(sq,2); let len=if sq==0.0 {0.0} else {sq*r}; + if len>f32::from_bits(0x3586_37bd) {scale(v,r)} else {fallback} +} +pub(super) fn unsigned_angle(a: Vector, b: Vector) -> f32 { + let aa=dot(a,a); let bb=dot(b,b); let gate=f32::from_bits(0x38d1_b717); + if !(aa>gate && bb>gate) {return 0.0;} + crate::trigonometry::acos(dot(scale(a,inverse(aa,1)),scale(b,inverse(bb,1))).max(-1.0).min(1.0)) +} +pub(super) fn wrap_angle(a: f32) -> f32 { + let t=a*f32::from_bits(0x3e22_f983); let f=t-t.floor(); + (f-if f>0.5 {1.0}else{0.0})*f32::from_bits(0x40c9_0fdb) +} +pub(super) fn clamp_axis(value: Vector, axis: Vector, low: f32, high: f32) -> Vector { + let projection=dot(value,axis); + madd(axis,clamp(projection,low,high),sub(value,scale(axis,projection))) +} +///82BD3E78 returns original target when close or cross-axis degenerate. +pub(super) fn clamp_angle(target: Vector, reference: Vector, limit: f32) -> Vector { + let a=normalize_or(target,[0.0;4]); let b=normalize_or(reference,[0.0;4]); + let axis=cross(a,b); + let angle=crate::trigonometry::acos(dot(a,b).max(-1.0).min(1.0)); + let turns=angle.mul_add(f32::from_bits(0x3e22_f983),0.5).floor(); + let wrapped=(-turns).mul_add(f32::from_bits(0x40c9_0fdb),angle); + if wrapped.abs()dot(axis,axis) {return target;} + let unit=scale(axis,inverse(dot(axis,axis),2)); + let (sin,cos)=crate::trigonometry::sin_cos((-limit)*0.5); + let mut q=scale(unit,sin); q[3]=cos; + let first=cross(q,reference); + let middle=madd(reference,cos,first); + let rotated=madd(cross(q,middle),2.0,reference); + scale(rotated,length(target)) +} diff --git a/crates/skate-core/src/player/offboard/surface_frame/tests.rs b/crates/skate-core/src/player/offboard/surface_frame/tests.rs new file mode 100644 index 00000000..aa9f19cc --- /dev/null +++ b/crates/skate-core/src/player/offboard/surface_frame/tests.rs @@ -0,0 +1,42 @@ +use super::*; +fn surface() -> SurfaceInput { SurfaceInput {flags:1,normal:UP,origin:[0.0;4],edge_point:[0.0,0.0,1.0,0.0],edge_normal:UP} } +fn spring() -> SpringInput { + SpringInput {velocity:[0.0;4],up:UP,right:[1.0,0.0,0.0,0.0],forward:[0.0,0.0,1.0,0.0],spring_right:[1.0,0.0,0.0,0.0],spring_forward:[0.0,0.0,1.0,0.0],right_delta:0.0,forward_delta:0.0,suppress_lean:false} +} +#[test] +fn missing_contact_preserves_both_normals() { + let mut state=State::default(); let before=state.clone(); + let mut input=surface(); input.flags=0; input.normal=[1.0,0.0,0.0,7.0]; + state.update_surface(&input); assert_eq!(state,before); +} +#[test] +fn large_normal_jump_updates_source_but_rejects_surface() { + let mut state=State::default(); let mut input=surface(); input.normal=[1.0,0.0,0.0,2.0]; + state.update_surface(&input); assert_eq!(state.source_normal,input.normal); assert_eq!(state.surface_normal,UP); +} +#[test] +fn accepted_normal_preserves_w_through_normalization() { + let mut state=State::default(); let mut input=surface(); input.normal=[0.0,1.0,0.0,2.0]; + state.update_surface(&input); assert_eq!(state.surface_normal,[0.0,1.0,0.0,1.0]); +} +#[test] +fn edge_degeneracy_and_angle_antiparallel_return_original() { + let mut state=State::default(); let mut input=surface(); input.flags=5; input.edge_point=input.origin; + state.update_surface(&input); assert_eq!(state.surface_normal,UP); + let target=[0.0,-1.0,0.0,3.0]; assert_eq!(clamp_angle(target,UP,0.5),target); +} +#[test] +fn lean_is_separate_from_spring_and_suppression_decays_it() { + let mut state=State::default(); let mut input=spring(); input.right_delta=1.0; + state.update_spring(&input); + assert_eq!(state.spring_normal,UP); assert_eq!(state.spring_delta,[0.0;4]); + assert!((state.lean[0]-0.035).abs()<0.000001); assert!(state.final_up[0]>0.0); + let prior=state.lean; input.suppress_lean=true; state.update_spring(&input); + assert_eq!(state.lean,scale(prior,f32::from_bits(0x3f73_3333))); +} +#[test] +fn angle_limit_rotates_reference_toward_target_and_retains_target_magnitude() { + let limited=clamp_angle([2.0,0.0,0.0,0.0],UP,f32::from_bits(0x3f49_0fdb)); + assert!((length(limited)-2.0).abs()<0.00001); + assert!((limited[0]-limited[1]).abs()<0.00001); assert!(limited[0]>0.0); +} diff --git a/crates/skate-core/src/player/offboard/twist_lean.rs b/crates/skate-core/src/player/offboard/twist_lean.rs new file mode 100644 index 00000000..2d1facfe --- /dev/null +++ b/crates/skate-core/src/player/offboard/twist_lean.rs @@ -0,0 +1,50 @@ +//! MatchTwistAndLean: TU3 Begin82BC0848, Update82BC0860, read82BC0968. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Observation { + /// Actual PhysOutSkeleton+496 and+500, produced by active-state Fill. + pub hips_right_angle: f32, + pub hips_up_angle: f32, + /// Live ISkaterAnim virtual28, not a physical stance snapshot. + pub mirrored: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct State { + pub twist: f32, + pub lean: f32, +} + +impl State { + /// Instance constructor82BA8DC0 initializes both retained values to zero. + /// An absent original component makes read82BC0968 leave them unchanged. + pub fn begin(&mut self, observation: Option) { + if let Some(observation) = observation { + *self = read(observation); + } + } + + /// The default publishes captured values; exactly update="always" rereads. + /// The live branch initializes its local values to zero before the read. + pub fn update(&self, always: bool, observation: Option) -> Self { + if always { + observation.map(read).unwrap_or_default() + } else { + *self + } + } +} + +fn read(observation: Observation) -> State { + State { + twist: if observation.mirrored { + -observation.hips_up_angle + } else { + observation.hips_up_angle + }, + lean: observation.hips_right_angle, + } +} + +#[cfg(test)] +#[path = "twist_lean/tests.rs"] +mod tests; diff --git a/crates/skate-core/src/player/offboard/twist_lean/tests.rs b/crates/skate-core/src/player/offboard/twist_lean/tests.rs new file mode 100644 index 00000000..4eceec09 --- /dev/null +++ b/crates/skate-core/src/player/offboard/twist_lean/tests.rs @@ -0,0 +1,31 @@ +use super::*; +fn observation(right: f32, up: f32, mirrored: bool) -> Option { + Some(Observation { hips_right_angle: right, hips_up_angle: up, mirrored }) +} + +#[test] +fn capture_preserves_twist_and_lean_after_physics_and_stance_change() { + let mut state = State::default(); + state.begin(observation(0.25, 0.75, true)); + assert_eq!(state.update(false, observation(1.5, 2.0, false)), State { twist: -0.75, lean: 0.25 }); + state.begin(observation(1.5, 2.0, false)); + assert_eq!(state.update(false, None), State { twist: 2.0, lean: 1.5 }); +} + +#[test] +fn always_reads_live_angles_without_changing_capture() { + let mut state = State::default(); + state.begin(observation(0.25, 0.75, false)); + assert_eq!(state.update(true, observation(1.5, 2.0, true)), State { twist: -2.0, lean: 1.5 }); + assert_eq!(state.update(false, None), State { twist: 0.75, lean: 0.25 }); +} + +#[test] +fn missing_original_components_preserve_capture_but_live_locals_start_zero() { + let mut state = State::default(); + state.begin(observation(0.25, 0.75, false)); + state.begin(None); + assert_eq!(state.update(false, None), State { twist: 0.75, lean: 0.25 }); + assert_eq!(state.update(true, None), State::default()); +} + diff --git a/crates/skate-core/src/player/post_input.rs b/crates/skate-core/src/player/post_input.rs new file mode 100644 index 00000000..23219d16 --- /dev/null +++ b/crates/skate-core/src/player/post_input.rs @@ -0,0 +1,219 @@ +//! Complete player-owned portion of TU3 `PhysicalPlayerHiLOD::PostInput` +//! (`0x82DB5588`) and its flag-latch helper (`0x82DB5CF8`). +//! +//! Grind, trajectory, the scalar calculation at `0x82DB5E10`, and candidate +//! registration remain required services because they own separate systems. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PostInputPlayerFields { + /// Player+1264, captured from PhysOut when its +442 byte is set. + pub jump_reference_1264: [u32; 4], + /// Player+1296. + pub flags_1296: u32, + /// Player+1304. + pub state_frames_1304: u32, + /// Player+1308. + pub jump_fix_frames_1308: u32, + /// Player+1320, owned by `0x82DB5CF8`. + pub latch_frames_1320: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PostInputProcessedFields { + /// ProcessedPhysIn+848. + pub jump_reference_848: [u32; 4], + pub word_2464: u32, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub current_state_2508: u32, + pub state_frames_2572: u32, + pub jump_fix_frames_2576: u32, + pub scalar_2740: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PostInputPhysOutFields { + /// PhysOut+32 -> +316. + pub reset_state_frames_316: bool, + /// PhysOut+8 -> +442. + pub capture_jump_reference_442: bool, + /// PhysOut+8 -> +128. + pub jump_reference_128: [u32; 4], + /// PhysOut+76, set after every successful PostInput pass. + pub complete_76: bool, +} + +/// Player+1840 data consumed by `0x82D740F8`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CandidatePublicationFields { + pub first_object_present_196: bool, + pub first_pending_288: bool, + pub second_object_present_500: bool, + pub second_pending_592: bool, + pub staged_word_12768: u32, + pub staged_valid_12772: u32, + pub staged_latched_12776: u32, + pub staged_pending_12780: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CandidateRegistration { + /// `sub_82762AB0(ProcessedPhysIn+1888, component)`. + First1888, + /// `sub_82762AB0(ProcessedPhysIn+2176, component+304)`. + Second2176, +} + +/// Calls to independent systems, in their exact `0x82DB5588` order. +pub trait PostInputServices { + fn update_grind_manager_82d8ab08(&mut self); + fn update_trajectory_selector_82d68800(&mut self) -> u8; + fn calculate_scalar_2740_82db5e10(&mut self) -> f32; + fn register_candidate_82762ab0(&mut self, registration: CandidateRegistration); +} + +pub struct PostInputContext<'a> { + pub player: &'a mut PostInputPlayerFields, + pub processed: &'a mut PostInputProcessedFields, + pub phys_out: &'a mut PostInputPhysOutFields, + pub candidates: &'a mut CandidatePublicationFields, +} + +/// Runs the full TU3 PostInput phase for the fields owned by PhysicalPlayer. +pub fn run_post_input(context: PostInputContext<'_>, services: &mut impl PostInputServices) { + let PostInputContext { + player, + processed, + phys_out, + candidates, + } = context; + + services.update_grind_manager_82d8ab08(); + + if phys_out.reset_state_frames_316 { + player.state_frames_1304 = 0; + } + player.state_frames_1304 = player.state_frames_1304.wrapping_add(1); + + let reset_state_frames = processed.flags_2480 & 0x0000_2000 != 0 + || (processed.flags_2468 & 0x0040_0000 != 0 && player.flags_1296 & 0x4000_0000 != 0); + if reset_state_frames { + player.state_frames_1304 = 0; + player.flags_1296 &= !0x4000_0000; + } else { + replace_bit( + &mut player.flags_1296, + 30, + u32::from(processed.flags_2468 & 0x0040_0000 == 0), + ); + processed.flags_2468 &= !0x0040_0000; + } + processed.state_frames_2572 = player.state_frames_1304; + + if phys_out.capture_jump_reference_442 { + player.jump_fix_frames_1308 = 1; + player.jump_reference_1264 = phys_out.jump_reference_128; + } else { + player.jump_fix_frames_1308 = player.jump_fix_frames_1308.wrapping_add(1); + } + processed.jump_fix_frames_2576 = player.jump_fix_frames_1308; + processed.jump_reference_848 = player.jump_reference_1264; + + replace_bit( + &mut processed.flags_2468, + 10, + u32::from(services.update_trajectory_selector_82d68800() & 1), + ); + processed.scalar_2740 = services.calculate_scalar_2740_82db5e10(); + replace_bit(&mut processed.flags_2484, 25, (player.flags_1296 >> 24) & 1); + + if processed.flags_2484 & 0x0000_0800 != 0 { + player.flags_1296 |= 0x0020_0000; + } else if processed.current_state_2508 == 103 { + replace_bit(&mut processed.flags_2484, 11, (player.flags_1296 >> 21) & 1); + } else { + player.flags_1296 &= !0x0020_0000; + } + + update_flag_latches_82db5cf8(player, processed); + publish_candidates_82d740f8(candidates, processed, services); + phys_out.complete_76 = true; +} + +/// Exact scalar bit transfers and latch lifetime from TU3 `0x82DB5CF8`. +fn update_flag_latches_82db5cf8( + player: &mut PostInputPlayerFields, + processed: &mut PostInputProcessedFields, +) { + if processed.flags_2468 & 0x0020_0000 != 0 { + player.latch_frames_1320 = 0; + player.flags_1296 |= 0x0200_0000; + } + if processed.flags_2472 & 0x0000_0020 != 0 || processed.flags_2468 & 0x0040_0000 != 0 { + player.latch_frames_1320 = 0; + player.flags_1296 |= 0x0600_0000; + } + + let flags_before_expiry = player.flags_1296; + if flags_before_expiry & 0x0200_0000 != 0 { + if processed.flags_2472 & 0x0000_0010 != 0 + || (flags_before_expiry & 0x0800_0000 != 0 && processed.flags_2472 & 0x0000_8000 == 0) + { + player.flags_1296 &= 0xF9FF_FFFF; + } + let frames = player.latch_frames_1320; + if frames > 20 && player.flags_1296 & 0x0800_0000 == 0 { + player.flags_1296 &= 0xF9FF_FFFF; + } + player.latch_frames_1320 = frames.wrapping_add(1); + } + + replace_bit(&mut player.flags_1296, 28, (processed.flags_2472 >> 4) & 1); + replace_bit(&mut player.flags_1296, 27, (processed.flags_2472 >> 15) & 1); + replace_bit(&mut player.flags_1296, 17, (processed.flags_2480 >> 17) & 1); + replace_bit(&mut processed.flags_2472, 3, (player.flags_1296 >> 26) & 1); + replace_bit(&mut processed.flags_2472, 2, (player.flags_1296 >> 25) & 1); +} + +/// Complete TU3 `0x82D740F8` publication/reset behavior. +fn publish_candidates_82d740f8( + fields: &mut CandidatePublicationFields, + processed: &mut PostInputProcessedFields, + services: &mut impl PostInputServices, +) { + let first = fields.first_pending_288 && fields.first_object_present_196; + if first { + services.register_candidate_82762ab0(CandidateRegistration::First1888); + } + replace_bit(&mut processed.flags_2480, 22, u32::from(first)); + fields.first_pending_288 = false; + + let second = fields.second_pending_592 && fields.second_object_present_500; + if second { + services.register_candidate_82762ab0(CandidateRegistration::Second2176); + } + replace_bit(&mut processed.flags_2480, 21, u32::from(second)); + fields.second_pending_592 = false; + + if fields.staged_pending_12780 { + fields.staged_latched_12776 = 0; + if fields.staged_valid_12772 != 0 { + fields.staged_latched_12776 = 1; + processed.flags_2480 |= 0x0010_0000; + processed.word_2464 = fields.staged_word_12768; + } else { + processed.flags_2480 &= !0x0010_0000; + } + } + fields.staged_pending_12780 = false; +} + +fn replace_bit(word: &mut u32, bit: u32, value: u32) { + *word = (*word & !(1 << bit)) | ((value & 1) << bit); +} + +#[cfg(test)] +#[path = "tests/post_input.rs"] +mod tests; diff --git a/crates/skate-core/src/player/post_output.rs b/crates/skate-core/src/player/post_output.rs new file mode 100644 index 00000000..5a2c2f27 --- /dev/null +++ b/crates/skate-core/src/player/post_output.rs @@ -0,0 +1,33 @@ +//! TU3 `PhysicalPlayerHiLOD::PostOutput` (`0x82DB6CB0`). +//! +//! This is the final player-owned phase before the world finishes output and +//! conditioners. The native body performs exactly two calls in this order. + +/// Required services called by the complete `0x82DB6CB0` wrapper. +pub trait PostOutputServices { + /// `0x82909510(Player+1392, 0, -1)`. + fn finish_player_output_82909510(&mut self, player_field_1392: &mut PlayerOutputField1392); + + /// `0x82BE2BA8(Player->Skeleton)`. + fn finish_skeleton_output_82be2ba8(&mut self); +} + +/// Opaque Player+1392-owned object passed to `0x82909510`. +/// +/// Its internal layout is deliberately absent: PostOutput only passes its +/// address and the fixed native arguments `0` and `-1`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PlayerOutputField1392; + +/// Runs the complete TU3 PostOutput phase. +pub fn run_post_output( + player_field_1392: &mut PlayerOutputField1392, + services: &mut impl PostOutputServices, +) { + services.finish_player_output_82909510(player_field_1392); + services.finish_skeleton_output_82be2ba8(); +} + +#[cfg(test)] +#[path = "tests/post_output.rs"] +mod tests; diff --git a/crates/skate-core/src/player/post_state.rs b/crates/skate-core/src/player/post_state.rs new file mode 100644 index 00000000..420dfa78 --- /dev/null +++ b/crates/skate-core/src/player/post_state.rs @@ -0,0 +1,18 @@ +//! TU3 `PhysicalPlayerHiLOD::PostState` thunk (`0x82DB62F8`). +//! +//! Raw instructions load Player+1796 and tail-call the current physical +//! state's vtable slot `+12`. The function has no other side effects. + +/// Mandatory current-state dispatch performed by `0x82DB62F8`. +pub trait PostStateServices { + fn update_current_state_vtable_12(&mut self); +} + +/// Runs the complete PhysicalPlayer PostState phase. +pub fn run_post_state(services: &mut impl PostStateServices) { + services.update_current_state_vtable_12(); +} + +#[cfg(test)] +#[path = "tests/post_state.rs"] +mod tests; diff --git a/crates/skate-core/src/player/pre_state.rs b/crates/skate-core/src/player/pre_state.rs new file mode 100644 index 00000000..cfc5ad27 --- /dev/null +++ b/crates/skate-core/src/player/pre_state.rs @@ -0,0 +1,75 @@ +//! Complete TU3 `PhysicalPlayerHiLOD::PreState` (`0x82DB6050`). + +/// The 72-byte stack packet filled through current-state vtable slot `+24`. +/// Only the vector at byte `+48` is consumed by the player wrapper, but the +/// entire packet remains part of the state callback contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreStatePacket { + pub words: [u32; 18], +} + +impl PreStatePacket { + pub const ZERO: Self = Self { words: [0; 18] }; + + pub const fn vector_48(self) -> [u32; 4] { + [ + self.words[12], + self.words[13], + self.words[14], + self.words[15], + ] + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreStatePlayerFields { + /// PhysicalPlayer+1312. + pub frame_counter_1312: u32, + /// Whether PhysicalPlayer+1840 owns the optional pre-state component. + pub component_1840_present: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PreStateSkeletonFields { + /// Skeleton+16112. + pub predicted_position_16112: [u32; 4], + /// Skeleton+16416. + pub predicted_position_set_16416: bool, + /// Skeleton+16432 -> nested object +3184. + pub nested_flag_3184: bool, +} + +/// Every call made by the wrapper, in native order. There is deliberately no +/// default implementation: state and component behavior must be supplied by +/// their recovered modules. +pub trait PreStateServices { + /// Current-state vtable slot `+24`. + fn fill_packet_vtable_24(&mut self, packet: &mut PreStatePacket); + /// `0x82D74270`, reached only when Player+1840 is present. + fn update_component_1840_82d74270(&mut self); + /// Current-state vtable slot `+4`. + fn update_before_state_vtable_4(&mut self); +} + +pub fn run_pre_state( + player: &mut PreStatePlayerFields, + skeleton: &mut PreStateSkeletonFields, + services: &mut impl PreStateServices, +) { + player.frame_counter_1312 = player.frame_counter_1312.wrapping_add(1); + + let mut packet = PreStatePacket::ZERO; + services.fill_packet_vtable_24(&mut packet); + skeleton.predicted_position_16112 = packet.vector_48(); + skeleton.predicted_position_set_16416 = true; + skeleton.nested_flag_3184 = false; + + if player.component_1840_present { + services.update_component_1840_82d74270(); + } + services.update_before_state_vtable_4(); +} + +#[cfg(test)] +#[path = "tests/pre_state.rs"] +mod tests; diff --git a/crates/skate-core/src/player/selector/air.rs b/crates/skate-core/src/player/selector/air.rs new file mode 100644 index 00000000..5ef95114 --- /dev/null +++ b/crates/skate-core/src/player/selector/air.rs @@ -0,0 +1,150 @@ +use super::{FrameFacts, StateSelectionInput, StateSelector}; +use crate::player::state::PhysicalStateId; + +const STATE_SETTLE_SECONDS: f32 = f32::from_bits(0x3DA3_D70A); +const KNOWN_AIR_COLLISION_GRACE_SECONDS: f32 = f32::from_bits(0x3D23_D70A); +const TRAJECTORY_WIPEOUT_SECONDS: f32 = f32::from_bits(0x3D4C_CCCD); +const TRAJECTORY_EXPIRED_SECONDS: f32 = f32::from_bits(0xBE4C_CCCD); + +impl StateSelector { + pub(super) fn select_physics_air( + &self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if p.has_2476(0x80) && !p.has_2484(1) { + return PhysicalStateId::BipedAir; + } + if facts.force_known_air { + if facts.colliding || p.has_2468(0x8000) { + return PhysicalStateId::WipeoutGround; + } + return if p.has_2468(0x400) { + PhysicalStateId::KnownAir + } else { + current + }; + } + if p.state_timer_2664 > STATE_SETTLE_SECONDS { + if facts.colliding { + if p.has_2472(4) { + return PhysicalStateId::PhysicsAir; + } + return if facts.skateboard_animated { + PhysicalStateId::GroundAnimation + } else { + PhysicalStateId::PhysicsGround + }; + } + if self.post_grind_jump_counter > 10 { + if let Some(grind) = facts.grind { + return grind; + } + } + } + if p.has_2468(0x400) { + PhysicalStateId::KnownAir + } else { + current + } + } + + pub(super) fn select_known_air( + &self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + if p.has_2480(0x200_0000) { + return PhysicalStateId::FootPlant; + } + if p.trajectory_collision_time_2772 < TRAJECTORY_WIPEOUT_SECONDS + && (p.has_2476(0x80) || p.has_2476(0x8000)) + { + return PhysicalStateId::WipeoutGround; + } + if p.has_2476(0x80) && !p.has_2484(1) { + return PhysicalStateId::BipedAir; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if facts.force_known_air { + return if facts.colliding || p.has_2468(0x8000) { + PhysicalStateId::WipeoutGround + } else { + current + }; + } + if p.state_timer_2664 > STATE_SETTLE_SECONDS && self.post_grind_jump_counter > 10 { + if let Some(grind) = facts.grind { + return grind; + } + } + if !facts.colliding || p.state_timer_2664 < KNOWN_AIR_COLLISION_GRACE_SECONDS { + if p.trajectory_collision_time_2772 < TRAJECTORY_EXPIRED_SECONDS { + return if p.has_2476(0x400_0000) { + PhysicalStateId::KnownAir + } else { + PhysicalStateId::PhysicsAir + }; + } + if p.has_2468(0x8000) { + return if p.has_2476(0x400_0000) { + PhysicalStateId::WipeoutGround + } else { + PhysicalStateId::PhysicsAir + }; + } + return current; + } + if p.has_2472(4) { + return PhysicalStateId::PhysicsAir; + } + if p.has_2480(0x40000) || p.has_2480(0x20000) || p.has_2480(0x10000) { + return PhysicalStateId::WipeoutGround; + } + if !p.has_2472(0x8000) && !p.has_2472(0x4000) { + PhysicalStateId::PhysicsGround + } else { + PhysicalStateId::GroundAnimation + } + } + + pub(super) fn select_grind( + &self, + _current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2484(0x10_0000) { + return PhysicalStateId::PhysicsAirSecondary; + } + if p.has_2476(0x80) { + return PhysicalStateId::BipedAir; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if p.has_2468(0x100) { + return p.air_variant_from_2468(); + } + facts.grind.unwrap_or(PhysicalStateId::Nonspecific) + } +} diff --git a/crates/skate-core/src/player/selector/conditions.rs b/crates/skate-core/src/player/selector/conditions.rs new file mode 100644 index 00000000..9ed98777 --- /dev/null +++ b/crates/skate-core/src/player/selector/conditions.rs @@ -0,0 +1,57 @@ +//! Small predicates called by TU3 `CalcSuggestedState` (`0x82D8ADE8`). + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoardBodyState { + /// `SkateboardBody + 856`. + pub field_856: f32, + /// `SkateboardBody + 7692`. + pub field_7692: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TwoStageThresholds { + pub field_856_primary: f32, + pub field_856_secondary: f32, + pub field_7692: f32, +} + +/// TU3 `0x82D8BBB8`. All comparisons are strict, matching `fcmpu`/`bgt`. +pub fn condition_is_off_ground_skitching( + body: BoardBodyState, + thresholds: TwoStageThresholds, +) -> bool { + body.field_856 > thresholds.field_856_primary + || (body.field_856 > thresholds.field_856_secondary + && body.field_7692 > thresholds.field_7692) +} + +/// TU3 `0x82D8BD50`. The separate settings source is intentional: the native +/// function reads these three values from the live physics-mode collection. +pub fn condition_is_off_ground(body: BoardBodyState, thresholds: TwoStageThresholds) -> bool { + body.field_856 > thresholds.field_856_primary + || (body.field_856 > thresholds.field_856_secondary + && body.field_7692 > thresholds.field_7692) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkeletonAnimationState { + /// `Skeleton + 16420`. + pub mode_16420: u32, + /// Lane one of the vector at `Skeleton + 12624 + 32`. + pub back_chain_lane_12656: f32, + /// `*(Skeleton + 24) + 800`. + pub threshold_800: f32, +} + +/// TU3 `0x82BDC6C8`. The native absolute value clears the scalar sign bit. +pub fn is_skateboard_animated(input: SkeletonAnimationState) -> bool { + if input.mode_16420 == 2 { + return true; + } + let magnitude = f32::from_bits(input.back_chain_lane_12656.to_bits() & 0x7fff_ffff); + if magnitude <= input.threshold_800 { + false + } else { + input.mode_16420 != 1 + } +} diff --git a/crates/skate-core/src/player/selector/ground.rs b/crates/skate-core/src/player/selector/ground.rs new file mode 100644 index 00000000..c38130fe --- /dev/null +++ b/crates/skate-core/src/player/selector/ground.rs @@ -0,0 +1,142 @@ +use super::{FrameFacts, StateSelectionInput, StateSelector}; +use crate::player::state::PhysicalStateId; + +impl StateSelector { + pub(super) fn select_ground_family( + &mut self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + match current { + PhysicalStateId::PhysicsGround => { + if p.has_2480(0x1000) && (!p.has_2480(0x400) || !p.has_2480(0x200)) { + // TU3 calls `sk83_na_f_01a4` at 0x82D8B23C here. The + // target is 0x82B61BB8 and consists solely of `blr`. + return PhysicalStateId::LandingOnDeck; + } + let handplant_contact = + p.has_2480(0x4000_0000) && input.skateboard_contact_count_869 <= 2; + if p.has_2476(1) && (handplant_contact || input.skateboard_contact_count_869 == 0) { + return PhysicalStateId::HandPlant; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if p.has_2476(0x80) { + return PhysicalStateId::BipedAir; + } + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.field_2572 == 1 { + return p.air_variant_from_2468(); + } + if p.field_2744 != 0.0 { + return PhysicalStateId::RevertGround; + } + if let Some(grind) = facts.grind { + return grind; + } + if !facts.colliding && facts.off_ground && self.teleport_countdown == 0 { + return PhysicalStateId::PhysicsAir; + } + if facts.skateboard_animated && self.teleport_countdown == 0 { + return PhysicalStateId::GroundAnimation; + } + if p.has_2476(0x20_0000) && p.has_2480(0x40_0000) && self.skitch_exit_countdown == 0 + { + return PhysicalStateId::Skitching; + } + if p.field_2732 != 0.0 { + PhysicalStateId::SlideGround + } else { + current + } + } + PhysicalStateId::SlideGround => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.field_2744 != 0.0 { + return PhysicalStateId::RevertGround; + } + if let Some(grind) = facts.grind { + return grind; + } + if !facts.colliding && facts.off_ground { + return PhysicalStateId::PhysicsAir; + } + if p.field_2732 == 0.0 { + PhysicalStateId::PhysicsGround + } else { + current + } + } + PhysicalStateId::RevertGround => { + if !facts.colliding && facts.off_ground { + return PhysicalStateId::PhysicsAir; + } + if p.has_2468(0x1_0000) || p.has_2472(0x2_0000) { + return PhysicalStateId::PhysicsGround; + } + if !p.has_2472(0x20_0000) { + self.revert_exited_normally = true; + return PhysicalStateId::PhysicsGround; + } + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else { + current + } + } + PhysicalStateId::GroundAnimation => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2480(0x4000) { + return PhysicalStateId::Boneless; + } + if p.has_2476(0x8000) { + return PhysicalStateId::BipedGround; + } + if p.has_2476(0x80) { + return PhysicalStateId::BipedAir; + } + if p.field_2572 == 1 { + return p.air_variant_from_2468(); + } + if !facts.colliding && facts.off_ground { + return PhysicalStateId::PhysicsAir; + } + if facts.skateboard_animated { + current + } else { + PhysicalStateId::PhysicsGround + } + } + PhysicalStateId::Skitching => { + if !p.has_2476(0x20_0000) { + return PhysicalStateId::PhysicsGround; + } + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if facts.off_ground_skitching && self.teleport_countdown == 0 { + PhysicalStateId::PhysicsGround + } else { + current + } + } + PhysicalStateId::FollowPath => { + if p.field_1776 as u32 & 0x200_0000 != 0 { + PhysicalStateId::PhysicsGround + } else { + current + } + } + _ => unreachable!("ground-family dispatch accepts only states 100..105"), + } + } +} diff --git a/crates/skate-core/src/player/selector/input.rs b/crates/skate-core/src/player/selector/input.rs new file mode 100644 index 00000000..41514d7e --- /dev/null +++ b/crates/skate-core/src/player/selector/input.rs @@ -0,0 +1,73 @@ +use super::conditions::{BoardBodyState, SkeletonAnimationState, TwoStageThresholds}; +use crate::player::state::PhysicalStateId; + +/// Values read by TU3 `PhysicalPlayerStateChanger::CalcSuggestedState`. +/// Offset-bearing names are retained where the producer's semantic name has +/// not yet been proved in Skate 3. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProcessedStateInput { + pub grind_type_1248: u32, + pub grind_candidate_1488: bool, + pub field_1776: i32, + 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 category_2512: u32, + pub wheel_contact_count_2556: i32, + pub field_2572: u32, + pub state_timer_2664: f32, + pub field_2732: f32, + pub field_2744: f32, + pub trajectory_collision_time_2772: f32, + pub grind_investigation_flags_1516: u32, +} + +impl ProcessedStateInput { + pub(crate) const fn has_2468(self, mask: u32) -> bool { + self.flags_2468 & mask != 0 + } + + pub(crate) const fn has_2472(self, mask: u32) -> bool { + self.flags_2472 & mask != 0 + } + + pub(crate) const fn has_2476(self, mask: u32) -> bool { + self.flags_2476 & mask != 0 + } + + pub(crate) const fn has_2480(self, mask: u32) -> bool { + self.flags_2480 & mask != 0 + } + + pub(crate) const fn has_2484(self, mask: u32) -> bool { + self.flags_2484 & mask != 0 + } + + pub(crate) const fn has_2488(self, mask: u32) -> bool { + self.flags_2488 & mask != 0 + } + + pub(crate) const fn air_variant_from_2468(self) -> PhysicalStateId { + if self.has_2468(0x400) { + PhysicalStateId::KnownAir + } else { + PhysicalStateId::PhysicsAir + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StateSelectionInput { + pub processed: ProcessedStateInput, + /// Byte at `SkateboardBody + 869`. + pub skateboard_contact_count_869: u8, + pub board_body: BoardBodyState, + pub skeleton: SkeletonAnimationState, + /// Three collection values consumed by TU3 `0x82D8BBB8`. + pub skitching_off_ground: TwoStageThresholds, + /// Three physics-mode values consumed by TU3 `0x82D8BD50`. + pub normal_off_ground: TwoStageThresholds, +} diff --git a/crates/skate-core/src/player/selector/mod.rs b/crates/skate-core/src/player/selector/mod.rs new file mode 100644 index 00000000..41e2e820 --- /dev/null +++ b/crates/skate-core/src/player/selector/mod.rs @@ -0,0 +1,250 @@ +//! Readable port of TU3 `PhysicalPlayerStateChanger::CalcSuggestedState` +//! (`0x82D8ADE8`) and its directly-called predicates. + +mod air; +pub mod conditions; +mod ground; +pub mod input; +mod offboard; + +use conditions::{ + condition_is_off_ground, condition_is_off_ground_skitching, is_skateboard_animated, +}; +pub use input::{ProcessedStateInput, StateSelectionInput}; + +use crate::player::state::PhysicalStateId; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StateSelector { + pub current_state: Option, + pub nonspecific_collision_free_frames: i32, + pub nonspecific_collision_frames: i32, + pub something_colliding_frames: i32, + pub two_wheel_counter: i32, + pub three_wheel_counter: i32, + pub post_grind_jump_counter: i32, + pub air_frames: i32, + pub teleport_countdown: i32, + pub skitch_exit_countdown: i32, + pub revert_exited_normally: bool, + pub request_teleport: bool, +} + +#[derive(Clone, Copy)] +pub(super) struct FrameFacts { + colliding: bool, + wipeout: bool, + force_known_air: bool, + skateboard_animated: bool, + off_ground: bool, + off_ground_skitching: bool, + grind: Option, +} + +impl StateSelector { + /// Executes the complete state decision tree and all state-changer-local + /// counter/flag writes performed by TU3 `0x82D8ADE8`. + pub fn calculate( + &mut self, + current: PhysicalStateId, + input: &StateSelectionInput, + ) -> PhysicalStateId { + let p = input.processed; + if self.teleport_countdown > 0 { + self.teleport_countdown = self.teleport_countdown.wrapping_sub(1); + } + self.current_state = Some(current); + self.revert_exited_normally = false; + self.request_teleport = false; + + let colliding = + p.wheel_contact_count_2556 > 0 || p.has_2468(0x1_0000) || p.has_2468(0x2_0000); + self.nonspecific_collision_free_frames = + if current == PhysicalStateId::Nonspecific && !colliding { + self.nonspecific_collision_free_frames.wrapping_add(1) + } else { + 0 + }; + self.nonspecific_collision_frames = if current == PhysicalStateId::Nonspecific && colliding + { + self.nonspecific_collision_frames.wrapping_add(1) + } else { + 0 + }; + self.two_wheel_counter = if input.skateboard_contact_count_869 > 1 { + self.two_wheel_counter.wrapping_add(1) + } else { + 0 + }; + self.three_wheel_counter = if input.skateboard_contact_count_869 > 2 { + self.three_wheel_counter.wrapping_add(1) + } else { + 0 + }; + self.something_colliding_frames = if colliding { + self.something_colliding_frames.wrapping_add(1) + } else { + 0 + }; + let special_grind = p.category_2512 == 400 && p.has_2468(0x40_0000); + self.post_grind_jump_counter = if special_grind { + 0 + } else { + self.post_grind_jump_counter.wrapping_add(1) + }; + self.air_frames = if p.category_2512 == 200 { + if p.has_2468(8) { + self.air_frames + } else { + self.air_frames.wrapping_add(1) + } + } else { + 0 + }; + self.skitch_exit_countdown = if current == PhysicalStateId::Skitching { + 10 + } else { + if self.skitch_exit_countdown > 0 { + self.skitch_exit_countdown.wrapping_sub(1) + } else { + 0 + } + }; + + if p.has_2468(2) || p.has_2472(0x4_0000) { + return PhysicalStateId::Teleporting; + } + if self.air_frames > 300 { + self.request_teleport = true; + return current; + } + if p.field_1776 < 0 + && (p.field_1776 as u32 & 0x200_0000) == 0 + && self.teleport_countdown == 0 + { + return PhysicalStateId::FollowPath; + } + + let facts = FrameFacts { + colliding, + wipeout: p.has_2468(0x4_0000), + force_known_air: p.has_2472(0x40), + skateboard_animated: is_skateboard_animated(input.skeleton), + off_ground: condition_is_off_ground(input.board_body, input.normal_off_ground), + off_ground_skitching: condition_is_off_ground_skitching( + input.board_body, + input.skitching_off_ground, + ), + grind: check_for_grind(p), + }; + + match current { + PhysicalStateId::PhysicsGround + | PhysicalStateId::SlideGround + | PhysicalStateId::RevertGround + | PhysicalStateId::GroundAnimation + | PhysicalStateId::Skitching + | PhysicalStateId::FollowPath => self.select_ground_family(current, input, facts), + PhysicalStateId::PhysicsAir => self.select_physics_air(current, input, facts), + PhysicalStateId::KnownAir => self.select_known_air(current, input, facts), + PhysicalStateId::PhysicsAirSecondary => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if !p.has_2484(0x10_0000) { + PhysicalStateId::PhysicsAir + } else { + current + } + } + PhysicalStateId::WipeoutGround + | PhysicalStateId::Sleeping + | PhysicalStateId::Teleporting => current, + PhysicalStateId::GrindBoardslide + | PhysicalStateId::GrindFiftyFifty + | PhysicalStateId::GrindTipslide + | PhysicalStateId::GrindFiveO + | PhysicalStateId::GrindBackslash + | PhysicalStateId::GrindDarkslide => self.select_grind(current, input, facts), + PhysicalStateId::BipedGround + | PhysicalStateId::BipedAir + | PhysicalStateId::OffBoardPushing + | PhysicalStateId::LandingOnDeck + | PhysicalStateId::HandPlant + | PhysicalStateId::FootPlant + | PhysicalStateId::Boneless => self.select_biped_or_plant(current, input, facts), + PhysicalStateId::Nonspecific => self.select_nonspecific(current, input, facts), + } + } + + fn select_nonspecific( + &self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + if p.has_2484(0x10_0000) { + return PhysicalStateId::PhysicsAirSecondary; + } + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2468(0x100) { + return p.air_variant_from_2468(); + } + if p.has_2468(0x40_0000) { + return current; + } + if p.has_2472(8) { + return if p.has_2484(0x20_0000) { + PhysicalStateId::WipeoutGround + } else { + PhysicalStateId::PhysicsAir + }; + } + if p.has_2472(4) { + return current; + } + if let Some(grind) = facts.grind { + return grind; + } + if p.grind_investigation_flags_1516 & 0x800_0000 != 0 { + if self.three_wheel_counter > 10 { + return PhysicalStateId::PhysicsGround; + } + } else if self.two_wheel_counter > 2 { + return if facts.skateboard_animated { + PhysicalStateId::GroundAnimation + } else { + PhysicalStateId::PhysicsGround + }; + } + if self.nonspecific_collision_free_frames > 2 { + return PhysicalStateId::PhysicsAir; + } + let collision_exit_frames = if p.has_2484(0x20_0000) { 30 } else { 60 }; + if self.nonspecific_collision_frames > collision_exit_frames { + PhysicalStateId::PhysicsGround + } else { + current + } + } +} + +fn check_for_grind(input: ProcessedStateInput) -> Option { + if !input.grind_candidate_1488 || input.has_2480(0x2_0000) || input.has_2480(0x400_0000) { + return None; + } + match input.grind_type_1248 { + 0 => Some(PhysicalStateId::GrindFiftyFifty), + 1 => Some(PhysicalStateId::GrindBoardslide), + 2 => Some(PhysicalStateId::GrindTipslide), + 3 => Some(PhysicalStateId::GrindFiveO), + 4 => Some(PhysicalStateId::GrindBackslash), + 5 => Some(PhysicalStateId::GrindDarkslide), + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/selector/offboard.rs b/crates/skate-core/src/player/selector/offboard.rs new file mode 100644 index 00000000..b0972921 --- /dev/null +++ b/crates/skate-core/src/player/selector/offboard.rs @@ -0,0 +1,110 @@ +use super::{FrameFacts, StateSelectionInput, StateSelector}; +use crate::player::state::PhysicalStateId; + +const HANDPLANT_GROUND_DELAY_SECONDS: f32 = f32::from_bits(0x3E4C_CCCD); + +impl StateSelector { + pub(super) fn select_biped_or_plant( + &self, + current: PhysicalStateId, + input: &StateSelectionInput, + facts: FrameFacts, + ) -> PhysicalStateId { + let p = input.processed; + match current { + PhysicalStateId::BipedGround => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2484(0x1_0000) { + return PhysicalStateId::BipedAir; + } + if p.has_2476(0x20_0000) { + return PhysicalStateId::OffBoardPushing; + } + if p.has_2476(0x8000) || p.has_2480(0x40000) { + return current; + } + if !p.has_2484(1) && !p.has_2488(0x1000_0000) && !p.has_2488(0x8000_0000) { + return PhysicalStateId::PhysicsAir; + } + PhysicalStateId::PhysicsGround + } + PhysicalStateId::BipedAir => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if !p.has_2480(0x10) { + if p.has_2484(1) { + if p.has_2476(0x8000) || p.has_2476(0x80) { + current + } else { + PhysicalStateId::PhysicsAir + } + } else { + PhysicalStateId::BipedGround + } + } else { + PhysicalStateId::LandingOnDeck + } + } + PhysicalStateId::OffBoardPushing => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if !p.has_2476(0x20_0000) { + PhysicalStateId::BipedGround + } else { + current + } + } + PhysicalStateId::LandingOnDeck => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if p.has_2476(0x1_0000) { + PhysicalStateId::PhysicsGround + } else if p.has_2476(0x80) { + PhysicalStateId::BipedAir + } else { + current + } + } + PhysicalStateId::HandPlant => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if (facts.colliding && p.state_timer_2664 > HANDPLANT_GROUND_DELAY_SECONDS) + || (p.flags_2480 as i32) >= 0 + { + PhysicalStateId::PhysicsGround + } else { + current + } + } + PhysicalStateId::FootPlant => { + if facts.wipeout { + PhysicalStateId::WipeoutGround + } else if !p.has_2480(1) { + p.air_variant_from_2468() + } else { + current + } + } + PhysicalStateId::Boneless => { + if facts.wipeout { + return PhysicalStateId::WipeoutGround; + } + if p.has_2468(0x400) { + return PhysicalStateId::KnownAir; + } + if p.has_2476(0x80) { + return PhysicalStateId::BipedAir; + } + if !p.has_2480(0x4000) && !p.has_2480(0x2000) { + PhysicalStateId::PhysicsAir + } else { + current + } + } + _ => unreachable!("off-board/plant dispatch received another state"), + } + } +} diff --git a/crates/skate-core/src/player/selector/tests.rs b/crates/skate-core/src/player/selector/tests.rs new file mode 100644 index 00000000..5c476ad4 --- /dev/null +++ b/crates/skate-core/src/player/selector/tests.rs @@ -0,0 +1,270 @@ +use super::conditions::{BoardBodyState, SkeletonAnimationState, TwoStageThresholds}; +use super::{ProcessedStateInput, StateSelectionInput, StateSelector}; +use crate::player::state::PhysicalStateId as State; + +fn input(category: u32) -> StateSelectionInput { + StateSelectionInput { + processed: ProcessedStateInput { + grind_type_1248: 6, + grind_candidate_1488: false, + field_1776: 0, + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + flags_2488: 0, + category_2512: category, + wheel_contact_count_2556: 0, + field_2572: 0, + state_timer_2664: 0.0, + field_2732: 0.0, + field_2744: 0.0, + trajectory_collision_time_2772: 0.0, + grind_investigation_flags_1516: 0, + }, + skateboard_contact_count_869: 0, + board_body: BoardBodyState { + field_856: 0.0, + field_7692: 0.0, + }, + skeleton: SkeletonAnimationState { + mode_16420: 1, + back_chain_lane_12656: 0.0, + threshold_800: 1.0, + }, + skitching_off_ground: thresholds(), + normal_off_ground: thresholds(), + } +} + +const fn thresholds() -> TwoStageThresholds { + TwoStageThresholds { + field_856_primary: 1.0, + field_856_secondary: 1.0, + field_7692: 1.0, + } +} + +fn calculate(selector: &mut StateSelector, state: State, input: &StateSelectionInput) -> State { + selector.calculate(state, input) +} + +#[test] +fn teleport_request_has_priority_and_fallthrough_sets_the_native_request_byte() { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.flags_2472 = 0x4_0000; + assert_eq!( + calculate(&mut selector, State::PhysicsGround, &frame), + State::Teleporting + ); + + frame.processed.flags_2472 = 0; + frame.processed.category_2512 = 200; + selector.air_frames = 300; + assert_eq!( + calculate(&mut selector, State::PhysicsAir, &frame), + State::PhysicsAir + ); + assert!(selector.request_teleport); +} + +#[test] +fn all_six_grind_types_use_the_exact_native_state_mapping() { + let expected = [ + State::GrindFiftyFifty, + State::GrindBoardslide, + State::GrindTipslide, + State::GrindFiveO, + State::GrindBackslash, + State::GrindDarkslide, + ]; + for (grind_type, expected) in expected.into_iter().enumerate() { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.grind_candidate_1488 = true; + frame.processed.grind_type_1248 = grind_type as u32; + assert_eq!( + calculate(&mut selector, State::PhysicsGround, &frame), + expected + ); + } +} + +#[test] +fn nonspecific_uses_exact_two_three_and_collision_counter_thresholds() { + let mut free = StateSelector::default(); + let frame = input(700); + assert_eq!( + calculate(&mut free, State::Nonspecific, &frame), + State::Nonspecific + ); + assert_eq!( + calculate(&mut free, State::Nonspecific, &frame), + State::Nonspecific + ); + assert_eq!( + calculate(&mut free, State::Nonspecific, &frame), + State::PhysicsAir + ); + + let mut two_wheels = StateSelector::default(); + let mut frame = input(700); + frame.skateboard_contact_count_869 = 2; + for _ in 0..2 { + assert_eq!( + calculate(&mut two_wheels, State::Nonspecific, &frame), + State::Nonspecific + ); + } + assert_eq!( + calculate(&mut two_wheels, State::Nonspecific, &frame), + State::PhysicsGround + ); + + let mut collision = StateSelector::default(); + let mut frame = input(700); + frame.processed.flags_2468 = 0x1_0000; + for _ in 0..60 { + assert_eq!( + calculate(&mut collision, State::Nonspecific, &frame), + State::Nonspecific + ); + } + assert_eq!( + calculate(&mut collision, State::Nonspecific, &frame), + State::PhysicsGround + ); +} + +#[test] +fn nonspecific_short_collision_threshold_comes_from_flags_2484_bit_21() { + let mut selector = StateSelector::default(); + let mut frame = input(700); + frame.processed.flags_2468 = 0x1_0000; + frame.processed.flags_2484 = 0x20_0000; + for _ in 0..30 { + assert_eq!( + calculate(&mut selector, State::Nonspecific, &frame), + State::Nonspecific + ); + } + assert_eq!( + calculate(&mut selector, State::Nonspecific, &frame), + State::PhysicsGround + ); +} + +#[test] +fn ground_to_landing_on_deck_takes_the_native_noop_call_branch() { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.flags_2480 = 0x1000; + let state = selector.calculate(State::PhysicsGround, &frame); + assert_eq!(state, State::LandingOnDeck); +} + +#[test] +fn revert_normal_exit_sets_the_recovered_state_changer_byte() { + let mut selector = StateSelector::default(); + let frame = input(100); + assert_eq!( + calculate(&mut selector, State::RevertGround, &frame), + State::PhysicsGround + ); + assert!(selector.revert_exited_normally); +} + +#[test] +fn known_air_transition_order_matches_the_tu3_tree() { + let mut selector = StateSelector::default(); + let mut frame = input(200); + frame.processed.flags_2480 = 0x200_0000; + assert_eq!( + calculate(&mut selector, State::KnownAir, &frame), + State::FootPlant + ); + + frame.processed.flags_2480 = 0; + frame.processed.flags_2476 = 0x80; + frame.processed.trajectory_collision_time_2772 = 0.04; + assert_eq!( + calculate(&mut selector, State::KnownAir, &frame), + State::WipeoutGround + ); +} + +#[test] +fn physics_air_collision_resolves_before_the_grind_candidate_path() { + let mut selector = StateSelector { + post_grind_jump_counter: 11, + ..StateSelector::default() + }; + let mut frame = input(200); + frame.processed.state_timer_2664 = 0.1; + frame.processed.flags_2468 = 0x1_0000; + frame.processed.grind_candidate_1488 = true; + frame.processed.grind_type_1248 = 0; + assert_eq!( + calculate(&mut selector, State::PhysicsAir, &frame), + State::PhysicsGround + ); +} + +#[test] +fn skitch_exit_countdown_is_loaded_to_ten_then_saturates() { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.flags_2476 = 0x20_0000; + assert_eq!( + calculate(&mut selector, State::Skitching, &frame), + State::Skitching + ); + assert_eq!(selector.skitch_exit_countdown, 10); + + frame.processed.flags_2476 = 0; + let _ = calculate(&mut selector, State::PhysicsGround, &frame); + assert_eq!(selector.skitch_exit_countdown, 9); +} + +#[test] +fn check_for_grind_rejects_both_native_flags() { + for rejected in [0x2_0000, 0x400_0000] { + let mut selector = StateSelector::default(); + let mut frame = input(100); + frame.processed.grind_candidate_1488 = true; + frame.processed.grind_type_1248 = 0; + frame.processed.flags_2480 = rejected; + assert_eq!( + calculate(&mut selector, State::PhysicsGround, &frame), + State::PhysicsGround + ); + } +} + +#[test] +fn skeleton_animation_predicate_uses_mode_and_sign_cleared_lane() { + use super::conditions::is_skateboard_animated; + + assert!(is_skateboard_animated(SkeletonAnimationState { + mode_16420: 2, + back_chain_lane_12656: 0.0, + threshold_800: 100.0, + })); + assert!(is_skateboard_animated(SkeletonAnimationState { + mode_16420: 0, + back_chain_lane_12656: -2.0, + threshold_800: 1.0, + })); + assert!(!is_skateboard_animated(SkeletonAnimationState { + mode_16420: 1, + back_chain_lane_12656: -2.0, + threshold_800: 1.0, + })); + assert!(is_skateboard_animated(SkeletonAnimationState { + mode_16420: 0, + back_chain_lane_12656: f32::NAN, + threshold_800: 1.0, + })); +} diff --git a/crates/skate-core/src/player/slide_state/forces.rs b/crates/skate-core/src/player/slide_state/forces.rs new file mode 100644 index 00000000..406534ec --- /dev/null +++ b/crates/skate-core/src/player/slide_state/forces.rs @@ -0,0 +1,249 @@ +//! Full non-launch arithmetic82D3AB14..AE4C and point force82D925E0. +//! Uses independent PC arithmetic with the original refinement/operation order. +use crate::{ + math::Vector3, + physics::{board_motion_output::inverse_length_squared, force_queue::QueuedPointForce}, + point_graph::PointGraph, + riding::collision_response::signed_angle, +}; +type V = [f32; 4]; +#[derive(Clone, Debug)] +pub struct SlideSettings { + pub input_remap: PointGraph<8>, + pub remap_vs_speed: PointGraph<8>, + pub force_vs_angle: PointGraph<8>, + pub force_vs_speed: PointGraph<8>, + pub softest_wheel_force: f32, + pub softest_wheel_spin: f32, + pub angular_force: f32, + pub force_y_offset: f32, +} +#[derive(Clone, Debug)] +pub struct SlideSurface { + pub speed_to_force: PointGraph<8>, + pub yaw_strength: f32, + pub yaw_damping: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct SlideInput { + pub velocity: V, + pub normal: V, + pub side: V, + pub effective_forward: V, + pub reference_forward: V, + pub angular_velocity: V, + pub absolute_speed: f32, + pub surface_speed: f32, + pub slide: f32, + pub elapsed: f32, + pub wheel_hardness: f32, +} +/// The source does not add a zero-velocity normalization fallback here. +/// Retain that branch behavior rather than manufacturing a desired direction. +pub fn angular_correction(s: &SlideSettings, surface: &SlideSurface, p: SlideInput) -> V { + let remap = s.input_remap.evaluate(p.slide.abs()); + let age = p.elapsed - 0.5; + let age = fsel(-age, 0.0, age); + let age = fsel(1.0 - age, age, 1.0); + let weight = s.remap_vs_speed.evaluate(p.surface_speed) * age; + let pi = f32::from_bits(0x4049_0fdb); + let signed = fsel(p.slide, remap, -remap); + let angle = (p.slide * pi).mul_add(1.0 - weight, (signed * pi) * weight); + let velocity = normalize(p.velocity); + let side = normalize(cross(p.normal, velocity)); + let desired = madd( + velocity, + crate::trigonometry::cos(angle), + scale(side, crate::trigonometry::sin(angle)), + ); + let mut error = cross(p.reference_forward, desired); + if dot(p.reference_forward, desired) < 0.0 { + error = if error[1] < 0.0 { + p.normal.map(|v| -v) + } else { + p.normal + }; + } + let strength = (1.0 - s.softest_wheel_spin).mul_add(p.wheel_hardness, s.softest_wheel_spin) + * surface.yaw_strength; + let displacement = scale( + madd( + error, + strength, + scale(p.angular_velocity, surface.yaw_damping), + ), + s.angular_force, + ); + scale(p.normal, dot(displacement, p.normal)) +} +///82D925E0 rejects normal velocity, measures the authored slip angle and adds +/// its signed sideways response.82E0A4E8 normalizes before its rejection again. +pub fn sliding_force(s: &SlideSettings, surface: &SlideSurface, p: SlideInput) -> QueuedPointForce { + let tangent = reject(p.velocity, p.normal); + let lateral = scale(p.side, dot(tangent, p.side)); + let response = surface.speed_to_force.evaluate(p.absolute_speed); + // Original ble is !GT, so unordered follows the <= branch too. + let hardness = if response > 0.0 { + p.wheel_hardness + } else { + 1.0 + }; + let strength = + (1.0 - s.softest_wheel_force).mul_add(hardness, s.softest_wheel_force) * response; + let side = normalize_safe(cross(p.normal, p.velocity)); + let longitudinal = reject(scale(lateral, strength), normalize_safe(side)); + let angle = if dot(p.velocity, p.velocity) * dot(p.effective_forward, p.effective_forward) + > f32::from_bits(0x3780_0000) + { + signed_angle( + xyz(reject(p.velocity, p.normal)), + xyz(reject(p.effective_forward, p.normal)), + xyz(p.normal), + ) + } else { + 0.0 + }; + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let wrapped = (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c9_0fdb); + let degrees = wrapped * f32::from_bits(0x4265_2ee1); + let sign = if degrees > 0.0 { 1.0 } else { -1.0 }; + let angular = scale( + scale(side, s.force_vs_angle.evaluate(degrees.abs())), + s.force_vs_speed.evaluate(p.surface_speed), + ); + let force = madd(angular, sign, longitudinal); + // bge skips negation for unordered as well as nonnegative. + let y = if dot(force, p.velocity) < 0.0 { + -s.force_y_offset + } else { + s.force_y_offset + }; + QueuedPointForce { + tag: 9, + force_world: xyz(force), + point_body: Vector3::new(0.0, y, 0.0), + } +} +fn dot(a: V, b: V) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} +fn scale(v: V, s: f32) -> V { + v.map(|v| v * s) +} +fn madd(v: V, s: f32, b: V) -> V { + std::array::from_fn(|i| v[i].mul_add(s, b[i])) +} +fn reject(v: V, n: V) -> V { + let d = dot(v, n); + std::array::from_fn(|i| v[i] - n[i] * d) +} +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]), + ] +} +fn normalize(v: V) -> V { + scale(v, inverse_length_squared(dot(v, v), 2)) +} +fn normalize_safe(v: V) -> V { + let d = dot(v, v); + let inv = inverse_length_squared(d, 2); + let length = if d == 0.0 { 0.0 } else { d * inv }; + if length > f32::from_bits(0x3586_37bd) { + scale(v, inv) + } else { + [0.0; 4] + } +} +fn fsel(v: f32, a: f32, b: f32) -> f32 { + if v >= -0.0 { a } else { b } +} +fn xyz(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + fn curve(value: f32) -> PointGraph<8> { + PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [value; 8], + } + } + fn settings() -> SlideSettings { + SlideSettings { + input_remap: curve(0.0), + remap_vs_speed: curve(0.0), + force_vs_angle: curve(0.0), + force_vs_speed: curve(0.0), + softest_wheel_force: 0.25, + softest_wheel_spin: 1.0, + angular_force: 0.1, + force_y_offset: -0.12, + } + } + fn input() -> SlideInput { + SlideInput { + velocity: [4., 0., 3., 0.], + normal: [0., 1., 0., 0.], + side: [1., 0., 0., 0.], + effective_forward: [0., 0., 1., 0.], + reference_forward: [0., 0., 1., 0.], + angular_velocity: [0.; 4], + absolute_speed: 5., + surface_speed: 5., + slide: 0., + elapsed: 0., + wheel_hardness: 0.5, + } + } + #[test] + fn negative_slide_force_bypasses_wheel_softness_and_reverses_application_height() { + // Analytic projection of a world-X force onto velocity(4,0,3). + // Source82D926A4 gates hardness only for a positive curve result; + //82D92924 chooses the point height from signed work, not force X. + let s = settings(); + let p = input(); + let mut surface = SlideSurface { + speed_to_force: curve(2.), + yaw_strength: 0., + yaw_damping: 0., + }; + let positive = sliding_force(&s, &surface, p); + assert!((positive.force_world.x - 3.2).abs() < 0.00001); + assert!((positive.force_world.z - 2.4).abs() < 0.00001); + assert_eq!(positive.point_body.y, -0.12); + surface.speed_to_force = curve(-2.); + let negative = sliding_force(&s, &surface, p); + assert!((negative.force_world.x + 5.12).abs() < 0.00001); + assert!((negative.force_world.z + 3.84).abs() < 0.00001); + assert_eq!(negative.point_body.y, 0.12); + } + #[test] + fn slide_yaw_past_ninety_degrees_uses_signed_ground_axis() { + // Opposing desired headings replace the cross error with +/-normal + // at82D3ACCC..AD38; damping still uses actual angular velocity. + let s = settings(); + let mut p = input(); + p.velocity = [0., 0., 4., 0.]; + p.angular_velocity = [9., -2., 5., 0.]; + p.slide = 0.75; + let surface = SlideSurface { + speed_to_force: curve(0.), + yaw_strength: 2., + yaw_damping: 0.3, + }; + let positive = angular_correction(&s, &surface, p); + assert!((positive[1] - 0.14).abs() < 0.00001); + assert_eq!(positive[0], 0.); + assert_eq!(positive[2], 0.); + p.slide = -0.75; + let negative = angular_correction(&s, &surface, p); + assert!((negative[1] + 0.26).abs() < 0.00001); + } +} diff --git a/crates/skate-core/src/player/slide_state/mod.rs b/crates/skate-core/src/player/slide_state/mod.rs new file mode 100644 index 00000000..8effc72c --- /dev/null +++ b/crates/skate-core/src/player/slide_state/mod.rs @@ -0,0 +1,34 @@ +//! PhysicsSlideGround101: Enter82D3A700, Exit82D3A828 and board82D3A900. +mod forces; +pub use forces::{SlideInput, SlideSettings, SlideSurface, angular_correction, sliding_force}; + +#[derive(Clone, Copy, Debug)] +pub struct SlideState { + pub start_speed: f32, + pub steering_push: f32, + pub damped_turn: f32, + pub flag48: bool, + pub wall_riding: bool, +} +impl SlideState { + /// Host storage; Enter defines every represented native field before use. + pub fn new() -> Self { + Self { + start_speed: 0.0, + steering_push: 1.0, + damped_turn: 0.0, + flag48: false, + wall_riding: false, + } + } + pub fn enter(&mut self, speed: f32) { + self.start_speed = speed; + self.exit(); + } + pub fn exit(&mut self) { + self.flag48 = false; + self.wall_riding = false; + self.steering_push = 1.0; + self.damped_turn = 0.0; + } +} diff --git a/crates/skate-core/src/player/state.rs b/crates/skate-core/src/player/state.rs new file mode 100644 index 00000000..e3e03c8d --- /dev/null +++ b/crates/skate-core/src/player/state.rs @@ -0,0 +1,185 @@ +//! Physical-player state identities and native object ownership from +//! `PhysicalPlayer` construction and `SetPhysicsState` (`0x82DB8540`). + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum PhysicalStateId { + PhysicsGround = 100, + SlideGround = 101, + RevertGround = 102, + GroundAnimation = 103, + Skitching = 104, + FollowPath = 105, + PhysicsAir = 200, + KnownAir = 201, + PhysicsAirSecondary = 202, + WipeoutGround = 300, + GrindBoardslide = 400, + GrindFiftyFifty = 401, + GrindTipslide = 402, + GrindFiveO = 403, + GrindBackslash = 404, + GrindDarkslide = 405, + BipedGround = 500, + BipedAir = 501, + OffBoardPushing = 502, + LandingOnDeck = 503, + HandPlant = 600, + FootPlant = 601, + Boneless = 602, + Sleeping = 700, + Nonspecific = 701, + Teleporting = 702, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UnknownPhysicalState(pub u32); + +impl TryFrom for PhysicalStateId { + type Error = UnknownPhysicalState; + + fn try_from(value: u32) -> Result { + let state = match value { + 100 => Self::PhysicsGround, + 101 => Self::SlideGround, + 102 => Self::RevertGround, + 103 => Self::GroundAnimation, + 104 => Self::Skitching, + 105 => Self::FollowPath, + 200 => Self::PhysicsAir, + 201 => Self::KnownAir, + 202 => Self::PhysicsAirSecondary, + 300 => Self::WipeoutGround, + 400 => Self::GrindBoardslide, + 401 => Self::GrindFiftyFifty, + 402 => Self::GrindTipslide, + 403 => Self::GrindFiveO, + 404 => Self::GrindBackslash, + 405 => Self::GrindDarkslide, + 500 => Self::BipedGround, + 501 => Self::BipedAir, + 502 => Self::OffBoardPushing, + 503 => Self::LandingOnDeck, + 600 => Self::HandPlant, + 601 => Self::FootPlant, + 602 => Self::Boneless, + 700 => Self::Sleeping, + 701 => Self::Nonspecific, + 702 => Self::Teleporting, + other => return Err(UnknownPhysicalState(other)), + }; + Ok(state) + } +} + +impl PhysicalStateId { + pub const ALL: [Self; 26] = [ + Self::PhysicsGround, + Self::SlideGround, + Self::RevertGround, + Self::GroundAnimation, + Self::Skitching, + Self::FollowPath, + Self::PhysicsAir, + Self::KnownAir, + Self::PhysicsAirSecondary, + Self::WipeoutGround, + Self::GrindBoardslide, + Self::GrindFiftyFifty, + Self::GrindTipslide, + Self::GrindFiveO, + Self::GrindBackslash, + Self::GrindDarkslide, + Self::BipedGround, + Self::BipedAir, + Self::OffBoardPushing, + Self::LandingOnDeck, + Self::HandPlant, + Self::FootPlant, + Self::Boneless, + Self::Sleeping, + Self::Nonspecific, + Self::Teleporting, + ]; + + /// Offset of the owning state pointer inside `PhysicalPlayer`. + pub const fn native_owner_offset(self) -> u32 { + match self { + Self::Sleeping => 1692, + Self::PhysicsGround => 1696, + Self::SlideGround => 1700, + Self::RevertGround => 1704, + Self::GroundAnimation => 1708, + Self::PhysicsAir => 1712, + Self::PhysicsAirSecondary => 1716, + Self::KnownAir => 1720, + Self::GrindDarkslide => 1724, + Self::GrindBoardslide => 1728, + Self::GrindFiftyFifty => 1732, + Self::GrindTipslide => 1736, + Self::GrindFiveO => 1740, + Self::GrindBackslash => 1744, + Self::WipeoutGround => 1748, + Self::Nonspecific => 1752, + Self::Teleporting => 1756, + Self::FollowPath => 1760, + Self::BipedGround => 1764, + Self::BipedAir => 1768, + Self::Skitching => 1772, + Self::OffBoardPushing => 1776, + Self::HandPlant => 1780, + Self::FootPlant => 1784, + Self::Boneless => 1788, + Self::LandingOnDeck => 1792, + } + } + + /// Category written by native `GetStateCategory` into ProcessedPhysIn. + pub const fn category(self) -> u32 { + (self as u32 / 100) * 100 + } + + pub const fn is_grind(self) -> bool { + matches!( + self, + Self::GrindBoardslide + | Self::GrindFiftyFifty + | Self::GrindTipslide + | Self::GrindFiveO + | Self::GrindBackslash + | Self::GrindDarkslide + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_native_state_round_trips_and_has_a_unique_owner_slot() { + let mut slots = PhysicalStateId::ALL.map(PhysicalStateId::native_owner_offset); + slots.sort_unstable(); + assert!(slots.windows(2).all(|pair| pair[0] != pair[1])); + for state in PhysicalStateId::ALL { + assert_eq!(PhysicalStateId::try_from(state as u32), Ok(state)); + assert_eq!(state.category(), (state as u32 / 100) * 100); + } + } + + #[test] + fn only_the_six_native_grind_states_form_the_grind_range() { + for state in PhysicalStateId::ALL { + assert_eq!(state.is_grind(), (400..=405).contains(&(state as u32))); + } + } + + #[test] + fn unknown_state_ids_are_rejected_without_a_fallback() { + assert_eq!(PhysicalStateId::try_from(0), Err(UnknownPhysicalState(0))); + assert_eq!( + PhysicalStateId::try_from(203), + Err(UnknownPhysicalState(203)) + ); + } +} diff --git a/crates/skate-core/src/player/state_phase.rs b/crates/skate-core/src/player/state_phase.rs new file mode 100644 index 00000000..b47a4dc4 --- /dev/null +++ b/crates/skate-core/src/player/state_phase.rs @@ -0,0 +1,62 @@ +//! TU3 `PhysicalPlayerHiLOD::State` wrapper (`0x82DB6120`). +//! +//! State bodies, skateboard-controller modes, the board force queue, and the +//! board's fixed-step cache are separate recovered systems. This module owns +//! their exact wrapper order and Player+1344 accumulation. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StatePhaseFields { + /// PhysicalPlayer+1344. + pub elapsed_1344: f32, + /// ProcessedPhysIn+2604. + pub timestep_2604: f32, + /// SkateboardController+448. + pub controller_state_448: u32, + /// SkateboardController+452. + pub controller_system_on_452: bool, +} + +/// Calls made by `0x82DB6120`. Implementors must connect each method to the +/// corresponding recovered system; no fallback behavior is defined here. +pub trait StatePhaseServices { + /// Current-state vtable slot `+8`. + fn update_current_state_vtable_8(&mut self); + + fn update_controller_82d75f00(&mut self); + fn update_controller_mode_1_82d751d8(&mut self); + fn update_controller_mode_2_82d750f0(&mut self); + fn update_controller_mode_3_82d75bb8(&mut self); + fn update_controller_mode_4_82d75d58(&mut self); + + /// First Player vtable `+116` lookup at `0x82DB61C4`. + fn touch_skateboard_vtable_116(&mut self); + /// Second Player vtable `+116` lookup followed by `0x82C03718`. + fn apply_skateboard_force_queue_82c03718(&mut self); + /// Third Player vtable `+116` lookup and the fixed-`1/60` VMX cache update + /// at `0x82DB61F0..0x82DB62D0`. + fn update_skateboard_fixed_step_cache_82db61f0(&mut self); +} + +pub fn run_state_phase(fields: &mut StatePhaseFields, services: &mut impl StatePhaseServices) { + services.update_current_state_vtable_8(); + + if fields.controller_system_on_452 { + services.update_controller_82d75f00(); + match fields.controller_state_448 { + 1 => services.update_controller_mode_1_82d751d8(), + 2 => services.update_controller_mode_2_82d750f0(), + 3 => services.update_controller_mode_3_82d75bb8(), + 4 => services.update_controller_mode_4_82d75d58(), + _ => {} + } + } + + services.touch_skateboard_vtable_116(); + services.apply_skateboard_force_queue_82c03718(); + services.update_skateboard_fixed_step_cache_82db61f0(); + fields.elapsed_1344 += fields.timestep_2604; +} + +#[cfg(test)] +#[path = "tests/state_phase.rs"] +mod tests; diff --git a/crates/skate-core/src/player/teleport_state.rs b/crates/skate-core/src/player/teleport_state.rs new file mode 100644 index 00000000..5a238547 --- /dev/null +++ b/crates/skate-core/src/player/teleport_state.rs @@ -0,0 +1,71 @@ +//! Teleporting702 from original TU3 VT823272FC. +//! Enter82D431D8, Update82D431F0 and FillPhysOut82D43280. +use super::input_phase::RawMatrix; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Target { + pub transform: RawMatrix, + pub on_board: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Update { + /// Skeleton16552 virtual36: ask the actor for a reset target. + RequestCheckpoint, + Captured, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Output { + pub transform: RawMatrix, + pub next_state: u32, + pub state_61: u8, + pub board_272: u8, +} + +#[derive(Clone, Debug)] +pub struct TeleportState { + target: RawMatrix, //48 + ready: bool, //116 bit31 + received: bool, //116 bit30; native serialization retains this bit. + on_board: bool, //116 bit29 +} +impl Default for TeleportState { + fn default() -> Self { + Self { + target: [[0; 4]; 4], + ready: false, + received: false, + on_board: true, + } + } +} +impl TeleportState { + /// Clears only the three lifecycle flags; the retained matrix is unread + /// until Update captures a new target. Native Exit is an empty leaf. + pub fn enter(&mut self) { + self.ready = false; + self.received = false; + self.on_board = true; + } + pub fn update(&mut self, flags_2468: u32, matrix_1536: RawMatrix, byte_1600: u8) -> Update { + if flags_2468 & 2 == 0 { + return Update::RequestCheckpoint; + } + self.received = true; + self.target = matrix_1536; + self.ready = true; + //rlwimi82D43254 inserts only byte1600 bit0, not a nonzero test. + self.on_board = byte_1600 & 1 != 0; + Update::Captured + } + /// This publisher does not clear the output when no target is ready. + pub fn output(&self) -> Option { + self.ready.then_some(Output { + transform: self.target, + next_state: if self.on_board { 100 } else { 500 }, + state_61: 1, + board_272: 1, + }) + } +} diff --git a/crates/skate-core/src/player/tests/adjust.rs b/crates/skate-core/src/player/tests/adjust.rs new file mode 100644 index 00000000..b096b63c --- /dev/null +++ b/crates/skate-core/src/player/tests/adjust.rs @@ -0,0 +1,117 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Event { + Condition, + BoardFlag, + Adjust(bool, u32), + State, + Selector, + Finish(u32), +} + +struct Services { + condition: u32, + board_flag: bool, + selector: u32, + events: Vec, +} + +impl AdjustServices for Services { + fn board_condition_82c02138(&mut self) -> u32 { + self.events.push(Event::Condition); + self.condition + } + + fn controller_mode_one_board_flag_868(&mut self) -> bool { + self.events.push(Event::BoardFlag); + self.board_flag + } + + fn adjust_skeleton_82bd80d8(&mut self, board_flag: bool, timestep: f32) { + self.events + .push(Event::Adjust(board_flag, timestep.to_bits())); + } + + fn update_current_state_post_physics_vtable_40(&mut self) { + self.events.push(Event::State); + } + + fn player_output_selector_82db9100(&mut self) -> u32 { + self.events.push(Event::Selector); + self.selector + } + + fn finish_skeleton_adjustment_82bd83e0(&mut self, selector: u32) { + self.events.push(Event::Finish(selector)); + } +} + +#[test] +fn publishes_board_condition_and_preserves_post_solver_call_order() { + let mut fields = AdjustFields { + processed_word_2512: 100, + timestep_2604: f32::from_bits(0x3c88_8889), + controller_state_448: 1, + output_flag_42: false, + output_scalar_144: 7.0, + output_counter_200: u32::MAX, + }; + let mut services = Services { + condition: 1, + board_flag: true, + selector: 19, + events: Vec::new(), + }; + + run_adjust(&mut fields, &mut services); + + assert!(fields.output_flag_42); + assert_eq!(fields.output_scalar_144.to_bits(), 0); + assert_eq!(fields.output_counter_200, 0); + assert_eq!( + services.events, + [ + Event::Condition, + Event::BoardFlag, + Event::Adjust(true, 0x3c88_8889), + Event::State, + Event::Selector, + Event::Finish(19), + ] + ); +} + +#[test] +fn state_500_and_other_controller_modes_skip_only_the_native_branches() { + let mut fields = AdjustFields { + processed_word_2512: 500, + timestep_2604: 0.25, + controller_state_448: 4, + output_flag_42: false, + output_scalar_144: 7.0, + output_counter_200: 8, + }; + let mut services = Services { + condition: 1, + board_flag: true, + selector: 3, + events: Vec::new(), + }; + + run_adjust(&mut fields, &mut services); + + assert!(!fields.output_flag_42); + assert_eq!(fields.output_scalar_144, 7.0); + assert_eq!(fields.output_counter_200, 8); + assert_eq!( + services.events, + [ + Event::Condition, + Event::Adjust(false, 0.25_f32.to_bits()), + Event::State, + Event::Selector, + Event::Finish(3), + ] + ); +} diff --git a/crates/skate-core/src/player/tests/frame.rs b/crates/skate-core/src/player/tests/frame.rs new file mode 100644 index 00000000..c98b9080 --- /dev/null +++ b/crates/skate-core/src/player/tests/frame.rs @@ -0,0 +1,205 @@ +use super::*; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone)] +struct Recorder { + calls: Rc>>, + fail: Option, +} + +impl Recorder { + fn call(&mut self, stage: FrameStage) -> Result<(), FrameStage> { + self.calls.borrow_mut().push(stage); + if self.fail == Some(stage) { + Err(stage) + } else { + Ok(()) + } + } +} + +impl AnimationPhases for Recorder { + type Error = FrameStage; + fn phase_one(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::AnimationPhaseOne) + } + fn phase_two(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::AnimationPhaseTwo) + } + fn phase_three(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::AnimationPhaseThree) + } + fn publish_physics_input(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::ActorPhysicsPublication) + } +} + +impl WorldPhases for Recorder { + type Error = FrameStage; + fn start_frame(&mut self, _: f32) -> Result<(), Self::Error> { + self.call(FrameStage::StartFrame) + } + fn pre_frame(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WorldPreFrame) + } + fn prepare(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WorldPrepare) + } + fn precursor(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WorldPrecursor) + } + fn after_input(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WorldAfterInput) + } + fn collect_solver_inputs(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::CollectSolverInputs) + } + fn finish_outputs(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::FinishOutputs) + } +} + +impl PhysicalPhases for Recorder { + type Error = FrameStage; + fn post_zero_jobs(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostZeroJobs) + } + fn input(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::Input) + } + fn post_input(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostInput) + } + fn logic(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::Logic) + } + fn pre_state(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PreState) + } + fn state(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::State) + } + fn post_state(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostState) + } + fn adjust(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::Adjust) + } + fn post_adjust(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostAdjust) + } + fn output(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::Output) + } + fn post_output(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PostOutput) + } +} + +impl SolverPhases for Recorder { + type Error = FrameStage; + fn prepare_jobs(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::PrepareSolverJobs) + } + fn submit(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::SubmitSolver) + } + fn wait(&mut self) -> Result<(), Self::Error> { + self.call(FrameStage::WaitSolver) + } +} + +fn recorders(fail: Option) -> (Rc>>, [Recorder; 4]) { + let calls = Rc::new(RefCell::new(Vec::new())); + let recorder = Recorder { + calls: calls.clone(), + fail, + }; + (calls, core::array::from_fn(|_| recorder.clone())) +} + +#[test] +fn runs_the_complete_native_order_across_the_solver_fence() { + let (calls, [mut animation, mut world, mut physical, mut solver]) = recorders(None); + let mut scheduler = FrameScheduler::default(); + scheduler + .run_before_solver( + 1.0 / 60.0, + &mut animation, + &mut world, + &mut physical, + &mut solver, + ) + .unwrap(); + assert_eq!(scheduler.state(), SchedulerState::SolverPending); + scheduler + .run_after_solver(&mut world, &mut physical, &mut solver) + .unwrap(); + assert_eq!(scheduler.state(), SchedulerState::Ready); + assert_eq!( + *calls.borrow(), + [ + FrameStage::StartFrame, + FrameStage::WorldPreFrame, + FrameStage::AnimationPhaseOne, + FrameStage::AnimationPhaseTwo, + FrameStage::WorldPrepare, + FrameStage::AnimationPhaseThree, + FrameStage::WorldPrecursor, + FrameStage::ActorPhysicsPublication, + FrameStage::PostZeroJobs, + FrameStage::Input, + FrameStage::WorldAfterInput, + FrameStage::PostInput, + FrameStage::Logic, + FrameStage::PreState, + FrameStage::State, + FrameStage::PostState, + FrameStage::CollectSolverInputs, + FrameStage::PrepareSolverJobs, + FrameStage::SubmitSolver, + FrameStage::WaitSolver, + FrameStage::Adjust, + FrameStage::PostAdjust, + FrameStage::Output, + FrameStage::PostOutput, + FrameStage::FinishOutputs, + ] + ); +} + +#[test] +fn post_solver_work_cannot_run_before_submission() { + let (_, [_, mut world, mut physical, mut solver]) = recorders(None); + let mut scheduler = FrameScheduler::default(); + assert_eq!( + scheduler.run_after_solver(&mut world, &mut physical, &mut solver), + Err(ScheduleError::WrongState { + expected: SchedulerState::SolverPending, + actual: SchedulerState::Ready, + }) + ); +} + +#[test] +fn a_failed_phase_stops_the_frame_and_cannot_publish_later_output() { + let (calls, [mut animation, mut world, mut physical, mut solver]) = + recorders(Some(FrameStage::Logic)); + let mut scheduler = FrameScheduler::default(); + assert_eq!( + scheduler.run_before_solver( + 1.0 / 60.0, + &mut animation, + &mut world, + &mut physical, + &mut solver + ), + Err(ScheduleError::Runtime { + stage: FrameStage::Logic, + source: FrameStage::Logic + }) + ); + assert_eq!(scheduler.state(), SchedulerState::Faulted); + assert_eq!(calls.borrow().last(), Some(&FrameStage::Logic)); + assert!(!calls.borrow().contains(&FrameStage::SubmitSolver)); +} diff --git a/crates/skate-core/src/player/tests/lifecycle.rs b/crates/skate-core/src/player/tests/lifecycle.rs new file mode 100644 index 00000000..0c05560f --- /dev/null +++ b/crates/skate-core/src/player/tests/lifecycle.rs @@ -0,0 +1,378 @@ +use super::*; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + GetType(StateBinding), + Exit(StateCall), + Enter(StateCall), + Hold, + LetGo, +} + +struct Recorder { + reported_type: PhysicalStateId, + events: Vec, +} + +#[derive(Clone)] +struct SharedRecorder { + reported_type: PhysicalStateId, + events: Rc>>, +} + +impl PhysicalStateCalls for SharedRecorder { + fn get_type(&mut self, state: StateBinding) -> PhysicalStateId { + self.events.borrow_mut().push(Event::GetType(state)); + self.reported_type + } + + fn exit(&mut self, call: StateCall) { + self.events.borrow_mut().push(Event::Exit(call)); + } + + fn enter(&mut self, call: StateCall) { + self.events.borrow_mut().push(Event::Enter(call)); + } +} + +impl SkateboardControllerActions for SharedRecorder { + fn hold_skateboard(&mut self) { + self.events.borrow_mut().push(Event::Hold); + } + + fn let_go_of_skateboard(&mut self) { + self.events.borrow_mut().push(Event::LetGo); + } +} + +impl PhysicalStateCalls for Recorder { + fn get_type(&mut self, state: StateBinding) -> PhysicalStateId { + self.events.push(Event::GetType(state)); + self.reported_type + } + + fn exit(&mut self, call: StateCall) { + self.events.push(Event::Exit(call)); + } + + fn enter(&mut self, call: StateCall) { + self.events.push(Event::Enter(call)); + } +} + +impl SkateboardControllerActions for Recorder { + fn hold_skateboard(&mut self) { + self.events.push(Event::Hold); + } + + fn let_go_of_skateboard(&mut self) { + self.events.push(Event::LetGo); + } +} + +fn seeded_data() -> StateChangeData { + StateChangeData { + player: PlayerStateChangeFields { + word_1312: 0x1312_1312, + previous_category_latch_1336: 400, + scalar_1344: 13.44, + }, + processed: ProcessedStateChangeFields { + flags_2480: 0, + requested_state_2500: 0x2500_2500, + previous_state_2504: 0x2504_2504, + current_state_2508: 0x2508_2508, + current_category_2512: 0x2512_2512, + previous_category_2516: 0x2516_2516, + previous_category_latch_2520: 0x2520_2520, + word_2564: 0x2564_2564, + scalar_2664: 26.64, + }, + skateboard_controller: SkateboardControllerFields { + word_444: 444, + state_448: 0, + system_on_452: false, + }, + } +} + +fn recorder(reported_type: PhysicalStateId) -> Recorder { + Recorder { + reported_type, + events: Vec::new(), + } +} + +#[test] +fn exits_old_pointer_before_switching_and_enters_new_pointer_afterward() { + let old = StateBinding::new(PhysicalStateId::PhysicsGround); + let new = StateBinding::new(PhysicalStateId::PhysicsAir); + let mut lifecycle = PhysicalPlayerStateLifecycle::new(old.state); + let mut data = seeded_data(); + let mut states = recorder(old.state); + let mut controller = recorder(old.state); + + assert_eq!( + lifecycle + .set_physics_state(new.state as u32, &mut data, &mut states, &mut controller) + .unwrap(), + new + ); + assert_eq!( + states.events, + [ + Event::GetType(old), + Event::Exit(StateCall { + state: old, + active: old, + }), + Event::Enter(StateCall { + state: new, + active: new, + }), + ] + ); + assert_eq!(lifecycle.active(), new); +} + +#[test] +fn publishes_exact_state_history_and_resets_transition_fields() { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::PhysicsGround); + let mut data = seeded_data(); + let mut states = recorder(PhysicalStateId::PhysicsGround); + let mut controller = recorder(PhysicalStateId::PhysicsGround); + + lifecycle + .set_physics_state( + PhysicalStateId::PhysicsAir as u32, + &mut data, + &mut states, + &mut controller, + ) + .unwrap(); + + assert_eq!(data.player.word_1312, 0); + assert_eq!(data.player.scalar_1344.to_bits(), 0.0f32.to_bits()); + assert_eq!(data.processed.scalar_2664.to_bits(), 0.0f32.to_bits()); + assert_eq!(data.processed.word_2564, 0); + assert_eq!(data.processed.previous_state_2504, 100); + assert_eq!(data.processed.previous_category_2516, 100); + assert_eq!(data.processed.requested_state_2500, 200); + assert_eq!(data.processed.current_state_2508, 200); + assert_eq!(data.processed.current_category_2512, 200); + assert_eq!(data.player.previous_category_latch_1336, 100); + assert_eq!(data.processed.previous_category_latch_2520, 100); + + states.reported_type = PhysicalStateId::PhysicsAir; + data.player.previous_category_latch_1336 = 700; + lifecycle + .set_physics_state( + PhysicalStateId::KnownAir as u32, + &mut data, + &mut states, + &mut controller, + ) + .unwrap(); + assert_eq!(data.processed.previous_state_2504, 200); + assert_eq!(data.processed.previous_category_2516, 200); + assert_eq!(data.processed.current_state_2508, 201); + assert_eq!(data.processed.current_category_2512, 200); + assert_eq!(data.player.previous_category_latch_1336, 700); + assert_eq!(data.processed.previous_category_latch_2520, 700); +} + +#[test] +fn every_native_state_selects_its_exact_owned_pointer_slot() { + let expected = [ + (PhysicalStateId::PhysicsGround, 1696), + (PhysicalStateId::SlideGround, 1700), + (PhysicalStateId::RevertGround, 1704), + (PhysicalStateId::GroundAnimation, 1708), + (PhysicalStateId::Skitching, 1772), + (PhysicalStateId::FollowPath, 1760), + (PhysicalStateId::PhysicsAir, 1712), + (PhysicalStateId::KnownAir, 1720), + (PhysicalStateId::PhysicsAirSecondary, 1716), + (PhysicalStateId::WipeoutGround, 1748), + (PhysicalStateId::GrindBoardslide, 1728), + (PhysicalStateId::GrindFiftyFifty, 1732), + (PhysicalStateId::GrindTipslide, 1736), + (PhysicalStateId::GrindFiveO, 1740), + (PhysicalStateId::GrindBackslash, 1744), + (PhysicalStateId::GrindDarkslide, 1724), + (PhysicalStateId::BipedGround, 1764), + (PhysicalStateId::BipedAir, 1768), + (PhysicalStateId::OffBoardPushing, 1776), + (PhysicalStateId::LandingOnDeck, 1792), + (PhysicalStateId::HandPlant, 1780), + (PhysicalStateId::FootPlant, 1784), + (PhysicalStateId::Boneless, 1788), + (PhysicalStateId::Sleeping, 1692), + (PhysicalStateId::Nonspecific, 1752), + (PhysicalStateId::Teleporting, 1756), + ]; + + for (requested, owner_offset) in expected { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::Sleeping); + let mut data = seeded_data(); + let mut states = recorder(PhysicalStateId::Sleeping); + let mut controller = recorder(PhysicalStateId::Sleeping); + let selected = lifecycle + .set_physics_state(requested as u32, &mut data, &mut states, &mut controller) + .unwrap(); + assert_eq!( + selected, + StateBinding { + state: requested, + owner_offset + } + ); + assert_eq!( + states.events.last(), + Some(&Event::Enter(StateCall { + state: selected, + active: selected, + })) + ); + } +} + +#[test] +fn invalid_ids_fail_before_callbacks_writes_or_pointer_changes() { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::PhysicsGround); + let active_before = lifecycle.active(); + let mut data = seeded_data(); + let data_before = data; + let mut states = recorder(PhysicalStateId::PhysicsGround); + let mut controller = recorder(PhysicalStateId::PhysicsGround); + + assert_eq!( + lifecycle.set_physics_state(203, &mut data, &mut states, &mut controller), + Err(UnknownPhysicalState(203)) + ); + assert_eq!(lifecycle.active(), active_before); + assert_eq!(data, data_before); + assert!(states.events.is_empty()); + assert!(controller.events.is_empty()); +} + +#[test] +fn offboard_entry_chooses_held_or_free_mode_from_native_flag_bits() { + let mut held_lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::PhysicsGround); + let mut held_data = seeded_data(); + held_data.skateboard_controller.state_448 = 4; + let mut held_states = recorder(PhysicalStateId::PhysicsGround); + let mut held_actions = recorder(PhysicalStateId::PhysicsGround); + held_lifecycle + .set_physics_state( + PhysicalStateId::BipedGround as u32, + &mut held_data, + &mut held_states, + &mut held_actions, + ) + .unwrap(); + assert_eq!(held_actions.events, [Event::Hold]); + assert_eq!(held_data.skateboard_controller.word_444, 0); + assert_eq!(held_data.skateboard_controller.state_448, 1); + assert!(held_data.skateboard_controller.system_on_452); + + let mut free_lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::PhysicsGround); + let mut free_data = seeded_data(); + free_data.processed.flags_2480 = 0x80; + free_data.skateboard_controller.state_448 = 1; + let mut free_states = recorder(PhysicalStateId::PhysicsGround); + let mut free_actions = recorder(PhysicalStateId::PhysicsGround); + free_lifecycle + .set_physics_state( + PhysicalStateId::OffBoardPushing as u32, + &mut free_data, + &mut free_states, + &mut free_actions, + ) + .unwrap(); + assert_eq!(free_actions.events, [Event::LetGo]); + assert_eq!(free_data.skateboard_controller.word_444, 0); + assert_eq!(free_data.skateboard_controller.state_448, 2); + assert!(free_data.skateboard_controller.system_on_452); +} + +#[test] +fn onboard_entry_runs_stop_controller_before_state_callbacks() { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::BipedGround); + let mut data = seeded_data(); + data.skateboard_controller = SkateboardControllerFields { + word_444: 99, + state_448: 1, + system_on_452: true, + }; + let events = Rc::new(RefCell::new(Vec::new())); + let mut states = SharedRecorder { + reported_type: PhysicalStateId::BipedGround, + events: events.clone(), + }; + let mut actions = states.clone(); + + lifecycle + .set_physics_state( + PhysicalStateId::PhysicsGround as u32, + &mut data, + &mut states, + &mut actions, + ) + .unwrap(); + + assert_eq!(data.skateboard_controller.word_444, 0); + assert_eq!(data.skateboard_controller.state_448, 0); + assert!(!data.skateboard_controller.system_on_452); + let old = StateBinding::new(PhysicalStateId::BipedGround); + let new = StateBinding::new(PhysicalStateId::PhysicsGround); + assert_eq!( + *events.borrow(), + [ + Event::LetGo, + Event::GetType(old), + Event::Exit(StateCall { + state: old, + active: old, + }), + Event::Enter(StateCall { + state: new, + active: new, + }), + ] + ); +} + +#[test] +fn an_already_running_offboard_controller_is_left_untouched() { + let mut lifecycle = PhysicalPlayerStateLifecycle::new(PhysicalStateId::BipedGround); + let mut data = seeded_data(); + data.processed.flags_2480 = 0x180; + data.skateboard_controller = SkateboardControllerFields { + word_444: 99, + state_448: 1, + system_on_452: true, + }; + let mut states = recorder(PhysicalStateId::BipedGround); + let mut actions = recorder(PhysicalStateId::BipedGround); + + lifecycle + .set_physics_state( + PhysicalStateId::BipedAir as u32, + &mut data, + &mut states, + &mut actions, + ) + .unwrap(); + + assert!(actions.events.is_empty()); + assert_eq!( + data.skateboard_controller, + SkateboardControllerFields { + word_444: 99, + state_448: 1, + system_on_452: true, + } + ); +} diff --git a/crates/skate-core/src/player/tests/logic.rs b/crates/skate-core/src/player/tests/logic.rs new file mode 100644 index 00000000..3929ed5d --- /dev/null +++ b/crates/skate-core/src/player/tests/logic.rs @@ -0,0 +1,180 @@ +use super::*; +use crate::player::{ + lifecycle::{ + PhysicalStateCalls, PlayerStateChangeFields, ProcessedStateChangeFields, + SkateboardControllerActions, SkateboardControllerFields, StateCall, StateChangeData, + }, + selector::{ + ProcessedStateInput, + conditions::{BoardBodyState, SkeletonAnimationState, TwoStageThresholds}, + }, + state::PhysicalStateId as State, +}; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + GetType(State), + Exit(State), + Enter(State), +} + +struct States { + events: Rc>>, +} + +impl PhysicalStateCalls for States { + fn get_type(&mut self, state: StateBinding) -> State { + self.events.borrow_mut().push(Event::GetType(state.state)); + state.state + } + + fn exit(&mut self, call: StateCall) { + self.events.borrow_mut().push(Event::Exit(call.state.state)); + } + + fn enter(&mut self, call: StateCall) { + self.events + .borrow_mut() + .push(Event::Enter(call.state.state)); + } +} + +struct Controller; + +impl SkateboardControllerActions for Controller { + fn hold_skateboard(&mut self) {} + fn let_go_of_skateboard(&mut self) {} +} + +fn input() -> StateSelectionInput { + let thresholds = TwoStageThresholds { + field_856_primary: 1.0, + field_856_secondary: 1.0, + field_7692: 1.0, + }; + StateSelectionInput { + processed: ProcessedStateInput { + grind_type_1248: 6, + grind_candidate_1488: false, + field_1776: 0, + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + flags_2488: 0, + category_2512: 100, + wheel_contact_count_2556: 0, + field_2572: 0, + state_timer_2664: 0.0, + field_2732: 0.0, + field_2744: 0.0, + trajectory_collision_time_2772: 0.0, + grind_investigation_flags_1516: 0, + }, + skateboard_contact_count_869: 0, + board_body: BoardBodyState { + field_856: 0.0, + field_7692: 0.0, + }, + skeleton: SkeletonAnimationState { + mode_16420: 1, + back_chain_lane_12656: 0.0, + threshold_800: 1.0, + }, + skitching_off_ground: thresholds, + normal_off_ground: thresholds, + } +} + +fn change_data() -> StateChangeData { + StateChangeData { + player: PlayerStateChangeFields { + word_1312: 1, + previous_category_latch_1336: 0, + scalar_1344: 1.0, + }, + processed: ProcessedStateChangeFields { + flags_2480: 0, + requested_state_2500: 0, + previous_state_2504: 0, + current_state_2508: 100, + current_category_2512: 100, + previous_category_2516: 0, + previous_category_latch_2520: 0, + word_2564: 1, + scalar_2664: 1.0, + }, + skateboard_controller: SkateboardControllerFields { + word_444: 0, + state_448: 0, + system_on_452: false, + }, + } +} + +fn staging() -> PhysicsOutputStaging { + PhysicsOutputStaging { + bytes_20_through_53: [0xaa; 34], + scalars_56_through_188: [1.0; 34], + word_200: 7, + } +} + +#[test] +fn unchanged_state_calls_get_type_once_and_still_clears_output_staging() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut logic = PhysicalPlayerLogic::new(State::PhysicsGround); + let mut data = change_data(); + let mut output = staging(); + let mut states = States { + events: events.clone(), + }; + let result = logic + .run( + &input(), + &mut data, + &mut output, + &mut states, + &mut Controller, + ) + .unwrap(); + + assert!(!result.changed); + assert_eq!(*events.borrow(), [Event::GetType(State::PhysicsGround)]); + assert_eq!(logic.frames_since_jump_1304, 0); + assert_eq!(output.bytes_20_through_53, [0; 34]); + assert_eq!(output.scalars_56_through_188, [0.0; 34]); + assert_eq!(output.word_200, 0); +} + +#[test] +fn state_edge_runs_native_transition_and_sets_100() { + let events = Rc::new(RefCell::new(Vec::new())); + let mut logic = PhysicalPlayerLogic::new(State::PhysicsGround); + let mut frame = input(); + frame.processed.flags_2480 = 0x1000; + let mut data = change_data(); + let mut output = staging(); + let mut states = States { + events: events.clone(), + }; + let result = logic + .run(&frame, &mut data, &mut output, &mut states, &mut Controller) + .unwrap(); + + assert!(result.changed); + assert_eq!(result.selected.state, State::LandingOnDeck); + assert_eq!(logic.frames_since_jump_1304, 100); + assert_eq!( + *events.borrow(), + [ + Event::GetType(State::PhysicsGround), + Event::GetType(State::PhysicsGround), + Event::Exit(State::PhysicsGround), + Event::Enter(State::LandingOnDeck), + ] + ); + assert_eq!(data.processed.current_state_2508, 503); +} diff --git a/crates/skate-core/src/player/tests/post_input.rs b/crates/skate-core/src/player/tests/post_input.rs new file mode 100644 index 00000000..1ac13529 --- /dev/null +++ b/crates/skate-core/src/player/tests/post_input.rs @@ -0,0 +1,191 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + Grind, + Trajectory, + Scalar, + Register(CandidateRegistration), +} + +struct Services { + events: Vec, + trajectory: u8, + scalar: f32, +} + +impl PostInputServices for Services { + fn update_grind_manager_82d8ab08(&mut self) { + self.events.push(Event::Grind); + } + + fn update_trajectory_selector_82d68800(&mut self) -> u8 { + self.events.push(Event::Trajectory); + self.trajectory + } + + fn calculate_scalar_2740_82db5e10(&mut self) -> f32 { + self.events.push(Event::Scalar); + self.scalar + } + + fn register_candidate_82762ab0(&mut self, registration: CandidateRegistration) { + self.events.push(Event::Register(registration)); + } +} + +fn player() -> PostInputPlayerFields { + PostInputPlayerFields { + jump_reference_1264: [1, 2, 3, 4], + flags_1296: 0, + state_frames_1304: 4, + jump_fix_frames_1308: 8, + latch_frames_1320: 0, + } +} + +fn processed() -> PostInputProcessedFields { + PostInputProcessedFields { + jump_reference_848: [0; 4], + word_2464: 0, + flags_2468: 0, + flags_2472: 0, + flags_2480: 0, + flags_2484: 0, + current_state_2508: 100, + state_frames_2572: 0, + jump_fix_frames_2576: 0, + scalar_2740: 0.0, + } +} + +fn phys_out() -> PostInputPhysOutFields { + PostInputPhysOutFields { + reset_state_frames_316: false, + capture_jump_reference_442: false, + jump_reference_128: [10, 11, 12, 13], + complete_76: false, + } +} + +fn candidates() -> CandidatePublicationFields { + CandidatePublicationFields { + first_object_present_196: false, + first_pending_288: false, + second_object_present_500: false, + second_pending_592: false, + staged_word_12768: 0, + staged_valid_12772: 0, + staged_latched_12776: 7, + staged_pending_12780: false, + } +} + +#[test] +fn phase_preserves_service_order_and_native_counter_publication() { + let mut player = player(); + let mut processed = processed(); + let mut output = phys_out(); + output.reset_state_frames_316 = true; + output.capture_jump_reference_442 = true; + let mut candidates = candidates(); + candidates.first_object_present_196 = true; + candidates.first_pending_288 = true; + candidates.second_object_present_500 = true; + candidates.second_pending_592 = true; + let mut services = Services { + events: Vec::new(), + trajectory: 3, + scalar: f32::from_bits(0x3E80_0000), + }; + + run_post_input( + PostInputContext { + player: &mut player, + processed: &mut processed, + phys_out: &mut output, + candidates: &mut candidates, + }, + &mut services, + ); + + assert_eq!( + services.events, + [ + Event::Grind, + Event::Trajectory, + Event::Scalar, + Event::Register(CandidateRegistration::First1888), + Event::Register(CandidateRegistration::Second2176), + ] + ); + assert_eq!(player.state_frames_1304, 1); + assert_eq!(processed.state_frames_2572, 1); + assert_eq!(player.jump_fix_frames_1308, 1); + assert_eq!(processed.jump_fix_frames_2576, 1); + assert_eq!(player.jump_reference_1264, output.jump_reference_128); + assert_eq!(processed.jump_reference_848, output.jump_reference_128); + assert_eq!(processed.flags_2468 & 0x400, 0x400); + assert_eq!(processed.scalar_2740.to_bits(), 0x3E80_0000); + assert_eq!(processed.flags_2480 & 0x0060_0000, 0x0060_0000); + assert!(!candidates.first_pending_288); + assert!(!candidates.second_pending_592); + assert!(output.complete_76); +} + +#[test] +fn reset_branch_clears_state_latch_and_staged_candidate_follows_validity() { + let mut player = player(); + player.flags_1296 = 0x4000_0000; + let mut processed = processed(); + processed.flags_2468 = 0x0040_0000; + let mut output = phys_out(); + let mut candidates = candidates(); + candidates.staged_word_12768 = 0x1122_3344; + candidates.staged_valid_12772 = 9; + candidates.staged_pending_12780 = true; + let mut services = Services { + events: Vec::new(), + trajectory: 0, + scalar: 0.0, + }; + + run_post_input( + PostInputContext { + player: &mut player, + processed: &mut processed, + phys_out: &mut output, + candidates: &mut candidates, + }, + &mut services, + ); + + assert_eq!(player.state_frames_1304, 0); + assert_eq!(processed.state_frames_2572, 0); + assert_eq!(player.flags_1296 & 0x4000_0000, 0); + assert_eq!(candidates.staged_latched_12776, 1); + assert_eq!(processed.flags_2480 & 0x0010_0000, 0x0010_0000); + assert_eq!(processed.word_2464, 0x1122_3344); + assert!(!candidates.staged_pending_12780); +} + +#[test] +fn latch_helper_preserves_the_twenty_frame_strict_expiry_and_mirrors_bits() { + let mut player = player(); + player.flags_1296 = 0x0600_0000; + player.latch_frames_1320 = 20; + let mut processed = processed(); + + update_flag_latches_82db5cf8(&mut player, &mut processed); + assert_eq!(player.latch_frames_1320, 21); + assert_eq!(player.flags_1296 & 0x0600_0000, 0x0600_0000); + + update_flag_latches_82db5cf8(&mut player, &mut processed); + assert_eq!(player.flags_1296 & 0x0600_0000, 0); + assert_eq!(processed.flags_2472 & 0x0000_000C, 0); + + processed.flags_2472 = 0x0000_8010; + processed.flags_2480 = 0x0002_0000; + update_flag_latches_82db5cf8(&mut player, &mut processed); + assert_eq!(player.flags_1296 & 0x1802_0000, 0x1802_0000); +} diff --git a/crates/skate-core/src/player/tests/post_output.rs b/crates/skate-core/src/player/tests/post_output.rs new file mode 100644 index 00000000..25de0179 --- /dev/null +++ b/crates/skate-core/src/player/tests/post_output.rs @@ -0,0 +1,30 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + Player, + Skeleton, +} + +#[derive(Default)] +struct Services(Vec); + +impl PostOutputServices for Services { + fn finish_player_output_82909510(&mut self, _: &mut PlayerOutputField1392) { + self.0.push(Event::Player); + } + + fn finish_skeleton_output_82be2ba8(&mut self) { + self.0.push(Event::Skeleton); + } +} + +#[test] +fn preserves_the_complete_native_call_order() { + let mut field = PlayerOutputField1392; + let mut services = Services::default(); + + run_post_output(&mut field, &mut services); + + assert_eq!(services.0, [Event::Player, Event::Skeleton]); +} diff --git a/crates/skate-core/src/player/tests/post_state.rs b/crates/skate-core/src/player/tests/post_state.rs new file mode 100644 index 00000000..128c74cd --- /dev/null +++ b/crates/skate-core/src/player/tests/post_state.rs @@ -0,0 +1,21 @@ +use super::*; + +#[derive(Default)] +struct Services { + calls: u32, +} + +impl PostStateServices for Services { + fn update_current_state_vtable_12(&mut self) { + self.calls += 1; + } +} + +#[test] +fn dispatches_the_current_state_post_state_slot_once() { + let mut services = Services::default(); + + run_post_state(&mut services); + + assert_eq!(services.calls, 1); +} diff --git a/crates/skate-core/src/player/tests/pre_state.rs b/crates/skate-core/src/player/tests/pre_state.rs new file mode 100644 index 00000000..f1c920aa --- /dev/null +++ b/crates/skate-core/src/player/tests/pre_state.rs @@ -0,0 +1,80 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + Fill, + Component, + State, +} + +struct Services { + events: Vec, + vector: [u32; 4], +} + +impl PreStateServices for Services { + fn fill_packet_vtable_24(&mut self, packet: &mut PreStatePacket) { + self.events.push(Event::Fill); + packet.words[12..16].copy_from_slice(&self.vector); + } + + fn update_component_1840_82d74270(&mut self) { + self.events.push(Event::Component); + } + + fn update_before_state_vtable_4(&mut self) { + self.events.push(Event::State); + } +} + +#[test] +fn publishes_packet_vector_and_preserves_full_native_call_order() { + let mut player = PreStatePlayerFields { + frame_counter_1312: 9, + component_1840_present: true, + }; + let mut skeleton = PreStateSkeletonFields { + predicted_position_16112: [0; 4], + predicted_position_set_16416: false, + nested_flag_3184: true, + }; + let vector = [0x3F80_0000, 0xC000_0000, 0x4040_0000, 0]; + let mut services = Services { + events: Vec::new(), + vector, + }; + + run_pre_state(&mut player, &mut skeleton, &mut services); + + assert_eq!(player.frame_counter_1312, 10); + assert_eq!(skeleton.predicted_position_16112, vector); + assert!(skeleton.predicted_position_set_16416); + assert!(!skeleton.nested_flag_3184); + assert_eq!( + services.events, + [Event::Fill, Event::Component, Event::State] + ); +} + +#[test] +fn absent_component_skips_only_82d74270_and_counter_wraps() { + let mut player = PreStatePlayerFields { + frame_counter_1312: u32::MAX, + component_1840_present: false, + }; + let mut skeleton = PreStateSkeletonFields { + predicted_position_16112: [7; 4], + predicted_position_set_16416: false, + nested_flag_3184: true, + }; + let mut services = Services { + events: Vec::new(), + vector: [1, 2, 3, 4], + }; + + run_pre_state(&mut player, &mut skeleton, &mut services); + + assert_eq!(player.frame_counter_1312, 0); + assert_eq!(services.events, [Event::Fill, Event::State]); + assert_eq!(skeleton.predicted_position_16112, [1, 2, 3, 4]); +} diff --git a/crates/skate-core/src/player/tests/state_phase.rs b/crates/skate-core/src/player/tests/state_phase.rs new file mode 100644 index 00000000..1d88ea7e --- /dev/null +++ b/crates/skate-core/src/player/tests/state_phase.rs @@ -0,0 +1,117 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Event { + State, + Controller, + Mode(u32), + BoardTouch, + Forces, + FixedStep, +} + +#[derive(Default)] +struct Services(Vec); + +impl StatePhaseServices for Services { + fn update_current_state_vtable_8(&mut self) { + self.0.push(Event::State); + } + + fn update_controller_82d75f00(&mut self) { + self.0.push(Event::Controller); + } + + fn update_controller_mode_1_82d751d8(&mut self) { + self.0.push(Event::Mode(1)); + } + + fn update_controller_mode_2_82d750f0(&mut self) { + self.0.push(Event::Mode(2)); + } + + fn update_controller_mode_3_82d75bb8(&mut self) { + self.0.push(Event::Mode(3)); + } + + fn update_controller_mode_4_82d75d58(&mut self) { + self.0.push(Event::Mode(4)); + } + + fn touch_skateboard_vtable_116(&mut self) { + self.0.push(Event::BoardTouch); + } + + fn apply_skateboard_force_queue_82c03718(&mut self) { + self.0.push(Event::Forces); + } + + fn update_skateboard_fixed_step_cache_82db61f0(&mut self) { + self.0.push(Event::FixedStep); + } +} + +#[test] +fn active_controller_mode_runs_between_state_and_board_work() { + for mode in 1..=4 { + let mut fields = StatePhaseFields { + elapsed_1344: 1.0, + timestep_2604: f32::from_bits(0x3C88_8889), + controller_state_448: mode, + controller_system_on_452: true, + }; + let mut services = Services::default(); + + run_state_phase(&mut fields, &mut services); + + assert_eq!( + services.0, + [ + Event::State, + Event::Controller, + Event::Mode(mode), + Event::BoardTouch, + Event::Forces, + Event::FixedStep, + ] + ); + assert_eq!( + fields.elapsed_1344.to_bits(), + (1.0f32 + f32::from_bits(0x3C88_8889)).to_bits() + ); + } +} + +#[test] +fn inactive_or_unknown_controller_skips_only_controller_calls() { + for (system_on, mode) in [(false, 2), (true, 0), (true, 5)] { + let mut fields = StatePhaseFields { + elapsed_1344: -0.0, + timestep_2604: 0.0, + controller_state_448: mode, + controller_system_on_452: system_on, + }; + let mut services = Services::default(); + + run_state_phase(&mut fields, &mut services); + + let expected = if system_on { + vec![ + Event::State, + Event::Controller, + Event::BoardTouch, + Event::Forces, + Event::FixedStep, + ] + } else { + vec![ + Event::State, + Event::BoardTouch, + Event::Forces, + Event::FixedStep, + ] + }; + assert_eq!(services.0, expected); + assert_eq!(fields.elapsed_1344.to_bits(), 0); + } +} diff --git a/crates/skate-core/src/player/wipeout/air.rs b/crates/skate-core/src/player/wipeout/air.rs new file mode 100644 index 00000000..b564f66c --- /dev/null +++ b/crates/skate-core/src/player/wipeout/air.rs @@ -0,0 +1,95 @@ +//! Complete Air checks82D90358 including bad landing82D90D08. +use super::{common, Frame, Mode, Requests, Settings}; +use crate::physics::native_arithmetic::dot3; +pub fn check(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame, use_com: bool) { + if state.mode != 2 { + state.mode = 2; + state.contact_frames = 0; + } + if f.board_contact { + state.contact_frames = 4; + } + if state.contact_frames > 0 { + state.contact_frames -= 1; + } + if state.cooldown > 0.0 { + state.cooldown -= f.timestep; + return; + } + common::vehicle(state, s, f); + let a = &s.air; + let body_flip = f.flags_2476 & (1 << 26) != 0; + let scalar = if body_flip { a.body_flip_scalar } else { 1.0 }; + let deck_scalar = if body_flip { + a.body_flip_acc_scalar + } else { + 1.0 + }; + let (mut xz, y) = if f.flags_2472 & (1 << 15) != 0 && f.jump_fix_frames > a.ignore_danger_frames + { + (a.xz_trick, a.y_trick) + } else if f.wheel_contact { + (mode.ground_xz * deck_scalar, s.ground.y_acceleration) + } else { + (a.xz_acceleration * deck_scalar, a.y_acceleration) + }; + if f.board_material_flags & (1 << 29) != 0 { + xz *= a.light_dmo_scalar; + } + common::closing(state, f, xz, y); + let displacement = a.max_displacement * scalar; + if mode.check_squash && f.maximum_pose_error > a.max_squash * scalar { + state.request(18, 0.0); + } else if dot3(f.pose_error, f.pose_error) > displacement * displacement { + state.request(1, 0.0); + } else if common::force(f, a.max_contact * scalar, a.max_arm_contact) { + state.request(0, 0.0); + } + if f.flip_active && f.flip_requested_speed == 0.0 && f.system_up_y < 0.0 { + state.request(4, 0.0); + } + bad_landing(state, s, mode, f, use_com); + common::leaning(state, s, f); + //Two independent sensitive conditions can request21 twice. + if f.flags_2476 & (1 << 26) != 0 && f.compliant { + state.request(21, 0.0); + } + if f.flags_2472 & (1 << 6) != 0 && (f.compliant || f.board_contact) { + state.request(21, 0.0); + } +} +fn bad_landing(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame, use_com: bool) { + if !mode.check_bad_landing || !f.board_contact { + return; + } + let velocity = if use_com { + f.com_velocity + } else { + f.deck_velocity + }; + let mut normal = f.board_contact_normal; + if f.grind_selected { + if f.grind_normal_valid { + normal = f.grind_normal; + } + if -dot3(normal, velocity) > s.air.max_grind_speed * mode.bad_landing_scale { + state.request(8, 0.0); + } + } else { + let into = -dot3(normal, velocity); + let tangent = std::array::from_fn(|i| normal[i].mul_add(into, velocity[i])); + let limit = + s.air.max_landing_angle.evaluate(common::length(tangent)) * mode.bad_landing_scale; + if f.landing_angle.abs() > limit { + state.request(5, 0.0); + } + let limit = if f.board_material_flags & (1 << 31) != 0 { + s.air.max_stairs_speed + } else { + s.air.max_landing_speed + }; + if into > limit * mode.bad_landing_scale { + state.request(6, into); + } + } +} diff --git a/crates/skate-core/src/player/wipeout/common.rs b/crates/skate-core/src/player/wipeout/common.rs new file mode 100644 index 00000000..9b8883f3 --- /dev/null +++ b/crates/skate-core/src/player/wipeout/common.rs @@ -0,0 +1,60 @@ +use super::{Frame, Requests, Settings}; +use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic::dot3}; +pub(super) fn length(v: [f32; 4]) -> f32 { + let sq = dot3(v, v); + let inv = inverse_length_squared(sq, 2); + if sq == 0.0 { + 0.0 + } else { + sq * inv + } +} +fn selected_max(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { + a + } else { + b + } +} +///82BD88A0 reads unweighted regional forces in this exact pair order. +pub(super) fn force(frame: &Frame, body: f32, arms: f32) -> bool { + let f = frame.regions_force; + let a = selected_max(f[0], f[1]); + let b = selected_max(f[5], f[4]); + let c = selected_max(f[7], f[6]); + selected_max(selected_max(a, b), c) > body || selected_max(f[2], f[3]) > arms +} +pub(super) fn vehicle(state: &mut Requests, s: &Settings, f: &Frame) { + let limit = if f.flags_2476 & 8 != 0 { + s.ground.skitch_contact + } else { + s.ground.vehicle_contact + }; + if f.vehicle_force > limit { + state.request(7, 0.0); + } +} +///82D90AB8 rotates closing velocity656 by the actual WorldToAnim basis. +pub(super) fn closing(state: &mut Requests, f: &Frame, xz: f32, y: f32) { + if !f.board_contact { + return; + } + let v = f.closing_velocity; + let m = f.world_to_animation; + let local: [f32; 4] = + std::array::from_fn(|i| m[2][i].mul_add(v[2], m[1][i].mul_add(v[1], m[0][i] * v[0]))); + if length([local[0], 0.0, local[2], local[3]]) > xz || local[1].abs() > y { + state.request(2, 0.0); + if force(f, 1.0, 20.0) { + state.request(0, 0.0); + } + } +} +pub(super) fn leaning(state: &mut Requests, s: &Settings, f: &Frame) { + if f.animation_up[1] < s.lean_contact_y + && f.compliant + && f.highest_normal[1] > f32::from_bits(0x3f34_fdf4) + { + state.request(19, 0.0); + } +} diff --git a/crates/skate-core/src/player/wipeout/data.rs b/crates/skate-core/src/player/wipeout/data.rs new file mode 100644 index 00000000..df34b69b --- /dev/null +++ b/crates/skate-core/src/player/wipeout/data.rs @@ -0,0 +1,109 @@ +//! Actual observation values consumed by Wipeout82D8F9E0/82D90358. +use crate::{physics::skeleton_animation_record::AnimationPartTransform, point_graph::PointGraph}; +pub type V = [f32; 4]; +#[derive(Clone, Copy, Debug)] +pub struct Frame { + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub category: u32, + pub timestep: f32, + pub time_on_ground: f32, + pub speed: f32, + pub animation_up: V, + pub landing_angle: f32, + pub deck_velocity: V, + pub com_velocity: V, + pub jump_fix_frames: i32, + ///Actual solver deck pose and the retained Processed0/16/32 basis. + pub deck: AnimationPartTransform, + pub input_board: AnimationPartTransform, + pub world_to_animation: AnimationPartTransform, + pub closing_velocity: V, + pub board_material_flags: u32, + pub board_contact: bool, + pub wheel_contact: bool, + pub board_contact_normal: V, + pub opposing_contact: f32, + pub regions_force: [f32; 8], + pub maximum_skater_force: f32, + pub vehicle_force: f32, + pub group_8: bool, + pub conflicting: bool, + pub compliant: bool, + pub highest_normal: V, + pub pose_error: V, + pub maximum_pose_error: f32, + pub flip_active: bool, + pub flip_requested_speed: f32, + pub system_up_y: f32, + pub grind_selected: bool, + pub grind_normal_valid: bool, + pub grind_normal: V, +} +#[derive(Clone, Copy)] +pub struct Mode { + pub check_squash: bool, + pub check_bad_landing: bool, + pub ground_xz: f32, + pub bad_landing_scale: f32, +} +pub struct GroundSettings { + pub vehicle_scalar: f32, + pub vehicle_contact: f32, + pub skitch_contact: f32, + pub skitch_scalar: f32, + pub skitch_arms_scalar: f32, + pub skater_scalar: f32, + pub max_squash: f32, + pub max_squash_coffin: f32, + pub max_displacement: f32, + pub max_contact: f32, + pub max_arm_contact: f32, + pub max_deck_error: f32, + pub opposing_contact: f32, + pub y_acceleration: f32, + pub light_dmo_scalar: f32, + pub player_scalar: f32, + pub ai_scalar: f32, + pub skitch_acc_scalar: f32, + pub balance_total: f32, + pub balance_min_speed: f32, + pub balance_base: f32, +} +pub struct AirSettings { + pub xz_trick: f32, + pub y_trick: f32, + pub xz_acceleration: f32, + pub y_acceleration: f32, + pub max_squash: f32, + pub max_displacement: f32, + pub max_contact: f32, + pub max_arm_contact: f32, + pub body_flip_scalar: f32, + pub body_flip_acc_scalar: f32, + pub light_dmo_scalar: f32, + pub ignore_danger_frames: i32, + pub max_landing_speed: f32, + pub max_stairs_speed: f32, + pub max_grind_speed: f32, + ///Layout0 PointNegGraph; original directly reads X16/Y48. + pub max_landing_angle: PointGraph<8>, +} +pub struct Settings { + pub ground: GroundSettings, + pub air: AirSettings, + pub lean_contact_y: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct RequestInput { + pub flags_2468: u32, + pub flags_2476: u32, + pub flags_2480: u32, + pub flags_2484: u32, + pub animation_up_y: f32, + pub category: u32, +} diff --git a/crates/skate-core/src/player/wipeout/ground.rs b/crates/skate-core/src/player/wipeout/ground.rs new file mode 100644 index 00000000..c762b1ed --- /dev/null +++ b/crates/skate-core/src/player/wipeout/ground.rs @@ -0,0 +1,128 @@ +//! Complete original Ground checks82D8F9E0 and all its nonexternal leaves. +use super::{common, Frame, Mode, Requests, Settings}; +use crate::{ + physics::native_arithmetic::{dot3, vector_max, vector_min}, + trigonometry, +}; +pub fn check(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame) { + state.enter_ground(); + if state.cooldown > 0.0 { + state.cooldown -= f.timestep; + return; + } + let g = &s.ground; + let vehicle = if f.group_8 { g.vehicle_scalar } else { 1.0 }; + let trick = if f.flags_2472 & (1 << 15) != 0 { + f32::from_bits(0x3e99_999a) + } else { + 1.0 + }; + let skitch = if f.flags_2476 & 8 != 0 { + g.skitch_scalar + } else { + 1.0 + }; + let arms = if f.flags_2476 & 8 != 0 { + g.skitch_arms_scalar + } else { + 1.0 + }; + let skater = if f.flags_2472 & (1 << 28) == 0 && f.maximum_skater_force > 0.0 { + g.skater_scalar + } else { + 1.0 + }; + let scale = ((vehicle * trick) * skitch) * skater; + let squash = if f.flags_2476 & (1 << 30) != 0 { + g.max_squash_coffin + } else { + g.max_squash + }; + let displacement = g.max_displacement * scale; + if (mode.check_squash || f.time_on_ground > f32::from_bits(0x3d4c_cccd)) + && f.maximum_pose_error > vehicle * squash + { + state.request(18, 0.0); + } else if dot3(f.pose_error, f.pose_error) > displacement * displacement { + state.request(1, 0.0); + } else if common::force(f, g.max_contact * scale, g.max_arm_contact * arms) { + state.request(0, 0.0); + } + common::vehicle(state, s, f); + if f.flags_2484 & (1 << 21) == 0 { + let cosine = vector_min( + vector_min( + dot3(f.deck[0], f.input_board[0]), + dot3(f.deck[1], f.input_board[1]), + ), + dot3(f.deck[2], f.input_board[2]), + ); + if trigonometry::acos(vector_min(vector_max(cosine, -1.0), 1.0)) > g.max_deck_error { + state.request(3, 0.0); + } + } + let (xz, y) = if f.flags_2472 & (1 << 15) != 0 { + (s.air.xz_trick, s.air.y_trick) + } else { + let (mut xz, mut y) = (mode.ground_xz, g.y_acceleration); + if f.board_material_flags & (1 << 30) != 0 { + let scalar = if f.flags_2472 & (1 << 28) != 0 { + g.ai_scalar + } else { + g.player_scalar + }; + xz *= scalar; + y *= scalar; + } + if f.board_material_flags & (1 << 29) != 0 { + xz *= g.light_dmo_scalar; + } + if f.flags_2476 & 8 != 0 { + xz *= g.skitch_acc_scalar; + y *= g.skitch_acc_scalar; + } + if f.flags_2484 & (1 << 21) != 0 { + xz = 0.0; + y = 0.0; + } + (xz, y) + }; + common::closing(state, f, xz, y); + let skitch_gate = if f.flags_2476 & (1 << 23) != 0 { + 0.0 + } else { + 1.0 + }; + let slide_gate = if f.flags_2468 & (1 << 5) != 0 { + 0.0 + } else { + 1.0 + }; + let speed = (g.balance_min_speed - f.speed) / g.balance_min_speed; + let amount = (speed * (1.0 - f.animation_up[1])) * slide_gate; + let delta = amount.mul_add(skitch_gate, -g.balance_base); + state.balance = if delta > 0.0 { + state.balance + delta + } else { + 0.0 + }; + if state.balance > g.balance_total { + state.request(11, 0.0); + } + if f.conflicting { + state.request(16, 0.0); + } + common::leaning(state, s, f); +} + +///GroundAnimation82D8F918. These additional checks run even when the shared +///ground check returns during its cooldown. Caller82D34140 supplies scale1. +pub fn check_animation(state: &mut Requests, s: &Settings, mode: &Mode, f: &Frame, scale: f32) { + check(state, s, mode, f); + if super::common::force(f, s.air.max_contact, s.air.max_arm_contact) { + state.request(0, 0.0); + } + if f.opposing_contact > s.ground.opposing_contact * scale { + state.request(20, 0.0); + } +} diff --git a/crates/skate-core/src/player/wipeout/mod.rs b/crates/skate-core/src/player/wipeout/mod.rs new file mode 100644 index 00000000..95a1dfdb --- /dev/null +++ b/crates/skate-core/src/player/wipeout/mod.rs @@ -0,0 +1,14 @@ +//! Original TU3 shared Wipeout request calculation and persistent history. +//! Physical bail/recovery state response is scheduled by the game coordinator. +mod air; +mod common; +mod data; +mod ground; +mod requests; +pub use air::check as check_air; +pub use data::{AirSettings, Frame, GroundSettings, Mode, RequestInput, Settings, V}; +pub use ground::{check as check_ground, check_animation as check_ground_animation}; +pub use requests::Requests; + +#[cfg(test)] +mod tests; diff --git a/crates/skate-core/src/player/wipeout/requests.rs b/crates/skate-core/src/player/wipeout/requests.rs new file mode 100644 index 00000000..6326f92b --- /dev/null +++ b/crates/skate-core/src/player/wipeout/requests.rs @@ -0,0 +1,71 @@ +//! Original request arrays20/56,count200,mode208 and timers192/196/204. +#[derive(Clone, Debug, PartialEq)] +pub struct Requests { + pub reasons: [bool; 34], + pub values: [f32; 34], + pub count: u32, + pub cooldown: f32, + pub contact_frames: i32, + pub balance: f32, + pub mode: u32, +} +impl Requests { + ///Player ctor82DB20B8..D8 and first ClearRequests initialize this storage. + pub fn new() -> Self { + Self { + reasons: [false; 34], + values: [0.0; 34], + count: 0, + cooldown: 0.0, + contact_frames: 0, + balance: 0.0, + mode: 0, + } + } + ///Player initialization82DB3024 supplies the short initial grace period. + pub fn initialize_player(&mut self) { + self.cooldown = f32::from_bits(0x3d23_d70a); + } + ///Teleport82DB8DB4 overwrites only the cooldown; ResetSystems is separate. + pub fn teleport(&mut self) { + self.cooldown = f32::from_bits(0x3ecc_cccd); + } + ///Ground Enter82D378E0..F4 and CheckForGroundWipeout share this state. + pub fn enter_ground(&mut self) { + if self.mode != 1 { + self.balance = 0.0; + self.mode = 1; + } + } + ///A repeated request increments the original counter even if its bit was set. + pub fn request(&mut self, index: usize, value: f32) { + self.reasons[index] = true; + self.values[index] = value; + self.count = self.count.wrapping_add(1); + } + ///SystemLogic82DB6000 after state selection: preserve cooldown and histories. + pub fn clear_after_selection(&mut self) { + self.reasons.fill(false); + self.values.fill(0.0); + self.count = 0; + } + ///ResetSystems82DB9354..9360 clears mode and count only. + pub fn reset_systems(&mut self) { + self.mode = 0; + self.count = 0; + } + ///82DB9188: count reflects individual triggers, not unique reasons. + pub fn requests_runout(&self, frame: &super::RequestInput) -> bool { + (self.count == 1 + && (self.reasons[2] || self.reasons[24] || self.reasons[10]) + && frame.flags_2468 & (1 << 5) == 0 + && frame.flags_2476 & (1 << 26) == 0 + && frame.animation_up_y.abs() > f32::from_bits(0x3f4f_5c29)) + || (frame.flags_2484 & (1 << 12) != 0 && frame.category == 400) + } + ///82DB9100 first excludes runout, then combines reason OR with2480bit3. + pub fn requests_wipeout(&self, frame: &super::RequestInput) -> bool { + !self.requests_runout(frame) + && (self.reasons.iter().any(|&v| v) || frame.flags_2480 & (1 << 3) != 0) + } +} diff --git a/crates/skate-core/src/player/wipeout/tests.rs b/crates/skate-core/src/player/wipeout/tests.rs new file mode 100644 index 00000000..5c5d4041 --- /dev/null +++ b/crates/skate-core/src/player/wipeout/tests.rs @@ -0,0 +1,230 @@ +use super::*; +use crate::physics::skeleton_animation_record::IDENTITY; +use crate::point_graph::PointGraph; +fn input() -> Frame { + Frame { + flags_2468: 0, + flags_2472: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + category: 100, + timestep: 1.0 / 60.0, + time_on_ground: 1.0, + speed: 0.0, + animation_up: [0.0, 1.0, 0.0, 0.0], + landing_angle: 0.0, + deck_velocity: [0.0; 4], + com_velocity: [0.0; 4], + jump_fix_frames: 1000, + deck: IDENTITY, + input_board: IDENTITY, + world_to_animation: IDENTITY, + closing_velocity: [0.0; 4], + board_material_flags: 0, + board_contact: true, + wheel_contact: true, + board_contact_normal: [0.0, 1.0, 0.0, 0.0], + opposing_contact: 0.0, + regions_force: [0.0; 8], + maximum_skater_force: 0.0, + vehicle_force: 0.0, + group_8: false, + conflicting: false, + compliant: false, + highest_normal: [0.0, 1.0, 0.0, 0.0], + pose_error: [0.0; 4], + maximum_pose_error: 0.0, + flip_active: false, + flip_requested_speed: 0.0, + system_up_y: 1.0, + grind_selected: false, + grind_normal_valid: false, + grind_normal: [0.0, 1.0, 0.0, 0.0], + } +} +fn mode() -> Mode { + Mode { + check_squash: true, + check_bad_landing: true, + ground_xz: 10.0, + bad_landing_scale: 1.0, + } +} +fn settings() -> Settings { + Settings { + ground: GroundSettings { + vehicle_scalar: 1.0, + vehicle_contact: 100.0, + skitch_contact: 100.0, + skitch_scalar: 1.0, + skitch_arms_scalar: 1.0, + skater_scalar: 1.0, + max_squash: 100.0, + max_squash_coffin: 100.0, + max_displacement: 100.0, + max_contact: 100.0, + max_arm_contact: 100.0, + max_deck_error: 1.0, + opposing_contact: 1.0, + y_acceleration: 10.0, + light_dmo_scalar: 1.0, + player_scalar: 1.0, + ai_scalar: 1.0, + skitch_acc_scalar: 1.0, + balance_total: 100.0, + balance_min_speed: 1.0, + balance_base: 1.0, + }, + air: AirSettings { + xz_trick: 10.0, + y_trick: 10.0, + xz_acceleration: 10.0, + y_acceleration: 10.0, + max_squash: 100.0, + max_displacement: 100.0, + max_contact: 100.0, + max_arm_contact: 100.0, + body_flip_scalar: 1.0, + body_flip_acc_scalar: 1.0, + light_dmo_scalar: 1.0, + ignore_danger_frames: 10, + max_landing_speed: 6.0, + max_stairs_speed: 8.0, + max_grind_speed: 5.0, + max_landing_angle: PointGraph { + x: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], + y: [1.0; 8], + }, + }, + lean_contact_y: -1.0, + } +} +fn request_input() -> RequestInput { + RequestInput { + flags_2468: 0, + flags_2476: 0, + flags_2480: 0, + flags_2484: 0, + animation_up_y: 1.0, + category: 100, + } +} +#[test] +fn wipeout_ground_priority_and_repeated_force_change_runout_selection() { + let mut state = Requests::new(); + let mut f = input(); + let s = settings(); + f.closing_velocity = [11.0, 0.0, 0.0, 0.0]; + check_ground(&mut state, &s, &mode(), &f); + assert_eq!(state.count, 1); + assert!(state.reasons[2]); + assert!(state.requests_runout(&request_input())); + assert!(!state.requests_wipeout(&request_input())); + state.clear_after_selection(); + f.maximum_pose_error = 101.0; + f.pose_error = [101.0, 0.0, 0.0, 0.0]; + f.regions_force[0] = 101.0; + check_ground(&mut state, &s, &mode(), &f); + assert_eq!(state.count, 3); + assert!(state.reasons[18] && state.reasons[2] && state.reasons[0]); + assert!(!state.reasons[1]); //The initial squash/displacement/force chain is exclusive. + assert!(!state.requests_runout(&request_input())); + assert!(state.requests_wipeout(&request_input())); + state.clear_after_selection(); + f.maximum_pose_error = 0.0; + f.pose_error = [0.0; 4]; + check_ground(&mut state, &s, &mode(), &f); + //Force is requested once by the main check and again after excessive closing speed. + assert_eq!(state.count, 3); + assert_eq!(state.reasons.iter().filter(|v| **v).count(), 2); +} +#[test] +fn wipeout_air_landing_uses_selected_velocity_and_preserves_impact_value() { + let mut state = Requests::new(); + let mut f = input(); + let s = settings(); + f.deck_velocity = [0.0, -2.0, 0.0, 0.0]; + f.com_velocity = [0.0, -7.0, 0.0, 0.0]; + check_air(&mut state, &s, &mode(), &f, false); + assert_eq!(state.count, 0); + check_air(&mut state, &s, &mode(), &f, true); + assert_eq!(state.count, 1); + assert!(state.reasons[6]); + assert_eq!(state.values[6], 7.0); + state.clear_after_selection(); + f.grind_selected = true; + f.grind_normal_valid = true; + f.grind_normal = [1.0, 0.0, 0.0, 0.0]; + check_air(&mut state, &s, &mode(), &f, true); + assert_eq!(state.count, 0); //The selector's actual normal replaces the board normal. + f.grind_normal_valid = false; + check_air(&mut state, &s, &mode(), &f, true); + assert_eq!(state.count, 1); + assert!(state.reasons[8]); + assert!(!state.reasons[6]); +} +#[test] +fn wipeout_sensitive_conditions_count_both_triggers_and_clear_only_requests() { + let mut state = Requests::new(); + let mut f = input(); + f.flags_2476 = 1 << 26; + f.flags_2472 = 1 << 6; + f.compliant = true; + check_air(&mut state, &settings(), &mode(), &f, false); + assert_eq!(state.count, 2); + assert!(state.reasons[21]); + assert_eq!(state.reasons.iter().filter(|v| **v).count(), 1); + state.balance = 3.0; + state.cooldown = 0.2; + state.clear_after_selection(); + assert_eq!(state.count, 0); + assert_eq!(state.balance, 3.0); + assert_eq!(state.cooldown, 0.2); + assert_eq!(state.mode, 2); + assert_eq!(state.contact_frames, 3); + state.reset_systems(); + assert_eq!(state.mode, 0); + assert_eq!(state.balance, 3.0); + assert_eq!(state.cooldown, 0.2); +} +#[test] +fn wipeout_cooldown_has_native_initial_and_teleport_lifetimes() { + let mut state = Requests::new(); + state.initialize_player(); + assert_eq!(state.cooldown.to_bits(), 0x3d23d70a); + let mut f = input(); + f.maximum_pose_error = 101.0; + check_air(&mut state, &settings(), &mode(), &f, false); + assert_eq!(state.count, 0); + assert_eq!(state.contact_frames, 3); + assert!(state.cooldown > 0.0); + state.teleport(); + assert_eq!(state.cooldown.to_bits(), 0x3ecccccd); + state.reset_systems(); + assert_eq!(state.cooldown.to_bits(), 0x3ecccccd); + state.cooldown = 0.001; + check_ground(&mut state, &settings(), &mode(), &f); + assert!(state.cooldown < 0.0); + assert_eq!(state.count, 0); + check_ground(&mut state, &settings(), &mode(), &f); + assert_eq!(state.count, 1); + assert!(state.reasons[18]); +} + +#[test] +fn wipeout_ground_animation_keeps_additional_checks_during_cooldown() { + let mut state = Requests::new(); + state.teleport(); + let mut f = input(); + f.regions_force[0] = 101.0; + f.opposing_contact = 1.5; + check_ground_animation(&mut state, &settings(), &mode(), &f, 1.0); + assert_eq!(state.count, 2); + assert!(state.reasons[0] && state.reasons[20]); + state.clear_after_selection(); + check_ground_animation(&mut state, &settings(), &mode(), &f, 2.0); + assert_eq!(state.count, 1); + assert!(state.reasons[0]); + assert!(!state.reasons[20]); +} diff --git a/crates/skate-core/src/player/wipeout_state/body.rs b/crates/skate-core/src/player/wipeout_state/body.rs new file mode 100644 index 00000000..1cba387d --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/body.rs @@ -0,0 +1,73 @@ +//! Original body adjustments used by WipeoutGround300. +use super::math::{self,V}; +use crate::{math::Vector3,physics::skeleton_body::SkeletonBody}; +pub fn set_velocity(body:&mut SkeletonBody,velocity:V) { + for part in &mut body.bodies_mut()[1..24] { + part.rates.linear_velocity=Vector3::new(velocity[0],velocity[1],velocity[2]); + } +} +pub fn add_velocity(body:&mut SkeletonBody,velocity:V) { + for part in &mut body.bodies_mut()[1..24] { + part.rates.linear_velocity.x+=velocity[0]; + part.rates.linear_velocity.y+=velocity[1]; + part.rates.linear_velocity.z+=velocity[2]; + } +} +///82BE74C8. Upper parts retain a tenth of the normal component; lower parts zero it. +pub fn remove_normal_velocity(body:&mut SkeletonBody,normal:V) { + for i in 1..24 { + let v=body.bodies()[i].rates.linear_velocity; + let v=[v.x,v.y,v.z,0.0]; + let projected=math::scale(normal,math::dot(normal,v)); + let next=math::madd(projected,if i<15 {0.1}else{0.0},math::sub(v,projected)); + let v=&mut body.bodies_mut()[i].rates.linear_velocity; + *v=Vector3::new(next[0],next[1],next[2]); + } +} +///82BE75B0 then82BD41B0: average with the requested velocity and limit distance2. +pub fn blend_velocity(body:&mut SkeletonBody,target:V) { + for part in &mut body.bodies_mut()[1..24] { + let v=part.rates.linear_velocity; + let average=math::madd(target,0.5,[v.x*0.5,v.y*0.5,v.z*0.5,0.0]); + let delta=math::sub(average,target); + let length=math::length(delta); + let next=if length<=2.0 {average} else {math::madd(delta,2.0/length,target)}; + part.rates.linear_velocity=Vector3::new(next[0],next[1],next[2]); + } +} +///82BE76B0: length and reciprocal each use the original two refinements. +pub fn limit_velocity(body:&mut SkeletonBody,maximum:f32) { + for part in &mut body.bodies_mut()[1..24] { + let v=part.rates.linear_velocity; + let v=[v.x,v.y,v.z,0.0]; + if math::dot(v,v)>maximum*maximum { + let next=math::scale(v,math::reciprocal(math::length(v))*maximum); + part.rates.linear_velocity=Vector3::new(next[0],next[1],next[2]); + } + } +} +///82D9CD60 and82D9CE58 publish per-second drag into the same live inertia. +pub fn set_drag(body:&mut SkeletonBody,linear:f32,angular:f32) { + for part in body.bodies_mut() { + part.inertia.linear_drag=linear*f32::from_bits(0x426F_FFFF); + part.inertia.angular_drag=angular*f32::from_bits(0x426F_FFFF); + } +} +///82BE32D0. +pub fn set_wipeout_drag(body:&mut SkeletonBody,drag:f32) { + set_drag(body,math::clamp(drag,0.0,1.0),math::clamp((drag+1.0)*0.5,0.0,1.0)); +} +///82BE3998. Source uses cached physical-record positions before the live writes. +pub fn special_surface(body:&mut SkeletonBody,height:f32) { + const WEIGHTS:[f32;23]=[1.7,1.5,1.2,1.0,1.0,1.0,1.2,1.0,1.0,1.0, + 1.2,1.0,1.0,1.0,0.5,0.5,0.5,0.6,0.5,0.5,0.5,0.6,1.0]; + for i in 1..24 { + let depth=(height+0.1)-body.record.pose[i][3][1]; + let drag=if depth>0.0 { + body.apply_part_displacement(i,[0.0,depth.mul_add(0.6,0.1)*WEIGHTS[i-1],0.0,0.0]); + 0.1 + } else {0.005}; + body.bodies_mut()[i].inertia.angular_drag=drag*f32::from_bits(0x426F_FFFF); + body.bodies_mut()[i].inertia.linear_drag=drag*f32::from_bits(0x426F_FFFF); + } +} diff --git a/crates/skate-core/src/player/wipeout_state/contact_response.rs b/crates/skate-core/src/player/wipeout_state/contact_response.rs new file mode 100644 index 00000000..01586de4 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/contact_response.rs @@ -0,0 +1,85 @@ +//! Wipeout82D3EC08's two persistent material responses, 82D91708..82D92440. +//! Each correction writes the actual 23 skater bodies' linear velocities. +mod material10; +mod material11; +use super::math::{V, dot, normalize_or, scale, sub}; +use crate::physics::skeleton_body::SkeletonBody; + +#[derive(Clone, Copy)] +pub struct ContactResponseInput { + /// Original Processed608 snapshot, reused by both helpers before physics. + pub com_velocity_608: V, + /// SkeletonCollision4079/1168; absence preserves the retained normal. + pub material10_normal: Option, + /// SkeletonCollision4080/1184; the helper owns its separate contact latch. + pub material11_normal: Option, + /// Original Processed224 (effective animation transform's third axis). + pub effective_axis_224: V, + /// Original Processed2824/2828, the stock Wipeout control attributes. + pub wipeout_control_2824_2828: [f32; 2], +} +#[derive(Clone, Copy, Debug)] +pub struct ContactResponseOutput { + pub material10_phase: u32, + /// One-update pulse at first helper2->3, copied to Wipeout572. + pub material10_finished_77: bool, + /// Second helper's retained byte73; selects caller's collision mode9. + pub material11_active_73: bool, +} +pub struct ContactResponse { + material10: material10::Response, + material11: material11::Response, +} +impl ContactResponse { + pub fn new() -> Self { + Self { + material10: material10::Response::new(), + material11: material11::Response::new(), + } + } + /// Wipeout Reset82D3B3A8 invokes both resets; byte73 survives the second. + pub fn reset(&mut self) { + self.material10.reset(); + self.material11.reset(); + } + pub fn update( + &mut self, + body: &mut SkeletonBody, + input: ContactResponseInput, + ) -> ContactResponseOutput { + //82D3EC58..5C: state selection precedes the first response's update. + self.material10 + .update(body, input.com_velocity_608, input.material10_normal); + self.material11.update(body, input); + ContactResponseOutput { + material10_phase: self.material10.phase, + material10_finished_77: self.material10.finished, + material11_active_73: self.material11.active, + } + } +} + +/// 82E0A4E8: reject the component along the safely normalized direction. +fn reject(value: V, direction: V) -> V { + let normal = normalize_or(direction, [0.0; 4]); + sub(value, scale(normal, dot(value, normal))) +} +/// 82C1E170 differs: positive component test uses the normalized direction, +/// but its subtraction multiplies the ORIGINAL direction, even if nonunit. +fn reject_positive(value: V, direction: V) -> V { + let projection = dot(value, normalize_or(direction, [0.0; 4])); + if projection > 0.0 { + sub(value, scale(direction, projection)) + } else { + value + } +} +fn apply_velocity_delta(body: &mut SkeletonBody, delta: V) { + //All original loops start at part1 and execute23 stores; root0/extras24/25 + //and angular velocities remain untouched. The host stores XYZ explicitly. + for part in &mut body.bodies_mut()[1..24] { + part.rates.linear_velocity.x += delta[0]; + part.rates.linear_velocity.y += delta[1]; + part.rates.linear_velocity.z += delta[2]; + } +} diff --git a/crates/skate-core/src/player/wipeout_state/contact_response/material10.rs b/crates/skate-core/src/player/wipeout_state/contact_response/material10.rs new file mode 100644 index 00000000..c8c66062 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/contact_response/material10.rs @@ -0,0 +1,105 @@ +//! First helper's Reset91708, Select917A0, Update91908 and response kernels. +use super::{apply_velocity_delta, reject_positive}; +use crate::{physics::skeleton_body::SkeletonBody, player::wipeout_state::math::*}; + +const STEP: f32 = f32::from_bits(0x3c88_8889); //820849C8 +const PHASE_DURATION: f32 = f32::from_bits(0x3d07_2b02); //8208F5C0:0.033 + +pub(super) struct Response { + previous_velocity: V, //16 + normal: V, //32 + target_velocity: V, //48 + time: f32, //64 + pub phase: u32, //72 + pub finished: bool, //77, cleared by caller before Select +} +impl Response { + pub fn new() -> Self { + Self { + previous_velocity: [0.0; 4], + normal: [0.0, 1.0, 0.0, 0.0], + target_velocity: [0.0; 4], + time: 0.0, + phase: 0, + finished: false, + } + } + pub fn reset(&mut self) { + *self = Self::new(); + } + pub fn update(&mut self, body: &mut SkeletonBody, velocity: V, contact: Option) { + if let Some(normal) = contact { + self.normal = normal; + } + self.finished = false; + self.select(contact.is_some()); + match self.phase { + 0 => { + let difference = sub(velocity, self.previous_velocity); + //Both original branches require the ordered greater-than bit. + self.previous_velocity = if self.time > f32::from_bits(0x3c75_c28f) + && dot(difference, difference) > 1.0 + { + madd( + velocity, + f32::from_bits(0x3d4c_cccd), + scale(self.previous_velocity, f32::from_bits(0x3f73_3333)), + ) + } else { + velocity + }; + } + 1 => { + //91A40: half-opposite COM velocity, conditional plane rejection, + //then an additional -normal term. This is a velocity delta. + let rejected = reject_positive(scale(velocity, -0.5), self.normal); + apply_velocity_delta(body, madd(self.normal, -1.0, rejected)); + } + 2 => { + //91B28 preserves the Processed608 snapshot used by phase1. + apply_velocity_delta(body, scale(sub(self.target_velocity, velocity), 1.0)); + //Global830BD330 initializer82F826B0 loads -9.8 from822F8B40. + self.target_velocity = madd( + [0.0, f32::from_bits(0xc11c_cccd), 0.0, 0.0], + STEP, + self.target_velocity, + ); + } + _ => {} + } + self.time += STEP; + } + fn select(&mut self, contact: bool) { + let next = match self.phase { + 0 if contact => { + if -dot(self.previous_velocity, self.normal) > 2.0 { + 1 + } else { + 4 + } + } + 1 if self.time > PHASE_DURATION => { + let twice_normal = scale(self.normal, 2.0); + self.target_velocity = scale( + sub( + self.previous_velocity, + scale(twice_normal, dot(self.previous_velocity, self.normal)), + ), + f32::from_bits(0x3f66_6666), + ); + 2 + } + 2 if self.time > PHASE_DURATION => { + self.finished = true; + 3 + } + 3 if !contact => 0, + //Native unsigned phase>3 bypasses transitions, including phase4. + phase => phase, + }; + if next != self.phase { + self.phase = next; + self.time = 0.0; + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/contact_response/material11.rs b/crates/skate-core/src/player/wipeout_state/contact_response/material11.rs new file mode 100644 index 00000000..65e3eb2f --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/contact_response/material11.rs @@ -0,0 +1,113 @@ +//! Second helper's Reset91BF8, Update91C98, response91DF8 and controls92160. +use super::{ContactResponseInput, apply_velocity_delta, reject}; +use crate::{ + physics::{native_arithmetic, skeleton_body::SkeletonBody}, + player::wipeout_state::math::*, +}; + +pub(super) struct Response { + normal: V, //16 + previous_velocity: V, //32 + velocity: V, //48 + frames_since_contact: i32, //64 + active_frames: i32, //68 + contact_latched: bool, //72, reset-owned, not cleared each update + pub active: bool, //73, explicitly preserved by native Reset +} +impl Response { + pub fn new() -> Self { + //The original constructors/reset omit initialization of byte73. Host + //storage begins false; subsequent native resets preserve its value. + Self { + normal: [0.0, 1.0, 0.0, 0.0], + previous_velocity: [0.0; 4], + velocity: [0.0; 4], + frames_since_contact: 100, + active_frames: 0, + contact_latched: false, + active: false, + } + } + pub fn reset(&mut self) { + let active = self.active; + *self = Self::new(); + self.active = active; + } + pub fn update(&mut self, body: &mut SkeletonBody, input: ContactResponseInput) { + self.velocity = input.com_velocity_608; + if dot(self.velocity, self.velocity) > 0.25 { + if let Some(normal) = input.material11_normal { + self.frames_since_contact = 0; + self.contact_latched = true; + self.normal = normal; + if !self.active { + self.previous_velocity = reject(self.previous_velocity, self.normal); + } + } + } + self.active = self.contact_latched || self.frames_since_contact < 3; + if self.active { + self.apply_response(body); + self.apply_control( + body, + input.effective_axis_224, + input.wipeout_control_2824_2828, + ); + self.active_frames = self.active_frames.wrapping_add(1); + } else { + self.previous_velocity = self.velocity; + self.active_frames = 0; + } + //Native signed comparison intentionally alternates100->101->100. + self.frames_since_contact = if self.frames_since_contact <= 100 { + self.frames_since_contact.wrapping_add(1) + } else { + 100 + }; + } + fn apply_response(&self, body: &mut SkeletonBody) { + let mut retained_delta = [0.0; 4]; + if self.active_frames < 3 { + let difference = reject(sub(self.previous_velocity, self.velocity), self.normal); + if dot(difference, self.velocity) > 0.0 { + retained_delta = scale(difference, f32::from_bits(0x3f66_6666)); + } + } + let friction = reject( + scale(self.velocity, f32::from_bits(0xbb03_126f)), + self.normal, + ); + let mut slope_delta = [0.0; 4]; + //91F9C bge skips the slope term at/above original normal.y limit0.97. + if self.normal[1] < f32::from_bits(0x3f78_51ec) { + let across = cross([0.0, 1.0, 0.0, 0.0], self.normal); + let mut direction = normalize_or(cross(across, self.normal), [0.0; 4]); + if direction[1] > 0.0 { + direction = scale(direction, -1.0); + } + //92058 loads retained NORMAL; only its Y is zeroed before length. + let mut horizontal_normal = self.normal; + horizontal_normal[1] = 0.0; + let candidate = scale( + direction, + length(horizontal_normal) * f32::from_bits(0x3dcc_cccd), + ); + if dot(candidate, self.velocity) > 0.0 { + slope_delta = candidate; + } + } + apply_velocity_delta(body, add(add(friction, retained_delta), slope_delta)); + } + fn apply_control(&self, body: &mut SkeletonBody, effective_axis: V, control: [f32; 2]) { + let planar_velocity = reject(self.velocity, self.normal); + let planar_direction = normalize_or(planar_velocity, [0.0; 4]); + if f32::from_bits(0x3f7f_be77) > dot([0.0, 1.0, 0.0, 0.0], effective_axis) { + let fraction = + reciprocal(10.0) * native_arithmetic::vector_min(10.0, length(planar_velocity)); + let input = [control[0], 0.0, control[1], 0.0]; + let tangent = reject(input, self.normal); + let lateral = reject(tangent, planar_direction); + apply_velocity_delta(body, scale(lateral, fraction * f32::from_bits(0x3d4c_cccd))); + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/control_air.rs b/crates/skate-core/src/player/wipeout_state/control_air.rs new file mode 100644 index 00000000..1e9be880 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/control_air.rs @@ -0,0 +1,39 @@ +//! Original alternate air-control82D3DD40, including its contact early return. +use super::{State, math::{self,V}, torque}; +use crate::physics::skeleton_body::SkeletonBody; +const UP:V=[0.0,1.0,0.0,0.0]; +pub fn update(state:&mut State,body:&mut SkeletonBody,physical_com:V, + input:[f32;2],contact:bool)->bool { + let spine=math::normalize_or( + math::sub(body.record.pose[11][3],body.record.pose[23][3]),[0.0;4]); + state.angular_velocity=torque::angular_velocity( + &body.record,&body.definition.animation_masses.fractional); + if contact {return false;} + let direction=math::normalize_or(state.velocity,[0.0;4]); + let mut sideways=math::normalize_or(math::cross(UP,spine),[0.0;4]); + sideways[1]=0.0; + sideways=math::normalize_or(sideways,[0.0;4]); + let velocity_side=math::cross(UP,direction); + if math::dot(velocity_side,sideways)<0.0 {sideways=math::scale(sideways,-1.0);} + let plane_normal=math::normalize_or(velocity_side,[0.0;4]); + let projected=math::sub(spine,math::scale(plane_normal,math::dot(plane_normal,spine))); + let projected=math::normalize_or(projected,[0.0;4]); + let mut automatic=math::scale(math::cross(spine,projected),0.5); + let hips_up=body.record.pose[23][1]; + if input[0].abs()<0.5 && hips_up[1]<0.0 { + let projected=math::sub(hips_up, + math::scale(plane_normal,math::dot(plane_normal,hips_up))); + let projected=math::normalize_or(projected,[0.0;4]); + automatic=math::madd(math::cross(hips_up,projected),0.5,automatic); + } + //Both original scalar limits820993E8/EC are8 degrees. + let limit=8.0*f32::from_bits(0x3C8E_FA35); + let side=math::scale(sideways,limit*input[1]); + let negative_spine=spine.map(|v|f32::from_bits(v.to_bits()^0x8000_0000)); + let target=math::add(math::madd(negative_spine,limit*input[0],side),automatic); + let target=math::scale(target,f32::from_bits(0x426F_FFFF)); + let delta=math::scale(math::scale(math::sub(target,state.angular_velocity), + f32::from_bits(0x4019_999A)),f32::from_bits(0x3C88_8889)); + torque::apply(body,physical_com,math::limit_length(delta,0.5)); + true +} diff --git a/crates/skate-core/src/player/wipeout_state/control_ground.rs b/crates/skate-core/src/player/wipeout_state/control_ground.rs new file mode 100644 index 00000000..4c5d3a03 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/control_ground.rs @@ -0,0 +1,43 @@ +//! Original Wipeout contact control82D3D660, using the world-space animation hands. +use super::{State,math::{self,V},profiles::Profile,torque}; +use crate::{physics::{skeleton_animation_record::AnimationPartTransform, + skeleton_body::SkeletonBody},trigonometry}; +pub fn update(state:&mut State,body:&mut SkeletonBody,physical_com:V, + effective:&AnimationPartTransform,ground_axis_464:V,profile:&Profile, + animation_to_world:&AnimationPartTransform,animation_pose:&[AnimationPartTransform;24]) { + state.angular_velocity=torque::angular_velocity( + &body.record,&body.definition.animation_masses.fractional); + let speed=math::length(state.velocity); + let mut rolling_axis=math::normalize_or(std::array::from_fn(|i| { + let x=effective[0][i]*profile.roll_axis[0]; + let y=effective[1][i].mul_add(profile.roll_axis[1],x); + effective[2][i].mul_add(profile.roll_axis[2],y) + }),[0.0;4]); + let hands=[3,7].map(|i| crate::physics::skeleton_animation_record::compose_affine( + animation_to_world,&animation_pose[i])); + let hands_close=math::length(math::sub(hands[0][3],hands[1][3]))<=0.6; + let mut alignment=[0.0;4]; + if profile.align_ground_with_velocity { + let axis=math::normalize_or(math::cross(ground_axis_464,state.velocity),[0.0;4]); + if math::dot(rolling_axis,axis)<0.0 {rolling_axis=math::scale(rolling_axis,-1.0);} + let perpendicular=math::cross(rolling_axis,axis); + let sine=math::clamp(math::length(perpendicular),-0.9999,0.9999); + let angle=math::wrap_angle(trigonometry::asin(sine)); + let magnitude=math::clamp(angle*1.2,0.0,1.0); + if speed>2.0 { + let axis=math::normalize_or(perpendicular,[0.0;4]); + let current=math::normalize_or(state.angular_velocity,[0.0;4]); + alignment=math::scale(math::sub(math::scale(axis,magnitude), + math::scale(axis,math::dot(current,axis))),profile.ground_align_torque); + } + } + let mut rolling=[0.0;4]; + if hands_close && profile.roll_on_ground && speed>2.0 { + let current=math::normalize_or(state.angular_velocity,[0.0;4]); + rolling=math::scale(math::sub( + math::scale(math::scale(rolling_axis,1.5),state.forward_input), + math::scale(rolling_axis,math::dot(current,rolling_axis))), + profile.ground_roll_torque); + } + torque::apply(body,physical_com,math::add(alignment,rolling)); +} diff --git a/crates/skate-core/src/player/wipeout_state/control_profile_air.rs b/crates/skate-core/src/player/wipeout_state/control_profile_air.rs new file mode 100644 index 00000000..1006ab8b --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/control_profile_air.rs @@ -0,0 +1,155 @@ +//! Original profile-driven Wipeout air rotation82D3CB50. +use super::{ + State, + math::{self, V}, + orientation, + profiles::Profile, + torque, +}; +use crate::{ + physics::{skeleton_animation_record::AnimationPartTransform, skeleton_body::SkeletonBody}, + trigonometry::sin_cos, +}; +const UP: V = [0.0, 1.0, 0.0, 0.0]; + +pub fn update( + state: &mut State, + body: &mut SkeletonBody, + physical_com: V, + effective: &AnimationPartTransform, + input: [f32; 2], + profile: &Profile, +) { + state.angular_velocity = + torque::angular_velocity(&body.record, &body.definition.animation_masses.fractional); + let radians = f32::from_bits(0x3C8E_FA35); + let tilt_input = [ + (input[1] * profile.tilt_degrees) * radians, + 0.0, + (input[0] * profile.tilt_degrees) * -radians, + 0.0, + ]; + state.retained_tilt = math::madd(tilt_input, 0.05, math::scale(state.retained_tilt, 0.95)); + let tilt_length = math::length(state.retained_tilt); + let mut tilt_rotation = [0.0, 0.0, 0.0, 1.0]; + if tilt_length > 0.01 { + let (sin, cos) = sin_cos(tilt_length * 0.5); + tilt_rotation = math::scale( + math::scale(state.retained_tilt, math::reciprocal(tilt_length)), + sin, + ); + tilt_rotation[3] = cos; + } + let desired = transform( + effective, + orientation::alignment_up(profile.align_euler[0], profile.align_euler[1]), + ); + let side = math::normalize_or(state.right, [0.0; 4]); + let projected_velocity = math::sub( + state.velocity, + math::scale(side, math::dot(state.velocity, side)), + ); + let direction = if math::dot(state.forward, projected_velocity) <= 0.0 { + math::normalize(math::add(desired, [0.0, -100.0, 0.0, 0.0])) + } else { + math::normalize_or(projected_velocity, desired) + }; + let tilted_direction = orientation::rotate(tilt_rotation, direction); + let axis = math::normalize_or(math::cross(tilted_direction, desired), desired); + let angle = math::wrap_angle(orientation::signed_angle(tilted_direction, desired, axis)); + let distance_torque = if profile.align_with_velocity { + math::scale(math::scale(axis, angle), profile.torque_distance) + } else { + [0.0; 4] + }; + + let input_side_axis = orientation::rotate(tilt_rotation, desired); + let input_forward_axis = orientation::rotate( + tilt_rotation, + math::normalize_or(math::cross(direction, UP), [0.0; 4]), + ); + state.retained_sideways_input = retain_input( + state.sideways_input, + state.retained_sideways_input, + profile.spin_inertia, + ); + state.retained_forward_input = retain_input( + state.forward_input, + state.retained_forward_input, + profile.spin_inertia, + ); + let side_spin = profile.sideways_spin * state.retained_sideways_input; + let forward_spin = profile.forward_spin * state.retained_forward_input; + let spin = math::madd( + input_forward_axis, + forward_spin, + math::madd(input_side_axis, side_spin, [0.0; 4]), + ); + if math::dot(spin, state.angular_velocity) <= 0.0 { + state.control_time = 0.0; + } + let spin = math::scale(spin, profile.spin_vs_time.evaluate(state.control_time)); + let horizontal = if side_spin.abs() <= 0.1 { + horizontal_alignment(profile, effective, direction, tilt_rotation) + } else { + [0.0; 4] + }; + let control = math::add( + math::madd( + math::sub(spin, state.angular_velocity), + profile.torque_velocity, + distance_torque, + ), + horizontal, + ); + torque::apply(body, physical_com, control); +} + +fn retain_input(input: f32, previous: f32, inertia: f32) -> f32 { + if input.abs() >= previous.abs() { + input + } else { + (1.0 - inertia) * input + previous * inertia + } +} + +fn horizontal_alignment( + profile: &Profile, + effective: &AnimationPartTransform, + direction: V, + tilt: V, +) -> V { + let local = math::normalize_or(profile.horizontal_axis, [1.0, 0.0, 0.0, 0.0]); + let world = transform(effective, local); + let mut target = orientation::rotate( + tilt, + math::normalize_or(math::cross(direction, UP), [0.0; 4]), + ); + if !profile.horizontal_directed { + target = math::scale( + target, + if math::dot(world, target) > 0.0 { + 1.0 + } else { + -1.0 + }, + ); + } + //82D3D4F0 uses the normalized authored axis here, before world composition; + //the angle query itself receives world/target. Preserve that distinction. + let axis = math::normalize_or(math::cross(local, target), [0.0; 4]); + if !(math::dot(axis, axis) > 0.9) { + return [0.0; 4]; + } + let angle = math::wrap_angle(orientation::projected_angle(world, target, axis)); + let limit = f32::from_bits(0x3F49_0FDB); + math::scale(axis, math::clamp(angle, -limit, limit) * 0.8) +} + +fn transform(frame: &AnimationPartTransform, vector: V) -> V { + math::madd( + frame[2], + vector[2], + math::madd(frame[1], vector[1], math::scale(frame[0], vector[0])), + ) +} diff --git a/crates/skate-core/src/player/wipeout_state/data.rs b/crates/skate-core/src/player/wipeout_state/data.rs new file mode 100644 index 00000000..e0e5defa --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/data.rs @@ -0,0 +1,90 @@ +//! Original WipeoutGround Reset82D3B3A8. Native offsets document ownership. +#[derive(Clone, Debug)] +pub struct State { + pub time: f32, //304 + pub settled_time: f32, //308 + pub time_until_teleport: f32, //312 + pub impaled_time: f32, //316 + pub no_support_time: f32, //320 + pub response_time: f32, //324 + pub extra_weight_zero_time: f32, //328 + pub slow_time: f32, //332 + pub surface_height: f32, //336 + pub board_offset: [f32; 4], //352 + pub forward: [f32; 4], //368 + pub right: [f32; 4], //384 + pub angular_velocity: [f32; 4], //400 + pub retained_tilt: [f32; 4], //416 + pub velocity: [f32; 4], //432 + pub retained_velocity: [f32; 4], //448 + pub predicted_position: [f32; 4], //464 + pub over: bool, //480 + pub slow: bool, //481 + pub material_eleven_response: bool, //482 + pub air_collision_mode: bool, //483 + pub request_teleport: bool, //484 + pub move_board: bool, //485 + pub special_surface: bool, //486 + pub below_surface: bool, //487 + pub retained_velocity_active: bool, //488 + pub sideways_input: f32, //492 + pub forward_input: f32, //496 + pub orientation: f32, //500 + pub collision_weight: f32, //504 + pub controlled_weight: f32, //508 + pub control_time: f32, //512 + pub retained_sideways_input: f32, //516 + pub retained_forward_input: f32, //520 + pub response_scalar: f32, //524 + pub extra_weight: f32, //528 + pub maximum_speed: f32, //532 + pub response_start_speed: f32, //536 + pub response_change: f32, //540 + pub board_move_frames: u32, //544, initialized by Enter + pub response_count: u32, //548 + pub response_frames: i32, //552 + pub airborne_frames: i32, //556 + pub teleport_countdown: i32, //560 + pub prevent_manual: bool, //564 + pub ignore_reset: bool, //565 + pub reset_ever: bool, //566 + pub ever_settled: bool, //567 + pub recovery_eligible: bool, //568 + pub teleport_pending: bool, //569 + pub ever_impaled: bool, //570 + pub direction_initialized: bool, //571 + pub material_ten_response: bool, //572 + pub imminent_surface_twelve: bool, //573 + pub allow_retained_velocity: bool, //574 + pub response_finished: bool, //575 + pub surface_query: bool, //576 + pub profile: usize, //580 + pub predicted_time: f32, //832 +} +impl Default for State { + fn default() -> Self { + Self { + time: 0.0, settled_time: 0.0, time_until_teleport: 0.0, + impaled_time: 0.0, no_support_time: 0.0, response_time: 0.0, + extra_weight_zero_time: 0.0, slow_time: 0.0, surface_height: 0.0, + board_offset: [0.0;4], forward: [0.0;4], right: [0.0;4], + angular_velocity: [0.0;4], retained_tilt: [0.0;4], velocity: [0.0;4], + retained_velocity: [0.0;4], predicted_position: [0.0;4], + over: false, slow: false, material_eleven_response: false, + air_collision_mode: false, request_teleport: false, move_board: false, + special_surface: false, below_surface: false, retained_velocity_active: false, + sideways_input: 0.0, forward_input: 0.0, orientation: 0.0, + collision_weight: 0.0, controlled_weight: 0.0, control_time: 0.0, + retained_sideways_input: 0.0, retained_forward_input: 0.0, + response_scalar: 0.0, extra_weight: 1.0, maximum_speed: 10.0, + response_start_speed: 0.0, response_change: 0.0, board_move_frames: 0, + response_count: 0, response_frames: -1, airborne_frames: -1, + teleport_countdown: -1, prevent_manual: false, ignore_reset: false, + reset_ever: false, ever_settled: false, recovery_eligible: false, + teleport_pending: false, ever_impaled: false, direction_initialized: false, + material_ten_response: false, imminent_surface_twelve: false, + allow_retained_velocity: true, response_finished: false, + surface_query: false, profile: 0, predicted_time: f32::MAX, + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/drives.rs b/crates/skate-core/src/player/wipeout_state/drives.rs new file mode 100644 index 00000000..65682d49 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/drives.rs @@ -0,0 +1,108 @@ +//! Original SkeletonDrives::UpdateRagdoll82BEB0C8 and root softness82BEB790. +use crate::{ + animation::foot_ik::inverse_affine, + physics::{ + board_motion_output::inverse_length_squared, + drive_parameters::{RetailDriveParams, RetailDriveType}, + skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + skeleton_body::{SkeletonDrives, bone_drive_frames}, + }, +}; +const FREQUENCY_SQUARED: f32 = f32::from_bits(0x4560_FFFE); +#[derive(Clone, Copy, Debug)] +pub struct Settings { + ///Original SkeletonBody748+36*part: start, normal, controlled, end, extra. + pub bone: [[f32; 5]; 24], + ///The four full64 root_drive_* stock scalars, in the same first-four order. + pub root: [f32; 4], + ///animation/default: layout424 DriveStrengthLocal,420 DriveStrengthRootLocal. + pub strength: [f32; 2], + ///physics_animation/default HookSoftDsp888, HookSoftStr884, HookSoftDmp892. + pub hook_spring: f32, + pub hook_strength: f32, + pub hook_damping: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct Weights { + pub start: f32, + pub end: f32, + pub controlled: f32, + ///Processed2484 bits5 and4 select the two independently supplied weights. + pub upper_extra: f32, + pub lower_extra: f32, +} +///Returns original retained residual16. It is not clamped or renormalized. +pub fn update( + drives: &mut SkeletonDrives, + pose: &[AnimationPartTransform; 24], + settings: &Settings, + weights: Weights, +) -> f32 { + let mut inverses = [IDENTITY; 24]; + for part in 1..24 { inverses[part] = inverse_affine(&pose[part]); } + let residual = ((1.0 - weights.start) - weights.end) - weights.controlled; + for part in 1..23 { + let bone = drives.bones[part].as_mut().expect("Original22 bone drives exist"); + let coefficients = settings.bone[part]; + let upper = (3..=10).contains(&part); + let lower = (15..=22).contains(&part); + let strengths = if upper && weights.upper_extra > 0.0 + || lower && weights.lower_extra > 0.0 + { + bone.dynamics.mode = 5; + let extra = if upper { weights.upper_extra } else { weights.lower_extra }; + let value = extra * coefficients[4]; + [value * settings.strength[0], value * settings.strength[1]] + } else { + bone.dynamics.mode = 4; + let [start, normal, controlled, end, _] = coefficients; + //82BEB424..444 preserves two distinct FMA accumulation orders. + let local = controlled.mul_add(weights.controlled, + start.mul_add(weights.start, normal.mul_add(residual, end * weights.end))); + let root_start = settings.root[0] * start; + let root_normal = settings.root[1] * normal; + let root_controlled = settings.root[2] * controlled; + let root_end = settings.root[3] * end; + let root = root_controlled.mul_add(weights.controlled, + root_start.mul_add(weights.start, root_end.mul_add(weights.end, + root_normal * residual))); + [local * settings.strength[0], root * settings.strength[1]] + }; + bone.dynamics.strengths = strengths; + for channel in 0..2 { + if !bone.active[channel] { continue; } + bone.frames[channel] = bone_drive_frames( + &pose[part], &inverses[part], &inverses[bone.parent[channel]]); + bone.dynamics.enable(channel, strengths[channel], drives.settings.bone); + } + } + residual +} +///82BEB790 changes only target0's linear drive; damping uses the unscaled spring. +pub fn set_linear_root(drives: &mut SkeletonDrives, s: &Settings, weight: f32) { + let weight = clamp(weight); + let spring = s.hook_spring; + let root = if spring == 0.0 { 0.0 } + else { spring * inverse_length_squared(spring, 2) }; + drives.targets.dynamics[0].linear = RetailDriveParams { + spring_or_max_velocity: spring * weight, + damping: root * 2.0 - spring * f32::from_bits(0x3C83_126F), + max_strength: (s.hook_strength * weight) * FREQUENCY_SQUARED, + drive_type: RetailDriveType::SoftDrive, + }; +} +///82D3BE98..BF24 uses the same target0 angular fields set during Enter. +///Enter uses spring*0.5 and strength*1799.9998; Update uses the supplied weight. +pub fn set_angular_root(drives: &mut SkeletonDrives, s: &Settings, weight: f32) { + let weight = clamp(weight); + drives.targets.dynamics[0].angular = RetailDriveParams { + spring_or_max_velocity: s.hook_spring * weight, + damping: s.hook_damping, + max_strength: (s.hook_strength * weight) * FREQUENCY_SQUARED, + drive_type: RetailDriveType::SoftDrive, + }; +} +fn clamp(v: f32) -> f32 { + let lower = if -v >= 0.0 { 0.0 } else { v }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} diff --git a/crates/skate-core/src/player/wipeout_state/math.rs b/crates/skate-core/src/player/wipeout_state/math.rs new file mode 100644 index 00000000..2c38db04 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/math.rs @@ -0,0 +1,47 @@ +//! Scalar/vector expressions recovered from original Wipeout control kernels. +use crate::physics::{board_motion_output::inverse_length_squared, native_arithmetic}; +pub type V = [f32;4]; +pub fn add(a:V,b:V)->V { std::array::from_fn(|i|a[i]+b[i]) } +pub fn sub(a:V,b:V)->V { std::array::from_fn(|i|a[i]-b[i]) } +pub fn scale(a:V,b:f32)->V { a.map(|v|v*b) } +pub fn madd(a:V,b:f32,c:V)->V { std::array::from_fn(|i|a[i].mul_add(b,c[i])) } +pub fn dot(a:V,b:V)->f32 { native_arithmetic::dot3(a,b) } +pub 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])] +} +pub fn length(a:V)->f32 { + let square=dot(a,a); + if square==0.0 {0.0} else {square*inverse_length_squared(square,2)} +} +pub fn normalize(a:V)->V {scale(a,inverse_length_squared(dot(a,a),2))} +///Original830BD350 is initialized to splatted358637BD, not a fitted tolerance. +pub fn normalize_or(a:V,fallback:V)->V { + if length(a)>f32::from_bits(0x3586_37BD) {normalize(a)} else {fallback} +} +pub fn reciprocal(v:f32)->f32 { + let mut inv=native_arithmetic::reciprocal_estimate(v); + for _ in 0..2 {inv=inv.mul_add((-inv).mul_add(v,1.0),inv);} + inv +} +pub fn clamp(v:f32,low:f32,high:f32)->f32 { + let v=if low-v>=0.0 {low}else{v}; + if high-v>=0.0 {v}else{high} +} + +///Original82BD3D90: short vectors retain all four lanes. +pub fn limit_length(v:V,maximum:f32)->V { + let len=length(v); + if !(len>=f32::from_bits(0x3780_0000)) {return v;} + let cap=if maximum-len>=0.0 {len}else{maximum}; + scale(scale(v,cap),reciprocal(len)) +} + +pub fn wrap_angle(angle:f32)->f32 { + let turns=angle*f32::from_bits(0x3E22_F983); + let fraction=turns-turns.floor(); + let centered=if fraction>0.5 {fraction-1.0}else{fraction}; + centered*f32::from_bits(0x40C9_0FDB) +} diff --git a/crates/skate-core/src/player/wipeout_state/mod.rs b/crates/skate-core/src/player/wipeout_state/mod.rs new file mode 100644 index 00000000..e5b2b40c --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/mod.rs @@ -0,0 +1,18 @@ +//! Original physical WipeoutGround300 and its internal air control. +mod data; +pub mod drives; +pub mod skeleton; +pub use data::State; +pub mod body; +pub mod contact_response; +pub mod control_air; +pub mod control_ground; +pub mod control_profile_air; +pub mod math; +pub mod orientation; +pub mod output; +pub mod profiles; +pub mod recovery; +pub mod response; +pub mod torque; +pub mod weights; diff --git a/crates/skate-core/src/player/wipeout_state/orientation.rs b/crates/skate-core/src/player/wipeout_state/orientation.rs new file mode 100644 index 00000000..0e194a5a --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/orientation.rs @@ -0,0 +1,59 @@ +//! Original Wipeout alignment82D3C770: Euler X/Y degrees -> rotated up axis. +use super::math::{self, V}; +use crate::trigonometry::sin_cos; + +pub fn alignment_up(x_degrees: f32, y_degrees: f32) -> V { + let radians = f32::from_bits(0x3C8E_FA35); + let (sx, cx) = sin_cos((x_degrees * radians) * 0.5); + let (sy, cy) = sin_cos((y_degrees * radians) * 0.5); + let (sz, cz) = sin_cos(0.0); + let cxcz = cx * cz; + let sxcz = sx * cz; + let sxsz = sx * sz; + let cxsz = cx * sz; + //82D3C8B0 word114D4AAE: D10=A13*C10+B9, not A13*B9+C10. + let qy = cy.mul_add(sxsz, sy * cxcz); + //82D3C8B4 word11AD42EE: D13=A13*C11+B8. + let qw = cy.mul_add(cxcz, sy * sxsz); + let qz = cy * cxsz - sy * sxcz; + let qx = cy * sxcz - sy * cxsz; + rotate([qx, qy, qz, qw], [0.0, 1.0, 0.0, 0.0]) +} + +///82D3C8CC..C8F8 and82D3CF84..CFFC retain the two fused cross stages. +pub fn rotate(quaternion: V, vector: V) -> V { + let first = math::cross(quaternion, vector); + let middle = math::madd(vector, quaternion[3], first); + math::madd(math::cross(quaternion, middle), 2.0, vector) +} + +///8296EC98: both vectors require squared length >0.0001, then one +///reciprocal-square-root refinement. The negative half returns 2pi-angle. +pub fn signed_angle(left: V, right: V, axis: V) -> f32 { + use crate::physics::board_motion_output::inverse_length_squared; + let a = math::dot(left, left); + let b = math::dot(right, right); + if !(a > f32::from_bits(0x38D1_B717) && b > f32::from_bits(0x38D1_B717)) { + return 0.0; + } + let left = math::scale(left, inverse_length_squared(a, 1)); + let right = math::scale(right, inverse_length_squared(b, 1)); + let angle = crate::trigonometry::acos(math::clamp(math::dot(left, right), -1.0, 1.0)); + if math::dot(math::cross(left, right), axis) < 0.0 { + f32::from_bits(0x40C9_0FDB) - angle + } else { + angle + } +} + +///8286CD88 checks the original lengths before projecting onto the axis plane. +pub fn projected_angle(left: V, right: V, axis: V) -> f32 { + if !(math::dot(left, left) * math::dot(right, right) > f32::from_bits(0x3780_0000)) { + return 0.0; + } + signed_angle( + math::sub(left, math::scale(axis, math::dot(axis, left))), + math::sub(right, math::scale(axis, math::dot(axis, right))), + axis, + ) +} diff --git a/crates/skate-core/src/player/wipeout_state/output.rs b/crates/skate-core/src/player/wipeout_state/output.rs new file mode 100644 index 00000000..08968121 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/output.rs @@ -0,0 +1,69 @@ +//! Physical output stores82D3EEE8. Optional fields are conditional writes, +//! not instructions to clear the corresponding shared output on false paths. +use super::{State, math, orientation}; +use crate::physics::skeleton_animation_record::AnimationPartTransform; + +pub struct Output { + pub over_599: bool, + pub scalar_544: f32, + pub no_support_time_548: f32, + pub can_leave_72: bool, + pub special_surface_81: bool, + pub below_surface_82: bool, + pub surface_height_32: f32, + pub surface_height_valid_83: bool, + pub collision_time_144: f32, + pub profile_148: u32, + pub response_strength_580: f32, + pub extra_weight_584: f32, + pub time_until_teleport_576: f32, + pub teleport_pending_604: bool, + pub material_ten_3479: bool, + pub material_eleven_3480: bool, + pub imminent_surface_twelve_3483: bool, + pub predicted_position_64: [f32; 4], + pub retained_air_velocity_160: Option<[f32; 4]>, + pub response_change_588: Option, + pub teleport_countdown_68: bool, + pub request_teleport_69: bool, + pub hips_right_angle_496: f32, + pub hips_up_angle_500: f32, +} +impl State { + pub fn output(&self, hips: &AnimationPartTransform, normal: [f32; 4]) -> Output { + let right = hips[0]; + let projected = math::sub(normal, math::scale(right, math::dot(normal, right))); + let right_angle = orientation::signed_angle(right, normal, [0.0; 4]); + let up_angle = math::wrap_angle( + orientation::signed_angle(projected, hips[1], right) + f32::from_bits(0x4049_0FDB), + ); + Output { + over_599: self.over, + scalar_544: self.orientation, + no_support_time_548: self.no_support_time, + can_leave_72: self.recovery_eligible && !self.prevent_manual && !self.ignore_reset, + special_surface_81: self.special_surface, + below_surface_82: self.below_surface, + surface_height_32: self.surface_height, + surface_height_valid_83: true, + collision_time_144: self.predicted_time, + profile_148: self.profile as u32, + response_strength_580: self.response_scalar, + extra_weight_584: self.extra_weight, + time_until_teleport_576: self.time_until_teleport, + teleport_pending_604: self.teleport_pending, + material_ten_3479: self.material_ten_response, + material_eleven_3480: self.material_eleven_response, + imminent_surface_twelve_3483: self.imminent_surface_twelve, + predicted_position_64: self.predicted_position, + retained_air_velocity_160: self + .retained_velocity_active + .then_some(self.retained_velocity), + response_change_588: self.response_finished.then_some(self.response_change), + teleport_countdown_68: self.teleport_countdown >= 0, + request_teleport_69: self.request_teleport, + hips_right_angle_496: right_angle, + hips_up_angle_500: up_angle, + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/profiles.rs b/crates/skate-core/src/player/wipeout_state/profiles.rs new file mode 100644 index 00000000..540dba9e --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/profiles.rs @@ -0,0 +1,70 @@ +//! The five authored physics_wipeout_control profiles and original82D3C3F8. +use super::{State,math::{self,V}}; +use crate::{point_graph::PointGraph,physics::skeleton_body::SkeletonBody}; +#[derive(Clone,Debug)] +pub struct Profile { + pub roll_axis:V, //0 + pub horizontal_axis:V, //16 + pub align_euler:V, //32 + pub spin_vs_time:PointGraph<8>, //48, source uses X64/Y96 + pub direction_follow:f32, //128 + pub torque_velocity:f32, //132 + pub torque_distance:f32, //136 + pub spin_inertia:f32, //140 + pub roll_on_ground:bool, //144 + pub ground_roll_torque:f32, //148 + pub sideways_spin:f32, //152 + pub forward_spin:f32, //156 + pub horizontal_directed:bool, //160 + pub drift_maximum_speed:f32, //164 + pub drift_forward:f32, //168 + pub drift_sideways:f32, //172 + pub align_with_velocity:bool, //176 + pub align_ground_with_velocity:bool,//177 + pub ground_align_torque:f32, //180 + pub tilt_degrees:f32, //184 + pub drift_drag:f32, //188 +} +pub fn prepare(state:&mut State,profiles:&[Profile;5], + gesture:[f32;2],input:[f32;2]) { + let [x,z]=gesture; + let selected=if x.abs()<0.2 && z.abs()<0.2 {0} + else if x.abs()<=z.abs() {if z<=0.0 {3}else{1}} + else if x<=0.0 {4}else{2}; + state.control_time+=f32::from_bits(0x3C88_8889); + if selected!=state.profile { + state.control_time=0.0;state.profile=selected; + state.retained_sideways_input=0.0;state.retained_forward_input=0.0; + } + let mut flat_velocity=state.velocity;flat_velocity[1]=0.0; + let normalized=math::normalize_or(flat_velocity,[0.0;4]); + state.forward=if state.direction_initialized { + let follow=profiles[selected].direction_follow; + math::normalize_or(math::madd(normalized,follow, + math::scale(state.forward,1.0-follow)),[0.0;4]) + } else { + state.direction_initialized=true;normalized + }; + state.right=math::cross([0.0,1.0,0.0,0.0],state.forward); + let input=[input[0],0.0,input[1],0.0]; + state.forward_input=math::dot(input,state.forward); + state.sideways_input=math::dot(input,state.right); +} +///Original82D3C928, after air rotational control; preserves body velocity W. +pub fn drift(state:&State,body:&mut SkeletonBody,p:&Profile) { + if state.retained_velocity_active {return;} + let desired=math::madd(state.right,p.drift_sideways*state.sideways_input, + math::scale(state.forward,p.drift_forward*state.forward_input)); + let mut change=state.velocity.map(|v| + f32::from_bits(v.to_bits()^0x8000_0000)*p.drift_drag); + change[0]*=0.02;change[2]*=0.02; + if math::dot(state.velocity,math::normalize_or(desired,[0.0;4])) f32 { + let speed = self.maximum_speed * 0.1; + let count = (4u32.wrapping_sub(self.response_count) as i32) as f32; + let remaining = (count + 1.0) * 0.5; + let result = if speed - remaining >= 0.0 { speed } else { remaining }; + if self.slow_time > 1.0 || result <= 0.2 || self.special_surface || self.below_surface { + 0.0 + } else { result } + } + + pub fn manage_recovery(&mut self, flags2468: u32, flags2472: u32, + flags2484: u32, timestep: f32, settings: &Settings) { + self.prevent_manual |= flags2468 & 1 != 0; + self.ignore_reset |= flags2472 & 0x8000_0000 != 0; + self.reset_ever |= flags2472 & 0x0010_0000 != 0; + self.ever_settled |= self.over; + self.recovery_eligible |= self.ever_settled || self.time > 1.6; + self.settled_time = if self.over { self.settled_time + DT } else { 0.0 }; + if self.teleport_countdown < 0 + && self.should_teleport(flags2472, flags2484, timestep, settings) { + self.teleport_countdown = 2; + } + if self.teleport_countdown == 0 { self.request_teleport = true; } + else if self.teleport_countdown > 0 { self.teleport_countdown -= 1; } + } + + fn should_teleport(&mut self, flags2472: u32, flags2484: u32, + timestep: f32, settings: &Settings) -> bool { + if self.ever_impaled { self.impaled_time += DT; } + if flags2484 & 0x0040_0000 != 0 { + self.ever_impaled = true; + if self.impaled_time > 0.5 { return true; } + } + if self.prevent_manual { return false; } + if self.recovery_eligible && self.reset_ever && !self.ignore_reset { return true; } + if self.teleport_pending { + self.time_until_teleport -= timestep; + return self.time_until_teleport <= 0.0; + } + let automatic = if flags2472 & 0x1000_0000 != 0 { + self.time > 8.0 + } else { + (self.time > settings.maximum_time && self.response_time > settings.maximum_time) + || (self.time > settings.minimum_time + && self.settled_time > settings.minimum_settled + && self.response_time > settings.minimum_settled) + }; + if automatic { + self.teleport_pending = true; + self.time_until_teleport = settings.fade_time; + } + false + } + + pub fn post_physics(&mut self, hips_velocity: [f32;4], neck_velocity: [f32;4], + support_contact: bool, settings: &Settings) { + let bypass_neck = self.extra_weight_zero_time > 0.5; + let speed = if bypass_neck { settings.over_speed * 2.0 } else { settings.over_speed }; + let minimum_time = self.time > settings.over_minimum_time; + let hips_squared = dot3(hips_velocity, hips_velocity); + let neck_squared = dot3(neck_velocity, neck_velocity); + self.over = hips_squared < speed * speed + && (neck_squared < speed * speed || bypass_neck) + && minimum_time && self.response_strength() == 0.0; + self.slow = hips_squared < 1.0 && minimum_time; + self.no_support_time = if support_contact { 0.0 } else { self.no_support_time + DT }; + } +} diff --git a/crates/skate-core/src/player/wipeout_state/response.rs b/crates/skate-core/src/player/wipeout_state/response.rs new file mode 100644 index 00000000..daed8703 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/response.rs @@ -0,0 +1,74 @@ +//! Original Wipeout response82D3E678 and retained-velocity82D3E348/82D3E8C0. +use super::{State,body,drives,math::{self,V}}; +use crate::physics::skeleton_body::{SkeletonBody,SkeletonDrives}; +///82D3C2B0: only upward collision/prediction normals are accepted. +pub fn response_normal(support:Option,prediction:Option)->V { + if let Some(normal)=support.filter(|n| n[1]>0.0) {return normal;} + prediction.filter(|n|n[1]>0.0).unwrap_or([0.0,1.0,0.0,0.0]) +} +pub fn trigger(state:&mut State,body:&mut SkeletonBody,normal:V,input:[f32;2]) { + state.response_frames=0; + state.response_start_speed=math::length(state.velocity); + let strength=state.response_strength(); + if state.maximum_speed*0.1<1.0 { + state.response_count=state.response_count.wrapping_add(1); + } else {state.response_count=0;} + state.maximum_speed=0.0; + state.extra_weight=0.0;state.response_scalar=0.0; + if strength==0.0 {return;} + state.response_time=0.0; + let strength=if strength-1.0>=0.0 {1.0}else{strength}; + state.response_scalar=strength;state.extra_weight=strength; + let mut control=[input[0],0.0,input[1],0.0]; + let projection=math::dot(normal,control); + if projection<0.0 {control=math::sub(control,math::scale(normal,projection));} + //82D3E868: raw vmadd A=normal,C=zero,B=control*1.25. + let velocity=math::scale(math::madd(normal,0.0,math::scale(control,1.25)),strength); + body::add_velocity(body,velocity); +} +pub fn update_counter(state:&mut State,contact:bool) { + state.response_finished=false; + if state.response_frames<0 {return;} + state.response_frames=state.response_frames.wrapping_add(1); + if state.response_frames==3 { + let factor=if state.response_start_speed<1.0 { + 1.0-state.response_start_speed + } else {state.response_start_speed*0.2}; + state.response_change=(math::length(state.velocity)-state.response_start_speed) + *(factor+1.0); + } + if state.response_frames==8 { + state.response_frames=-1;state.response_finished=true; + if !contact {state.response_change=state.response_change.abs()*2.0;} + state.response_change=math::clamp((state.response_change-0.1)*0.2,0.0,1.0); + } +} +pub fn retained_velocity(state:&mut State,body:&mut SkeletonBody, + targets:&mut SkeletonDrives,settings:&drives::Settings, + contact:bool,flags2468:u32,com_velocity:V) { + let requested=flags2468&8!=0; + if state.retained_velocity_active || requested { + if contact { + state.allow_retained_velocity=false; + release(state,body,targets,settings); + } else if !requested { + release(state,body,targets,settings); + } else if !state.retained_velocity_active + &&state.allow_retained_velocity&&com_velocity[1]<2.0 { + drives::set_linear_root(targets,settings,1.0); + state.retained_velocity_active=true; + state.retained_velocity=state.velocity; + } + } + if state.retained_velocity_active { + state.velocity=state.retained_velocity; + state.time-=f32::from_bits(0x3C88_8889); + } +} +fn release(state:&mut State,body:&mut SkeletonBody,targets:&mut SkeletonDrives, + settings:&drives::Settings) { + if !state.retained_velocity_active {return;} + drives::set_linear_root(targets,settings,0.0); + body::set_velocity(body,state.retained_velocity); + state.retained_velocity_active=false;state.velocity=[0.0;4]; +} diff --git a/crates/skate-core/src/player/wipeout_state/skeleton.rs b/crates/skate-core/src/player/wipeout_state/skeleton.rs new file mode 100644 index 00000000..ec4814ac --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/skeleton.rs @@ -0,0 +1,36 @@ +//! Original Skeleton::UpdateRootTransformsWipeout82BDFBE8. +use crate::physics::{ + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, + skeleton_root::{SkeletonRootFrames, inverse_rigid}, +}; +const DT: f32 = f32::from_bits(0x3C88_8889); + +///Reconstruct the root from the cached physical hips and animated hips, +///then blend its predicted position toward the actual weighted physical COM. +///This updates animation roots; it does not overwrite any solved body rates. +pub fn update_roots( + roots: &mut SkeletonRootFrames, + animation_hips: &Transform, + physical_hips: &Transform, + hips_velocity: [f32; 4], + physical_com: [f32; 4], + com_velocity: [f32; 4], + state_time: f32, +) { + let inverse_hips = inverse_rigid(animation_hips); + let mut predicted_hips = *physical_hips; + predicted_hips[3] = std::array::from_fn(|i| + hips_velocity[i].mul_add(DT, physical_hips[3][i])); + let mut root = compose_affine(&predicted_hips, &inverse_hips); + let weight = state_time + DT; + let weight = if -weight >= 0.0 { 0.0 } else { weight }; + let weight = if 1.0 - weight >= 0.0 { weight } else { 1.0 }; + let mut desired = physical_com; + desired[1] += f32::from_bits(0xBF4C_CCCD); + root[3] = std::array::from_fn(|i| { + let predicted = com_velocity[i].mul_add(DT, root[3][i]); + desired[i].mul_add(weight, predicted * (1.0 - weight)) + }); + roots.animation_to_world = root; + roots.world_to_animation = inverse_rigid(&root); +} diff --git a/crates/skate-core/src/player/wipeout_state/torque.rs b/crates/skate-core/src/player/wipeout_state/torque.rs new file mode 100644 index 00000000..4f7825b3 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/torque.rs @@ -0,0 +1,32 @@ +//! Original SkeletonState angular estimate82BEC0E8 and torque82BE3B58. +use super::math::{self,V}; +use crate::physics::skeleton_body::{SkeletonBody,SkeletonPhysicalRecord}; + +///4656 contains the same fractional masses used by physical COM publication: +///original Init82BEBAA8 divides each4560 mass by total5216 at82BEBB80. +pub fn angular_velocity(record:&SkeletonPhysicalRecord,fractional:&[f32;24])->V { + let mut result=[0.0;4]; + for part in 1..24 { + let radial=math::sub(record.positions[part],record.centre_of_mass); + let velocity=math::sub(record.velocities[part],record.centre_of_mass_velocity); + let squared=math::dot(radial,radial); + if squared>f32::from_bits(0x38D1_B717) { + let term=math::scale(math::cross(radial,velocity),math::reciprocal(squared)); + result=math::madd(term,fractional[part],result); + } + } + result +} + +///82BE3B58 converts the control magnitude and direction into original +///per-part displacement accumulators. Actual solved velocities remain intact. +pub fn apply(body:&mut SkeletonBody,physical_com:V,control:V) { + let magnitude=math::length(control); + for part in 1..24 { + let radial=math::sub(body.record.pose[part][3],physical_com); + if math::dot(radial,radial)>0.001 { + let tangent=math::normalize_or(math::cross(control,radial),[0.0;4]); + body.apply_part_displacement(part,math::scale(tangent,magnitude)); + } + } +} diff --git a/crates/skate-core/src/player/wipeout_state/weights.rs b/crates/skate-core/src/player/wipeout_state/weights.rs new file mode 100644 index 00000000..a6dcd3b5 --- /dev/null +++ b/crates/skate-core/src/player/wipeout_state/weights.rs @@ -0,0 +1,65 @@ +//! Wipeout Update82D3BE04..82D3BFE8, after the selected physical control. +use super::{State, math}; + +#[derive(Clone, Copy)] +pub struct Settings { + pub remove_target_time: f32, + pub remove_drives_time: f32, + /// Native352 is a per-update increment, not a per-second velocity. + pub collision_step: f32, + /// Native348 is also a per-update increment. + pub controlled_step: f32, +} + +#[derive(Clone, Copy)] +pub struct Output { + pub target_weight: f32, + pub start: f32, + pub end: f32, + pub controlled: f32, + pub extra: f32, +} + +pub fn update( + state: &mut State, + settings: Settings, + contact: bool, + control_applied: bool, + flags_2472: u32, + gesture: [f32; 2], +) -> Output { + let target_weight = fade(settings.remove_target_time, state.time); + if state.special_surface || (gesture[0].abs() < 0.2 && gesture[1].abs() < 0.2) { + let step = if contact { + settings.collision_step + } else { + -settings.collision_step + }; + state.collision_weight = math::clamp(state.collision_weight + step, 0.0, 1.0); + } else { + state.collision_weight = 0.0; + } + let drive_fade = fade(settings.remove_drives_time, state.time); + let drive_fade_squared = drive_fade * drive_fade; + let enabled = (control_applied || flags_2472 & 0x0008_0000 != 0) && !state.special_surface; + let step = if enabled { + settings.controlled_step + } else { + -settings.controlled_step + }; + state.controlled_weight = math::clamp(state.controlled_weight + step, 0.0, 1.0); + let available = 1.0 - state.collision_weight; + let start = available * drive_fade_squared; + Output { + target_weight, + start, + end: state.collision_weight, + controlled: (available - start) * state.controlled_weight, + extra: state.extra_weight * 0.03, + } +} + +fn fade(duration: f32, time: f32) -> f32 { + let divisor = if duration == 0.0 { 0.01 } else { duration }; + math::clamp((duration - time) / divisor, 0.0, 1.0) +} diff --git a/crates/skate-core/src/point_graph.rs b/crates/skate-core/src/point_graph.rs new file mode 100644 index 00000000..77108ada --- /dev/null +++ b/crates/skate-core/src/point_graph.rs @@ -0,0 +1,34 @@ +//! TU3 `PointGraph` scalar evaluator, `0x82481E10`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PointGraph { + pub x: [f32; N], + pub y: [f32; N], +} + +impl PointGraph { + /// Native endpoint handling and slope-first binary32 interpolation. + pub fn evaluate(&self, input: f32) -> f32 { + assert!(N > 0); + if input < self.x[0] { + return self.y[0]; + } + // The native branch enters the scan only on an ordered less-than. + // Unordered input or a NaN final key therefore selects the last value. + if !(input < self.x[N - 1]) { + return self.y[N - 1]; + } + for upper in 1..N { + if !(input < self.x[upper]) { + continue; + } + let lower = upper - 1; + let width = self.x[upper] - self.x[lower]; + if width <= 0.0 { + return self.y[upper]; + } + let slope = (self.y[upper] - self.y[lower]) / width; + return slope.mul_add(input - self.x[lower], self.y[lower]); + } + self.y[0] + } +} diff --git a/crates/skate-core/src/riding/anti_flip.rs b/crates/skate-core/src/riding/anti_flip.rs new file mode 100644 index 00000000..708124a4 --- /dev/null +++ b/crates/skate-core/src/riding/anti_flip.rs @@ -0,0 +1,49 @@ +//! Complete TU3 Toolkit_AntiFlipTorque 82D94190, distinct from heading/manual. +use super::vector::{cross, dot3, normalize}; +use crate::{point_graph::PointGraph, trigonometry::acos}; +pub struct AntiFlipSettings { + pub axis_96_response: PointGraph<8>, // +1832/+1864 + pub axis_64_response: PointGraph<8>, // +1912/+1944 + pub normal_threshold: [f32; 4], // live 830BD350 +} +pub struct AntiFlipInput { + pub flags_2468: u32, + pub balance: f32, // +2720 + pub axis_96: [f32; 4], + pub axis_64: [f32; 4], + pub projection_axis_544: [f32; 4], +} +pub fn calculate(s: &AntiFlipSettings, i: &AntiFlipInput) -> [f32; 4] { + if i.flags_2468 & 0x2000_0000 != 0 { + return [0.0; 4]; + } + let project = |axis: [f32; 4]| { + let dot = dot3(i.projection_axis_544, axis); + normalize( + core::array::from_fn(|n| axis[n] - i.projection_axis_544[n] * dot), + s.normal_threshold, + ) + }; + let a = project(i.axis_96); + let b = project(i.axis_64); + let angle = |axis, projected| { + let d = dot3(axis, projected); + let d = if 0.0 > d { 0.0 } else { d }; + let d = if 1.0 < d { 1.0 } else { d }; + acos(d) * f32::from_bits(0x3f22_f983) + }; + let sign_a = if dot3(i.axis_64, cross(i.axis_96, a)) >= 0.0 { + 1.0 + } else { + -1.0 + }; + let sign_b = if dot3(i.axis_96, cross(i.axis_64, b)) >= 0.0 { + 1.0 + } else { + -1.0 + }; + let b_strength = s.axis_64_response.evaluate(angle(i.axis_64, b)) * sign_b; + let a_strength = (s.axis_96_response.evaluate(angle(i.axis_96, a)) * sign_a) + * if i.balance == 0.0 { 1.0 } else { 0.0 }; + core::array::from_fn(|n| i.axis_64[n].mul_add(a_strength, i.axis_96[n] * b_strength)) +} diff --git a/crates/skate-core/src/riding/braking.rs b/crates/skate-core/src/riding/braking.rs new file mode 100644 index 00000000..b1a16ef7 --- /dev/null +++ b/crates/skate-core/src/riding/braking.rs @@ -0,0 +1,96 @@ +//! TU3 ground braking and linear drag, using the existing ground-force +//! direct82D94AE0/82D94A20 exports and SKATE3_MAIN_SKATER_FUNCTIONS.md. +//! Cached ground settings are explicit inputs; no replacement tuning defaults. +use crate::{math::Vector3, physics::force_queue::QueuedPointForce}; + +#[derive(Clone, Copy, Debug)] +pub struct BrakeSettings { + /// Cached settings +20, multiplied by processed input +2728. + pub input_force: f32, + /// Cached settings +24. The second request overrides the first. + pub override_force: f32, + /// Cached settings +28, compared against processed absolute speed +2616. + pub minimum_speed: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct BrakeInput { + pub flags_2468: u32, + pub input_2728: f32, + pub signed_speed: f32, + pub absolute_body_speed: f32, + /// Selected surface collection's layout +264. + pub surface_factor: f32, + /// Processed vector +368, used without normalization. + pub direction: Vector3, +} + +/// Toolkit_CalcBraking 82D94AE0. GroundUpdate appends even a zero result +/// with tag 2 at 82D391A0. This result is force, not a velocity assignment. +pub fn calculate_braking(input: BrakeInput, settings: BrakeSettings) -> QueuedPointForce { + let mut amount = if input.flags_2468 & 0x4000_0000 != 0 { + input.input_2728 * settings.input_force + } else { + 0.0 + }; + if input.flags_2468 & 0x2000_0000 != 0 { + amount = settings.override_force; + } + let scaled = input.surface_factor * amount; + let sign = if input.signed_speed < 0.0 { 1.0 } else { -1.0 }; + amount = sign * scaled; + //82D94B78 bge retains the amount when the comparison is unordered. + if input.absolute_body_speed < settings.minimum_speed { + amount = 0.0; + } + QueuedPointForce { + tag: 2, + force_world: Vector3::new( + input.direction.x * amount, + input.direction.y * amount, + input.direction.z * amount, + ), + point_body: Vector3::ZERO, + } +} + +#[derive(Clone, Copy, Debug)] +pub struct LinearDragSettings { + /// Cached +1244; multiplied by native constant 2.0 at 82060C50. + pub brake_speed: f32, + /// Cached +1248. + pub balance_speed: f32, + /// Cached +1252. Original semantic name remains unresolved. + pub comparison_threshold: f32, + /// Cached +1256. + pub balance_drag: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct LinearDragInput { + pub flags_2468: u32, + pub absolute_body_speed: f32, + pub balance_2720: f32, + pub scalar_2724: f32, + /// Floating argument f1 supplied by GroundUpdate, distinct from speed. + pub comparison_scalar: f32, +} + +/// Toolkit_CalcLinearDrag 82D94A20, including its low-speed brake override. +/// Applying the result to inertia is a separate caller operation. +pub fn calculate_linear_drag(input: LinearDragInput, settings: LinearDragSettings) -> f32 { + let brake_override = input.absolute_body_speed < settings.brake_speed * 2.0 + && input.flags_2468 & 0x6000_0000 != 0 + && !(input.scalar_2724 > 0.0) + && input.balance_2720 == 0.0; + let balance_drag = input.balance_2720 != 0.0 + && input.absolute_body_speed < settings.balance_speed + && input.comparison_scalar > settings.comparison_threshold; + if brake_override { + 1.0 + } else if balance_drag { + settings.balance_drag + } else { + 0.0 + } +} diff --git a/crates/skate-core/src/riding/collision_response.rs b/crates/skate-core/src/riding/collision_response.rs new file mode 100644 index 00000000..9db6a2af --- /dev/null +++ b/crates/skate-core/src/riding/collision_response.rs @@ -0,0 +1,189 @@ +//! Complete collision response calculation, TU3 82D944E8 and its angle helpers. +use crate::{ + math::Vector3, + physics::{ + board_ground::angle_between, + board_motion_output::{dot, inverse_length_squared, length}, + native_arithmetic::reciprocal_estimate, + }, + point_graph::PointGraph, +}; +#[derive(Clone, Debug)] +pub struct CollisionResponseSettings { + pub maximum_velocity_delta: f32, + pub force_y_offset: f32, + pub force_scalar: f32, + pub target_displacement_velocity: f32, + pub torque_vs_angle: PointGraph<8>, +} +#[derive(Clone, Copy, Debug)] +pub struct CollisionResponsePhysical { + pub flags_2472: u32, + /// Processed736,416,448,544 and Ground1216 respectively. + pub collision_displacement: [f32; 4], + pub velocity: [f32; 4], + pub forward: [f32; 4], + pub up: [f32; 4], + pub ground_normal: [f32; 4], + /// Processed2604 and2660. + pub time_step: f32, + pub mass: f32, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CollisionResponse { + /// Native return value. False after the delta threshold still writes all + /// three outputs, including the target velocity and angular correction. + pub applied: bool, + pub force: [f32; 4], + pub point: [f32; 4], + pub angular_displacement: [f32; 4], + pub target_velocity: [f32; 4], +} +/// None represents the two early returns which write no outputs. The native +/// late false return must remain distinguishable from them. +pub fn collision_response( + settings: &CollisionResponseSettings, + input: CollisionResponsePhysical, +) -> Option { + if input.flags_2472 & 0x20000 == 0 { + return None; + } + let projection = dot(xyz(input.collision_displacement), xyz(input.ground_normal)); + let displaced: [f32; 4] = std::array::from_fn(|i| { + input.collision_displacement[i] - input.ground_normal[i] * projection + }); + let distance = length(xyz(displaced)); + if distance < f32::from_bits(0x3780_0000) { + return None; + } + let inverse_distance = 1.0 / distance; //scalar fdivs, not vector estimate. + let direction: [f32; 4] = displaced.map(|v| v * inverse_distance); + let speed_toward = dot(xyz(input.velocity), xyz(direction)); + let tangent: [f32; 4] = + std::array::from_fn(|i| input.velocity[i] - direction[i] * speed_toward); + let target_velocity = std::array::from_fn(|i| { + direction[i].mul_add(settings.target_displacement_velocity, tangent[i]) + }); + let mut delta = + std::array::from_fn(|i| (target_velocity[i] - input.velocity[i]) * settings.force_scalar); + if speed_toward > settings.target_displacement_velocity { + delta = [0.; 4]; + } + let magnitude = length(xyz(delta)); + let inverse_dt = reciprocal_refined(input.time_step); + let mut force = delta.map(|v| (v * input.mass) * inverse_dt); + let applied = magnitude >= 0.01; + if applied { + let clamp = if magnitude <= settings.maximum_velocity_delta { + 1.0 + } else { + settings.maximum_velocity_delta / magnitude + }; + force = force.map(|v| v * clamp); + } else { + force = [0.; 4]; + } + let angle = wrap_angle(signed_angle( + xyz(input.forward), + xyz(direction), + xyz(input.up), + )); + let magnitude = settings + .torque_vs_angle + .evaluate(angle.abs() * f32::from_bits(0x3ea2_f983)); + let signed = if angle >= 0.0 { magnitude } else { -magnitude }; + Some(CollisionResponse { + applied, + force, + point: [0.0, settings.force_y_offset, 0.0, 0.0], + angular_displacement: input.up.map(|v| v * signed), + target_velocity, + }) +} +///8296EC98, one-refinement normalization and signed cross about supplied up. +pub fn signed_angle(a: Vector3, b: Vector3, up: Vector3) -> f32 { + let a_squared = dot(a, a); + let b_squared = dot(b, b); + if !(a_squared > 0.0001 && b_squared > 0.0001) { + return 0.0; + } + let angle = angle_between(a, b); + let a = scale(a, inverse_length_squared(a_squared, 1)); + let b = scale(b, inverse_length_squared(b_squared, 1)); + let cross = Vector3::new( + (-a.z).mul_add(b.y, a.y * b.z), + (-a.x).mul_add(b.z, a.z * b.x), + (-a.y).mul_add(b.x, a.x * b.y), + ); + if dot(cross, up) < 0.0 { + f32::from_bits(0x40c9_0fdb) - angle + } else { + angle + } +} +///8258DB98 scalar truncation, fused wrap subtraction, then single range repair. +fn wrap_angle(mut angle: f32) -> f32 { + let pi = f32::from_bits(0x4049_0fdb); + let tau = f32::from_bits(0x40c9_0fdb); + if angle < -pi || !(angle < pi) { + let turns = (angle * f32::from_bits(0x3e22_f983)) as i32; + angle = (-(turns as f32)).mul_add(tau, angle); + if !(angle < pi) { + angle -= tau; + } else if angle < -pi { + angle += tau; + } + } + angle +} +fn reciprocal_refined(value: f32) -> f32 { + let estimate = reciprocal_estimate(value); + let first = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + first.mul_add((-first).mul_add(value, 1.0), first) +} +fn scale(v: Vector3, k: f32) -> Vector3 { + Vector3::new(v.x * k, v.y * k, v.z * k) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn late_false_still_publishes_collision_target_and_angle() { + let settings = CollisionResponseSettings { + maximum_velocity_delta: 8., + force_y_offset: -0.05, + force_scalar: 1., + target_displacement_velocity: 1., + torque_vs_angle: PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [0.03; 8], + }, + }; + let mut input = CollisionResponsePhysical { + flags_2472: 0x20000, + collision_displacement: [1., 0., 0., 0.], + velocity: [2., 0., 4., 0.], + forward: [0., 0., 1., 0.], + up: [0., 1., 0., 0.], + ground_normal: [0., 1., 0., 0.], + time_step: 1. / 60., + mass: 8., + }; + let late = collision_response(&settings, input).unwrap(); + assert!(!late.applied); + assert_eq!(late.force, [0.; 4]); + assert_eq!(late.target_velocity, [1., 0., 4., 0.]); + assert!(late.angular_displacement[1] > 0.); + input.velocity = [-20., 0., 4., 0.]; + let clamped = collision_response(&settings, input).unwrap(); + assert!(clamped.applied); + assert!((clamped.force[0] - 3840.).abs() < 0.001); + assert_eq!(clamped.point, [0., -0.05, 0., 0.]); + input.collision_displacement = input.ground_normal; + assert!(collision_response(&settings, input).is_none()); + } +} + diff --git a/crates/skate-core/src/riding/ground_contact_response.rs b/crates/skate-core/src/riding/ground_contact_response.rs new file mode 100644 index 00000000..caeeb9a7 --- /dev/null +++ b/crates/skate-core/src/riding/ground_contact_response.rs @@ -0,0 +1,148 @@ +//! Wall-ride contact response, TU3 82D93DF0. Inputs are the native published +//! board contact record and processed physical observations, not ray guesses. +use crate::{ + math::Vector3, + physics::{board_motion_output::dot, force_queue::QueuedPointForce}, + point_graph::PointGraph, + riding::grounded::state::board_types::{GroundContactFrame, GroundContactResponse}, +}; + +#[derive(Clone, Debug)] +pub struct WallRideSettings { + pub anti_gravity_vs_time: PointGraph<8>, + pub max_dot_floor_wall: f32, + pub foot_force_time: f32, + pub auto_jump_height: f32, + pub max_time: f32, + pub velocity_time_to_consider: f32, + pub auto_jump_y_down_scalar: f32, + pub auto_jump_force: f32, +} +#[derive(Clone, Copy, Debug)] +pub struct WallRidePhysical { + /// Processed464,544 and400 respectively. + pub board_normal: [f32; 4], + pub up: [f32; 4], + pub velocity: [f32; 4], + /// Processed2660,2648,2652,2556. + pub board_mass: f32, + pub gravity: f32, + pub speed: f32, + pub contact_count: i32, +} + +pub fn wall_ride_response( + settings: &WallRideSettings, + physical: WallRidePhysical, + frame: GroundContactFrame, + previous_velocity: [f32; 4], +) -> GroundContactResponse { + let mut response = GroundContactResponse { + active_2731: false, + tag_16_force: QueuedPointForce { + tag: 16, + ..Default::default() + }, + vector_2688: previous_velocity, + scalar_2704: 0.0, + animated_board_2708: false, + }; + //82D94C10 installs literal .5 and .71 in the two first gates. + if !frame.flag_8084 + || !(physical.board_normal[1].abs() < 0.5) + || !(dot(xyz(physical.up), xyz(physical.board_normal)) > 0.71) + { + return response; + } + response.active_2731 = true; + let acceleration = settings.anti_gravity_vs_time.evaluate(frame.scalar_2752); + response.tag_16_force.force_world = Vector3::new( + 0.0, + -((acceleration * physical.board_mass) * physical.gravity), + 0.0, + ); + let time = frame.scalar_2756; + let consider = (time < 0.0 && time > -1.0) || (physical.speed < 3.0 && time > 0.1); + if !(consider + || (frame.scalar_2752 > 0.01 && physical.contact_count > 0) + || frame.scalar_2752 > settings.foot_force_time) + { + return response; + } + //The dot is loaded BEFORE the temporary slot is overwritten by the + //height/velocity expression at82D94044. Older decompiles alias the two. + let floor_wall = dot(xyz(frame.vector_8064), xyz(physical.board_normal)); + let height = (frame.vector_8048[1] - frame.vector_8032[1]).abs(); + let predicted_height = physical.velocity[1].mul_add(settings.velocity_time_to_consider, height); + if !(floor_wall < settings.max_dot_floor_wall + && (consider || predicted_height < settings.auto_jump_height || time > settings.max_time)) + { + return response; + } + response.animated_board_2708 = true; + let into_normal = dot(xyz(physical.board_normal), xyz(physical.velocity)); + let mut tangent: [f32;4] = + std::array::from_fn(|i| physical.velocity[i] - (physical.board_normal[i] * into_normal)); + if tangent[1] < 0.0 && tangent[1] > -6.0 { + tangent[1] *= settings.auto_jump_y_down_scalar; + } + response.vector_2688 = std::array::from_fn(|i| { + physical.board_normal[i].mul_add(settings.auto_jump_force, tangent[i]) + }); + response +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn contact_force_and_auto_jump_have_distinct_gates_and_keep_old_velocity() { + let settings = WallRideSettings { + anti_gravity_vs_time: PointGraph { x: [0., 1., 2., 3., 4., 5., 6., 7.], y: [0.1; 8] }, + max_dot_floor_wall: 0.5, + foot_force_time: 0.09, + auto_jump_height: 0.6, + max_time: 1., + velocity_time_to_consider: 0.25, + auto_jump_y_down_scalar: 0.66, + auto_jump_force: 3., + }; + let physical = WallRidePhysical { + board_normal: [1., 0., 0., 0.], + up: [1., 0., 0., 0.], + velocity: [2., -2., 4., 0.], + board_mass: 8., + gravity: 9.81, + speed: 5., + contact_count: 0, + }; + let mut frame = GroundContactFrame { + vector_8032: [0.; 4], + vector_8048: [0., 0.2, 0., 0.], + vector_8064: [0., 1., 0., 0.], + word_8080: 0, + flag_8084: true, + scalar_2752: 0., + scalar_2756: 0., + }; + let old = [9.; 4]; + let force = wall_ride_response(&settings, physical, frame, old); + assert!(force.active_2731); + assert!(!force.animated_board_2708); + assert_eq!(force.vector_2688, old); + assert!((force.tag_16_force.force_world.y + 7.848).abs() < 0.00001); + frame.scalar_2752 = 0.1; + let launch = wall_ride_response(&settings, physical, frame, old); + assert!(launch.animated_board_2708); + assert_eq!(launch.vector_2688, [3., -1.32, 4., 0.]); + frame.vector_8064 = physical.board_normal; + assert!(!wall_ride_response(&settings, physical, frame, old).animated_board_2708); + frame.flag_8084 = false; + let no_contact = wall_ride_response(&settings, physical, frame, old); + assert!(!no_contact.active_2731); + assert_eq!(no_contact.tag_16_force.force_world, Vector3::ZERO); + } +} diff --git a/crates/skate-core/src/riding/ground_correction_math.rs b/crates/skate-core/src/riding/ground_correction_math.rs new file mode 100644 index 00000000..690dc3f1 --- /dev/null +++ b/crates/skate-core/src/riding/ground_correction_math.rs @@ -0,0 +1,184 @@ +//! Remaining numerical ground corrections from TU3 82D38800 and its callees. +//! These operate on actual physical vectors; phase/counter gates stay in the +//! grounded state owner and all body writes target the shared board solver. +use crate::{ + math::Vector3, + physics::{ + board_motion_output::{dot, inverse_length_squared, length}, + native_arithmetic::reciprocal_estimate, + }, +}; + +///82D38838..AC: length of Skeleton11008, with two reciprocal-square-root refinements. +pub fn center_of_mass_height(com_to_deck_world: [f32; 4]) -> f32 { + length(xyz(com_to_deck_world)) +} + +pub fn dot_product(a: [f32; 4], b: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} + +/// 82D38E18..82D38EA8: normalize the force with two refinements, reject +/// lengths no greater than the live native epsilon, then dot with velocity. +/// Velocity deliberately remains unnormalized. +pub fn collision_force_projection(force: [f32; 4], velocity: [f32; 4]) -> f32 { + let squared = dot(xyz(force), xyz(force)); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + let direction = if length > f32::from_bits(0x3586_37bd) { + force.map(|v| v * inverse) + } else { + [0.0; 4] + }; + dot(xyz(direction), xyz(velocity)) +} + +/// 82D37048: edge direction is divided by its computed length. The native +/// code uses two reciprocal refinements after its two square-root refinements; +/// it does not replace that sequence with direct inverse-length scaling. +pub fn edge_direction(start: [f32; 4], end: [f32; 4]) -> [f32; 4] { + let delta = subtract(end, start); + let inverse = reciprocal_refined(length(xyz(delta))); + delta.map(|v| v * inverse) +} + +/// 82D370F8: positive-Y perpendicular to an edge. The degenerate result is +/// native world X (82139A10), not world Y or an arbitrary orthogonal vector. +pub fn edge_up(direction: [f32; 4]) -> [f32; 4] { + let first = cross([0., 1., 0., 0.], direction); + let perpendicular = cross(first, direction); + let magnitude = length(xyz(perpendicular)); + if magnitude <= 0.0 { + return [1., 0., 0., 0.]; + } + let signed_length = if perpendicular[1] < 0.0 { + -magnitude + } else { + magnitude + }; + let inverse = reciprocal_refined(signed_length); + perpendicular.map(|v| v * inverse) +} + +/// 82D395AC..82D396AC: push away from the obstructing edge and upward. +/// Inputs are Processed1312,1328 and the actual deck position112. +pub fn hang_force(edge_start: [f32; 4], edge_end: [f32; 4], deck: [f32; 4]) -> [f32; 4] { + let direction = edge_direction(edge_start, edge_end); + let up = edge_up(direction); + let side = cross(up, direction); + let side = if dot(xyz(subtract(deck, edge_start)), xyz(side)) < 0.0 { + side.map(|v| v * -1.0) + } else { + side + }; + let lift = [0., 160., 0., 0.]; + std::array::from_fn(|i| side[i].mul_add(50.0, lift[i])) +} + +/// 82D39950..82D399FC: signed deck-Z cross deck-Y, scaled by the native +/// displacement0.007. The caller already checked halfpipe/contact geometry. +pub fn wheel_catch_displacement(deck_y: [f32; 4], deck_z: [f32; 4]) -> [f32; 4] { + let signed_z = if deck_z[1] > 0.0 { + deck_z + } else { + deck_z.map(|v| f32::from_bits(v.to_bits() ^ 0x8000_0000)) + }; + cross(signed_z, deck_y).map(|v| v * f32::from_bits(0x3be5_6042)) +} + +/// 82D39B04..82D39B8C: only captured X/Z are restored. Current position Y +/// appears in both terms, so no vertical pinning correction is introduced. +pub fn pinning_velocity(position: [f32; 4], captured_x: f32, captured_z: f32, dt: f32) -> [f32; 4] { + let target = [captured_x, position[1], captured_z, 0.0]; + let inverse_dt = reciprocal_refined(dt); + subtract(target, position).map(|v| v * inverse_dt) +} + +pub fn scale_to_magnitude(vector: [f32; 4], squared: f32, magnitude: f32) -> [f32; 4] { + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + // 82D394D8 divides50 by the computed length with scalar fdivs. + let factor = magnitude / length; + vector.map(|v| v * factor) +} + +fn reciprocal_refined(value: f32) -> f32 { + let estimate = reciprocal_estimate(value); + let first = estimate.mul_add((-estimate).mul_add(value, 1.0), estimate); + first.mul_add((-first).mul_add(value, 1.0), first) +} +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]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn subtract(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + std::array::from_fn(|i| a[i] - b[i]) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn corrections_preserve_native_axes_and_units() { + let force = hang_force([0.; 4], [0., 0., 2., 0.], [1., 0., 0., 0.]); + assert!((force[0] - 50.).abs() < 0.0001); + assert_eq!(force[1], 160.); + assert_eq!(edge_up([0., 1., 0., 0.]), [1., 0., 0., 0.]); + assert_eq!( + collision_force_projection([0., 0., 4., 0.], [0., 0., -3., 0.]), + -3. + ); + assert_eq!(collision_force_projection([0.; 4], [1., 2., 3., 0.]), 0.); + let pin = pinning_velocity([4., 9., 8., 0.], 3., 10., 1. / 60.); + assert!((pin[0] + 60.).abs() < 0.00001); + assert_eq!(pin[1], 0.); + assert!((pin[2] - 120.).abs() < 0.00002); + assert!(wheel_catch_displacement([0., 0., 1., 0.], [0., 1., 0., 0.])[0] > 0.); + } +} + +/// ApplyWorldSpaceForceToDeck82C03F98. Its moment arm is measured from the +/// physical deck part origin returned by82585CB0, not the body's COM origin. +pub fn apply_world_force( + body: &mut crate::physics::assembly::BodySnapshot, + deck_part_position: Vector3, + force: Vector3, + point: Vector3, +) { + let arm = Vector3::new( + point.x - deck_part_position.x, + point.y - deck_part_position.y, + point.z - deck_part_position.z, + ); + let torque = cross([arm.x, arm.y, arm.z, 0.], [force.x, force.y, force.z, 0.]); + let inverse = body.inertia.inverse_mass; + body.rates.force_acceleration.x += force.x * inverse; + body.rates.force_acceleration.y += force.y * inverse; + body.rates.force_acceleration.z += force.z * inverse; + let columns = body.rates.world_inverse_inertia.columns; + let angular = |i: usize| { + columns[2][i].mul_add( + torque[2], + columns[1][i].mul_add(torque[1], columns[0][i] * torque[0]), + ) + }; + body.rates.torque_acceleration.x += angular(0); + body.rates.torque_acceleration.y += angular(1); + body.rates.torque_acceleration.z += angular(2); + body.rates.cool_down = 0; +} diff --git a/crates/skate-core/src/riding/ground_force.rs b/crates/skate-core/src/riding/ground_force.rs new file mode 100644 index 00000000..ab6ea08c --- /dev/null +++ b/crates/skate-core/src/riding/ground_force.rs @@ -0,0 +1,43 @@ +//! Complete TU3 82D931E8, called twice by Ground Update for tags 4 and 5. +//! The research index has not recovered its original name. Keep its four +//! caller-supplied scalar arguments explicit instead of inventing units. +use super::vector::{clamp, dot3, normalize}; +pub struct GroundForceSettings { + pub range_1220: f32, + pub speed_scale_1224: f32, + pub scale_1228: f32, + pub normal_threshold: [f32; 4], +} +pub struct GroundForceInput { + pub argument_1: f32, + pub application_z: f32, + pub argument_3: f32, + pub argument_4: f32, + pub balance: f32, // Processed +2720 + pub surface_speed: f32, // +2656 + pub axis_384: [f32; 4], + pub velocity_400: [f32; 4], + pub axis_544: [f32; 4], +} +pub fn calculate(s: &GroundForceSettings, i: &GroundForceInput) -> [f32; 8] { + let fraction = clamp(i.argument_1 / s.range_1220, 0.0, 1.0); + // Native double literals at822F8700/+8 are0 and1, narrowed at zero arg1. + let zero_scalar = if i.argument_1 != 0.0 || i.balance != 0.0 { + 1.0 + } else { + 0.0 + }; + let speed = (i.surface_speed * 0.125) * s.speed_scale_1224; + let remainder = ((1.0 - fraction) * s.scale_1228) * zero_scalar; + let magnitude = -(speed.mul_add(fraction, remainder) * i.argument_4); + let force = + core::array::from_fn(|n| i.axis_544[n].mul_add(magnitude, (-i.axis_384[n]) * i.argument_3)); + let direction = normalize(i.velocity_400, s.normal_threshold); + let projected = dot3(direction, force); + let mut result = [0.0; 8]; + for n in 0..4 { + result[n] = force[n] - direction[n] * projected; + } + result[6] = i.application_z; + result +} diff --git a/crates/skate-core/src/riding/ground_orientation.rs b/crates/skate-core/src/riding/ground_orientation.rs new file mode 100644 index 00000000..d38c6e91 --- /dev/null +++ b/crates/skate-core/src/riding/ground_orientation.rs @@ -0,0 +1,190 @@ +//! Riding up-vector and ground-normal calculation in TU3 Reckoning82D8C8F0. +//! Inputs are assembled by82D8E5C0; this module owns the three persistent +//! filters, target, acceleration and damping. Transform/tilt publication is a +//! separate downstream calculation. Numerical primitives remain unverified +//! against independent hardware captures. +use crate::{air::ground_normal::GroundNormalFilter, math::Vector3, point_graph::PointGraph}; +use crate::physics::{board_ground::angle_between, board_motion_output::{add, subtract, scale, dot, inverse_length_squared, length}, native_arithmetic}; + +const UP: Vector3 = Vector3::new(0.0, 1.0, 0.0); +const EPSILON: f32 = f32::from_bits(0x3586_37BD); //82F826F8 installs82181A88 at830BD350. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundOrientationSettings { + pub ground_normal_smoothing: [f32; 4], + pub up_vector_smoothing_slow: [f32; 4], + pub up_vector_smoothing_fast: [f32; 4], + pub dynamic_up_vs_ground_y: PointGraph<8>, + pub ground_vector_blend: PointGraph<8>, + /// The eight x/y pairs inside PointNegGraphData8 at layout256+16. + ///82D8CF10 calls ordinary PointGraphEval directly, ignoring its sign mode. + pub deck_angle_usage_vs_speed: PointGraph<8>, + pub up_vector_smoothing_vs_speed: PointGraph<8>, + pub up_vector_max_delta_vs_speed: PointGraph<8>, + pub ground_blend_max_delta: f32, + pub up_vector_max_acceleration: f32, + pub anti_wobble_damping: f32, + pub extra_side_damping: f32, + pub minimum_wheels_for_ground_blend: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundOrientationInput { + /// Processed752, copied from Skeleton11008 in82BD8918. + pub com_to_deck: Vector3, + /// Processed464 <- Ground96 (retained wheel normal). + pub ground_normal: Vector3, + /// Processed528 <- Ground64 (overall normal). + pub dynamic_up: Vector3, + /// Processed2652 <- SkateboardMotion160 (raw deck speed). + pub speed: f32, + pub wheel_contact_count: i32, + /// Processed2720; the native deck-angle gate tests exact zero. + pub animation_balance: f32, + /// Processed2616 input to DeckAngleUsageVsSpeed. + pub deck_angle_curve_input: f32, + /// Processed80/96: current board transform Y/Z. + pub board_up: Vector3, + pub board_forward: Vector3, + /// Processed160: effective board transform Z. + pub effective_board_forward: Vector3, + /// The previous complete Reckoning transform X (native816), including flip. + pub previous_reckoning_right: Vector3, + /// Processed2476 bit30, the coffin restriction. + pub prevent_up_behind_board: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GroundOrientation { + pub dynamic_up: Vector3, + pub up: Vector3, + pub target: Vector3, + pub up_velocity: Vector3, + pub ground_normal: Vector3, + pub ground_blend: f32, + pub(crate) ground_filter: GroundNormalFilter, + pub(crate) slow_filter: GroundNormalFilter, + pub(crate) fast_filter: GroundNormalFilter, +} +impl GroundOrientation { + /// Native82D8DA68 filter history and82D8C3A8/82D8BE38 vector seeds. + pub fn new(settings: &GroundOrientationSettings) -> Self { + Self { + dynamic_up: UP, up: UP, target: UP, up_velocity: Vector3::ZERO, + ground_normal: UP, ground_blend: 0.0, + ground_filter: GroundNormalFilter::initialized(settings.ground_normal_smoothing, lanes(UP)), + slow_filter: GroundNormalFilter::initialized(settings.up_vector_smoothing_slow, lanes(UP)), + fast_filter: GroundNormalFilter::initialized(settings.up_vector_smoothing_fast, lanes(UP)), + } + } + + /// Reset82D8C3A8 resets these vectors/scalars but preserves filter history. + pub fn reset(&mut self) { + self.dynamic_up = UP; + self.up = UP; + self.target = UP; + self.up_velocity = Vector3::ZERO; + self.ground_normal = UP; + self.ground_blend = 0.0; + } + /// Complete up/normal branch82D8C934..82D8D650, before CalculateTransform, + /// CalculateDynamicLean and CalculateTilt. BodySpin runs before this stage. + pub fn update(&mut self, settings: &GroundOrientationSettings, input: GroundOrientationInput) { + let normalized_com = normalize_safe(input.com_to_deck, Vector3::ZERO); + let amount = settings.dynamic_up_vs_ground_y.evaluate(input.ground_normal.y); + let prediction = normalize_safe(blend( + self.ground_normal, add(UP, subtract(input.dynamic_up, normalized_com)), amount, + ), Vector3::ZERO); + self.dynamic_up = input.dynamic_up; + self.ground_normal = vector(self.ground_filter.update( + settings.ground_normal_smoothing, lanes(input.ground_normal), + )); + let horizontal_axis = cross(UP, self.ground_normal); + if dot(horizontal_axis, horizontal_axis) > f32::from_bits(0x3A83_126F) { + let axis = scale(horizontal_axis, inverse_length_squared(dot(horizontal_axis, horizontal_axis), 2)); + let projected = subtract(prediction, scale(axis, dot(axis, prediction))); + let prediction_to_ground = angle_between(projected, self.ground_normal); + let prediction_to_up = angle_between(projected, UP); + let ground_to_up = angle_between(self.ground_normal, UP); + let old_up_to_up = angle_between(self.up, UP); + let mut candidate = if prediction_to_ground < prediction_to_up { self.ground_normal } else { UP }; + if prediction_to_ground < ground_to_up && prediction_to_up < ground_to_up { candidate = projected; } + let alpha = (input.speed + 1.0) * f32::from_bits(0x3A83_126F); + if !(old_up_to_up > ground_to_up || prediction_to_up > old_up_to_up) { + candidate = madd(self.up, 1.0 - alpha, scale(candidate, alpha)); + } + let slope = unit_saturate(ground_to_up * f32::from_bits(0x3EA2_F983)); + let mut target_blend = settings.ground_vector_blend.evaluate(slope); + if input.wheel_contact_count <= settings.minimum_wheels_for_ground_blend { target_blend = 0.0; } + let delta = target_blend - self.ground_blend; + let lower = fsel(-settings.ground_blend_max_delta - delta, -settings.ground_blend_max_delta, delta); + self.ground_blend += fsel(settings.ground_blend_max_delta - lower, lower, settings.ground_blend_max_delta); + self.target = normalize_safe(blend(candidate, self.ground_normal, self.ground_blend), Vector3::ZERO); + } else { + self.target = self.ground_normal; + } + if input.wheel_contact_count >= 2 && input.animation_balance == 0.0 { + let usage = settings.deck_angle_usage_vs_speed.evaluate(input.deck_angle_curve_input); + let axis = normalize_safe(cross(input.board_forward, self.ground_normal), Vector3::ZERO); + let deck_up = normalize_safe(subtract(input.board_up, scale(axis, dot(input.board_up, axis))), Vector3::ZERO); + self.target = normalize_safe(blend(self.target, deck_up, usage), Vector3::ZERO); + } + let slow = normalize_safe(vector(self.slow_filter.filter_raw(settings.up_vector_smoothing_slow, lanes(self.target))), Vector3::ZERO); + let fast = normalize_safe(vector(self.fast_filter.filter_raw(settings.up_vector_smoothing_fast, lanes(self.target))), Vector3::ZERO); + let speed_fraction = input.speed * f32::from_bits(0x3DCC_CCCD); + let mix = settings.up_vector_smoothing_vs_speed.evaluate(speed_fraction); + let filtered = normalize_safe(blend(slow, fast, mix), Vector3::ZERO); + let max_delta = settings.up_vector_max_delta_vs_speed.evaluate(speed_fraction) * f32::from_bits(0x3E4C_CCCD); + let difference = subtract(filtered, self.up); + let magnitude = length(difference); + let bounded = fsel(magnitude - max_delta, max_delta, magnitude); + let stepped = normalize_safe(madd(normalize_safe(difference, Vector3::ZERO), bounded, self.up), Vector3::ZERO); + let desired_velocity = subtract(stepped, self.up); + let acceleration = clamp_length(subtract(desired_velocity, self.up_velocity), + settings.up_vector_max_acceleration * f32::from_bits(0x3C88_8889)); + self.up_velocity = add(self.up_velocity, acceleration); + let side = scale(input.previous_reckoning_right, dot(self.up_velocity, input.previous_reckoning_right)); + self.up_velocity = madd(side, settings.extra_side_damping, subtract(self.up_velocity, side)); + let unnormalized = add(self.up, self.up_velocity); + self.up = normalize_safe(unnormalized, unnormalized); + if 0.0 > dot(self.up_velocity, desired_velocity) { + self.up_velocity = scale(self.up_velocity, settings.anti_wobble_damping); + } + if input.prevent_up_behind_board && dot(self.up, input.effective_board_forward) < 0.0 { + let side = cross(self.up, input.effective_board_forward); + self.up = normalize_safe(cross(input.effective_board_forward, side), self.up); + self.up_velocity = scale(self.up_velocity, 0.5); + } + self.slow_filter.publish_current(lanes(self.up)); + self.fast_filter.publish_current(lanes(self.up)); + } +} + +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]) } +fn fsel(test: f32, positive: f32, negative: f32) -> f32 { if test >= -0.0 { positive } else { negative } } +fn unit_saturate(value: f32) -> f32 { let value = fsel(-value, 0.0, value); fsel(1.0-value, value, 1.0) } +fn madd(v: Vector3, factor: f32, offset: Vector3) -> Vector3 { + Vector3::new(v.x.mul_add(factor,offset.x),v.y.mul_add(factor,offset.y),v.z.mul_add(factor,offset.z)) +} +fn blend(from: Vector3, to: Vector3, amount: f32) -> Vector3 { madd(to,amount,scale(from,1.0-amount)) } +fn cross(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new((-a.z).mul_add(b.y,a.y*b.z),(-a.x).mul_add(b.z,a.z*b.x),(-a.y).mul_add(b.x,a.x*b.y)) +} +fn normalize_safe(value: Vector3, fallback: Vector3) -> Vector3 { + let squared=dot(value,value); let inverse=inverse_length_squared(squared,2); + let magnitude=if squared==0.0 {0.0} else {squared*inverse}; + if magnitude > EPSILON { scale(value,inverse) } else { fallback } +} +/// Complete ClampVectorWithinMaxLength82BD3D90. +pub(crate) fn clamp_length(value: Vector3, maximum: f32) -> Vector3 { + let magnitude=length(value); + if magnitude < f32::from_bits(0x3780_0000) { return value; } + let bounded=fsel(maximum-magnitude,magnitude,maximum); + let mut inverse=native_arithmetic::reciprocal_estimate(magnitude); + for _ in 0..2 { let error=(-inverse).mul_add(magnitude,1.0);inverse=inverse.mul_add(error,inverse); } + scale(scale(value,bounded),inverse) +} +#[cfg(test)] +#[path = "tests/ground_orientation.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/grounded/drag.rs b/crates/skate-core/src/riding/grounded/drag.rs new file mode 100644 index 00000000..7cf34ec2 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/drag.rs @@ -0,0 +1,103 @@ +//! Ground drag calculation82D39100/104 and inertia publication82D392A8..B4. +//! Body::SetLinearDrag82D9CD60 writes inertia fields, not current velocities. +use crate::{ + physics::rigid_body::RetailInertiaDynamics, + riding::braking::{LinearDragInput, LinearDragSettings, calculate_linear_drag}, +}; + +/// Literal at822F860C, verified in the original TU3 image. It is not replaced +/// by60 or recomputed from the current simulation timestep. +pub const DRAG_FREQUENCY: f32 = f32::from_bits(0x426f_ffff); + +#[derive(Clone, Copy, Debug)] +pub struct GroundDragInput { + pub flags_2468: u32, + pub absolute_body_speed_2616: f32, + pub balance_2720: f32, + pub scalar_2724: f32, + /// Ground context+1216 vector Y, passed in f1 at82D39100. This is not + /// Processed speed, a manual output, or a timestep. + pub ground_normal_y: f32, +} + +impl GroundDragInput { + /// Calculate before the manual-output80 branch at82D39110. The returned + /// drag must be retained across force submission, then applied afterward. + pub fn calculate(self, settings: LinearDragSettings) -> f32 { + calculate_linear_drag( + LinearDragInput { + flags_2468: self.flags_2468, + absolute_body_speed: self.absolute_body_speed_2616, + balance_2720: self.balance_2720, + scalar_2724: self.scalar_2724, + comparison_scalar: self.ground_normal_y, + }, + settings, + ) + } +} + +/// Native body+24 assembly contains part count+28 and definitions+8; each +///96-byte part resolves body+76 -> inertia+92. Shared inertias remain shared: +/// this binding references the live dynamics objects, not copies per part. +pub struct BodyInertias<'a> { + pub part_inertia_indices: &'a [usize], + pub inertias: &'a mut [RetailInertiaDynamics], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DragSelection { + /// Native r5=-1, used by the inspected Ground update and exit callsites. + AllParts, + Part(usize), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DragBindingError { + PartOutsideAssembly, + InertiaOutsideStorage, +} + +impl BodyInertias<'_> { + /// Exact valid-binding setter82D9CD60. Invalid host bindings are rejected + /// before writes; native would address invalid storage rather than recover. + /// All-parts order is0..count, with four resolved handles before each group + /// of four writes and a remaining single-part tail. Only inertia+32 changes. + pub fn set_linear_drag( + &mut self, + drag: f32, + selection: DragSelection, + ) -> Result<(), DragBindingError> { + let selected = match selection { + DragSelection::AllParts => self.part_inertia_indices, + DragSelection::Part(part) => self + .part_inertia_indices + .get(part..part.saturating_add(1)) + .filter(|parts| parts.len() == 1) + .ok_or(DragBindingError::PartOutsideAssembly)?, + }; + if selected.iter().any(|&index| index >= self.inertias.len()) { + return Err(DragBindingError::InertiaOutsideStorage); + } + let mut groups = selected.chunks_exact(4); + for group in &mut groups { + let [first, second, third, fourth] = [group[0], group[1], group[2], group[3]]; + let coefficient = drag * DRAG_FREQUENCY; + self.inertias[first].linear_drag = coefficient; + self.inertias[second].linear_drag = coefficient; + self.inertias[third].linear_drag = coefficient; + self.inertias[fourth].linear_drag = coefficient; + } + for &index in groups.remainder() { + self.inertias[index].linear_drag = drag * DRAG_FREQUENCY; + } + Ok(()) + } + + /// Ground82D392B4: after either ordinary tag16 or alternate tag7 submission, + /// apply the drag retained from82D39104 to all parts. The caller owns the + /// remaining force-strength flag and ground-correction helpers afterward. + pub fn apply_ground_drag(&mut self, calculated_drag: f32) -> Result<(), DragBindingError> { + self.set_linear_drag(calculated_drag, DragSelection::AllParts) + } +} diff --git a/crates/skate-core/src/riding/grounded/manual_entry.rs b/crates/skate-core/src/riding/grounded/manual_entry.rs new file mode 100644 index 00000000..1fe3f48e --- /dev/null +++ b/crates/skate-core/src/riding/grounded/manual_entry.rs @@ -0,0 +1,111 @@ +//! Ground's manual entry slice82D376F4..7750 and velocity helper82D37960. +//! The complete Ground Enter function and native projection are not supplied. +use crate::physics::manual::state::{ManualEntryContinuation, ManualState}; + +#[derive(Clone, Copy, Debug)] +pub struct ManualGroundInput { + /// ProcessedPhysIn+2720; either signed zero bypasses every body operation. + pub balance: f32, + /// ProcessedPhysIn+464, used directly without normalization. + pub ground_normal: [f32; 4], + /// ProcessedPhysIn+2468. Bit20 swaps the two contact-controlled groups. + pub flags_2468: u32, + /// ProcessedPhysIn+2472. Bits26/27 select the two groups. + pub flags_2472: u32, +} + +/// Direct part-body velocity access, not a force queue or delayed write list. +/// Resolve the live board assembly part on each call: native reads follow every +/// earlier write, including when multiple parts happen to refer to one body. +pub trait ManualGroundBodies { + /// Part indices0..6 resolve body pointers at assembly+76+96*part; velocity + /// is the four-component body field+32. No angular velocity is touched. + fn linear_velocity(&mut self, part: usize) -> [f32; 4]; + fn set_linear_velocity(&mut self, part: usize, velocity: [f32; 4]); +} + +/// Required numerical dependency with no production or fallback implementation. +pub trait ManualGroundProjection { + type Error; + /// Native three-component projection at82D3799C (and six repeated sites). + /// W does not contribute. Preserve native accumulation/rounding when this + /// operation is recovered; generic host dot arithmetic is not a substitute. + fn normal_speed( + &mut self, + ground_normal: [f32; 4], + linear_velocity: [f32; 4], + ) -> Result; +} + +/// Performs the actual manual-state reset/scaling, then its required velocity +/// continuation before returning to Ground Enter's subsequent category200 work. +/// An error retains the executed state/body prefix and must abort the tick. +pub fn enter_ground( + state: &mut ManualState, + previous_category: u32, + powerslide_exit_scale: f32, + input: ManualGroundInput, + bodies: &mut B, + projection: &mut P, +) -> Result<(), P::Error> { + if state.enter_ground(previous_category, powerslide_exit_scale) + == ManualEntryContinuation::RemoveVelocityIntoGround + { + remove_velocity_into_ground(input, bodies, projection)?; + } + Ok(()) +} + +/// Full branch/write order of82D37960, conditional on the required projection. +/// Despite the recovered name, both signs of normal motion are removed. There +/// is no clamp, normal-length division, timestep or balance-sign selection. +pub fn remove_velocity_into_ground( + input: ManualGroundInput, + bodies: &mut B, + projection: &mut P, +) -> Result<(), P::Error> { + if input.balance == 0.0 { + return Ok(()); + } + remove_part(6, input.ground_normal, bodies, projection)?; + + // Native flags are read after the deck write82D379B4..B8. + let reversed = input.flags_2468 & (1 << 20) != 0; + let contact26 = input.flags_2472 & (1 << 26) != 0; + let contact27 = input.flags_2472 & (1 << 27) != 0; + let (first_group, second_group) = if reversed { + (contact26, contact27) + } else { + (contact27, contact26) + }; + if first_group { + for part in [0, 1, 4] { + remove_part(part, input.ground_normal, bodies, projection)?; + } + } + if second_group { + for part in [2, 3, 5] { + remove_part(part, input.ground_normal, bodies, projection)?; + } + } + Ok(()) +} + +fn remove_part( + part: usize, + ground_normal: [f32; 4], + bodies: &mut B, + projection: &mut P, +) -> Result<(), P::Error> { + let velocity = bodies.linear_velocity(part); + let normal_speed = projection.normal_speed(ground_normal, velocity)?; + let mut remaining = velocity; + for axis in 0..3 { + // Separate multiply then subtract82D379A4..A8, not a fused operation. + let component = ground_normal[axis] * normal_speed; + remaining[axis] = velocity[axis] - component; + } + // W is retained bit-for-bit by the native insert before each store. + bodies.set_linear_velocity(part, remaining); + Ok(()) +} diff --git a/crates/skate-core/src/riding/grounded/mod.rs b/crates/skate-core/src/riding/grounded/mod.rs new file mode 100644 index 00000000..52efc3cf --- /dev/null +++ b/crates/skate-core/src/riding/grounded/mod.rs @@ -0,0 +1,6 @@ +//! Recovered grounded producer units; the complete ground state is not ported. +pub mod drag; +pub mod manual_entry; +pub mod mode; +pub mod propulsion; +pub mod state; diff --git a/crates/skate-core/src/riding/grounded/mode.rs b/crates/skate-core/src/riding/grounded/mode.rs new file mode 100644 index 00000000..63f7172d --- /dev/null +++ b/crates/skate-core/src/riding/grounded/mode.rs @@ -0,0 +1,24 @@ +//! Mode binding producer, TU3 ProcessInput 82DB4420..447C. +//! Numeric mode identities are preserved without assigning guessed names. + +/// The two fields are deliberately separate: an unsupported request is still +/// published, while the previously selected attribute binding remains active. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProcessedMode { + /// ProcessedPhysIn+2528, copied from skeleton+10928 on every update. + pub requested_mode: u32, + /// ProcessedPhysIn+2548. Supply actual resource bindings, not host addresses. + pub selected_attributes: T, +} + +impl ProcessedMode { + /// `attributes` correspond in order to player+1408/1424/1440/1456/1472. + /// Their constructor/asset loading is a separate dependency. There is no + /// guessed initial binding and no default-mode fallback for invalid input. + pub fn update(&mut self, requested_mode: u32, attributes: &[T; 5]) { + if requested_mode <= 4 { + self.selected_attributes = attributes[requested_mode as usize]; + } + self.requested_mode = requested_mode; + } +} diff --git a/crates/skate-core/src/riding/grounded/propulsion.rs b/crates/skate-core/src/riding/grounded/propulsion.rs new file mode 100644 index 00000000..adc2a70a --- /dev/null +++ b/crates/skate-core/src/riding/grounded/propulsion.rs @@ -0,0 +1,136 @@ +//! Brake/push production82D38EB4..8F08 and submission82D39194..91B0. +//! These are two distinct slices of Ground UpdateSkateboard, not an entire +//! ground update: intervening native force/manual producers must execute. +use crate::{ + math::Vector3, + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::controller::ManualEffect, + }, + riding::{ + braking::{BrakeInput, BrakeSettings, calculate_braking}, + push::{PushAcceleration, PushInput, PushLimits, calculate_acceleration}, + }, +}; + +/// Actual ProcessedPhysIn fields consumed by these two native calculations. +/// No raw button mapping, recomputed mass, normalized axis or host timestep. +#[derive(Clone, Copy, Debug)] +pub struct GroundPropulsionInput { + pub flags_2468: u32, + pub flags_2472: u32, + ///+2608, produced by Skeleton's push attribute path82BDAC24/2C. + pub target_speed: f32, + ///+2612, copied by ProcessInput82DB4878 from player1800->4 field168. + pub signed_speed: f32, + ///+2616, separate from the signed projection above. + pub absolute_body_speed: f32, + ///+2660, consumed directly. Existing mass-producer attribution82C0140C + ///still needs a fresh containing-body audit; do not recompute here. + pub scalar_2660: f32, + ///+2604, unlike Ground's fixed pumping timestep. + pub timestep: f32, + ///+2728, Skeleton attribute scalar consumer82BDB0E8. + pub brake_input: f32, + ///+352 and+368 are distinct effective axes, used verbatim. + pub push_direction: Vector3, + pub brake_direction: Vector3, + /// Processed+2544 -> collection+4 -> layout+264. + pub surface_braking_factor: f32, +} + +#[derive(Clone, Copy, Debug)] +pub struct GroundPropulsionSettings { + /// Ground+32 toolkit offsets20/24/28, cached by82D94D08/1C/34. + pub braking: BrakeSettings, + /// Toolkit+16 from physics_push layout+8 at82D94CF4. + pub maximum_pushable_speed: f32, + /// Processed+2548 -> selected collection+4 -> layout+48/+52. + /// These are separate from the pumping fields at that mode binding. + pub mode_speed_changes: [f32; 2], +} + +/// Native stack results retained until the later force-submission phase. +#[derive(Clone, Copy, Debug)] +pub struct GroundPropulsion { + pub braking: QueuedPointForce, + pub push: PushAcceleration, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropulsionSubmission { + BrakingAndPush([bool; 2]), + ManualCorrection(bool), +} + +/// Run only after the earlier velocity/collision branches select82D38EB4. +/// The force helpers run even if the later manual branch suppresses submission. +/// Publishes Ground+2730 after push calculation, including clearing old state. +pub fn calculate( + input: GroundPropulsionInput, + settings: GroundPropulsionSettings, + push_suppressed_2730: &mut u8, +) -> GroundPropulsion { + let braking = calculate_braking( + BrakeInput { + flags_2468: input.flags_2468, + input_2728: input.brake_input, + signed_speed: input.signed_speed, + absolute_body_speed: input.absolute_body_speed, + surface_factor: input.surface_braking_factor, + direction: input.brake_direction, + }, + settings.braking, + ); + let push = calculate_acceleration( + PushInput { + flags_2468: input.flags_2468, + flags_2472: input.flags_2472, + target_speed: input.target_speed, + current_speed: input.signed_speed, + absolute_body_speed: input.absolute_body_speed, + scale: input.scalar_2660, + delta_seconds: input.timestep, + direction: input.push_direction, + }, + PushLimits { + maximum_pushable_speed: settings.maximum_pushable_speed, + low_speed_change: settings.mode_speed_changes[0], + high_speed_change: settings.mode_speed_changes[1], + }, + ); + *push_suppressed_2730 = u8::from(push.suppressed); + GroundPropulsion { braking, push } +} + +impl GroundPropulsion { + /// Call at82D39194 after native correction82D39338 and any tag4/5 writes, + /// before angular changes/tag6/1/8/16. Queues both records, even zero forces. + /// A true actual CalcManualEffect output80 selects the alternate tag7 path + /// and queues its corrective force+48/point+64 instead. Output81 is not + /// this selector.82D94A20 writes + /// no r9, preserving the output80 byte through the intervening drag call. + /// Returns each append result; the native21-entry limit may drop a record. + pub fn submit( + self, + manual: &ManualEffect, + queue: &mut BoardForceQueue, + ) -> PropulsionSubmission { + if manual.correction_active { + let force = manual.corrective_force_world; + let point = manual.corrective_point_body; + return PropulsionSubmission::ManualCorrection(queue.append(QueuedPointForce { + tag: 7, + force_world: Vector3::new(force[0], force[1], force[2]), + point_body: Vector3::new(point[0], point[1], point[2]), + })); + } + let brake = queue.append(self.braking); + let push = queue.append(QueuedPointForce { + tag: 3, + force_world: self.push.vector, + point_body: self.push.local_point, + }); + PropulsionSubmission::BrakingAndPush([brake, push]) + } +} diff --git a/crates/skate-core/src/riding/grounded/state/board.rs b/crates/skate-core/src/riding/grounded/state/board.rs new file mode 100644 index 00000000..7ee6e93d --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/board.rs @@ -0,0 +1,160 @@ +//! Branch composition of `PhysicsGround::UpdateSkateboard` (`82D38800`). + +use crate::riding::{speed_wobble, steering}; + +use super::{ + board_types::{ + GroundBoardComponents, GroundBoardError, GroundBoardInput, GroundBoardServices, + GroundBoardSettings, GroundBoardStage, + }, + data::PhysicsGroundState, + ordinary, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundBoardOutcome { + Animated, + Collision { tag_15_queued: bool }, + Ordinary(ordinary::OrdinaryGroundResult), +} + +/// Runs the native steering/contact branch prefix, then exactly one of the +/// animated, collision-response, or ordinary grounded paths. +pub fn update( + state: &mut PhysicsGroundState, + components: GroundBoardComponents<'_, '_>, + settings: GroundBoardSettings<'_>, + mut input: GroundBoardInput, + services: &mut S, +) -> Result> { + let tilt = steering::calculate_tilt( + settings.steering, + input.steering, + Some(&mut state.steering_push_scalar_2640), + Some(&mut state.steering_damped_turn_2644), + ); + input.speed_wobble.tilt = tilt; + input.speed_wobble.center_of_mass_height = service( + GroundBoardStage::CenterOfMassHeight, + services.center_of_mass_height_82d38838(), + )?; + let tilt = speed_wobble::calculate( + components.speed_wobble, + settings.speed_wobble, + input.speed_wobble, + ); + components.truck_steering.update( + tilt, + settings.steering.tilt_blending, + input.truck_flags_2468, + input.truck_flags_2472, + ); + + service( + GroundBoardStage::SetWheelMaterials, + services.set_contact_wheel_materials(), + )?; + let contact = service( + GroundBoardStage::ContactResponse, + services.contact_response_82d93df0(input.contact, state.vector_2688), + )?; + state.flag_2731 = contact.active_2731; + state.vector_2688 = contact.vector_2688; + state.scalar_2704 = contact.scalar_2704; + state.flag_2708 = contact.animated_board_2708; + state.flag_2720 = false; + + service( + GroundBoardStage::UpdateBodyAccumulator, + services.update_body_accumulator_82d389dc(), + )?; + if state.flag_2708 { + return animated(state, components, services); + } + + let collision = service( + GroundBoardStage::CollisionForce, + services.collision_force_82d944e8(input.ground_vector_1216), + )?; + if let Some(collision) = collision { + state.collision_force_2528 = collision.force_2528; + state.collision_point_2544 = collision.point_2544; + state.vector_2592 = collision.vector_2592; + state.collision_countdown_2652 = settings.collision_response_duration; + service( + GroundBoardStage::ApplyCollisionVector, + services.apply_vector_82c07000(collision.vector_2592), + )?; + components.speed_model.flags_1360 |= 0x8000_0000; + let queued = components.force_queue.append(collision.tagged_force()); + let projection = service( + GroundBoardStage::CollisionProjection, + services.collision_force_dot_velocity_82d38e18( + collision.force_2528, + input.ground_force.velocity_400, + ), + )?; + state.flag_2722 = projection < -0.75; + return Ok(GroundBoardOutcome::Collision { + tag_15_queued: queued, + }); + } + + let result = ordinary::update( + state, + components, + settings, + input, + contact.tag_16_force, + services, + )?; + Ok(GroundBoardOutcome::Ordinary(result)) +} + +fn animated( + state: &mut PhysicsGroundState, + components: GroundBoardComponents<'_, '_>, + services: &mut S, +) -> Result> { + service( + GroundBoardStage::SetAnimatedVelocity, + services.set_animated_velocity_82c04168(state.vector_2688), + )?; + service( + GroundBoardStage::WriteProcessedVelocity, + services.write_processed_velocity_400(state.vector_2688), + )?; + components + .inertias + .set_linear_drag(0.0, crate::riding::grounded::drag::DragSelection::AllParts) + .map_err(GroundBoardError::Drag)?; + let pose = service( + GroundBoardStage::BuildAnimatedPose, + services.build_animated_pose_82d33448(), + )?; + service( + GroundBoardStage::PublishAnimatedPose, + services.publish_animated_pose_82be33d0(&pose), + )?; + service( + GroundBoardStage::UpdateExternalPlayer, + services.update_external_player_82d67848(&pose, state.vector_2688), + )?; + service( + GroundBoardStage::CommitExternalPlayer, + services.commit_external_player_82d68800(), + )?; + service( + GroundBoardStage::FinalizeAnimatedBoard, + services.finalize_animated_board_sk83_na_f_01a4(), + )?; + state.flag_2720 = true; + Ok(GroundBoardOutcome::Animated) +} + +pub(super) fn service( + stage: GroundBoardStage, + result: Result, +) -> Result> { + result.map_err(|source| GroundBoardError::Service { stage, source }) +} diff --git a/crates/skate-core/src/riding/grounded/state/board_types.rs b/crates/skate-core/src/riding/grounded/state/board_types.rs new file mode 100644 index 00000000..b5891379 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/board_types.rs @@ -0,0 +1,246 @@ +//! Typed boundaries for `PhysicsGround::UpdateSkateboard` (`82D38800`). + +use crate::{ + math::Vector3, + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::{ + controller::{ManualAngleMeasurement, ManualInput}, + settings::{ManualMode, ManualSettings}, + state::ManualState, + }, + }, + riding::{ + anti_flip::{AntiFlipInput, AntiFlipSettings}, + braking::LinearDragSettings, + ground_force::GroundForceSettings, + grounded::{ + drag::{BodyInertias, GroundDragInput}, + propulsion::{GroundPropulsionInput, GroundPropulsionSettings}, + }, + heading::{HeadingInput, HeadingSettings}, + pumping::PumpForceInput, + slide_friction::{SlideFrictionInput, SlideFrictionSettings}, + speed_model::{SpeedModelInput, SpeedModelSettings, SpeedModelState}, + speed_wobble::{SpeedWobbleInput, SpeedWobbleSettings, SpeedWobbleState}, + steering::{SteeringInput, SteeringSettings, TruckSteeringState}, + straighten::{StraightenInput, StraightenSettings}, + }, +}; + +use super::corrections::{ + AntiFlipNudgeInput, AntiFlipNudgeMath, HalfpipeWheelCatchInput, HalfpipeWheelCatchServices, + HangUpInput, HangUpServices, PinningInput, PinningServices, +}; + +pub struct GroundBoardSettings<'a> { + pub steering: &'a SteeringSettings, + pub speed_wobble: &'a SpeedWobbleSettings, + pub propulsion: GroundPropulsionSettings, + pub ground_force: &'a GroundForceSettings, + pub speed_model: &'a SpeedModelSettings, + pub slide_friction: &'a SlideFrictionSettings, + pub straighten: &'a StraightenSettings, + pub heading: &'a HeadingSettings, + pub anti_flip: &'a AntiFlipSettings, + pub manual: &'a ManualSettings, + pub manual_mode: ManualMode, + pub linear_drag: LinearDragSettings, + /// Global selected collision layout +172. + pub collision_response_duration: f32, + /// Global selected collision layout +176. + pub collision_response_scale: f32, + /// Global selected ground layout +272. + pub ground_force_contact_time_limit: f32, + /// Live scalar `flt_82098D0C`. + pub speed_model_reset_force_squared: f32, +} + +/// Values used to construct both `82D931E8` calls in their native caller. +pub struct GroundForceFrame { + pub argument_1_2752: f32, + pub processed_2776: f32, + pub processed_2780: f32, + pub previous_state_scalar_56: f32, + pub ground_scalar_1216: f32, + pub ground_scalar_1232: f32, + pub ground_scalar_1236: f32, + pub ground_scalar_1240: f32, + pub balance_2720: f32, + pub surface_speed_2656: f32, + pub axis_384: [f32; 4], + pub velocity_400: [f32; 4], + pub axis_544: [f32; 4], +} + +/// Contiguous local record copied from board-body +8032 through +8084 before +/// the `82D93DF0` call. The names deliberately retain offsets where semantics +/// have not been independently recovered. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundContactFrame { + pub vector_8032: [f32; 4], + pub vector_8048: [f32; 4], + pub vector_8064: [f32; 4], + pub word_8080: u32, + pub flag_8084: bool, + pub scalar_2752: f32, + pub scalar_2756: f32, +} + +pub struct GroundBoardInput { + pub steering: SteeringInput, + pub speed_wobble: SpeedWobbleInput, + pub truck_flags_2468: u32, + pub truck_flags_2472: u32, + pub contact: GroundContactFrame, + /// Ground context +1216, copied verbatim for `82D944E8`. + pub ground_vector_1216: [f32; 4], + pub propulsion: GroundPropulsionInput, + pub ground_force: GroundForceFrame, + pub speed_model: SpeedModelInput, + pub slide_friction: SlideFrictionInput, + pub pump_force: PumpForceInput, + pub straighten: StraightenInput, + pub heading: HeadingInput, + pub anti_flip: AntiFlipInput, + pub manual: ManualInput, + pub ground_drag: GroundDragInput, + pub contact_time_2756: f32, + pub trajectory_state_1776_bits: u32, + pub anti_flip_nudge: AntiFlipNudgeInput, + pub hang_up: HangUpInput, + pub halfpipe_wheel_catch: HalfpipeWheelCatchInput, + pub pinning: PinningInput, +} + +pub struct GroundBoardComponents<'a, 'body> { + pub speed_wobble: &'a mut SpeedWobbleState, + pub truck_steering: &'a mut TruckSteeringState, + pub speed_model: &'a mut SpeedModelState, + pub manual: &'a mut ManualState, + /// Board+284, separate from the five-field manual controller state. + pub heading_previous: &'a mut f32, + pub force_queue: &'a mut BoardForceQueue, + pub inertias: &'a mut BodyInertias<'body>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundContactResponse { + pub active_2731: bool, + pub tag_16_force: QueuedPointForce, + pub vector_2688: [f32; 4], + pub scalar_2704: f32, + pub animated_board_2708: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CollisionForceResponse { + pub force_2528: [f32; 4], + pub point_2544: [f32; 4], + pub vector_2592: [f32; 4], +} + +impl CollisionForceResponse { + pub fn tagged_force(self) -> QueuedPointForce { + QueuedPointForce { + tag: 15, + force_world: xyz(self.force_2528), + point_body: xyz(self.point_2544), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundBoardStage { + CenterOfMassHeight, + SetWheelMaterials, + ContactResponse, + UpdateBodyAccumulator, + SetAnimatedVelocity, + WriteProcessedVelocity, + BuildAnimatedPose, + PublishAnimatedPose, + UpdateExternalPlayer, + CommitExternalPlayer, + FinalizeAnimatedBoard, + CollisionForce, + ApplyCollisionVector, + CollisionProjection, + ManualEffect, + PushForceSquared, + AntiFlipNudge, + ApplyCollisionDecay, + ApplyStraighten, + ApplyHeading, + ApplyAntiFlip, + ApplyManual, + HangUps, + HalfpipeWheelCatch, + Pinning, + StrongForceSquared, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GroundBoardError { + Service { stage: GroundBoardStage, source: E }, + Manual(crate::physics::manual::controller::ManualError), + Drag(crate::riding::grounded::drag::DragBindingError), +} + +/// Engine/numerical operations whose bodies do not yet exist in readable core +/// code. Every operation is mandatory on the branch where native code calls it. +pub trait GroundBoardServices: + ManualAngleMeasurement + + AntiFlipNudgeMath + + HangUpServices + + HalfpipeWheelCatchServices + + PinningServices +{ + type BoardError; + type AnimatedPose; + + fn center_of_mass_height_82d38838(&mut self) -> Result; + fn set_contact_wheel_materials(&mut self) -> Result<(), Self::BoardError>; + fn contact_response_82d93df0( + &mut self, + frame: GroundContactFrame, + previous_vector_2688: [f32; 4], + ) -> Result; + fn update_body_accumulator_82d389dc(&mut self) -> Result<(), Self::BoardError>; + fn set_animated_velocity_82c04168( + &mut self, + velocity: [f32; 4], + ) -> Result<(), Self::BoardError>; + fn write_processed_velocity_400(&mut self, velocity: [f32; 4]) -> Result<(), Self::BoardError>; + fn build_animated_pose_82d33448(&mut self) -> Result; + fn publish_animated_pose_82be33d0( + &mut self, + pose: &Self::AnimatedPose, + ) -> Result<(), Self::BoardError>; + fn update_external_player_82d67848( + &mut self, + pose: &Self::AnimatedPose, + velocity: [f32; 4], + ) -> Result<(), Self::BoardError>; + fn commit_external_player_82d68800(&mut self) -> Result<(), Self::BoardError>; + fn finalize_animated_board_sk83_na_f_01a4(&mut self) -> Result<(), Self::BoardError>; + fn collision_force_82d944e8( + &mut self, + ground_vector_1216: [f32; 4], + ) -> Result, Self::BoardError>; + /// Exact normalize/threshold/dot sequence at `82D38E18..82D38EA8`. + fn collision_force_dot_velocity_82d38e18( + &mut self, + force: [f32; 4], + velocity_400: [f32; 4], + ) -> Result; + fn apply_vector_82c07000(&mut self, vector: [f32; 4]) -> Result<(), Self::BoardError>; + fn apply_angular_displacement_82c075b8( + &mut self, + vector: [f32; 4], + ) -> Result<(), Self::BoardError>; +} + +fn xyz(value: [f32; 4]) -> Vector3 { + Vector3::new(value[0], value[1], value[2]) +} diff --git a/crates/skate-core/src/riding/grounded/state/contact_state.rs b/crates/skate-core/src/riding/grounded/state/contact_state.rs new file mode 100644 index 00000000..eefcbe57 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/contact_state.rs @@ -0,0 +1,210 @@ +//! Ground contact identity/update leaf (`82D38430`). + +use super::data::GroundContactHistory; + +pub type Vector4 = [f32; 4]; + +/// Values copied into the native contact publication record. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundContactStateInput { + pub flags_2476: u32, + pub flags_2480: u32, + pub velocity_400: Vector4, + pub axis_464: Vector4, + pub contact_position_560: Vector4, + pub trajectory_position_592: Vector4, + pub reckoning_vector_1200: Vector4, + /// Selected global collection +396 -> layout+4 -> +560. + pub differing_contact_frame_limit: i32, +} + +/// Required owner/collision operations around state-owned counter logic. +pub trait GroundContactServices { + type Error; + type Handle: Eq; + + /// `82D62F20`, called first on the inactive branch. + fn stop_contact_tracking_82d62f20(&mut self) -> Result<(), Self::Error>; + /// Exact owner+3024 reset at `82D38468..82D3848C`. + fn clear_inactive_contact_output_3024(&mut self) -> Result<(), Self::Error>; + /// Owner+1920, read only when the retained handle is empty. + fn initial_contact_handle_1920(&mut self) -> Result, Self::Error>; + /// `82D63C30` over the first three vectors of the publication record. + fn identify_contact_82d63c30( + &mut self, + frame: &GroundContactStateInput, + ) -> Result, Self::Error>; + /// `82D61268`, with all five vectors and the retained handle. + fn publish_contact_82d61268( + &mut self, + frame: &GroundContactStateInput, + retained: Option<&Self::Handle>, + ) -> Result<(), Self::Error>; +} + +/// Runs the full TU3 state/call order. Counter updates use wrapping integer +/// arithmetic because the native body uses ordinary `addi` instructions. +pub fn update( + history: &mut GroundContactHistory, + input: GroundContactStateInput, + services: &mut S, +) -> Result<(), S::Error> { + if input.flags_2476 & 0x0040_0000 == 0 { + services.stop_contact_tracking_82d62f20()?; + services.clear_inactive_contact_output_3024()?; + return Ok(()); + } + + if input.flags_2480 & 0x2000_0000 != 0 || input.flags_2480 & 0x1000_0000 == 0 { + history.tracked_contact_2760 = None; + history.differing_contact_frames_2756 = 0; + } + + if input.flags_2480 & 0x1000_0000 != 0 { + if input.axis_464[1] < f32::from_bits(0x3f73_3333) { + if history.tracked_contact_2760.is_none() { + history.tracked_contact_2760 = services.initial_contact_handle_1920()?; + } + let current = services.identify_contact_82d63c30(&input)?; + history.differing_contact_frames_2756 = if current == history.tracked_contact_2760 { + 0 + } else { + history.differing_contact_frames_2756.wrapping_add(1) + }; + if history.differing_contact_frames_2756 > input.differing_contact_frame_limit { + history.tracked_contact_2760 = None; + } + } else { + // Native label clears only the retained handle here. + history.tracked_contact_2760 = None; + } + } + + services.publish_contact_82d61268(&input, history.tracked_contact_2760.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default)] + struct Services { + events: Vec<&'static str>, + initial: Option, + identified: Option, + published: Option>, + } + + impl GroundContactServices for Services { + type Error = (); + type Handle = u32; + + fn stop_contact_tracking_82d62f20(&mut self) -> Result<(), Self::Error> { + self.events.push("stop"); + Ok(()) + } + + fn clear_inactive_contact_output_3024(&mut self) -> Result<(), Self::Error> { + self.events.push("clear"); + Ok(()) + } + + fn initial_contact_handle_1920(&mut self) -> Result, Self::Error> { + self.events.push("initial"); + Ok(self.initial) + } + + fn identify_contact_82d63c30( + &mut self, + _frame: &GroundContactStateInput, + ) -> Result, Self::Error> { + self.events.push("identify"); + Ok(self.identified) + } + + fn publish_contact_82d61268( + &mut self, + _frame: &GroundContactStateInput, + retained: Option<&Self::Handle>, + ) -> Result<(), Self::Error> { + self.events.push("publish"); + self.published = Some(retained.copied()); + Ok(()) + } + } + + fn input() -> GroundContactStateInput { + GroundContactStateInput { + flags_2476: 0x0040_0000, + flags_2480: 0x1000_0000, + velocity_400: [1.0; 4], + axis_464: [0.0, 0.5, 0.0, 0.0], + contact_position_560: [2.0; 4], + trajectory_position_592: [3.0; 4], + reckoning_vector_1200: [4.0; 4], + differing_contact_frame_limit: 5, + } + } + + #[test] + fn inactive_branch_preserves_history_and_runs_both_owner_calls() { + let mut history = GroundContactHistory { + differing_contact_frames_2756: 7, + tracked_contact_2760: Some(9), + }; + let mut frame = input(); + frame.flags_2476 = 0; + let mut services = Services::default(); + + update(&mut history, frame, &mut services).unwrap(); + + assert_eq!(services.events, ["stop", "clear"]); + assert_eq!(history.differing_contact_frames_2756, 7); + assert_eq!(history.tracked_contact_2760, Some(9)); + } + + #[test] + fn candidate_latches_initial_handle_then_counts_identity_changes() { + let mut history = GroundContactHistory::empty(); + let mut services = Services { + initial: Some(10), + identified: Some(11), + ..Default::default() + }; + + update(&mut history, input(), &mut services).unwrap(); + + assert_eq!(services.events, ["initial", "identify", "publish"]); + assert_eq!(history.differing_contact_frames_2756, 1); + assert_eq!(history.tracked_contact_2760, Some(10)); + assert_eq!(services.published, Some(Some(10))); + } + + #[test] + fn limit_and_axis_rejection_clear_only_the_native_fields() { + let mut history = GroundContactHistory { + differing_contact_frames_2756: 5, + tracked_contact_2760: Some(10), + }; + let mut frame = input(); + frame.differing_contact_frame_limit = 5; + let mut services = Services { + identified: Some(11), + ..Default::default() + }; + update(&mut history, frame, &mut services).unwrap(); + assert_eq!(history.differing_contact_frames_2756, 6); + assert_eq!(history.tracked_contact_2760, None); + assert_eq!(services.published, Some(None)); + + history.differing_contact_frames_2756 = 13; + history.tracked_contact_2760 = Some(12); + frame.axis_464[1] = 0.95; + services.events.clear(); + services.published = None; + update(&mut history, frame, &mut services).unwrap(); + assert_eq!(history.differing_contact_frames_2756, 13); + assert_eq!(history.tracked_contact_2760, None); + assert_eq!(services.events, ["publish"]); + } +} diff --git a/crates/skate-core/src/riding/grounded/state/corrections.rs b/crates/skate-core/src/riding/grounded/state/corrections.rs new file mode 100644 index 00000000..a7a3b188 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/corrections.rs @@ -0,0 +1,392 @@ +//! Ground correction paths called by `82D38800`. + +use crate::{ + math::Vector3, + physics::force_queue::{BoardForceQueue, QueuedPointForce}, +}; + +use super::data::PhysicsGroundState; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AntiFlipNudgeInput { + pub deck_speed_2652: f32, + pub deck_axis_96: [f32; 4], +} + +pub trait AntiFlipNudgeMath { + type Error; + + /// Exact `vmsum3fp128` operation. Generic host dot arithmetic is not a + /// substitute for the recovered Xenon operation. + fn dot3(&mut self, left: [f32; 4], right: [f32; 4]) -> Result; + /// Exact `vrsqrtefp128` plus two refinements and final scale at + /// `82D39498..82D394EC`. + fn scale_to_magnitude( + &mut self, + vector: [f32; 4], + squared_length: f32, + magnitude: f32, + ) -> Result<[f32; 4], Self::Error>; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AntiFlipNudgeResult { + pub attempted: bool, + pub queued: bool, +} + +/// Complete state/branch/queue contract of `UpdateAntiFlipNudge` `82D39338`. +pub fn update_anti_flip_nudge( + state: &mut PhysicsGroundState, + input: AntiFlipNudgeInput, + queue: &mut BoardForceQueue, + math: &mut M, +) -> Result { + let anti_flip_squared = math.dot3(state.anti_flip_torque_2624, state.anti_flip_torque_2624)?; + state.anti_flip_nudge_frames_2752 = if anti_flip_squared > 0.0 { + state.anti_flip_nudge_frames_2752.wrapping_add(1) + } else { + // `ble` after `fcmpu` also takes the unordered case. + 0 + }; + if state.anti_flip_nudge_frames_2752 <= 12 + || !(input.deck_speed_2652 < f32::from_bits(0x3f8c_cccd)) + { + return Ok(AntiFlipNudgeResult { + attempted: false, + queued: false, + }); + } + + let mut direction = input.deck_axis_96; + if direction[1] > 0.0 { + direction = direction.map(flip_sign_bit); + } + direction[1] = 0.0; + let squared = math.dot3(direction, direction)?; + if !(squared > f32::from_bits(0x3a83_126f)) { + return Ok(AntiFlipNudgeResult { + attempted: false, + queued: false, + }); + } + let force = math.scale_to_magnitude(direction, squared, 50.0)?; + let queued = queue.append(QueuedPointForce { + tag: 17, + force_world: xyz(force), + point_body: Vector3::ZERO, + }); + // The native byte is set after the append call regardless of capacity. + state.anti_flip_nudge_applied_2723 = true; + Ok(AntiFlipNudgeResult { + attempted: true, + queued, + }) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HangUpInput { + pub flags_1516: u32, + pub deck_speed_2652: f32, + pub scalar_84: f32, + /// Exact `vmsum3fp128` comparison at `82D39744..82D39764`. + pub geometry_axis_dot_positive: bool, +} + +pub trait HangUpServices { + type Error; + + /// `82D37048`, `82D370F8` and the direction/up-vector construction at + /// `82D395AC..82D396AC`, including its signed side test. + fn build_hang_force(&mut self) -> Result<[f32; 4], Self::Error>; + /// Direct `ApplyWorldSpaceForceToDeck` call at `82D396DC`. + fn apply_hang_force(&mut self, force: [f32; 4]) -> Result<(), Self::Error>; + /// Exact `82C20530/5D8/728/C08/6C0` geometry-object lifetime and result. + fn detect_hung_up_geometry(&mut self) -> Result; + fn request_wipeout(&mut self) -> Result<(), Self::Error>; +} + +/// Complete counter and effect order of `ManageHangUps` `82D39510`. +pub fn manage_hang_ups( + state: &mut PhysicsGroundState, + input: HangUpInput, + services: &mut S, +) -> Result<(), S::Error> { + let investigating = input.flags_1516 & 0x0800_0000 != 0; + if investigating { + if input.deck_speed_2652 < f32::from_bits(0x3f8c_cccd) + && input.scalar_84 < f32::from_bits(0x3f66_6666) + && input.scalar_84 > 0.0 + { + state.hang_detection_frames_2740 = state.hang_detection_frames_2740.wrapping_add(1); + } + } else { + state.hang_detection_frames_2740 = 0; + } + + if state.hang_detection_frames_2740 == 20 { + state.hang_force_frames_2744 = 10; + state.vector_2608 = services.build_hang_force()?; + } + if state.hang_force_frames_2744 > 0 { + if investigating { + services.apply_hang_force(state.vector_2608)?; + } + state.hang_force_frames_2744 = state.hang_force_frames_2744.wrapping_sub(1); + if state.hang_force_frames_2744 == 0 { + state.hang_detection_frames_2740 = 0; + } + } + + let geometry_candidate = input.deck_speed_2652 < f32::from_bits(0x3f8c_cccd) + && input.flags_1516 & 0x0c00_0000 == 0x0c00_0000 + && input.geometry_axis_dot_positive; + let hung = if geometry_candidate { + services.detect_hung_up_geometry()? + } else { + false + }; + state.hung_wipeout_frames_2748 = if hung { + state.hung_wipeout_frames_2748.wrapping_add(1) + } else { + 0 + }; + if state.hung_wipeout_frames_2748 > 20 { + services.request_wipeout()?; + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HalfpipeWheelCatchInput { + pub flags_1516: u32, + pub deck_speed_2652: f32, + pub deck_x_axis_y: f32, + pub deck_y_axis_y: f32, + pub signed_deck_distance: f32, +} + +pub trait HalfpipeWheelCatchServices { + type Error; + + /// Exact signed axis/cross-product construction at `82D39950..82D399E8`. + fn angular_displacement(&mut self) -> Result<[f32; 4], Self::Error>; + fn apply_angular_displacement(&mut self, value: [f32; 4]) -> Result<(), Self::Error>; +} + +/// Gating and effect order of `ManageHalfpipeWheelCatches` `82D39868`. +pub fn manage_halfpipe_wheel_catches( + input: HalfpipeWheelCatchInput, + services: &mut S, +) -> Result { + let candidate = input.flags_1516 & 0x0800_0000 != 0 + && input.deck_speed_2652 < 3.0 + && input.deck_x_axis_y.abs() < f32::from_bits(0x3f11_eb85) + && input.deck_y_axis_y.abs() < f32::from_bits(0x3df5_c28f) + && input.signed_deck_distance < f32::from_bits(0x3de1_47ae) + && input.signed_deck_distance > 0.0; + if !candidate { + return Ok(false); + } + let displacement = services.angular_displacement()?; + services.apply_angular_displacement(displacement)?; + Ok(true) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PinningInput { + pub flags_2488: u32, + pub frames_since_teleport_2584: i32, + pub flags_2472: u32, +} + +pub trait PinningServices { + type Error; + + /// Exact reciprocal-refinement velocity and `82C04168` application at + /// `82D39B04..82D39B8C` using captured X/Z, live Y, position and timestep. + fn pin_to_captured_position( + &mut self, + captured_x: f32, + captured_z: f32, + ) -> Result<(), Self::Error>; +} + +/// Complete state gating of `ConsiderPinning` `82D39A18`. +pub fn consider_pinning( + state: &mut PhysicsGroundState, + input: PinningInput, + services: &mut S, +) -> Result<(), S::Error> { + if input.flags_2488 & 1 != 0 { + state.pinning_2727 = false; + state.was_pinning_2728 = false; + return Ok(()); + } + if state.human_player_2724 && input.frames_since_teleport_2584 < 90 { + let eligible = state.was_pinning_2728 || input.frames_since_teleport_2584 < 30; + let controls_seen = + input.frames_since_teleport_2584 > 0 && input.flags_2472 & 0x0080_0000 == 0; + state.controls_latched_2725 |= controls_seen; + if eligible && state.captured_position_valid_2726 && !state.controls_latched_2725 { + services.pin_to_captured_position( + state.captured_position_x_2656, + state.captured_position_z_2660, + )?; + state.pinning_2727 = true; + } + } + state.was_pinning_2728 = state.pinning_2727; + Ok(()) +} + +fn flip_sign_bit(value: f32) -> f32 { + f32::from_bits(value.to_bits() ^ 0x8000_0000) +} + +fn xyz(value: [f32; 4]) -> Vector3 { + Vector3::new(value[0], value[1], value[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physics::force_queue::FORCE_CAPACITY; + + struct NudgeMath { + dots: [f32; 2], + dot_count: usize, + scale_count: usize, + } + + impl AntiFlipNudgeMath for NudgeMath { + type Error = (); + + fn dot3(&mut self, _left: [f32; 4], _right: [f32; 4]) -> Result { + let value = self.dots[self.dot_count]; + self.dot_count += 1; + Ok(value) + } + + fn scale_to_magnitude( + &mut self, + vector: [f32; 4], + _squared_length: f32, + magnitude: f32, + ) -> Result<[f32; 4], Self::Error> { + self.scale_count += 1; + Ok(vector.map(|lane| lane * magnitude)) + } + } + + fn state() -> PhysicsGroundState { + PhysicsGroundState { + collision_force_2528: [0.0; 4], + collision_point_2544: [0.0; 4], + word_2560: 0, + word_2564: 0, + vector_2592: [0.0; 4], + vector_2608: [0.0; 4], + anti_flip_torque_2624: [1.0, 0.0, 0.0, 0.0], + steering_push_scalar_2640: 1.0, + steering_damped_turn_2644: 0.0, + elapsed_2648: 0.0, + collision_countdown_2652: 0.0, + captured_position_x_2656: 0.0, + captured_position_z_2660: 0.0, + scalar_2664: 0.0, + scalar_2668: 0.0, + straighten_scale_2672: 1.0, + vector_2688: [0.0; 4], + scalar_2704: 0.0, + flag_2708: false, + flag_2720: false, + flag_2721: false, + flag_2722: false, + anti_flip_nudge_applied_2723: false, + human_player_2724: false, + controls_latched_2725: false, + captured_position_valid_2726: false, + pinning_2727: false, + was_pinning_2728: false, + flag_2729: false, + push_suppressed_2730: false, + flag_2731: false, + manual_correction_2732: false, + manual_opposition_2733: false, + hang_detection_frames_2740: 0, + hang_force_frames_2744: 0, + hung_wipeout_frames_2748: 0, + anti_flip_nudge_frames_2752: 13, + } + } + + #[test] + fn nudge_unordered_comparisons_take_the_native_rejection_paths() { + let mut state = state(); + let mut queue = BoardForceQueue::default(); + let mut math = NudgeMath { + dots: [f32::NAN, 1.0], + dot_count: 0, + scale_count: 0, + }; + let result = update_anti_flip_nudge( + &mut state, + AntiFlipNudgeInput { + deck_speed_2652: 0.0, + deck_axis_96: [1.0, 0.0, 0.0, 0.0], + }, + &mut queue, + &mut math, + ) + .unwrap(); + assert_eq!(state.anti_flip_nudge_frames_2752, 0); + assert!(!result.attempted); + + state.anti_flip_nudge_frames_2752 = 13; + math.dots = [1.0, f32::NAN]; + math.dot_count = 0; + let result = update_anti_flip_nudge( + &mut state, + AntiFlipNudgeInput { + deck_speed_2652: 0.0, + deck_axis_96: [1.0, 0.0, 0.0, 0.0], + }, + &mut queue, + &mut math, + ) + .unwrap(); + assert!(!result.attempted); + assert_eq!(math.scale_count, 0); + } + + #[test] + fn nudge_latches_presence_even_when_the_native_queue_is_full() { + let mut state = state(); + let mut queue = BoardForceQueue::default(); + for _ in 0..FORCE_CAPACITY { + assert!(queue.append(QueuedPointForce::default())); + } + let mut math = NudgeMath { + dots: [1.0, 1.0], + dot_count: 0, + scale_count: 0, + }; + + let result = update_anti_flip_nudge( + &mut state, + AntiFlipNudgeInput { + deck_speed_2652: 0.0, + deck_axis_96: [1.0, 0.0, 0.0, 0.0], + }, + &mut queue, + &mut math, + ) + .unwrap(); + + assert!(result.attempted); + assert!(!result.queued); + assert!(state.anti_flip_nudge_applied_2723); + assert_eq!(queue.entries().len(), FORCE_CAPACITY); + } +} diff --git a/crates/skate-core/src/riding/grounded/state/data.rs b/crates/skate-core/src/riding/grounded/state/data.rs new file mode 100644 index 00000000..ba10e8fa --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/data.rs @@ -0,0 +1,207 @@ +//! Persistent fields owned by TU3 `PhysState_PhysicsGround`. +//! +//! Offsets remain in names when the original semantic name has not been +//! recovered. This prevents an adapter from silently assigning an invented +//! meaning to a stock field. + +use super::contact_state::GroundContactStateInput; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsGroundState { + /// Collision-force record retained by the successful `82D944E8` branch. + pub collision_force_2528: [f32; 4], + pub collision_point_2544: [f32; 4], + pub word_2560: u32, + pub word_2564: u32, + /// Collision response retained and decayed by `82D38800`. + pub vector_2592: [f32; 4], + /// Direct hang-up force assembled by `82D39510`. + pub vector_2608: [f32; 4], + /// Output of `Toolkit_AntiFlipTorque` (`82D94190`). + pub anti_flip_torque_2624: [f32; 4], + /// Optional steering history arguments passed to `82D92440`. + pub steering_push_scalar_2640: f32, + pub steering_damped_turn_2644: f32, + /// Accumulated by ProcessedPhysIn+2604 once per Ground Update. + pub elapsed_2648: f32, + /// Collision-response countdown set from the selected global settings. + pub collision_countdown_2652: f32, + /// First contacted position captured once for spawn pinning. + pub captured_position_x_2656: f32, + pub captured_position_z_2660: f32, + /// Time remaining before the skater may enter the skitch path. + pub scalar_2664: f32, + /// Height of the selected skitch spline. + pub scalar_2668: f32, + /// Multiplies the straighten-out torque before heading calculation. + pub straighten_scale_2672: f32, + /// Animated-board velocity/displacement state used by Enter/Exit and Update. + pub vector_2688: [f32; 4], + /// Explicitly zeroed by `82D93DF0` through its Ground+2688 output record. + pub scalar_2704: f32, + /// Selects the animated-board branch of `82D38800`. + pub flag_2708: bool, + pub flag_2720: bool, + pub flag_2721: bool, + pub flag_2722: bool, + pub anti_flip_nudge_applied_2723: bool, + /// Pinning is restricted to the human-player path. + pub human_player_2724: bool, + pub controls_latched_2725: bool, + pub captured_position_valid_2726: bool, + pub pinning_2727: bool, + pub was_pinning_2728: bool, + pub flag_2729: bool, + pub push_suppressed_2730: bool, + pub flag_2731: bool, + pub manual_correction_2732: bool, + pub manual_opposition_2733: bool, + pub hang_detection_frames_2740: i32, + pub hang_force_frames_2744: i32, + pub hung_wipeout_frames_2748: i32, + pub anti_flip_nudge_frames_2752: i32, +} + +impl PhysicsGroundState { + /// Ground constructor82D37358..37534. Fields whose first native write is + /// Enter are safe host zero storage until Enter replaces them; this method + /// does not imply that entering Ground has already occurred. + pub fn before_first_enter(human_player: bool) -> Self { + Self { + collision_force_2528: [0.0; 4], + collision_point_2544: [0.0; 4], + word_2560: 0, + word_2564: 0, + vector_2592: [0.0; 4], + vector_2608: [0.0; 4], + anti_flip_torque_2624: [0.0; 4], + steering_push_scalar_2640: 0.0, + steering_damped_turn_2644: 0.0, + elapsed_2648: 0.0, + collision_countdown_2652: 0.0, + captured_position_x_2656: 0.0, + captured_position_z_2660: 0.0, + scalar_2664: 0.0, + scalar_2668: 0.0, + straighten_scale_2672: 0.0, + vector_2688: [0.0; 4], + scalar_2704: 0.0, + flag_2708: false, + flag_2720: false, + flag_2721: false, + flag_2722: false, + anti_flip_nudge_applied_2723: false, + human_player_2724: human_player, + controls_latched_2725: false, + captured_position_valid_2726: false, + pinning_2727: false, + was_pinning_2728: false, + flag_2729: false, + push_suppressed_2730: false, + flag_2731: false, + manual_correction_2732: false, + manual_opposition_2733: false, + hang_detection_frames_2740: 0, + hang_force_frames_2744: 0, + hung_wipeout_frames_2748: 0, + anti_flip_nudge_frames_2752: 0, + } + } + + /// State-owned prefix82D3758C..5C0 after the physical collision mode change. + pub fn begin_entry(&mut self) { + self.word_2560 = 0; + self.word_2564 = 0; + self.flag_2708 = false; + self.controls_latched_2725 = false; + self.captured_position_valid_2726 = false; + self.was_pinning_2728 = false; + self.flag_2731 = false; + self.manual_correction_2732 = false; + self.manual_opposition_2733 = false; + } + /// State-owned tail82D378AC..7914, after pumping and controller-mode reset. + pub fn finish_entry(&mut self, previous_state: u32) { + self.straighten_scale_2672 = if previous_state == 101 { + f32::from_bits(0x3e23_d70a) + } else { + 1.0 + }; + self.steering_push_scalar_2640 = 1.0; + self.steering_damped_turn_2644 = 0.0; + self.elapsed_2648 = 0.0; + self.collision_countdown_2652 = 0.0; + self.vector_2592 = [0.0; 4]; + self.vector_2608 = [0.0; 4]; + self.anti_flip_torque_2624 = [0.0; 4]; + self.hang_detection_frames_2740 = 0; + self.hang_force_frames_2744 = 0; + self.hung_wipeout_frames_2748 = 0; + self.anti_flip_nudge_frames_2752 = 0; + self.flag_2721 = false; + self.anti_flip_nudge_applied_2723 = false; + self.push_suppressed_2730 = false; + } + + /// Prefix of `Update` `82D37CB4..82D37D28`, before contact-state work. + pub fn begin_update(&mut self, input: GroundUpdateInput) -> GroundUpdateSignals { + self.begin_update_at(input.wheel_contact_count_2556, input.position_112) + } + + pub fn begin_update_at( + &mut self, + wheel_contacts: i32, + position: [f32; 4], + ) -> GroundUpdateSignals { + if !self.captured_position_valid_2726 && wheel_contacts > 0 { + self.captured_position_x_2656 = position[0]; + self.captured_position_z_2660 = position[2]; + self.captured_position_valid_2726 = true; + } + self.anti_flip_torque_2624 = [0.0; 4]; + self.flag_2722 = false; + self.anti_flip_nudge_applied_2723 = false; + self.pinning_2727 = false; + self.scalar_2664 = -1.0; + self.flag_2729 = false; + GroundUpdateSignals { + set_skeleton_flag_16505: self.elapsed_2648 > f32::from_bits(0x3d4c_cccd), + } + } + + /// Tail of `Update` `82D37EBC..82D37EEC` after prediction succeeds. + pub fn finish_update(&mut self, timestep_2604: f32) { + self.elapsed_2648 += timestep_2604; + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundUpdateInput { + pub wheel_contact_count_2556: i32, + pub position_112: [f32; 4], + pub timestep_2604: f32, + pub contact_state: GroundContactStateInput, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundUpdateSignals { + pub set_skeleton_flag_16505: bool, +} + +/// Contact identity history updated by `82D38430`. The concrete collision +/// object handle is supplied by the world adapter rather than stored as a host +/// pointer in the engine-independent core. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroundContactHistory { + pub differing_contact_frames_2756: i32, + pub tracked_contact_2760: Option, +} + +impl GroundContactHistory { + pub const fn empty() -> Self { + Self { + differing_contact_frames_2756: 0, + tracked_contact_2760: None, + } + } +} diff --git a/crates/skate-core/src/riding/grounded/state/lifecycle.rs b/crates/skate-core/src/riding/grounded/state/lifecycle.rs new file mode 100644 index 00000000..f1b50251 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/lifecycle.rs @@ -0,0 +1,243 @@ +//! Ground Enter `82D37538` and Exit `82D37B20` lifecycle order. + +use crate::{ + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::state::ManualState, + }, + riding::{ + grounded::{ + drag::{BodyInertias, DragBindingError, DragSelection}, + manual_entry::{ + ManualGroundBodies, ManualGroundInput, ManualGroundProjection, enter_ground, + }, + }, + pumping::state::PumpingState, + speed_wobble::SpeedWobbleState, + }, +}; + +use super::data::PhysicsGroundState; + +#[derive(Clone, Copy, Debug)] +pub struct GroundEnterInput { + pub previous_category_2516: u32, + pub previous_state_2504: u32, + pub powerslide_exit_scale: f32, + pub manual_ground: ManualGroundInput, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundEnterStage { + SetBoardStandard, + InitializeSkeleton, + SetCollisionState, + ResetExternalObjects, + InitializeBoardSpeed, + EnterManual, + EnterFromAir, + BuildLandingOnDeckForce, + SetControllerMode, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GroundEnterError { + Service { stage: GroundEnterStage, source: S }, + ManualProjection { stage: GroundEnterStage, source: P }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GroundEnterResult { + pub landing_force_attempted: bool, + pub landing_force_queued: bool, +} + +/// Required object operations surrounding the state-owned resets. None have a +/// default because their complete side effects are part of Ground entry. +pub trait GroundEnterServices { + type Error; + + fn set_board_standard_82c03af8(&mut self) -> Result<(), Self::Error>; + fn initialize_ground_skeleton(&mut self) -> Result<(), Self::Error>; + fn set_collision_state_82d911a8(&mut self, state: u32) -> Result<(), Self::Error>; + fn reset_ground_external_objects(&mut self) -> Result<(), Self::Error>; + /// Full `82D375D0..82D376CC` board transform/velocity projection and target + /// speed initialization, preserving native VMX arithmetic. + fn initialize_board_speed_82d375d0(&mut self) -> Result<(), Self::Error>; + fn enter_from_air_82bf2f18(&mut self) -> Result<(), Self::Error>; + /// Full tag-19 producer `82D377AC..82D378A8`. + fn landing_on_deck_force(&mut self) -> Result; + fn set_controller_mode_one(&mut self) -> Result<(), Self::Error>; +} + +pub struct GroundEntryComponents<'a> { + pub pumping: &'a mut PumpingState, + pub speed_wobble: &'a mut SpeedWobbleState, + pub manual: &'a mut ManualState, + pub force_queue: &'a mut BoardForceQueue, +} + +/// Runs every verified Ground entry operation in native order. The service +/// boundaries are unresolved engine/numerical functions, not optional hooks. +pub fn enter( + state: &mut PhysicsGroundState, + components: GroundEntryComponents<'_>, + input: GroundEnterInput, + bodies: &mut B, + projection: &mut P, + services: &mut S, +) -> Result> +where + S: GroundEnterServices, + B: ManualGroundBodies, + P: ManualGroundProjection, +{ + enter_call( + GroundEnterStage::SetBoardStandard, + services.set_board_standard_82c03af8(), + )?; + enter_call( + GroundEnterStage::InitializeSkeleton, + services.initialize_ground_skeleton(), + )?; + enter_call( + GroundEnterStage::SetCollisionState, + services.set_collision_state_82d911a8(6), + )?; + + state.word_2560 = 0; + state.word_2564 = 0; + state.flag_2708 = false; + state.controls_latched_2725 = false; + state.captured_position_valid_2726 = false; + state.was_pinning_2728 = false; + state.flag_2731 = false; + state.manual_correction_2732 = false; + state.manual_opposition_2733 = false; + + enter_call( + GroundEnterStage::ResetExternalObjects, + services.reset_ground_external_objects(), + )?; + enter_call( + GroundEnterStage::InitializeBoardSpeed, + services.initialize_board_speed_82d375d0(), + )?; + components.speed_wobble.reset(); + enter_ground( + components.manual, + input.previous_category_2516, + input.powerslide_exit_scale, + input.manual_ground, + bodies, + projection, + ) + .map_err(|source| GroundEnterError::ManualProjection { + stage: GroundEnterStage::EnterManual, + source, + })?; + + if input.previous_category_2516 == 200 { + enter_call( + GroundEnterStage::EnterFromAir, + services.enter_from_air_82bf2f18(), + )?; + } + let mut result = GroundEnterResult { + landing_force_attempted: false, + landing_force_queued: false, + }; + if input.previous_state_2504 == 503 { + let mut force = enter_call( + GroundEnterStage::BuildLandingOnDeckForce, + services.landing_on_deck_force(), + )?; + force.tag = 19; + result.landing_force_attempted = true; + result.landing_force_queued = components.force_queue.append(force); + } + + state.straighten_scale_2672 = if input.previous_state_2504 == 101 { + f32::from_bits(0x3e23_d70a) + } else { + 1.0 + }; + components.pumping.reset(); + enter_call( + GroundEnterStage::SetControllerMode, + services.set_controller_mode_one(), + )?; + + state.steering_push_scalar_2640 = 1.0; + state.steering_damped_turn_2644 = 0.0; + state.elapsed_2648 = 0.0; + state.collision_countdown_2652 = 0.0; + state.vector_2592 = [0.0; 4]; + state.vector_2608 = [0.0; 4]; + state.anti_flip_torque_2624 = [0.0; 4]; + state.hang_detection_frames_2740 = 0; + state.hang_force_frames_2744 = 0; + state.hung_wipeout_frames_2748 = 0; + state.anti_flip_nudge_frames_2752 = 0; + state.flag_2721 = false; + state.anti_flip_nudge_applied_2723 = false; + state.push_suppressed_2730 = false; + Ok(result) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundExitStage { + SetWheelMaterials, + RestoreAnimatedBoardVelocity, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GroundExitError { + Drag(DragBindingError), + Service { stage: GroundExitStage, source: E }, +} + +pub trait GroundExitServices { + type Error; + + fn set_default_wheel_materials(&mut self) -> Result<(), Self::Error>; + /// Full `82D37BAC..82D37C30` jump-velocity clamp and board velocity write. + fn restore_animated_board_velocity(&mut self, vector_2688: [f32; 4]) + -> Result<(), Self::Error>; +} + +pub fn exit( + state: &mut PhysicsGroundState, + pumping: &mut PumpingState, + inertias: &mut BodyInertias<'_>, + services: &mut S, +) -> Result<(), GroundExitError> { + state.steering_damped_turn_2644 = 0.0; + state.steering_push_scalar_2640 = 1.0; + pumping.reset(); + inertias + .set_linear_drag(0.0, DragSelection::AllParts) + .map_err(GroundExitError::Drag)?; + services + .set_default_wheel_materials() + .map_err(|source| GroundExitError::Service { + stage: GroundExitStage::SetWheelMaterials, + source, + })?; + if state.flag_2708 { + services + .restore_animated_board_velocity(state.vector_2688) + .map_err(|source| GroundExitError::Service { + stage: GroundExitStage::RestoreAnimatedBoardVelocity, + source, + })?; + } + Ok(()) +} + +fn enter_call( + stage: GroundEnterStage, + result: Result, +) -> Result> { + result.map_err(|source| GroundEnterError::Service { stage, source }) +} diff --git a/crates/skate-core/src/riding/grounded/state/mod.rs b/crates/skate-core/src/riding/grounded/state/mod.rs new file mode 100644 index 00000000..44a844a6 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/mod.rs @@ -0,0 +1,19 @@ +//! Readable composition of the TU3 `PhysState_PhysicsGround` lifecycle. +//! +//! This module owns native state order. Collision queries, skeleton writes and +//! other engine objects remain required interfaces; no branch has a no-op +//! implementation. + +pub mod board; +pub mod board_types; +pub mod contact_state; +pub mod corrections; +pub mod data; +pub mod lifecycle; +pub mod motion; +mod ordinary; +pub mod output; +pub mod post_physics; +pub mod update; + +pub use data::{GroundContactHistory, GroundUpdateInput, PhysicsGroundState}; diff --git a/crates/skate-core/src/riding/grounded/state/motion.rs b/crates/skate-core/src/riding/grounded/state/motion.rs new file mode 100644 index 00000000..6243f5dc --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/motion.rs @@ -0,0 +1,89 @@ +//! Physical Ground entry82D37604..82D378A8 and future-deck82D38630 math. +//! These operate on actual board rates/force records. Skeleton publication is +//! separate because prediction moves its animation target, not the board. +use crate::{ + math::Vector3, + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + native_arithmetic::reciprocal_estimate, + }, + riding::ground_correction_math::dot_product, +}; + +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn words(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn reciprocal(value: f32) -> f32 { + let mut r = reciprocal_estimate(value); + for _ in 0..2 { + r = r.mul_add((-value).mul_add(r, 1.0), r); + } + r +} +/// Projection is applied directly to deck angular velocity, before speed seed. +pub fn entry_angular_velocity(normal: [f32; 4], angular: [f32; 4]) -> Vector3 { + let speed = dot_product(normal, angular); + xyz(normal.map(|v| v * speed)) +} +/// Board+260 is the smaller absolute longitudinal speed before/after normal +/// rejection. The source fsel retains the raw value on an unordered compare. +pub fn entry_target_speed(velocity: Vector3, normal: [f32; 4], forward: [f32; 4]) -> f32 { + let v = words(velocity); + let along_normal = dot_product(v, normal); + let tangent = core::array::from_fn(|i| v[i] - normal[i] * along_normal); + let raw = dot_product(v, forward).abs(); + let planar = dot_product(tangent, forward).abs(); + if raw - planar >= 0.0 { planar } else { raw } +} +/// Exact tag19 producer. Gravity and normal velocity are not added here. +pub fn landing_on_deck_force( + physical_velocity: [f32; 4], + animation_velocity: [f32; 4], + axis: [f32; 4], + mass: f32, + strength: f32, + point_y: f32, +) -> QueuedPointForce { + let delta = core::array::from_fn(|i| animation_velocity[i] - physical_velocity[i]); + let projected = dot_product(delta, axis); + let step_inverse = reciprocal(f32::from_bits(0x3c88_8889)); + let force = core::array::from_fn(|i| { + ((delta[i] - axis[i] * projected) * strength) * mass * step_inverse + }); + QueuedPointForce { + tag: 19, + force_world: xyz(force), + point_body: Vector3::new(0.0, point_y, 0.0), + } +} +/// Complete displacement calculation in PredictFutureDeck. Force summation +/// preserves queue order; normal rejection occurs after the first dt/mass. +/// Returns None when the source deliberately preserves both skeleton targets. +pub fn future_deck_displacement( + forces: &BoardForceQueue, + mass: f32, + dt: f32, + normal: [f32; 4], + pushing: bool, + manual_correction: bool, +) -> Option { + if pushing || manual_correction { + return None; + } + let mut sum = [0.0; 4]; + for force in forces.entries() { + let v = words(force.force_world); + for i in 0..4 { + sum[i] += v[i]; + } + } + let scale = reciprocal(mass) * dt; + let delta = sum.map(|v| v * scale); + let projection = dot_product(delta, normal); + Some(xyz(core::array::from_fn(|i| { + (delta[i] - normal[i] * projection) * dt + }))) +} diff --git a/crates/skate-core/src/riding/grounded/state/ordinary.rs b/crates/skate-core/src/riding/grounded/state/ordinary.rs new file mode 100644 index 00000000..d20e2f92 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/ordinary.rs @@ -0,0 +1,293 @@ +//! Ordinary (non-animated, non-collision-response) `82D38800` path. + +use crate::{ + math::Vector3, + physics::{force_queue::QueuedPointForce, manual::controller}, + riding::{ + anti_flip, ground_force, + grounded::propulsion::{self, PropulsionSubmission}, + heading, pumping, slide_friction, speed_model, straighten, + }, +}; + +use super::{ + board::service, + board_types::{ + GroundBoardComponents, GroundBoardError, GroundBoardInput, GroundBoardServices, + GroundBoardSettings, GroundBoardStage, GroundForceFrame, + }, + corrections, + data::PhysicsGroundState, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OrdinaryGroundResult { + pub manual_correction: bool, + pub terminal_force_tag: u32, + pub terminal_force_queued: bool, + pub speed_model_reset: bool, +} + +pub(super) fn update( + state: &mut PhysicsGroundState, + components: GroundBoardComponents<'_, '_>, + settings: GroundBoardSettings<'_>, + mut input: GroundBoardInput, + mut contact_force: QueuedPointForce, + services: &mut S, +) -> Result> { + let mut suppressed = 0_u8; + let propulsion = propulsion::calculate(input.propulsion, settings.propulsion, &mut suppressed); + state.push_suppressed_2730 = suppressed != 0; + + let (ground_a, ground_b) = ground_forces(settings.ground_force, &input.ground_force); + input.speed_model.manual_state_276 = components.manual.angular_correction; + let speed_force = speed_model::update( + components.speed_model, + settings.speed_model, + &input.speed_model, + ); + input.slide_friction.heading_time = state.elapsed_2648; + // Both helpers read wheel hardness from ProcessedPhysIn+2764 + // (82D92AAC / 82D92B80), supplied by the animation profile. + let slide_force = slide_friction::calculate(settings.slide_friction, &input.slide_friction); + let pump_force = pumping::calculate(&input.pump_force); + input.straighten.heading_time = state.elapsed_2648; + let straighten = straighten::calculate(settings.straighten, &input.straighten); + let scaled_straighten = straighten.map(|lane| lane * state.straighten_scale_2672); + let heading = heading::calculate( + settings.heading, + &input.heading, + components.heading_previous, + ); + let anti_flip = anti_flip::calculate(settings.anti_flip, &input.anti_flip); + state.anti_flip_torque_2624 = anti_flip; + + input.manual.powersliding = false; + let manual = controller::calculate( + components.manual, + settings.manual, + settings.manual_mode, + &input.manual, + services, + ) + .map_err(GroundBoardError::Manual)?; + state.manual_correction_2732 = manual.correction_active; + state.manual_opposition_2733 = manual.opposing_motion_without_correction; + let drag = input.ground_drag.calculate(settings.linear_drag); + + let terminal_queued = if manual.correction_active { + match propulsion.submit(&manual, components.force_queue) { + PropulsionSubmission::ManualCorrection(queued) => queued, + PropulsionSubmission::BrakingAndPush(_) => unreachable!(), + } + } else { + let push = vector4(propulsion.push.vector); + let push_squared = service( + GroundBoardStage::PushForceSquared, + services.dot3(push, push), + )?; + if push_squared > 1.0 { + state.flag_2721 = true; + } + corrections::update_anti_flip_nudge( + state, + input.anti_flip_nudge, + components.force_queue, + services, + ) + .map_err(|source| GroundBoardError::Service { + stage: GroundBoardStage::AntiFlipNudge, + source, + })?; + if input.contact_time_2756 < settings.ground_force_contact_time_limit { + components.force_queue.append(force_record(4, ground_a)); + components.force_queue.append(force_record(5, ground_b)); + } + match propulsion.submit(&manual, components.force_queue) { + PropulsionSubmission::BrakingAndPush(_) => {} + PropulsionSubmission::ManualCorrection(_) => unreachable!(), + } + apply_body_vectors( + state, + settings.collision_response_scale, + input.propulsion.timestep, + scaled_straighten, + heading, + anti_flip, + manual.angular_displacement, + services, + )?; + if input.trajectory_state_1776_bits & 0x8000_0000 == 0 { + components.force_queue.append(force_record(6, speed_force)); + } + components.force_queue.append(force_record(1, slide_force)); + components.force_queue.append(force_record(8, pump_force)); + contact_force.tag = 16; + components.force_queue.append(contact_force) + }; + + components + .inertias + .apply_ground_drag(drag) + .map_err(GroundBoardError::Drag)?; + let reset = update_speed_model_reset( + components.speed_model, + settings.speed_model_reset_force_squared, + propulsion.braking.force_world, + propulsion.push.vector, + pump_force, + services, + )?; + corrections::manage_hang_ups(state, input.hang_up, services).map_err(|source| { + GroundBoardError::Service { + stage: GroundBoardStage::HangUps, + source, + } + })?; + corrections::manage_halfpipe_wheel_catches(input.halfpipe_wheel_catch, services).map_err( + |source| GroundBoardError::Service { + stage: GroundBoardStage::HalfpipeWheelCatch, + source, + }, + )?; + corrections::consider_pinning(state, input.pinning, services).map_err(|source| { + GroundBoardError::Service { + stage: GroundBoardStage::Pinning, + source, + } + })?; + Ok(OrdinaryGroundResult { + manual_correction: manual.correction_active, + terminal_force_tag: if manual.correction_active { 7 } else { 16 }, + terminal_force_queued: terminal_queued, + speed_model_reset: reset, + }) +} + +fn ground_forces( + settings: &ground_force::GroundForceSettings, + frame: &GroundForceFrame, +) -> ([f32; 8], [f32; 8]) { + let mut side_scalar = 0.0; + if frame.processed_2776 == 0.0 || frame.processed_2780 < 0.0 { + let value = frame.ground_scalar_1232 * frame.previous_state_scalar_56; + side_scalar = if -value >= 0.0 { 0.0 } else { value }; + } + let common = |application_z, argument_3, argument_4| ground_force::GroundForceInput { + argument_1: frame.argument_1_2752, + application_z, + argument_3, + argument_4, + balance: frame.balance_2720, + surface_speed: frame.surface_speed_2656, + axis_384: frame.axis_384, + velocity_400: frame.velocity_400, + axis_544: frame.axis_544, + }; + let first = ground_force::calculate( + settings, + &common( + frame.ground_scalar_1216, + side_scalar, + first_balance_selector(frame.balance_2720), + ), + ); + let second = ground_force::calculate( + settings, + &common( + -frame.ground_scalar_1240, + frame.ground_scalar_1236 * side_scalar, + second_balance_selector(frame.balance_2720), + ), + ); + (first, second) +} + +fn apply_body_vectors( + state: &mut PhysicsGroundState, + collision_scale: f32, + timestep: f32, + straighten: [f32; 4], + heading: [f32; 4], + anti_flip: [f32; 4], + manual: [f32; 4], + services: &mut S, +) -> Result<(), GroundBoardError> { + if state.collision_countdown_2652 > 0.0 { + state.collision_countdown_2652 -= timestep; + state.vector_2592 = state.vector_2592.map(|lane| lane * collision_scale); + service( + GroundBoardStage::ApplyCollisionDecay, + services.apply_vector_82c07000(state.vector_2592), + )?; + } + service( + GroundBoardStage::ApplyStraighten, + services.apply_vector_82c07000(straighten), + )?; + service( + GroundBoardStage::ApplyHeading, + services.apply_vector_82c07000(heading), + )?; + service( + GroundBoardStage::ApplyAntiFlip, + services.apply_angular_displacement_82c075b8(anti_flip), + )?; + service( + GroundBoardStage::ApplyManual, + services.apply_angular_displacement_82c075b8(manual), + )?; + Ok(()) +} + +fn update_speed_model_reset( + speed_model: &mut crate::riding::speed_model::SpeedModelState, + threshold: f32, + brake: Vector3, + push: Vector3, + pump: [f32; 8], + services: &mut S, +) -> Result> { + let combined = [ + (push.x + brake.x) + pump[0], + (push.y + brake.y) + pump[1], + (push.z + brake.z) + pump[2], + pump[3], + ]; + let squared = service( + GroundBoardStage::StrongForceSquared, + services.dot3(combined, combined), + )?; + if squared > threshold { + speed_model.flags_1360 |= 0x8000_0000; + Ok(true) + } else { + Ok(false) + } +} + +fn first_balance_selector(value: f32) -> f32 { + if value <= 0.0 { + if value >= -0.0 { 1.0 } else { 2.0 } + } else { + 0.0 + } +} + +fn second_balance_selector(value: f32) -> f32 { + let nonnegative = if value <= 0.0 { 1.0 } else { 2.0 }; + if value >= -0.0 { nonnegative } else { 0.0 } +} + +fn force_record(tag: u32, value: [f32; 8]) -> QueuedPointForce { + QueuedPointForce { + tag, + force_world: Vector3::new(value[0], value[1], value[2]), + point_body: Vector3::new(value[4], value[5], value[6]), + } +} + +fn vector4(value: Vector3) -> [f32; 4] { + [value.x, value.y, value.z, 0.0] +} diff --git a/crates/skate-core/src/riding/grounded/state/output.rs b/crates/skate-core/src/riding/grounded/state/output.rs new file mode 100644 index 00000000..c88d7542 --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/output.rs @@ -0,0 +1,321 @@ +//! `PhysState_PhysicsGround::FillPhysOut` (`82D3A388`). +//! +//! The TU3 body is a leaf: it reads Ground and ProcessedPhysIn fields and +//! writes distinct PhysOut records. Names recovered from Skate 2 are used only +//! where the TU3 offsets and operations agree. Remaining fields retain their +//! destination offsets. Xenon vector arithmetic uses the project's isolated +//! accepted host approximation; no generated runtime code is included. + +use crate::physics::native_arithmetic; + +use super::data::PhysicsGroundState; + +pub type Vector4 = [f32; 4]; + +/// ProcessedPhysIn values consumed by the TU3 output leaf. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundOutputFrame { + pub axis_464: Vector4, + pub velocity_608: Vector4, + pub absolute_body_speed_2616: f32, + pub deck_speed_2652: f32, + pub state_timer_2664: f32, + pub scalar_2720: f32, + pub flags_2476: u32, + pub flags_2484: u32, + /// ProcessedPhysIn+2548 -> selected collection+4 -> byte+109. + pub selected_mode_flag_109: bool, +} + +/// Live collection values loaded through the global collection singleton. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundOutputSettings { + /// Selected ground-layout +4 and +8, added before the pushable-speed test. + pub pushable_speed_terms_4_8: [f32; 2], + /// Selected ground-layout +0, used by the conditional output+52/+58 test. + pub mode_speed_threshold_0: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SkateboardMotionOutput { + /// PhysOut pointer+4, byte+274. + pub is_push_accelerating: bool, + /// PhysOut pointer+4, byte+276. + pub is_at_pushable_speed: bool, +} + +/// Conditional writes to the record reached through PhysOut pointer+36. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundVelocityProjectionOutput { + /// Destination +144. + pub velocity_without_axis_component: Vector4, + /// Destination +164. The native branch only ever writes true. + pub active: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroundRecordOutput { + /// PhysOut pointer+32, bytes +316/+317/+318. + pub wall_ride_exit: bool, + pub anti_flip_nudge_present: bool, + pub is_pinning: bool, + /// PhysOut pointer+32, vector+128. + pub anti_flip_torque: Vector4, + /// PhysOut pointer+32, floats +276/+280. + pub time_to_skitch: f32, + pub skitch_spline_height: f32, + /// PhysOut pointer+32, float+304. + pub processed_scalar_2720: f32, + /// PhysOut pointer+32, byte+325. + pub processed_flag_2484_bit_13: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StateRecordOutput { + /// PhysOut pointer+28, words +36/+40. + pub grab_spline_type: u32, + pub grab_spline_object_id: u32, + /// PhysOut pointer+28, byte+84. Stock name is not yet proven. + pub flag_84: bool, + /// PhysOut pointer+28, byte+86. + pub has_world_grab_intent_without_object: bool, + /// PhysOut pointer+28, byte+78 is untouched when this is `None`. + pub manual_correction_write_78: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IntentRecordOutput { + /// PhysOut pointer+52, byte+55. + pub has_world_grab_intent: bool, + /// PhysOut pointer+52, byte+58. Stock name is not yet proven. + pub selected_mode_below_speed_threshold_58: bool, +} + +/// All writes made by TU3 `82D3A388`. Field grouping follows the pointer table +/// reached through the PhysOut argument. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PhysicsGroundOutput { + pub skateboard_motion_4: SkateboardMotionOutput, + /// `None` means the native `ProcessedPhysIn+2664 == 0.0` branch did not + /// touch pointer+36 vector+144 or byte+164. + pub velocity_projection_36: Option, + pub ground_32: GroundRecordOutput, + pub state_28: StateRecordOutput, + pub intents_52: IntentRecordOutput, + /// PhysOut pointer+72, byte+304. + pub is_grabbing_object_72_304: bool, + /// PhysOut pointer+56, byte+168. + pub manual_opposition_56_168: bool, + /// PhysOut pointer+20, byte+596. + pub push_suppressed_20_596: bool, +} + +/// Complete TU3 output leaf. Every comparison uses the same strict relation as +/// its `fcmpu`/branch pair, so unordered values do not select either true path. +pub fn fill_physics_output( + state: &PhysicsGroundState, + frame: GroundOutputFrame, + settings: GroundOutputSettings, +) -> PhysicsGroundOutput { + let has_world_grab_intent = frame.flags_2476 & 0x0040_0000 != 0; + let velocity_projection_36 = + (frame.state_timer_2664 == 0.0).then(|| GroundVelocityProjectionOutput { + velocity_without_axis_component: reject_axis_component( + frame.velocity_608, + frame.axis_464, + ), + active: true, + }); + + PhysicsGroundOutput { + skateboard_motion_4: SkateboardMotionOutput { + is_push_accelerating: state.flag_2721, + is_at_pushable_speed: frame.absolute_body_speed_2616 + < settings.pushable_speed_terms_4_8[0] + settings.pushable_speed_terms_4_8[1], + }, + velocity_projection_36, + ground_32: GroundRecordOutput { + wall_ride_exit: state.flag_2720, + anti_flip_nudge_present: state.anti_flip_nudge_applied_2723, + is_pinning: state.pinning_2727, + anti_flip_torque: state.anti_flip_torque_2624, + time_to_skitch: state.scalar_2664, + skitch_spline_height: state.scalar_2668, + processed_scalar_2720: frame.scalar_2720, + processed_flag_2484_bit_13: frame.flags_2484 & 0x0000_2000 != 0, + }, + state_28: StateRecordOutput { + grab_spline_type: state.word_2560, + grab_spline_object_id: state.word_2564, + flag_84: state.flag_2731, + has_world_grab_intent_without_object: has_world_grab_intent && !state.flag_2729, + manual_correction_write_78: state.manual_correction_2732.then_some(true), + }, + intents_52: IntentRecordOutput { + has_world_grab_intent, + selected_mode_below_speed_threshold_58: frame.selected_mode_flag_109 + && frame.deck_speed_2652 < settings.mode_speed_threshold_0, + }, + is_grabbing_object_72_304: state.flag_2729, + manual_opposition_56_168: state.manual_opposition_2733, + push_suppressed_20_596: state.push_suppressed_2730, + } +} + +fn reject_axis_component(velocity: Vector4, axis: Vector4) -> Vector4 { + // Direct TU3 sequence: vmsum3fp128, vmulfp128, vsubfp128. `dot3` is the + // isolated, explicitly documented host approximation for the Xenon sum. + let projection = native_arithmetic::dot3(velocity, axis); + core::array::from_fn(|lane| { + let scaled = flush_subnormal(axis[lane] * projection); + flush_subnormal(velocity[lane] - scaled) + }) +} + +fn flush_subnormal(value: f32) -> f32 { + let bits = value.to_bits(); + if bits & 0x7f80_0000 == 0 && bits & 0x007f_ffff != 0 { + f32::from_bits(bits & 0x8000_0000) + } else { + value + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state() -> PhysicsGroundState { + PhysicsGroundState { + collision_force_2528: [0.0; 4], + collision_point_2544: [0.0; 4], + word_2560: 0x1122_3344, + word_2564: 0x5566_7788, + vector_2592: [0.0; 4], + vector_2608: [0.0; 4], + anti_flip_torque_2624: [1.0, 2.0, 3.0, 4.0], + steering_push_scalar_2640: 1.0, + steering_damped_turn_2644: 0.0, + elapsed_2648: 0.0, + collision_countdown_2652: 0.0, + captured_position_x_2656: 0.0, + captured_position_z_2660: 0.0, + scalar_2664: 6.0, + scalar_2668: 7.0, + straighten_scale_2672: 1.0, + vector_2688: [0.0; 4], + scalar_2704: 0.0, + flag_2708: false, + flag_2720: true, + flag_2721: true, + flag_2722: false, + anti_flip_nudge_applied_2723: true, + human_player_2724: true, + controls_latched_2725: false, + captured_position_valid_2726: true, + pinning_2727: true, + was_pinning_2728: false, + flag_2729: false, + push_suppressed_2730: true, + flag_2731: true, + manual_correction_2732: true, + manual_opposition_2733: true, + hang_detection_frames_2740: 0, + hang_force_frames_2744: 0, + hung_wipeout_frames_2748: 0, + anti_flip_nudge_frames_2752: 0, + } + } + + fn frame() -> GroundOutputFrame { + GroundOutputFrame { + axis_464: [1.0, 0.0, 0.0, 0.0], + velocity_608: [5.0, 2.0, 3.0, 4.0], + absolute_body_speed_2616: 4.0, + deck_speed_2652: 2.0, + state_timer_2664: 0.0, + scalar_2720: 8.0, + flags_2476: 0x0040_0000, + flags_2484: 0x0000_2000, + selected_mode_flag_109: true, + } + } + + fn settings() -> GroundOutputSettings { + GroundOutputSettings { + pushable_speed_terms_4_8: [2.0, 3.0], + mode_speed_threshold_0: 3.0, + } + } + + #[test] + fn fill_maps_every_unconditional_field_and_projection_branch() { + let output = fill_physics_output(&state(), frame(), settings()); + + assert_eq!( + output.skateboard_motion_4, + SkateboardMotionOutput { + is_push_accelerating: true, + is_at_pushable_speed: true, + } + ); + assert_eq!( + output.velocity_projection_36, + Some(GroundVelocityProjectionOutput { + velocity_without_axis_component: [0.0, 2.0, 3.0, 4.0], + active: true, + }) + ); + assert!(output.ground_32.wall_ride_exit); + assert!(output.ground_32.anti_flip_nudge_present); + assert!(output.ground_32.is_pinning); + assert_eq!(output.ground_32.anti_flip_torque, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(output.ground_32.time_to_skitch, 6.0); + assert_eq!(output.ground_32.skitch_spline_height, 7.0); + assert_eq!(output.ground_32.processed_scalar_2720, 8.0); + assert!(output.ground_32.processed_flag_2484_bit_13); + assert_eq!(output.state_28.grab_spline_type, 0x1122_3344); + assert_eq!(output.state_28.grab_spline_object_id, 0x5566_7788); + assert!(output.state_28.flag_84); + assert!(output.state_28.has_world_grab_intent_without_object); + assert_eq!(output.state_28.manual_correction_write_78, Some(true)); + assert!(output.intents_52.has_world_grab_intent); + assert!(output.intents_52.selected_mode_below_speed_threshold_58); + assert!(!output.is_grabbing_object_72_304); + assert!(output.manual_opposition_56_168); + assert!(output.push_suppressed_20_596); + } + + #[test] + fn fill_preserves_native_conditional_writes_and_unordered_comparisons() { + let mut state = state(); + state.flag_2729 = true; + state.manual_correction_2732 = false; + let mut frame = frame(); + frame.state_timer_2664 = f32::NAN; + frame.absolute_body_speed_2616 = f32::NAN; + frame.deck_speed_2652 = f32::NAN; + + let output = fill_physics_output(&state, frame, settings()); + + assert_eq!(output.velocity_projection_36, None); + assert!(!output.skateboard_motion_4.is_at_pushable_speed); + assert!(!output.intents_52.selected_mode_below_speed_threshold_58); + assert!(!output.state_28.has_world_grab_intent_without_object); + assert_eq!(output.state_28.manual_correction_write_78, None); + } + + #[test] + fn fill_extracts_only_the_two_recovered_processed_bits() { + let mut frame = frame(); + frame.flags_2476 = 0x0020_0000; + frame.flags_2484 = 0x0000_1000; + frame.state_timer_2664 = -0.0; + + let output = fill_physics_output(&state(), frame, settings()); + + assert!(!output.intents_52.has_world_grab_intent); + assert!(!output.ground_32.processed_flag_2484_bit_13); + assert!(output.velocity_projection_36.is_some()); + } +} diff --git a/crates/skate-core/src/riding/grounded/state/post_physics.rs b/crates/skate-core/src/riding/grounded/state/post_physics.rs new file mode 100644 index 00000000..4af2084a --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/post_physics.rs @@ -0,0 +1,43 @@ +//! `PhysState_PhysicsGround::UpdatePostPhysics` (`82D387A8`). + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundPostPhysicsStage { + CheckForGroundWipeout, + UpdateBoardPath, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroundPostPhysicsError { + pub stage: GroundPostPhysicsStage, + pub source: E, +} + +/// Wipeout::CheckForGroundWipeout82D8F9E0 and board path82C05EC0. +/// There is deliberately no empty implementation. +pub trait GroundPostPhysicsServices { + type Error; + + fn check_for_ground_wipeout_82d8f9e0(&mut self) -> Result<(), Self::Error>; + fn update_board_path_82c05ec0(&mut self) -> Result<(), Self::Error>; +} + +pub fn update_post_physics( + offboard_trajectory_state_1776: i32, + services: &mut S, +) -> Result<(), GroundPostPhysicsError> { + services + .check_for_ground_wipeout_82d8f9e0() + .map_err(|source| GroundPostPhysicsError { + stage: GroundPostPhysicsStage::CheckForGroundWipeout, + source, + })?; + if offboard_trajectory_state_1776 < 0 { + services + .update_board_path_82c05ec0() + .map_err(|source| GroundPostPhysicsError { + stage: GroundPostPhysicsStage::UpdateBoardPath, + source, + })?; + } + Ok(()) +} diff --git a/crates/skate-core/src/riding/grounded/state/update.rs b/crates/skate-core/src/riding/grounded/state/update.rs new file mode 100644 index 00000000..0ef76bde --- /dev/null +++ b/crates/skate-core/src/riding/grounded/state/update.rs @@ -0,0 +1,428 @@ +//! `PhysState_PhysicsGround::Update` (`82D37C88`) call composition. + +use crate::riding::pumping::{ + controller::{self, PumpingGeometry, PumpingSample}, + settings::{PumpingMode, PumpingSettings}, + state::PumpingState, +}; + +use super::{ + contact_state::{self, GroundContactServices}, + data::{GroundContactHistory, GroundUpdateInput, PhysicsGroundState}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroundUpdateStage { + SetElapsedSkeletonFlag, + UpdateContactState, + LoadPumpingFrame, + UpdatePumping, + UpdateSkateboard, + PredictFutureDeck, + SetCollisionSkeletonFlag, + SetGroundSkeletonFlag, + UpdateSpecialState, + UpdateTrajectory, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroundUpdateError { + pub stage: GroundUpdateStage, + pub source: E, +} + +/// Required engine-facing operations in the exact `82D37C88` order. This +/// trait has no default implementation because each call mutates stock-owned +/// objects that are outside `skate-core`. +pub trait GroundUpdateServices: + PumpingGeometry + + GroundContactServices +{ + type UpdateError; + type ContactHandle; + + fn set_elapsed_skeleton_flag(&mut self) -> Result<(), Self::UpdateError>; + /// Supplies the exact deck-angle result produced at `82D37D34..82D37E54` + /// together with the other direct Pumping inputs. Generic host angle math + /// is not an allowed implementation of this producer. + fn pumping_frame( + &mut self, + ) -> Result<(PumpingSettings, PumpingMode, PumpingSample), Self::UpdateError>; + fn update_skateboard( + &mut self, + state: &mut PhysicsGroundState, + ) -> Result<(), Self::UpdateError>; + fn predict_future_deck(&mut self) -> Result<(), Self::UpdateError>; + fn set_collision_skeleton_flag(&mut self) -> Result<(), Self::UpdateError>; + fn set_ground_skeleton_flag(&mut self) -> Result<(), Self::UpdateError>; + fn update_special_state(&mut self) -> Result<(), Self::UpdateError>; + fn update_trajectory(&mut self) -> Result<(), Self::UpdateError>; +} + +/// Runs the full recovered outer Ground update. State writes before a failing +/// service remain visible, matching the native non-transactional execution. +pub fn update( + state: &mut PhysicsGroundState, + pumping: &mut PumpingState, + contacts: &mut GroundContactHistory, + input: GroundUpdateInput, + services: &mut S, +) -> Result<(), GroundUpdateError> { + let signals = state.begin_update(input); + if signals.set_skeleton_flag_16505 { + call( + GroundUpdateStage::SetElapsedSkeletonFlag, + services.set_elapsed_skeleton_flag(), + )?; + } + call( + GroundUpdateStage::UpdateContactState, + contact_state::update(contacts, input.contact_state, services), + )?; + let (settings, mode, sample) = call( + GroundUpdateStage::LoadPumpingFrame, + services.pumping_frame(), + )?; + call( + GroundUpdateStage::UpdatePumping, + controller::update_ground(pumping, &settings, mode, sample, services), + )?; + call( + GroundUpdateStage::UpdateSkateboard, + services.update_skateboard(state), + )?; + call( + GroundUpdateStage::PredictFutureDeck, + services.predict_future_deck(), + )?; + if state.flag_2722 { + call( + GroundUpdateStage::SetCollisionSkeletonFlag, + services.set_collision_skeleton_flag(), + )?; + } + call( + GroundUpdateStage::SetGroundSkeletonFlag, + services.set_ground_skeleton_flag(), + )?; + state.finish_update(input.timestep_2604); + if input.contact_state.flags_2476 & 0x0040_0000 != 0 { + call( + GroundUpdateStage::UpdateSpecialState, + services.update_special_state(), + ) + } else { + call( + GroundUpdateStage::UpdateTrajectory, + services.update_trajectory(), + ) + } +} + +fn call(stage: GroundUpdateStage, result: Result) -> Result> { + result.map_err(|source| GroundUpdateError { stage, source }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::point_graph::PointGraph; + use crate::riding::grounded::state::contact_state::GroundContactStateInput; + + #[derive(Default)] + struct Services { + events: Vec<&'static str>, + collision_signal: bool, + } + + impl PumpingGeometry for Services { + type Error = &'static str; + + fn height( + &mut self, + _normal: [f32; 4], + _com_to_deck: [f32; 4], + ) -> Result { + self.events.push("pumping-height"); + Ok(2.0) + } + + fn speed( + &mut self, + _previous: [f32; 4], + _current: [f32; 4], + _dt: f32, + ) -> Result { + Err("unexpected speed") + } + + fn angular_speed( + &mut self, + _previous_position: [f32; 4], + _previous_normal: [f32; 4], + _sample: &PumpingSample, + _dt: f32, + ) -> Result { + Err("unexpected angular speed") + } + + fn inclination_radians(&mut self, _normal_y: f32) -> Result { + Err("unexpected inclination") + } + } + + impl GroundContactServices for Services { + type Error = &'static str; + type Handle = u32; + + fn stop_contact_tracking_82d62f20(&mut self) -> Result<(), Self::Error> { + self.events.push("contact-stop"); + Ok(()) + } + + fn clear_inactive_contact_output_3024(&mut self) -> Result<(), Self::Error> { + self.events.push("contact-clear"); + Ok(()) + } + + fn initial_contact_handle_1920(&mut self) -> Result, Self::Error> { + self.events.push("contact-initial"); + Ok(None) + } + + fn identify_contact_82d63c30( + &mut self, + _frame: &GroundContactStateInput, + ) -> Result, Self::Error> { + self.events.push("contact-identify"); + Ok(None) + } + + fn publish_contact_82d61268( + &mut self, + _frame: &GroundContactStateInput, + _retained: Option<&Self::Handle>, + ) -> Result<(), Self::Error> { + self.events.push("contact-publish"); + Ok(()) + } + } + + impl GroundUpdateServices for Services { + type UpdateError = &'static str; + type ContactHandle = u32; + + fn set_elapsed_skeleton_flag(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("elapsed-flag"); + Ok(()) + } + + fn pumping_frame( + &mut self, + ) -> Result<(PumpingSettings, PumpingMode, PumpingSample), Self::UpdateError> { + self.events.push("pumping-frame"); + let graph = PointGraph { + x: [0.0; 8], + y: [0.0; 8], + }; + Ok(( + PumpingSettings { + pump_vs_speed: graph, + pump_vs_time: graph, + min_crouch_vs_ground_angle: graph, + compression_vs_ground_angle: graph, + compression_vs_deck_angle: graph, + height_change_damping: 0.0, + minimum_height_change: 0.0, + maximum_height_change: 0.0, + ground_compression_scale: 0.0, + deck_compression_scale: 0.0, + angular_speed_damping: 0.0, + }, + PumpingMode { + maximum_absorption_per_second: 0.0, + maximum_acceleration_per_second: 0.0, + absorption_factor: 0.0, + acceleration_factor: 0.0, + }, + PumpingSample { + position: [0.0; 4], + normal: [0.0, 1.0, 0.0, 0.0], + com_to_deck_world: [0.0; 4], + deck_angle: 0.0, + intentional_pumping: 0, + }, + )) + } + + fn update_skateboard( + &mut self, + state: &mut PhysicsGroundState, + ) -> Result<(), Self::UpdateError> { + self.events.push("skateboard"); + state.flag_2722 = self.collision_signal; + Ok(()) + } + + fn predict_future_deck(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("predict"); + Ok(()) + } + + fn set_collision_skeleton_flag(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("collision-flag"); + Ok(()) + } + + fn set_ground_skeleton_flag(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("ground-flag"); + Ok(()) + } + + fn update_special_state(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("special"); + Ok(()) + } + + fn update_trajectory(&mut self) -> Result<(), Self::UpdateError> { + self.events.push("trajectory"); + Ok(()) + } + } + + fn state() -> PhysicsGroundState { + PhysicsGroundState { + collision_force_2528: [0.0; 4], + collision_point_2544: [0.0; 4], + word_2560: 0, + word_2564: 0, + vector_2592: [0.0; 4], + vector_2608: [0.0; 4], + anti_flip_torque_2624: [9.0; 4], + steering_push_scalar_2640: 1.0, + steering_damped_turn_2644: 0.0, + elapsed_2648: 0.1, + collision_countdown_2652: 0.0, + captured_position_x_2656: 0.0, + captured_position_z_2660: 0.0, + scalar_2664: 8.0, + scalar_2668: 0.0, + straighten_scale_2672: 1.0, + vector_2688: [0.0; 4], + scalar_2704: 0.0, + flag_2708: false, + flag_2720: false, + flag_2721: false, + flag_2722: false, + anti_flip_nudge_applied_2723: true, + human_player_2724: false, + controls_latched_2725: false, + captured_position_valid_2726: false, + pinning_2727: true, + was_pinning_2728: false, + flag_2729: true, + push_suppressed_2730: false, + flag_2731: false, + manual_correction_2732: false, + manual_opposition_2733: false, + hang_detection_frames_2740: 0, + hang_force_frames_2744: 0, + hung_wipeout_frames_2748: 0, + anti_flip_nudge_frames_2752: 0, + } + } + + fn input(flags_2476: u32) -> GroundUpdateInput { + GroundUpdateInput { + wheel_contact_count_2556: 1, + position_112: [3.0, 4.0, 5.0, 0.0], + timestep_2604: 0.25, + contact_state: GroundContactStateInput { + flags_2476, + flags_2480: 0, + velocity_400: [0.0; 4], + axis_464: [0.0; 4], + contact_position_560: [0.0; 4], + trajectory_position_592: [0.0; 4], + reckoning_vector_1200: [0.0; 4], + differing_contact_frame_limit: 0, + }, + } + } + + #[test] + fn update_preserves_complete_outer_order_and_state_prefix() { + let mut state = state(); + let mut pumping = PumpingState::reset_state(); + let mut contacts = GroundContactHistory::empty(); + let mut services = Services { + collision_signal: true, + ..Default::default() + }; + + update( + &mut state, + &mut pumping, + &mut contacts, + input(0), + &mut services, + ) + .unwrap(); + + assert_eq!( + services.events, + [ + "elapsed-flag", + "contact-stop", + "contact-clear", + "pumping-frame", + "pumping-height", + "skateboard", + "predict", + "collision-flag", + "ground-flag", + "trajectory", + ] + ); + assert_eq!(state.captured_position_x_2656, 3.0); + assert_eq!(state.captured_position_z_2660, 5.0); + assert!(state.captured_position_valid_2726); + assert_eq!(state.anti_flip_torque_2624, [0.0; 4]); + assert!(!state.anti_flip_nudge_applied_2723); + assert!(!state.pinning_2727); + assert!(!state.flag_2729); + assert_eq!(state.scalar_2664, -1.0); + assert_eq!(state.elapsed_2648, 0.35); + } + + #[test] + fn active_contact_and_special_tail_select_only_their_native_paths() { + let mut state = state(); + state.elapsed_2648 = 0.0; + let mut pumping = PumpingState::reset_state(); + let mut contacts = GroundContactHistory::empty(); + let mut services = Services::default(); + + update( + &mut state, + &mut pumping, + &mut contacts, + input(0x0040_0000), + &mut services, + ) + .unwrap(); + + assert_eq!( + services.events, + [ + "contact-publish", + "pumping-frame", + "pumping-height", + "skateboard", + "predict", + "ground-flag", + "special", + ] + ); + } +} diff --git a/crates/skate-core/src/riding/heading.rs b/crates/skate-core/src/riding/heading.rs new file mode 100644 index 00000000..4aceec8d --- /dev/null +++ b/crates/skate-core/src/riding/heading.rs @@ -0,0 +1,114 @@ +//! Complete TU3 Toolkit_CalcHeadingTorque 82D92CF8. Ground calls this before +//! AntiFlipTorque; the two are distinct. The caller owns board+284 history. +use crate::{physics::reciprocal_sqrt::estimate, point_graph::PointGraph, trigonometry::acos}; + +pub struct HeadingSettings { + pub manual_wrong_wheel_scalar: f32, // +904 + pub manual_damping: f32, // +908 + pub speed_max: f32, // +912 + pub heading_strength: f32, // +916 + pub turn_strength: f32, // +920 + pub angular_response: PointGraph<8>, // +940/+972 + pub manual_response: PointGraph<8>, // +1020/+1052 + pub inclination_response: PointGraph<8>, // +1084/+1116 + pub speed_response: PointGraph<8>, // +1148/+1180 + /// Live four-lane threshold at 830BD350, initialized by 82F826F8. + pub normal_threshold: [f32; 4], +} + +pub struct HeadingInput { + pub balance: f32, // Processed +2720 + pub manual_turn: f32, // +2672 + pub flags_2472: u32, + pub timestep: f32, // +2604 + pub signed_speed: f32, // +2612 + pub manual_curve_input: f32, // +2652 + pub turn_2712: f32, + pub scalar_2740: f32, + pub velocity: [f32; 4], // +432 + pub normal: [f32; 4], // +464 + pub angular_velocity: [f32; 4], // +720 + /// Ground caller's r6 matrix, in native column order. No host orthogonalization. + pub transform: [[f32; 4]; 4], +} + +pub fn calculate(settings: &HeadingSettings, input: &HeadingInput, previous: &mut f32) -> [f32; 4] { + if input.balance != 0.0 && input.manual_turn != 0.0 { + let wrong = (input.flags_2472 & 0x0800_0000 != 0 && input.balance < 0.0) + || (input.flags_2472 & 0x0400_0000 != 0 && input.balance > 0.0); + let turn = if wrong { + input.manual_turn * settings.manual_wrong_wheel_scalar + } else { + input.manual_turn + }; + let target = settings.manual_response.evaluate(input.manual_curve_input) + * f32::from_bits(0x3C8E_FA35); + *previous = + (1.0 - settings.manual_damping).mul_add(*previous, target * settings.manual_damping); + let scalar = (input.timestep * *previous) * turn; + return input.normal.map(|n| n * scalar); + } + *previous = 0.0; + let matrix = input.transform; + let rows: [[f32; 4]; 3] = + core::array::from_fn(|i| [matrix[0][i], matrix[1][i], matrix[2][i], 0.0]); + let cosine = clamp(rows[1][1], -1.0, 1.0); + let angle = acos(cosine) * f32::from_bits(0x3F22_F983); + let angle = if angle > 1.2 { 0.0 } else { angle }; + let mut downhill: [f32; 4] = core::array::from_fn(|i| { + let v = rows[0][i] * 0.0; + let v = rows[1][i].mul_add(-1.0, v); + rows[2][i].mul_add(0.0, v) + }); + downhill[1] = 0.0; + let squared = dot3(downhill, downhill); + 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 length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + let inclination = settings.inclination_response.evaluate(angle); + for i in 0..4 { + downhill[i] = (if length > settings.normal_threshold[i] { + downhill[i] * inverse + } else { + 0.0 + }) * inclination; + } + let mut velocity: [f32; 4] = core::array::from_fn(|i| { + let v = rows[0][i] * input.velocity[0]; + let v = rows[1][i].mul_add(input.velocity[1], v); + rows[2][i].mul_add(input.velocity[2], v) + }); + velocity[1] = 0.0; + // Native cyclic permutations select the Y component of velocity x downhill. + let cross_y = (-velocity[0]).mul_add(downhill[2], velocity[2] * downhill[0]); + let speed = clamp(input.signed_speed.abs(), 0.0, settings.speed_max); + let heading = (cross_y * settings.heading_strength) + * settings.speed_response.evaluate(speed / settings.speed_max); + let opposing = dot3(input.angular_velocity, input.normal) * -input.turn_2712; + let opposing = if opposing >= 0.0 { opposing } else { 0.0 }; + let turn = ((settings.angular_response.evaluate(opposing) * input.scalar_2740) + * settings.turn_strength) + * input.turn_2712; + let scalar = if !(heading * turn < 0.0) && !(turn.abs() > heading.abs()) { + heading + } else { + turn + }; + input.normal.map(|n| n * scalar) +} + +fn clamp(value: f32, lower: f32, upper: f32) -> f32 { + let v = if lower - value >= 0.0 { lower } else { value }; + if upper - v >= 0.0 { v } else { upper } +} + +fn dot3(a: [f32; 4], b: [f32; 4]) -> f32 { + super::vector::dot3(a, b) +} diff --git a/crates/skate-core/src/riding/mod.rs b/crates/skate-core/src/riding/mod.rs new file mode 100644 index 00000000..8a2f5490 --- /dev/null +++ b/crates/skate-core/src/riding/mod.rs @@ -0,0 +1,23 @@ +//! Recovered riding calculations. Inputs are processed physics inputs, not +//! platform stick/button values; action-graph producers remain a separate owner. +pub mod anti_flip; +pub mod braking; +pub mod ground_force; +pub mod grounded; +pub mod heading; +pub mod pumping; +pub mod push; +pub mod push_animation; +pub mod slide_friction; +pub mod speed_model; +pub mod speed_and_slope; +pub mod ground_orientation; +pub mod speed_wobble; +pub mod steering; +pub mod straighten; +mod vector; +pub mod push_behaviors; +pub mod ground_contact_response; +pub mod collision_response; +pub mod ground_correction_math; +pub mod reckoning_frames; diff --git a/crates/skate-core/src/riding/pumping.rs b/crates/skate-core/src/riding/pumping.rs new file mode 100644 index 00000000..20a2a291 --- /dev/null +++ b/crates/skate-core/src/riding/pumping.rs @@ -0,0 +1,31 @@ +//! Pumping lifecycle, native geometry and CalcPumpForce82D933D0 publication. +//! Ground supplies the current stock mode and queues the resulting board force. +pub mod controller; +pub mod geometry; +pub mod settings; +pub mod state; + +use super::vector::normalize; +pub struct PumpForceInput { + pub flags_2476: u32, + pub mode_multiplier: f32, // selected-mode layout+8 + pub pumping_scalar: f32, // Pumping+48 argument f1 + pub input_scalar_2660: f32, + pub timestep: f32, // Processed+2604 + pub direction_432: [f32; 4], + pub normal_threshold: [f32; 4], +} +pub fn calculate(i: &PumpForceInput) -> [f32; 8] { + let pumping = if i.flags_2476 & 2 == 0 { + i.mode_multiplier * i.pumping_scalar + } else { + i.pumping_scalar + }; + let scalar = (i.input_scalar_2660 * pumping) / i.timestep; + let direction = normalize(i.direction_432, i.normal_threshold); + let mut result = [0.0; 8]; + for n in 0..4 { + result[n] = direction[n] * scalar; + } + result +} diff --git a/crates/skate-core/src/riding/pumping/controller.rs b/crates/skate-core/src/riding/pumping/controller.rs new file mode 100644 index 00000000..9f7564b2 --- /dev/null +++ b/crates/skate-core/src/riding/pumping/controller.rs @@ -0,0 +1,174 @@ +//! Recovered scalar/control flow of TU3 82D8F228 and 82D8F470. +//! +//! Production uses geometry::NativePumpingGeometry for the original vector +//! operations, refinement order and inverse-cosine kernel. The Ground adapter +//! supplies the current physical sample and the selected stock mode each tick. +use super::{settings::*, state::PumpingState}; + +/// Literal loaded by Ground Update 82D37EA0, independent of Processed+2604. +pub const GROUND_PUMPING_TIMESTEP: f32 = f32::from_bits(0x3c88_8889); + +#[derive(Clone, Copy, Debug)] +pub struct PumpingSample { + /// Ground caller: ProcessedPhysIn+112, ground context+1216, skeleton+11008. + pub position: [f32; 4], + pub normal: [f32; 4], + pub com_to_deck_world: [f32; 4], + /// Ground's signed-angle difference calculation supplies the absolute angle. + pub deck_angle: f32, + /// Ground caller extracts ProcessedPhysIn+2476 bit 1. + pub intentional_pumping: u8, +} + +/// Recovered numeric operations, implemented by geometry::NativePumpingGeometry. +/// Keeping this boundary explicit allows independent measurements to validate +/// the surrounding lifecycle without asserting numeric parity from mocks. +pub trait PumpingGeometry { + type Error; + /// 82D8F280..290: three-component COM-to-deck projection onto the normal. + fn height(&mut self, normal: [f32; 4], com_to_deck: [f32; 4]) -> Result; + /// 82D8F4A0..574: displacement divided by dt, then its length. + fn speed(&mut self, previous: [f32; 4], current: [f32; 4], dt: f32) + -> Result; + /// 82D8F590..6AC: dot(cross(previous_normal, normal)/dt, + /// safe_normalize(cross(position-previous_position, normal))). + /// Preserve the recovered fused cross-product and refinement order. + fn angular_speed( + &mut self, + previous_position: [f32; 4], + previous_normal: [f32; 4], + sample: &PumpingSample, + dt: f32, + ) -> Result; + /// 82453298 called at 82D8F6EC, with previous normal.y clamped to [0,1]. + fn inclination_radians(&mut self, clamped_normal_y: f32) -> Result; +} + +/// Executes the recovered lifecycle only when the required geometry succeeds. +/// An error leaves the already executed prefix visible, and must abort the +/// caller's simulation tick; it is never permission to publish partial forces. +pub fn update( + state: &mut PumpingState, + settings: &PumpingSettings, + mode: PumpingMode, + sample: PumpingSample, + timestep: f32, + geometry: &mut G, +) -> Result<(), G::Error> { + state.pump_acceleration = 0.0; + let height = geometry.height(sample.normal, sample.com_to_deck_world)?; + if state.record_valid { + state.intentional_pumping = sample.intentional_pumping; + let change = height - state.previous_height; + let rising_change = if change >= 0.0 { change } else { 0.0 }; + let retained = (1.0 - settings.height_change_damping) * state.smoothed_height_change; + let smoothed = rising_change.mul_add(settings.height_change_damping, retained); + state.smoothed_height_change = smoothed; + // bge after fcmpu takes the unordered path too: do not replace with >=. + let mut effect = if smoothed < settings.minimum_height_change { + 0.0 + } else { + smoothed + }; + state.pumping_time = if effect > 0.0 { + state.pumping_time + timestep + } else { + 0.0 + }; + if effect.abs() > settings.maximum_height_change { + let sign = if effect >= 0.0 { + if effect > 0.0 { 1.0 } else { 0.0 } + } else { + -1.0 + }; + effect = sign * settings.maximum_height_change; + } + effect = settings.pump_vs_time.evaluate(state.pumping_time) * effect; + let pumping = calculate(state, settings, &sample, timestep, geometry)?; + state.pumping = pumping; + let factor = if pumping * effect > 0.0 { + mode.acceleration_factor + } else { + mode.absorption_factor + }; + let raw = (pumping * factor) * effect; + let lower = -(mode.maximum_absorption_per_second * timestep); + let upper = mode.maximum_acceleration_per_second * timestep; + let lower_clamped = if lower - raw >= 0.0 { lower } else { raw }; + state.pump_acceleration = if upper - lower_clamped >= 0.0 { + lower_clamped + } else { + upper + }; + } + state.previous_position = sample.position; + state.previous_normal = sample.normal; + state.record_valid = true; + state.previous_height = height; + Ok(()) +} + +/// Pumping slice of Ground Update 82D37EA8. Force assembly must follow only +/// after this succeeds; it has its own ProcessedPhysIn timestep for force scaling. +pub fn update_ground( + state: &mut PumpingState, + settings: &PumpingSettings, + mode: PumpingMode, + sample: PumpingSample, + geometry: &mut G, +) -> Result<(), G::Error> { + update( + state, + settings, + mode, + sample, + GROUND_PUMPING_TIMESTEP, + geometry, + ) +} + +/// Scalar portion of Calculate, including output write/curve evaluation order. +/// Does not update the Update-owned cached pose, pump time or pump acceleration. +pub fn calculate( + state: &mut PumpingState, + settings: &PumpingSettings, + sample: &PumpingSample, + timestep: f32, + geometry: &mut G, +) -> Result { + let speed = geometry.speed(state.previous_position, sample.position, timestep)?; + let speed_effect = settings.pump_vs_speed.evaluate(speed); + let angular_speed = geometry.angular_speed( + state.previous_position, + state.previous_normal, + sample, + timestep, + )?; + let measured = angular_speed * settings.angular_speed_damping; + state.angular_speed = + (1.0 - settings.angular_speed_damping).mul_add(state.angular_speed, measured); + state.absorption = -(speed * state.angular_speed); + let inclination = geometry.inclination_radians(unit(state.previous_normal[1]))?; + // Stock 822F8B7C: 2/pi rounded to binary32. This is not host acos(). + let radians_to_quarter_turns = f32::from_bits(0x3f22_f983); + let ground_angle = unit(radians_to_quarter_turns * inclination); + state.ground_normal_absorption = settings.compression_vs_ground_angle.evaluate(ground_angle) + * settings.ground_compression_scale; + state.minimum_crouch = settings.min_crouch_vs_ground_angle.evaluate(ground_angle); + state.deck_angle_absorption = settings + .compression_vs_deck_angle + .evaluate(sample.deck_angle * radians_to_quarter_turns); + let deck_compression = settings.deck_compression_scale * state.deck_angle_absorption; + let pumping = state.angular_speed * speed_effect; + if deck_compression * deck_compression + > state.ground_normal_absorption * state.ground_normal_absorption + { + state.ground_normal_absorption = deck_compression; + } + Ok(pumping) +} + +fn unit(value: f32) -> f32 { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} diff --git a/crates/skate-core/src/riding/pumping/geometry.rs b/crates/skate-core/src/riding/pumping/geometry.rs new file mode 100644 index 00000000..4349a990 --- /dev/null +++ b/crates/skate-core/src/riding/pumping/geometry.rs @@ -0,0 +1,69 @@ +//! TU3 Pumping geometry82D8F280 and82D8F4A0..6AC. The reciprocal and +//! reciprocal-square-root estimate primitives retain their documented host +//! approximation boundary; source refinements, fused crosses and gates remain. +use super::controller::{PumpingGeometry, PumpingSample}; +use crate::physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::{dot3, reciprocal_estimate}, +}; +pub struct NativePumpingGeometry; +impl PumpingGeometry for NativePumpingGeometry { + type Error = core::convert::Infallible; + fn height(&mut self, normal: [f32; 4], com: [f32; 4]) -> Result { + Ok(dot3(normal, com)) + } + fn speed( + &mut self, + previous: [f32; 4], + current: [f32; 4], + dt: f32, + ) -> Result { + let inverse = reciprocal(dt); + let velocity = std::array::from_fn(|i| (current[i] - previous[i]) * inverse); + let squared = dot3(velocity, velocity); + let length = squared * inverse_length_squared(squared, 2); + Ok(if squared == 0.0 { 0.0 } else { length }) + } + fn angular_speed( + &mut self, + previous_position: [f32; 4], + previous_normal: [f32; 4], + sample: &PumpingSample, + dt: f32, + ) -> Result { + let displacement = std::array::from_fn(|i| sample.position[i] - previous_position[i]); + let axis = cross(displacement, sample.normal); + let squared = dot3(axis, axis); + let inverse = inverse_length_squared(squared, 2); + let length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + let axis = if length > f32::from_bits(0x3586_37bd) { + axis.map(|v| v * inverse) + } else { + [0.0; 4] + }; + let inverse_dt = reciprocal(dt); + let angular = cross(previous_normal, sample.normal).map(|v| v * inverse_dt); + Ok(dot3(angular, axis)) + } + fn inclination_radians(&mut self, y: f32) -> Result { + Ok(crate::trigonometry::acos(y)) + } +} +fn reciprocal(value: f32) -> f32 { + let mut inverse = reciprocal_estimate(value); + for _ in 0..2 { + let error = (-inverse).mul_add(value, 1.0); + inverse = inverse.mul_add(error, inverse); + } + inverse +} +fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + let cyclic = [1, 2, 0, 3]; + let product: [f32; 4] = + std::array::from_fn(|i| (-a[cyclic[i]]).mul_add(b[i], a[i] * b[cyclic[i]])); + cyclic.map(|i| product[i]) +} diff --git a/crates/skate-core/src/riding/pumping/settings.rs b/crates/skate-core/src/riding/pumping/settings.rs new file mode 100644 index 00000000..214b5200 --- /dev/null +++ b/crates/skate-core/src/riding/pumping/settings.rs @@ -0,0 +1,37 @@ +//! Stock layout bindings consumed by TU3 Pumping Update/Calculate. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct PumpingSettings { + /// physics_pumping +16/+48 (the graph's four header floats are skipped). + pub pump_vs_speed: PointGraph<8>, + /// +96/+128, also skipping the PointNegGraphData8 header. + pub pump_vs_time: PointGraph<8>, + /// +160/+192, +224/+256, +288/+320. + pub min_crouch_vs_ground_angle: PointGraph<8>, + pub compression_vs_ground_angle: PointGraph<8>, + pub compression_vs_deck_angle: PointGraph<8>, + /// +364 PumpEffectDamping; +372 MinChangeInCOMBeforePumping. + pub height_change_damping: f32, + pub minimum_height_change: f32, + /// +376 MaxDeltaHeightAllowedPerFrame. + pub maximum_height_change: f32, + /// +380 CompressionGroundScalar; +384 CompressionDeckScalar. + pub ground_compression_scale: f32, + pub deck_compression_scale: f32, + /// +392 AngularSpeedDamping. + pub angular_speed_damping: f32, +} + +/// Ground 82D37E74 passes ProcessedPhysIn+2548, then Update loads wrapper+4. +/// ProcessInput 82DB4420..446C selects that wrapper from the five player modes. +#[derive(Clone, Copy, Debug)] +pub struct PumpingMode { + /// +12 Hash_9D1AEE3D7D8A4A7. Name describes its use, not a recovered name. + pub maximum_absorption_per_second: f32, + /// +16 Hash_D77AFD320B6241C5. Multiplied by the pumping timestep. + pub maximum_acceleration_per_second: f32, + /// +20 PumpEffectFactorAbsorption; +24 PumpEffectFactor. + pub absorption_factor: f32, + pub acceleration_factor: f32, +} diff --git a/crates/skate-core/src/riding/pumping/state.rs b/crates/skate-core/src/riding/pumping/state.rs new file mode 100644 index 00000000..2186a1bd --- /dev/null +++ b/crates/skate-core/src/riding/pumping/state.rs @@ -0,0 +1,83 @@ +//! Owned pumping fields. Layout offsets are evidence, not a runtime memory map. + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PumpingState { + /// +0 and +16, cached after every successful Update, including the first. + pub previous_position: [f32; 4], + pub previous_normal: [f32; 4], + /// +32/+36/+40. + pub smoothed_height_change: f32, + pub pumping_time: f32, + pub previous_height: f32, + /// +44/+48/+52. + pub pumping: f32, + pub pump_acceleration: f32, + pub angular_speed: f32, + /// +56/+60/+64/+68, also copied to physics output. + pub absorption: f32, + pub ground_normal_absorption: f32, + pub minimum_crouch: f32, + pub deck_angle_absorption: f32, + /// +72 is reset but not read or written by Update/Calculate. + pub reset_only_scalar: f32, + /// +76 and +81 are the only bytes touched by these three functions. + pub record_valid: bool, + pub intentional_pumping: u8, +} + +impl PumpingState { + /// TU3 82D8F1A0. Does not initialize bytes +77..80 or +82 onward; those + /// are outside this owned state and must be preserved by any binary adapter. + /// Ground Enter 82D378DC and Exit 82D37B58 both call this reset. + pub fn reset(&mut self) { + *self = Self::reset_state(); + } + + /// The output of the recovered reset, not a claim about the constructor. + pub const fn reset_state() -> Self { + Self { + previous_position: [0.0; 4], + previous_normal: [0.0; 4], + smoothed_height_change: 0.0, + pumping_time: 0.0, + previous_height: 0.0, + pumping: 0.0, + pump_acceleration: 0.0, + angular_speed: 0.0, + absorption: 0.0, + ground_normal_absorption: 0.0, + minimum_crouch: 0.0, + deck_angle_absorption: 0.0, + reset_only_scalar: 0.0, + record_valid: false, + intentional_pumping: 0, + } + } + + /// Inline FillPhysOut at 82DB6FA8..7000. Read after Update; force assembly + /// consumes pump_acceleration earlier, within Ground Update 82D38800. + pub fn physics_output(&self) -> PumpingOutput { + PumpingOutput { + compression: 0.0, + absorption: self.absorption, + ground_normal_absorption: self.ground_normal_absorption, + minimum_crouch: self.minimum_crouch, + deck_angle_absorption: self.deck_angle_absorption, + pump_acceleration: self.pump_acceleration, + intentional_pumping: self.intentional_pumping, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PumpingOutput { + /// Skeleton output +512/+516/+520/+528/+532. + pub compression: f32, + pub absorption: f32, + pub ground_normal_absorption: f32, + pub minimum_crouch: f32, + pub deck_angle_absorption: f32, + /// Ground output +268/+324. + pub pump_acceleration: f32, + pub intentional_pumping: u8, +} diff --git a/crates/skate-core/src/riding/push.rs b/crates/skate-core/src/riding/push.rs new file mode 100644 index 00000000..871b6256 --- /dev/null +++ b/crates/skate-core/src/riding/push.rs @@ -0,0 +1,112 @@ +//! TU3 Toolkit_CalcPushAcceleration (`0x82D948B8`). Despite the native name, +//! the returned vector includes total body mass and is a force-queue payload. +//! It is queued with tag 3; deck inverse mass is applied by `0x82C03718`. +use crate::math::Vector3; +use crate::physics::force_queue::{BoardForceQueue, QueuedPointForce, total_body_mass}; + +#[derive(Clone, Copy, Debug)] +pub struct PushInput { + pub flags_2468: u32, + pub flags_2472: u32, + pub target_speed: f32, + pub current_speed: f32, + pub absolute_body_speed: f32, + /// Input +2660, populated at 0x82C0140C by 0x82C06F58's sum of body + /// masses. This is not a tunable push strength or direct velocity scale. + pub scale: f32, + pub delta_seconds: f32, + pub direction: Vector3, +} + +#[derive(Clone, Copy, Debug)] +pub struct PushLimits { + pub maximum_pushable_speed: f32, + pub low_speed_change: f32, + pub high_speed_change: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PushAcceleration { + pub vector: Vector3, + pub local_point: Vector3, + /// Native output byte; set only when push is requested and flag 0x200 is set. + pub suppressed: bool, +} + +pub fn calculate_acceleration(input: PushInput, limits: PushLimits) -> PushAcceleration { + if input.flags_2468 & 0x0200_0000 == 0 { + return PushAcceleration::default(); + } + if input.flags_2472 & 0x200 != 0 { + return PushAcceleration { + suppressed: true, + ..Default::default() + }; + } + let gap = input.target_speed - input.current_speed; + //82D94930 bgt: unordered and both signed zeros take the zero branch. + let mut speed_change = if gap > 0.0 { gap } else { 0.0 }; + let speed_fraction = input.current_speed / limits.maximum_pushable_speed; + //82D94960/68 are ordered fsel tests, not host clamp. Unordered ratios + //select the original ratio first, then1 on the upper test. + let nonnegative = if -speed_fraction >= 0.0 { + 0.0 + } else { + speed_fraction + }; + let fraction = if 1.0 - nonnegative >= 0.0 { + nonnegative + } else { + 1.0 + }; + // Skate 3 blends two limits here. Skate 2 uses a single limit. + let limit = + (1.0 - fraction).mul_add(limits.low_speed_change, fraction * limits.high_speed_change); + let below_lower = speed_change < -limit; + if speed_change > limit { + speed_change = limit; + } + if below_lower { + speed_change = -limit; + } + if input.absolute_body_speed + speed_change > limits.maximum_pushable_speed { + let remaining = limits.maximum_pushable_speed - input.absolute_body_speed; + speed_change = if remaining >= 0.0 { remaining } else { 0.0 }; + } + let force_magnitude = (input.scale * speed_change) / input.delta_seconds; + PushAcceleration { + vector: Vector3::new( + input.direction.x * force_magnitude, + input.direction.y * force_magnitude, + input.direction.z * force_magnitude, + ), + ..Default::default() + } +} + +/// Standalone convenience for callers that explicitly need a fresh mass sum. +/// Input +2660 is populated by 0x82C0140C, and 0x82D391B0 appends the result. +/// The input scale is overwritten with the live body mass sum. No direct +/// velocity adjustment is made; the force consumer and solver own its effect. +/// Ground82D38800 instead consumes the already-produced Processed+2660 and +/// defers submission until after the manual branch; use grounded::propulsion +/// for that caller. This convenience does not reproduce Ground's scheduling. +pub fn enqueue_push( + mut input: PushInput, + limits: PushLimits, + inverse_masses: &[f32], + queue: &mut BoardForceQueue, +) -> PushAcceleration { + input.scale = total_body_mass(inverse_masses); + let output = calculate_acceleration(input, limits); + queue.append(QueuedPointForce { + tag: 3, + force_world: output.vector, + point_body: output.local_point, + }); + output +} + +#[cfg(test)] +#[path = "tests/push.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/push_animation.rs b/crates/skate-core/src/riding/push_animation.rs new file mode 100644 index 00000000..c25098bc --- /dev/null +++ b/crates/skate-core/src/riding/push_animation.rs @@ -0,0 +1,100 @@ +//! TU3 push animation calculations, upstream of the contact-driven push force. +//! `0x82BAD658` maps held time to strength; `0x82BACF50` updates the three +//! Andale parameters. These do not select an animation or synthesize root motion. +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct PushAnimationCurves { + /// anim_motion/pushing, collection +640 (x +656, y +688). + pub button_time_max: PointGraph<8>, + /// Collection +560 (x +576, y +608). + pub button_time_to_dv: PointGraph<8>, + /// Collection +720 (x +736, y +768). + pub blend_speed_over_frames: PointGraph<8>, + /// Collection +800 (x +816, y +848). + pub blend_acc_over_frames: PointGraph<8>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushBlendParameters { + pub hstr_vel_b: f32, + pub lstr_vel_b: f32, + /// A negative current value is the native uninitialized sentinel. + pub vel_e: f32, +} + +impl PushAnimationCurves { + pub fn strength(&self, held_seconds: f32, speed: f32) -> f32 { + let duration = self.button_time_max.evaluate(speed); + self.button_time_to_dv + .evaluate(unit_interval(held_seconds / duration)) + } + + /// Returns the values written to shared properties +8/+12/+16 and then + /// published as HSTR_VEL_B, LSTR_VEL_B, VEL_E, in that order. + pub fn update_blend( + &self, + current: PushBlendParameters, + target: PushBlendParameters, + forward_speed: f32, + delta_seconds: f32, + ) -> PushBlendParameters { + let current = if current.vel_e < 0.0 { target } else { current }; + // This factor is a native constant at 0x820849C8, not the graph dt. + let frame_seconds = f32::from_bits(0x3c88_8889); + let velocity_rate = + 1.0 / (self.blend_speed_over_frames.evaluate(current.vel_e) * frame_seconds); + let speed = if forward_speed >= 0.0 { + forward_speed + } else { + 0.0 + }; + let strength_rate = 1.0 / (self.blend_acc_over_frames.evaluate(speed) * frame_seconds); + PushBlendParameters { + hstr_vel_b: approach( + current.hstr_vel_b, + target.hstr_vel_b, + velocity_rate, + delta_seconds, + ), + lstr_vel_b: approach( + current.lstr_vel_b, + target.lstr_vel_b, + velocity_rate, + delta_seconds, + ), + vel_e: approach(current.vel_e, target.vel_e, strength_rate, delta_seconds), + } + } +} + +fn approach(current: f32, target: f32, rate: f32, dt: f32) -> f32 { + let difference = target - current; + let result = if difference.abs() > 0.001 { + if difference > 0.0 { + let candidate = dt.mul_add(rate, current); + if candidate - target >= 0.0 { + target + } else { + candidate + } + } else { + // Native fnmsubs, followed by fsel; preserve the fused operation. + let candidate = -dt.mul_add(rate, -current); + if candidate - target >= 0.0 { + candidate + } else { + target + } + } + } else { + target + }; + unit_interval(result) +} + +fn unit_interval(value: f32) -> f32 { + // Native fsel sequence also canonicalizes negative zero to positive zero. + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} diff --git a/crates/skate-core/src/riding/push_behaviors.rs b/crates/skate-core/src/riding/push_behaviors.rs new file mode 100644 index 00000000..1454bdc3 --- /dev/null +++ b/crates/skate-core/src/riding/push_behaviors.rs @@ -0,0 +1,365 @@ +//! Push behavior state and coefficient calculations from Skate 3 TU3. +//! Animation-tree selection and graph transitions belong to the authored graph. +use super::push_animation::{PushAnimationCurves, PushBlendParameters}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushClipMetrics { + pub length: f32, + pub begin_velocity: f32, + pub end_velocity: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushAttributes { + /// Native order: low-speed/high-strength, high-speed/high-strength, + /// low-speed/low-strength, high-speed/low-strength. + pub clips: [PushClipMetrics; 4], + pub minimum_begin_velocity: f32, + pub maximum_begin_velocity: f32, + pub minimum_delta_velocity: f32, + pub maximum_delta_velocity: f32, +} + +impl PushAttributes { + /// 82B954D8, after 82B95620 obtains each tree's length and its last + /// HStr_Vel_B/LStr_Vel_B and Vel_E attributes. No fitted clip coefficients. + pub fn from_clips(clips: [PushClipMetrics; 4]) -> Self { + let begin = clips.map(|clip| clip.begin_velocity); + let delta = clips.map(|clip| clip.end_velocity - clip.begin_velocity); + Self { + clips, + minimum_begin_velocity: native_min( + native_min(begin[0], begin[1]), + native_min(begin[2], begin[3]), + ), + maximum_begin_velocity: native_max( + native_max(begin[0], begin[1]), + native_max(begin[2], begin[3]), + ), + minimum_delta_velocity: native_min( + native_min(delta[0], delta[1]), + native_min(delta[2], delta[3]), + ), + maximum_delta_velocity: native_max( + native_max(delta[0], delta[1]), + native_max(delta[2], delta[3]), + ), + } + } + + /// ComputeTargetCoefsFromSpeedAndStrength, 82BAD260. The blend accounts + /// for differing clip lengths; a linear velocity interpolation is wrong. + pub fn target(&self, forward_speed: f32, strength: f32) -> PushBlendParameters { + let speed = bounded( + nonnegative(forward_speed), + self.minimum_begin_velocity, + self.maximum_begin_velocity, + ); + let strength = bounded( + strength, + self.minimum_delta_velocity, + self.maximum_delta_velocity, + ); + let [slow_strong, fast_strong, slow_gentle, fast_gentle] = self.clips; + let strong_speed = bounded( + speed, + slow_strong.begin_velocity, + fast_strong.begin_velocity, + ); + let gentle_speed = bounded( + speed, + slow_gentle.begin_velocity, + fast_gentle.begin_velocity, + ); + let strong = velocity_blend( + strong_speed, + slow_strong.length, + slow_strong.begin_velocity, + fast_strong.length, + fast_strong.begin_velocity, + ); + let gentle = velocity_blend( + gentle_speed, + slow_gentle.length, + slow_gentle.begin_velocity, + fast_gentle.length, + fast_gentle.begin_velocity, + ); + let strong_length = (1.0 - strong).mul_add(slow_strong.length, fast_strong.length * strong); + let gentle_length = (1.0 - gentle).mul_add(slow_gentle.length, fast_gentle.length * gentle); + let strong_end = blended_end(strong, slow_strong, fast_strong); + let gentle_end = blended_end(gentle, slow_gentle, fast_gentle); + let target_end = bounded(strength + speed, gentle_end, strong_end); + let strength_blend = velocity_blend( + target_end, + gentle_length, + gentle_end, + strong_length, + strong_end, + ); + PushBlendParameters { + hstr_vel_b: unit(strong), + lstr_vel_b: unit(gentle), + vel_e: unit(strength_blend), + } + } + + /// ComputeRepushDeadline::End, 82BADA90. Despite its graph name, this + /// writes PushState.out_factor, not a time deadline or a graph transition. + pub fn out_factor( + &self, + forward_speed: f32, + strength: f32, + speed_weight: f32, + maximum_out: f32, + ) -> f32 { + let first_speed = self.minimum_begin_velocity + self.minimum_delta_velocity; + let speed = bounded( + nonnegative(forward_speed), + first_speed, + self.maximum_begin_velocity, + ); + let strength = bounded( + strength, + self.minimum_delta_velocity, + self.maximum_delta_velocity, + ); + let strength_factor = unit( + (strength - self.minimum_delta_velocity) + / (self.maximum_delta_velocity - self.minimum_delta_velocity), + ); + let speed_factor = + unit((speed - first_speed) / (self.maximum_begin_velocity - first_speed)); + let combined = strength_factor.mul_add(1.0 - speed_weight, speed_factor * speed_weight); + let lower = if -combined >= 0.0 { 0.0 } else { combined }; + native_min(maximum_out, lower) + } +} + +fn velocity_blend( + speed: f32, + low_length: f32, + low_velocity: f32, + high_length: f32, + high_velocity: f32, +) -> f32 { + let low_distance = low_length * low_velocity; + let speed_distance = speed * low_length; + let sum = high_length.mul_add(high_velocity, speed_distance); + let numerator = speed_distance - low_distance; + let denominator = -speed.mul_add(high_length, -(sum - low_distance)); + unit(numerator / denominator) +} +fn blended_end(coefficient: f32, low: PushClipMetrics, high: PushClipMetrics) -> f32 { + let coefficient = unit(coefficient); + let high_weight = coefficient * high.length; + let low_weight = (1.0 - coefficient) * low.length; + high.end_velocity + .mul_add(high_weight, low.end_velocity * low_weight) + / (high_weight + low_weight) +} + +fn native_min(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { b } else { a } +} +fn native_max(a: f32, b: f32) -> f32 { + if a - b >= 0.0 { a } else { b } +} +fn bounded(value: f32, minimum: f32, maximum: f32) -> f32 { + native_min(maximum, native_max(minimum, value)) +} +fn nonnegative(value: f32) -> f32 { + if value >= 0.0 { value } else { 0.0 } +} +fn unit(value: f32) -> f32 { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushState { + pub out_factor: f32, + pub current_push_dv: f32, + pub current: PushBlendParameters, + pub target: PushBlendParameters, + pub continue_push: bool, +} +impl PushState { + /// InitPush::Begin, 82BAD5B8, preserves out_factor. + pub fn initialize_push(&mut self) { + self.continue_push = false; + self.current_push_dv = 0.0; + self.current = PushBlendParameters { + hstr_vel_b: -1.0, + lstr_vel_b: -1.0, + vel_e: -1.0, + }; + self.target = self.current; + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FirstPushStrength { + pub simulated_held_seconds: f32, + pub push_from_teleport: bool, + pub first_push: bool, + pub first_update: bool, +} +impl FirstPushStrength { + /// Begin82BAD6E8: capture teleport eligibility once per activation. + pub fn begin(time_since_teleport: f32, teleport_window: f32) -> Self { + Self { + simulated_held_seconds: 0.0, + push_from_teleport: time_since_teleport < teleport_window, + first_push: true, + first_update: true, + } + } + /// Update82BAD7A8: the teleport path evaluates the old simulated time + /// before adding dt. A released first push cannot be resumed here. + pub fn update( + &mut self, + state: &mut PushState, + curves: &PushAnimationCurves, + pushing: Option, + forward_speed: f32, + dt: f32, + ) { + if pushing.is_none() { + self.first_push = false; + } + if self.first_push || self.push_from_teleport { + let held = if self.push_from_teleport { + let held = self.simulated_held_seconds; + self.simulated_held_seconds = dt + self.simulated_held_seconds; + held + } else { + pushing.unwrap_or(0.0) + }; + let strength = curves.strength(held, nonnegative(forward_speed)); + if strength > state.current_push_dv { + state.current_push_dv = strength; + } + } + self.first_update = false; + } +} + +/// A snapshot of the authored MG intent list, not raw controller buttons. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PushIntents { + pub pushing: Option, + pub new_push: bool, + /// Presence of the configured newPushName (RightPush or LeftPush in stock). + pub configured_foot: bool, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PushCyclePhase { + HoldingFirst, + WaitingNext, + HoldingNext, + NextReleased, +} +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushCycle { + pub phase: PushCyclePhase, + pub next_push_dv: f32, +} +impl PushCycle { + /// Begin82BADCD0. + pub fn begin( + state: &mut PushState, + curves: &PushAnimationCurves, + intents: PushIntents, + ) -> Self { + let holding = intents.pushing.is_some() && intents.configured_foot; + state.continue_push = holding; + Self { + phase: if holding { + PushCyclePhase::HoldingFirst + } else { + PushCyclePhase::WaitingNext + }, + next_push_dv: curves.button_time_to_dv.evaluate(0.0), + } + } + /// Update82BADE38. Separate phase checks intentionally allow the release + /// and next-press path to run in one graph update, as in the recovered code. + pub fn update( + &mut self, + state: &mut PushState, + curves: &PushAnimationCurves, + intents: PushIntents, + forward_speed: f32, + maximum_holding_acceleration: f32, + ) { + let speed = nonnegative(forward_speed); + if self.phase == PushCyclePhase::HoldingFirst { + if let Some(held) = intents.pushing { + self.next_push_dv = curves.strength(held, speed); + if self.next_push_dv > maximum_holding_acceleration { + self.next_push_dv = maximum_holding_acceleration; + state.current_push_dv = maximum_holding_acceleration; + } + if state.current_push_dv < self.next_push_dv { + state.current_push_dv = self.next_push_dv; + } + } else { + self.phase = PushCyclePhase::WaitingNext; + self.next_push_dv = curves.button_time_to_dv.evaluate(0.0); + state.continue_push = false; + } + } + if self.phase == PushCyclePhase::WaitingNext && intents.new_push && intents.configured_foot + { + self.phase = PushCyclePhase::HoldingNext; + } + if self.phase == PushCyclePhase::HoldingNext && !intents.configured_foot { + self.phase = PushCyclePhase::NextReleased; + state.current_push_dv = self.next_push_dv; + } + if self.phase == PushCyclePhase::HoldingNext { + self.next_push_dv = curves.strength(intents.pushing.unwrap_or(0.0), speed); + if state.current_push_dv < self.next_push_dv { + state.current_push_dv = self.next_push_dv; + } + state.continue_push = true; + } + } +} +/// Physics outputs consumed by GetFootPos82595B28. All positions are world +/// space; deck axes come from the live reckoning transform, not a render pose. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PushFootFrame { + pub left_foot: [f32; 4], + pub right_foot: [f32; 4], + pub deck_position: [f32; 4], + pub deck_y: [f32; 4], + pub deck_z: [f32; 4], + pub skateboard_flipped: bool, +} +impl PushFootFrame { + /// TU382595B28. Reuses the isolated, approximation-derived Xenon dot + /// lowering; independent hardware arithmetic validation remains pending. + pub fn out_distance(&self, right_foot: bool) -> [f32; 2] { + let foot = if right_foot { + self.right_foot + } else { + self.left_foot + }; + let relative = std::array::from_fn(|i| foot[i] - self.deck_position[i]); + let z = if self.skateboard_flipped { + self.deck_z.map(|v| -v) + } else { + self.deck_z + }; + [ + crate::physics::native_arithmetic::dot3(relative, z), + crate::physics::native_arithmetic::dot3(relative, self.deck_y), + ] + } +} + +#[cfg(test)] +#[path = "tests/push_behaviors.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/reckoning_frames.rs b/crates/skate-core/src/riding/reckoning_frames.rs new file mode 100644 index 00000000..75968fd2 --- /dev/null +++ b/crates/skate-core/src/riding/reckoning_frames.rs @@ -0,0 +1,196 @@ +//! Reckoning transform, dynamic lean and lateral tilt, TU3 +//!82D8D688/82D8D930/82D8C4F8. State scheduling and frame producers are separate. +//! Numerical primitives retain their documented hardware-validation boundary. +use crate::{ + math::Vector3, + physics::{ + board_motion_output::inverse_length_squared, + native_arithmetic::dot3, + skeleton_animation_record::{AnimationPartTransform, IDENTITY, compose_affine}, + }, + point_graph::PointGraph, + riding::collision_response::signed_angle, + trigonometry, +}; + +#[derive(Clone, Debug)] +pub struct ReckoningFrames { + ///752: ground frame, whose translation is retained by CalculateTransform. + pub ground: AnimationPartTransform, + ///816: system frame after application of the body-flip transform. + pub system: AnimationPartTransform, + ///880: same frame before body flip, used by CalculateTilt. + pub unflipped: AnimationPartTransform, + ///944: rigid inverse of the final system frame. + pub inverse_system: AnimationPartTransform, + ///1008: body-flip transform, produced by UpdateBodyFlip. + pub body_flip: AnimationPartTransform, + ///1200: heading, reprojected onto the current up plane on each calculation. + pub heading: [f32; 4], + ///1576, published to PhysOutSystemReckoning160 with the stance sign. + pub target_lean_angle: f32, + ///1248: first lane consumed by the body-tilt animation handler. + pub lateral_tilt: [f32; 4], +} + +impl ReckoningFrames { + ///82D33178 initializes each matrix independently to identity and seeds + ///heading to world X. Reset82D8C3A8 later preserves the matrices. + pub fn new() -> Self { + Self { + ground: IDENTITY, + system: IDENTITY, + unflipped: IDENTITY, + inverse_system: IDENTITY, + body_flip: IDENTITY, + heading: [1.0, 0.0, 0.0, 0.0], + target_lean_angle: 0.0, + lateral_tilt: [0.0; 4], + } + } + + ///82D8D688. Cross products use the unnormalized intermediate axis; + ///normalization has two refinements and no epsilon fallback in this leaf. + pub fn calculate_transform(&mut self, up: [f32; 4], ground_normal: [f32; 4]) { + let right = cross(up, self.heading); + let forward = cross(right, up); + self.heading = normalize(forward); + self.system[0] = normalize(right); + self.system[1] = up; + self.system[2] = self.heading; + self.unflipped = self.system; + self.system = compose_affine(&self.body_flip, &self.system); + self.inverse_system = inverse(&self.system); + + let ground_right = cross(ground_normal, self.heading); + let ground_forward = cross(ground_right, ground_normal); + self.ground[0] = normalize(ground_right); + self.ground[1] = ground_normal; + self.ground[2] = normalize(ground_forward); + } + + ///82D8D930. Signed angle is wrapped by fraction/floor, not scalar atan2. + pub fn calculate_dynamic_lean(&mut self, up: [f32; 4], dynamic_up: [f32; 4]) { + let axis = self.system[2]; + let project = |value| { + let amount = dot3(value, axis); + std::array::from_fn(|i| value[i] - axis[i] * amount) + }; + let from = project(up); + let to = project(dynamic_up); + let threshold = f32::from_bits(0x3727_c5ac); //8219B100 + self.target_lean_angle = if dot3(from, from) > threshold && dot3(to, to) > threshold { + wrap_fraction(signed_angle(xyz(from), xyz(to), xyz(axis))) + } else { + 0.0 + }; + } + + ///82D8C4F8. Degenerate projection preserves the preceding lateral tilt. + ///The stock source uses the unflipped up/forward axes and two authored + ///curves, including its absolute wrapped angle and stance sign change. + pub fn calculate_tilt( + &mut self, + reverse_stance: bool, + tilt_vs_angle: &PointGraph<8>, + tilt_vs_up: &PointGraph<8>, + ) { + let axis = self.unflipped[1]; + let world_up = [0.0, 1.0, 0.0, 0.0]; + let dot = dot3(axis, world_up); + let parallel = axis.map(|v| v * dot); + let projected = std::array::from_fn(|i| world_up[i] - parallel[i]); + let squared = dot3(projected, projected); + if squared <= f32::from_bits(0x3a03_126f) { + return; + } + let angle = if world_up == parallel { + 0.0 + } else { + signed_angle(xyz(self.unflipped[2]), xyz(normalize(projected)), xyz(axis)) + }; + let half_pi = f32::from_bits(0x3fc9_0fdb); + let scale = f32::from_bits(0x3f22_f983); + let mut normalized_angle = + ((wrap_fraction(angle - half_pi).abs() - half_pi) * -1.0) * scale; + if reverse_stance { + normalized_angle = -normalized_angle; + } + //Inline native asin polynomial82D8C704..7E0 is the same operation + //tree retained by trigonometry::asin, including one rsqrt refinement. + let normalized_up_angle = (half_pi - trigonometry::asin(axis[1])) * scale; + let angle_tilt = tilt_vs_angle.evaluate(normalized_angle.abs()); + let up_tilt = tilt_vs_up.evaluate(normalized_up_angle.abs()); + let signed = if normalized_angle >= -0.0 { + angle_tilt + } else { + -angle_tilt + }; + self.lateral_tilt = [up_tilt * signed, 0.0, 0.0, 0.0]; + } +} + +fn normalize(value: [f32; 4]) -> [f32; 4] { + let reciprocal = inverse_length_squared(dot3(value, value), 2); + value.map(|v| v * reciprocal) +} +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]), + (-a[3]).mul_add(b[3], a[3] * b[3]), + ] +} +fn inverse(frame: &AnimationPartTransform) -> AnimationPartTransform { + let axes: [[f32; 4]; 3] = std::array::from_fn(|i| [frame[0][i], frame[1][i], frame[2][i], 0.0]); + let p = frame[3].map(|v| 0.0 - v); + let position = std::array::from_fn(|i| { + let z = axes[2][i] * p[2]; + let y = axes[1][i].mul_add(p[1], z); + axes[0][i].mul_add(p[0], y) + }); + [axes[0], axes[1], axes[2], position] +} +fn wrap_fraction(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let fraction = fraction - if fraction > 0.5 { 1.0 } else { 0.0 }; + fraction * f32::from_bits(0x40c9_0fdb) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn body_flip_changes_system_but_retains_ground_and_preflip_frames() { + let mut state = ReckoningFrames::new(); + state.heading = [0.0, 0.0, 1.0, 0.0]; + state.body_flip = [ + [0., 0., -1., 0.], + [0., 1., 0., 0.], + [1., 0., 0., 0.], + [3., 0., 0., 0.], + ]; + state.calculate_transform([0., 1., 0., 0.], [0., 1., 0., 0.]); + assert_eq!(state.unflipped, IDENTITY); + assert_eq!(state.ground, IDENTITY); + assert_eq!(state.system, state.body_flip); + assert_eq!( + compose_affine(&state.system, &state.inverse_system), + IDENTITY + ); + state.calculate_dynamic_lean([0., 1., 0., 0.], [0., 1., 0., 0.]); + assert!(state.target_lean_angle.abs() < 0.001); + state.lateral_tilt = [0.3, 0., 0., 0.]; + let curve = PointGraph { + x: [0.; 8], + y: [0.; 8], + }; + state.calculate_tilt(false, &curve, &curve); + assert_eq!(state.lateral_tilt, [0.3, 0., 0., 0.]); + } +} diff --git a/crates/skate-core/src/riding/slide_friction.rs b/crates/skate-core/src/riding/slide_friction.rs new file mode 100644 index 00000000..257df578 --- /dev/null +++ b/crates/skate-core/src/riding/slide_friction.rs @@ -0,0 +1,46 @@ +//! Complete TU3 Toolkit_CalcSlideFriction 82D92970; Ground force tag 1. +use super::vector::{clamp, dot3}; +use crate::{point_graph::PointGraph, trigonometry::acos}; + +pub struct SlideFrictionSettings { + pub angle_response: PointGraph<16>, // +196/+260 + pub speed_response: PointGraph<8>, // +324/+356 + pub time_response: PointGraph<16>, // +388/+452 + pub scalar_516: f32, + pub heading_time_limit: f32, // surface +236 + pub friction: f32, // surface +240 +} +pub struct SlideFrictionInput { + pub heading_time: f32, + pub normal: [f32; 4], // Processed +464 + pub velocity: [f32; 4], // +400 + pub side_axis: [f32; 4], // +64 + pub surface_speed: f32, // +2656 + pub scalar_2764: f32, +} +pub fn calculate(s: &SlideFrictionSettings, i: &SlideFrictionInput) -> [f32; 8] { + // VMX min/max select the second argument on unordered comparisons. + let y = if -1.0 > i.normal[1] { + -1.0 + } else { + i.normal[1] + }; + let y = if 1.0 < y { 1.0 } else { y }; + let angle = clamp(acos(y) * f32::from_bits(0x3f22_f983), 0.0, 1.0); + let angle = 1.0 - s.angle_response.evaluate(angle); + let speed = 1.0 - s.speed_response.evaluate(i.surface_speed * 0.1); + let time = s + .time_response + .evaluate(clamp(i.heading_time, 0.0, s.heading_time_limit) / s.heading_time_limit); + let blend = (1.0 - s.scalar_516).mul_add(i.scalar_2764, s.scalar_516); + let response = if angle - speed >= 0.0 { angle } else { speed }; + let scalar = -(((time * blend) * response) * s.friction); + let normal_speed = dot3(i.velocity, i.normal); + let tangent = core::array::from_fn(|n| i.velocity[n] - i.normal[n] * normal_speed); + let side_speed = dot3(tangent, i.side_axis); + let mut output = [0.0; 8]; + for n in 0..4 { + output[n] = (i.side_axis[n] * side_speed) * scalar; + } + output +} diff --git a/crates/skate-core/src/riding/speed_and_slope.rs b/crates/skate-core/src/riding/speed_and_slope.rs new file mode 100644 index 00000000..8f64e079 --- /dev/null +++ b/crates/skate-core/src/riding/speed_and_slope.rs @@ -0,0 +1,62 @@ +//! Speed/slope coefficient published to the stock ActionGraph. +//! +//! TU3 PhysicalPlayerHiLOD::CalculateHeadingAdjustFactor82DB5E10 is called +//! during PostInput82DB56C0. ProcessOutput82DB7580 publishes its result to +//! SkateboardMotion+192. The source uses the *turn torque* curves, despite +//! the function name; the nearby HeadingAdjustVsSpeed/Slope curves differ. +use crate::{physics::native_arithmetic, point_graph::PointGraph, trigonometry}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpeedAndSlopeSettings { + /// physics_heading layout336/368. + pub turn_torque_vs_speed: PointGraph<8>, + /// physics_heading layout400/432. + pub turn_torque_vs_slope: PointGraph<8>, + /// physics_heading layout600. + pub heading_adjust_max_speed: f32, +} + +impl SpeedAndSlopeSettings { + /// Complete82DB5E10, including the slope cutoff and speed saturation. + /// + /// `ground_normal_y` is ground output+96.Y, copied to ProcessedPhysIn+464; + /// `forward_speed` is skateboard output+168, copied to ProcessedPhysIn+2612. + /// The reciprocal/acos primitives retain their documented numerical + /// approximation boundary until independent hardware validation. + pub fn calculate(&self, ground_normal_y: f32, forward_speed: f32) -> f32 { + let normal_y = native_arithmetic::vector_min( + 1.0, + native_arithmetic::vector_max(-1.0, ground_normal_y), + ); + let half_pi = f32::from_bits(0x3FC9_0FDB); + let mut inverse_half_pi = native_arithmetic::reciprocal_estimate(half_pi); + for _ in 0..2 { + let error = (-inverse_half_pi).mul_add(half_pi, 1.0); + inverse_half_pi = inverse_half_pi.mul_add(error, inverse_half_pi); + } + let mut slope = inverse_half_pi * trigonometry::acos(normal_y); + if slope > f32::from_bits(0x3F99_999A) { + slope = 0.0; + } + + let absolute_speed = forward_speed.abs(); + let nonnegative_speed = if -absolute_speed >= -0.0 { + 0.0 + } else { + absolute_speed + }; + let bounded_speed = if self.heading_adjust_max_speed - nonnegative_speed >= -0.0 { + nonnegative_speed + } else { + self.heading_adjust_max_speed + }; + let speed_fraction = bounded_speed / self.heading_adjust_max_speed; + let speed_factor = self.turn_torque_vs_speed.evaluate(speed_fraction); + let slope_factor = self.turn_torque_vs_slope.evaluate(slope); + speed_factor * slope_factor + } +} + +#[cfg(test)] +#[path = "tests/speed_and_slope.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/speed_model.rs b/crates/skate-core/src/riding/speed_model.rs new file mode 100644 index 00000000..c3bbd1c2 --- /dev/null +++ b/crates/skate-core/src/riding/speed_model.rs @@ -0,0 +1,188 @@ +//! TU3 Skateboard::UpdateSpeedModel 82C04F68, including its force-enable gate. +//! The caller supplies GetEffectiveTransform and GetMass outputs, not a guessed +//! direction or mass. Ground submits this result as force tag 6. +use super::vector::{clamp, dot3, normalize}; +use crate::point_graph::PointGraph; + +pub struct SpeedModelSettings { + /// physics_speedmodel layout +0,+8,+16,+20,+24. + pub negative_gain: f32, + pub maximum_gravity_acceleration: f32, + pub gravity: f32, + pub positive_gain: f32, + pub coffin_acceleration: f32, + /// Surface layout +260 and curve +96/+128. + pub speed_error_bound: f32, + pub surface_friction: PointGraph<8>, + /// physics_manual layout +180,+212,+220. + pub manual_acceleration: f32, + pub negative_manual_angle_limit: f32, + pub manual_angle_limit: f32, + /// Global +184 layout +356 and curves +144/+176,+224/+256. + pub no_input_delay: f32, + pub no_input_friction: PointGraph<8>, + pub manual_friction: PointGraph<8>, + /// Selected-mode layout +44 and +40. + pub override_enabled: bool, + pub override_speed: f32, + /// Live constants 830BD350 and 830BD380 (four native lanes each). + pub normal_threshold: [f32; 4], + pub override_direction_threshold: [f32; 4], +} + +#[derive(Clone, Copy, Debug)] +pub struct SpeedModelState { + pub target_speed: f32, // Board +260 + pub flags_1360: u32, +} + +pub struct SpeedModelInput { + pub flags_2468: u32, + pub flags_2476: u32, + pub wheel_contact_count: i32, // +2556 + pub frames_2580: i32, + pub timestep: f32, // +2604 + pub signed_speed: f32, // +2612 + pub angle_2652: f32, + pub surface_speed: f32, // +2656 + pub turn_2672: f32, + pub balance: f32, // +2720 + pub elapsed_without_input: f32, // +2748 + pub manual_state_276: f32, // Board +276 + pub vector_160: [f32; 4], + pub vector_352: [f32; 4], + pub velocity_416: [f32; 4], + pub normal_464: [f32; 4], + /// Z column of 82C01BF8's result; stance correction has already happened. + pub effective_forward: [f32; 4], + /// 82C06F58 result, whose two-accumulator summation is implemented in mass. + pub mass: f32, +} + +/// Force vector followed by zeroed local application point, all four lanes. +pub fn update( + state: &mut SpeedModelState, + settings: &SpeedModelSettings, + input: &SpeedModelInput, +) -> [f32; 8] { + let speed = input.signed_speed.abs(); + let dt = input.timestep; + let bound = settings.speed_error_bound; + let lower = speed - bound; + let lower = if -lower >= 0.0 { 0.0 } else { lower }; + let mut target = if state.flags_1360 & 0x8000_0000 != 0 { + state.flags_1360 &= 0x7fff_ffff; + speed + } else { + clamp(state.target_speed, lower, bound + speed) + }; + let coffin = if input.flags_2476 & 0x4000_0000 != 0 { + settings.coffin_acceleration * dt + } else { + 0.0 + }; + let projection = dot3(input.velocity_416, input.effective_forward); + let projected = input.effective_forward.map(|v| v * projection); + let normal_speed = dot3(projected, input.normal_464); + let tangent = normalize( + core::array::from_fn(|i| projected[i] - input.normal_464[i] * normal_speed), + settings.normal_threshold, + ); + let gravity = clamp( + -tangent[1] * settings.gravity, + -settings.maximum_gravity_acceleration, + settings.maximum_gravity_acceleration, + ) * dt; + + if input.frames_2580 < 30 { + let active = if input.manual_state_276 == 0.0 { + 0.0 + } else { + 1.0 + }; + let mut direction = if input.balance > 0.0 { 1.0 } else { -1.0 }; + if 0.0 > dot3(input.vector_352, tangent) { + direction *= -1.0; + } + let angle = input.angle_2652.abs(); + let within = if angle > settings.manual_angle_limit { + 0.0 + } else { + 1.0 + }; + let negative_within = if angle > settings.negative_manual_angle_limit { + 0.0 + } else { + 1.0 + }; + let mut acceleration = + ((((input.turn_2672 * input.turn_2672) * settings.manual_acceleration) * within) + * direction) + * active; + if acceleration < 0.0 { + acceleration *= negative_within; + } + target = acceleration + target; + } + let friction = -settings.surface_friction.evaluate(input.surface_speed) * dt; + let surface_delta = if gravity > 0.0 { + friction + gravity + } else if friction - gravity >= 0.0 { + gravity + } else { + friction + }; + target = (surface_delta + coffin) + target; + if input.elapsed_without_input > settings.no_input_delay { + target = -settings + .no_input_friction + .evaluate(input.surface_speed) + .mul_add(dt, -target); + } + if input.balance != 0.0 { + target = -settings + .manual_friction + .evaluate(input.surface_speed) + .mul_add(dt, -target); + } + state.target_speed = if target >= 0.0 { target } else { 0.0 }; + let mut error = clamp(state.target_speed - speed, -bound, bound); + if settings.override_enabled + && input.flags_2476 & 0x0040_0000 != 0 + && input.normal_464[1] > 0.65 + { + let mut goal = settings.override_speed * f32::from_bits(0x3E8E_38E4); + let along = dot3(input.velocity_416, input.vector_160); + if settings + .override_direction_threshold + .iter() + .all(|t| *t > along) + && 1.0 > input.surface_speed + { + goal *= -1.0; + } + error = clamp(goal - speed, -0.4, 0.4); + } + let enabled = input.flags_2468 & 0x6201_0000 == 0 + && input.flags_2476 & 0x0800_0000 == 0 + && input.wheel_contact_count >= 2; + let gain = if !enabled { + 0.0 + } else if error < 0.0 { + settings.negative_gain + } else { + settings.positive_gain + }; + let scalar = ((input.mass * gain) * error) / dt; + let forward = dot3(input.effective_forward, tangent) > 0.0; + let mut output = [0.0; 8]; + for i in 0..4 { + let axis = if forward { + input.effective_forward[i] + } else { + -input.effective_forward[i] + }; + output[i] = axis * scalar; + } + output +} diff --git a/crates/skate-core/src/riding/speed_wobble.rs b/crates/skate-core/src/riding/speed_wobble.rs new file mode 100644 index 00000000..ff210cee --- /dev/null +++ b/crates/skate-core/src/riding/speed_wobble.rs @@ -0,0 +1,101 @@ +//! Complete TU3 SpeedWobble::CalculateSpeedWobble (82C0DBE8). +//! UpdateSpeedWobble 82C03638 supplies the selected-mode attributes and body +//! inputs; Ground Update 82D38800 applies the returned tilt before truck targets. +use crate::{point_graph::PointGraph, trigonometry::sin}; + +#[derive(Clone, Copy, Debug)] +pub struct SpeedWobbleSettings { + /// Layout arrays +0/+32, +64/+96, +128/+160, +192/+224. + pub frequency_time: PointGraph<8>, + pub frequency_speed: PointGraph<8>, + pub amplitude_time: PointGraph<8>, + pub amplitude_speed: PointGraph<8>, + /// Layout +256 through +284, in native order. + pub tightness_threshold: f32, + pub time_range: f32, + pub speed_range: f32, + pub frequency_scale: f32, + pub amplitude_scale: f32, + pub crouch_threshold: f32, + pub height_min: f32, + pub height_max: f32, +} + +/// Fields +16..+44, including untouched +20 and opaque/reset-only +24/+28. +/// The active flag is a byte; retain its other three packed bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpeedWobbleState(pub [u32; 8]); + +impl SpeedWobbleState { + /// The same six-float/one-byte reset used in Ground Enter 82D376D0. + pub fn reset(&mut self) { + for index in [0, 2, 3, 4, 5, 6] { + self.0[index] = 0; + } + self.0[7] &= 0x00ff_ffff; + } + + fn active(&self) -> bool { + self.0[7] >> 24 != 0 + } +} + +#[derive(Clone, Copy, Debug)] +pub struct SpeedWobbleInput { + pub tilt: f32, + /// Signed speed, ProcessedPhysIn +2612. This routine does not take abs(). + pub speed: f32, + pub center_of_mass_height: f32, + pub truck_tightness: f32, + /// Resolved mode Hash_77AFCE78FE1206CA / Hash_5B57F2CCCCEEF430. + pub activation_threshold: f32, + pub amplitude_multiplier: f32, +} + +pub fn calculate( + state: &mut SpeedWobbleState, + settings: &SpeedWobbleSettings, + input: SpeedWobbleInput, +) -> f32 { + let height = (input.center_of_mass_height - settings.height_min) + / (settings.height_max - settings.height_min); + let crouch = unit(1.0 - height); + let threshold = crouch.mul_add( + settings.crouch_threshold, + settings.tightness_threshold * input.truck_tightness, + ) + input.activation_threshold; + if !state.active() && input.speed > threshold { + state.reset(); + state.0[7] |= 0x0100_0000; + } + if !state.active() { + return input.tilt; + } + + let time = f32::from_bits(state.0[0]); + let time_fraction = unit(time / settings.time_range); + let speed_fraction = unit((input.speed - threshold) / settings.speed_range); + let frequency = (settings.frequency_time.evaluate(time_fraction) + * settings.frequency_speed.evaluate(speed_fraction)) + * settings.frequency_scale; + let amplitude = settings.amplitude_speed.evaluate(speed_fraction) + * settings.amplitude_time.evaluate(time_fraction); + let phase = frequency.mul_add(f32::from_bits(0x3DD6_7751), f32::from_bits(state.0[4])); + let scale = (settings.amplitude_scale * amplitude) * input.amplitude_multiplier; + let displacement = sin(phase) * scale; + state.0[0] = (time + f32::from_bits(0x3C88_8889)).to_bits(); + state.0[4] = phase.to_bits(); + state.0[5] = amplitude.to_bits(); + state.0[6] = displacement.to_bits(); + let result = (displacement + input.tilt) * 0.5; + // This frame still returns the computed tilt when speed drops below threshold. + if input.speed < threshold { + state.reset(); + } + result +} + +fn unit(value: f32) -> f32 { + let lower = if -value >= 0.0 { 0.0 } else { value }; + if 1.0 - lower >= 0.0 { lower } else { 1.0 } +} diff --git a/crates/skate-core/src/riding/steering.rs b/crates/skate-core/src/riding/steering.rs new file mode 100644 index 00000000..e7966f47 --- /dev/null +++ b/crates/skate-core/src/riding/steering.rs @@ -0,0 +1,130 @@ +//! TU3 steering tilt (`0x82D92440`) and truck targets (`0x82C040F0`). +use crate::point_graph::PointGraph; + +#[derive(Clone, Copy, Debug)] +pub struct SteeringSettings { + pub hard_turn_increase: f32, + pub damping: f32, + pub speed_graph_max_speed: f32, + pub push_scalar_increment: f32, + pub push_scalar_decrement: f32, + pub push_scalar_min: f32, + pub manual_scalar: f32, + pub general_scalar: f32, + pub tight_trucks_scalar: f32, + pub speed_graph: PointGraph<8>, + pub input_graph: PointGraph<8>, + pub tilt_blending: f32, +} + +/// Field names describe the reads from `ProcessedPhysIn`. No yaw-rate or +/// bicycle steering interpretation is applied to the returned tilt. +#[derive(Clone, Copy, Debug, Default)] +pub struct SteeringInput { + pub turn: f32, + pub hard_turn: f32, + pub absolute_body_speed: f32, + pub flipped_controls_scalar: f32, + pub balance: f32, + pub truck_tightness: f32, + /// `ProcessedPhysIn+2468`, bit 27 (distinct from push-force bit 25). + pub pushing: bool, +} + +/// Optional pointers in the native function are represented independently. +/// Updates are per physics call; the native routine has no timestep argument. +pub fn calculate_tilt( + settings: &SteeringSettings, + input: SteeringInput, + push_scalar: Option<&mut f32>, + damped_turn: Option<&mut f32>, +) -> f32 { + let hard_scalar = input + .hard_turn + .abs() + .mul_add(settings.hard_turn_increase, 1.0); + let mut turn = input.turn; + if input.hard_turn != 0.0 { + turn = if turn >= 0.0 { 1.0 } else { -1.0 }; + } + if let Some(previous) = damped_turn { + turn = (1.0 - settings.damping).mul_add(*previous, settings.damping * turn); + *previous = turn; + } + let speed_scalar = settings + .speed_graph + .evaluate((input.absolute_body_speed / settings.speed_graph_max_speed).clamp(0.0, 1.0)); + let input_scalar = settings.input_graph.evaluate(turn.abs()); + let push = if let Some(previous) = push_scalar { + let next = if input.pushing { + *previous - settings.push_scalar_decrement + } else { + settings.push_scalar_increment + *previous + }; + *previous = next.clamp(settings.push_scalar_min, 1.0); + *previous + } else { + 1.0 + }; + let manual = if input.balance != 0.0 { + settings.manual_scalar + } else { + 1.0 + }; + let tightness = settings + .tight_trucks_scalar + .mul_add(input.truck_tightness, 1.0) + - input.truck_tightness; + input.flipped_controls_scalar + * settings.general_scalar + * manual + * tightness + * push + * input_scalar + * speed_scalar + * hard_scalar + * turn +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TruckSteeringState { + pub deck_tilt: f32, + /// Native `SkateboardBody+7680/+7684` order, not stance-relative labels. + pub targets: [f32; 2], + pub activation_time: [f32; 2], +} + +impl TruckSteeringState { + /// Flags are retained in their native words to make stance-dependent + /// contact ownership explicit (`+2468` bit 20, `+2472` bits 26 and 27). + pub fn update(&mut self, target: f32, blend: f32, flags_2468: u32, flags_2472: u32) { + self.deck_tilt = (1.0 - blend).mul_add(self.deck_tilt, blend * target); + let mut active = [flags_2472 & (1 << 27) != 0, flags_2472 & (1 << 26) != 0]; + if flags_2468 & (1 << 20) != 0 { + active.swap(0, 1); + } + for (index, enabled) in active.into_iter().enumerate() { + let timer = &mut self.activation_time[index]; + let angle = &mut self.targets[index]; + if enabled { + if *timer <= 0.16500001 { + *timer += f32::from_bits(0x3C88_8889); + let fraction = (*timer * 6.060606).clamp(0.0, 1.0); + *angle = (1.0 - fraction).mul_add(*angle, fraction * self.deck_tilt); + } else { + *angle = self.deck_tilt; + } + } else { + // The first inactive frame holds the angle; subsequent frames decay. + if *timer == 0.0 { + *angle *= 0.983; + } + *timer = 0.0; + } + } + } +} + +#[cfg(test)] +#[path = "tests/steering.rs"] +mod tests; diff --git a/crates/skate-core/src/riding/straighten.rs b/crates/skate-core/src/riding/straighten.rs new file mode 100644 index 00000000..6a4b9a55 --- /dev/null +++ b/crates/skate-core/src/riding/straighten.rs @@ -0,0 +1,37 @@ +//! Complete TU3 Toolkit_CalcStraightenOutTorque 82D92B60. +use super::vector::{clamp, cross, dot3}; +use crate::point_graph::PointGraph; +pub struct StraightenSettings { + pub time_response: PointGraph<16>, // settings +768/+832 + pub opposite_turn_limit: f32, // +896 + pub time_scalar: f32, // +900 + pub heading_time_limit: f32, // surface +236 + pub strength: f32, // surface +232 +} +pub struct StraightenInput { + pub heading_time: f32, + pub scalar_2764: f32, + pub turn: f32, // +2676 + pub forward: [f32; 4], // +352 + pub velocity: [f32; 4], // +400 + pub normal: [f32; 4], // +464 +} +pub fn calculate(s: &StraightenSettings, i: &StraightenInput) -> [f32; 4] { + let limit = (1.0 - s.time_scalar).mul_add(i.scalar_2764, s.time_scalar) * s.heading_time_limit; + let strength = s + .time_response + .evaluate(clamp(i.heading_time, 0.0, limit) / limit) + * s.strength; + let forward = if dot3(i.forward, i.velocity) < 0.0 { + i.forward.map(|v| -v) + } else { + i.forward + }; + let error = dot3(cross(forward, i.velocity), i.normal); + let mut output = i.normal.map(|v| v * (error * strength)); + if i.turn * error < 0.0 { + let scale = 1.0 - clamp(i.turn.abs(), 0.0, s.opposite_turn_limit) / s.opposite_turn_limit; + output = output.map(|v| v * scale); + } + output +} diff --git a/crates/skate-core/src/riding/tests/ground_orientation.rs b/crates/skate-core/src/riding/tests/ground_orientation.rs new file mode 100644 index 00000000..0bd4443c --- /dev/null +++ b/crates/skate-core/src/riding/tests/ground_orientation.rs @@ -0,0 +1,39 @@ +use super::*; +fn constant(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 settings() -> GroundOrientationSettings { + GroundOrientationSettings { + ground_normal_smoothing: [1.0,0.0,0.0,1.0], + up_vector_smoothing_slow: [1.0,0.0,0.0,1.0], + up_vector_smoothing_fast: [1.0,0.0,0.0,1.0], + dynamic_up_vs_ground_y: constant(1.0), ground_vector_blend: constant(0.0), + deck_angle_usage_vs_speed: constant(0.0), up_vector_smoothing_vs_speed: constant(0.0), + up_vector_max_delta_vs_speed: constant(1.0), ground_blend_max_delta:0.1, + up_vector_max_acceleration:2.0, anti_wobble_damping:0.5, extra_side_damping:0.6, + minimum_wheels_for_ground_blend:0, + } +} +fn input(normal: Vector3) -> GroundOrientationInput { + GroundOrientationInput { com_to_deck: UP, ground_normal:normal,dynamic_up:normal, + speed:0.0,wheel_contact_count:4,animation_balance:0.0,deck_angle_curve_input:0.0, + board_up:UP,board_forward:Vector3::new(0.0,0.0,1.0), + effective_board_forward:Vector3::new(0.0,0.0,1.0), + previous_reckoning_right:Vector3::new(1.0,0.0,0.0),prevent_up_behind_board:false } +} +#[test] +fn stationary_filter_startup_has_no_false_tilt_or_acceleration() { + let s=settings();let mut state=GroundOrientation::new(&s); + for _ in 0..120 {state.update(&s,input(UP));} + assert_eq!(state.up,UP);assert_eq!(state.ground_normal,UP); + assert_eq!(state.up_velocity,Vector3::ZERO); +} +#[test] +fn changed_contact_normal_does_not_instantly_replace_the_skater_up() { + let s=settings();let mut state=GroundOrientation::new(&s); + let wall=Vector3::new(1.0,0.0,0.0);state.update(&s,input(wall)); + assert_eq!(state.ground_normal,wall); + assert_ne!(state.up,state.ground_normal); + assert!(state.up.y>0.99 && state.up.x>0.0 && state.up.x<0.04); + assert_eq!(&state.slow_filter.words()[4..8],&lanes(state.up).map(f32::to_bits)); +} \ No newline at end of file diff --git a/crates/skate-core/src/riding/tests/push.rs b/crates/skate-core/src/riding/tests/push.rs new file mode 100644 index 00000000..e0983296 --- /dev/null +++ b/crates/skate-core/src/riding/tests/push.rs @@ -0,0 +1,82 @@ +use super::*; + +fn input() -> PushInput { + PushInput { + flags_2468: 0x0200_0000, + flags_2472: 0, + target_speed: 10.0, + current_speed: 0.0, + absolute_body_speed: 0.0, + scale: 0.5, + delta_seconds: 0.125, + direction: Vector3::new(0.0, 0.0, 1.0), + } +} +fn limits() -> PushLimits { + PushLimits { + maximum_pushable_speed: 8.5, + low_speed_change: 2.0, + high_speed_change: 1.0, + } +} + +#[test] +fn inactive_and_suppressed_push_clear_both_output_vectors() { + let mut i = input(); + i.flags_2468 = 0; + i.flags_2472 = 0x200; + assert_eq!( + calculate_acceleration(i, limits()), + PushAcceleration::default() + ); + i.flags_2468 = 0x0200_0000; + assert_eq!( + calculate_acceleration(i, limits()), + PushAcceleration { + suppressed: true, + ..Default::default() + } + ); +} + +#[test] +fn tu3_blends_the_two_speed_limits_before_time_conversion() { + assert_eq!(calculate_acceleration(input(), limits()).vector.z, 8.0); + let i = PushInput { + current_speed: 4.25, + absolute_body_speed: 4.25, + ..input() + }; + assert_eq!(calculate_acceleration(i, limits()).vector.z, 6.0); +} + +#[test] +fn speed_cap_is_based_on_absolute_speed_and_never_brakes() { + let i = PushInput { + current_speed: 8.25, + absolute_body_speed: 8.25, + ..input() + }; + assert_eq!(calculate_acceleration(i, limits()).vector.z, 1.0); + let i = PushInput { + absolute_body_speed: 9.0, + ..i + }; + assert_eq!(calculate_acceleration(i, limits()).vector, Vector3::ZERO); + let i = PushInput { + target_speed: -1.0, + ..input() + }; + assert_eq!(calculate_acceleration(i, limits()).vector, Vector3::ZERO); +} + +#[test] +fn native_force_direction_keeps_vertical_component_and_zero_application_point() { + let i = PushInput { + direction: Vector3::new(0.0, 0.6, 0.8), + ..input() + }; + let result = calculate_acceleration(i, limits()); + assert_eq!(result.vector, Vector3::new(0.0, 4.8, 6.4)); + assert_eq!(result.local_point, Vector3::ZERO); +} diff --git a/crates/skate-core/src/riding/tests/push_behaviors.rs b/crates/skate-core/src/riding/tests/push_behaviors.rs new file mode 100644 index 00000000..97e00372 --- /dev/null +++ b/crates/skate-core/src/riding/tests/push_behaviors.rs @@ -0,0 +1,222 @@ +use super::*; +use crate::point_graph::PointGraph; +fn curves() -> PushAnimationCurves { + let linear = PointGraph { + x: std::array::from_fn(|i| i as f32 / 7.0), + y: std::array::from_fn(|i| i as f32), + }; + let constant = PointGraph { + x: linear.x, + y: [1.0; 8], + }; + PushAnimationCurves { + button_time_max: constant, + button_time_to_dv: linear, + blend_speed_over_frames: constant, + blend_acc_over_frames: constant, + } +} +fn state() -> PushState { + let zero = PushBlendParameters { + hstr_vel_b: 0.0, + lstr_vel_b: 0.0, + vel_e: 0.0, + }; + PushState { + out_factor: 0.375, + current_push_dv: 0.0, + current: zero, + target: zero, + continue_push: false, + } +} +fn clips() -> PushAttributes { + PushAttributes::from_clips([ + PushClipMetrics { + length: 1.0, + begin_velocity: 0.0, + end_velocity: 4.0, + }, + PushClipMetrics { + length: 2.0, + begin_velocity: 4.0, + end_velocity: 8.0, + }, + PushClipMetrics { + length: 1.0, + begin_velocity: 0.0, + end_velocity: 1.0, + }, + PushClipMetrics { + length: 2.0, + begin_velocity: 4.0, + end_velocity: 5.0, + }, + ]) +} +fn near(a: f32, b: f32) { + assert!((a - b).abs() < 0.000002, "{a} != {b}"); +} + +#[test] +fn unequal_clip_lengths_require_distance_weighted_velocity_blend() { + let target = clips().target(2.0, 2.5); + // At coefficient1/3, equal weighted durations make the begin velocity2. + // The strong/gentle end velocities are6 and3, so their midpoint is4.5. + near(target.hstr_vel_b, 1.0 / 3.0); + near(target.lstr_vel_b, 1.0 / 3.0); + near(target.vel_e, 0.5); +} +#[test] +fn authored_bounds_cap_begin_speed_and_strength_before_blending() { + let attributes = clips(); + assert_eq!(attributes.minimum_begin_velocity, 0.0); + assert_eq!(attributes.maximum_begin_velocity, 4.0); + assert_eq!(attributes.minimum_delta_velocity, 1.0); + assert_eq!(attributes.maximum_delta_velocity, 4.0); + assert_eq!( + attributes.target(-5.0, -3.0), + PushBlendParameters { + hstr_vel_b: 0.0, + lstr_vel_b: 0.0, + vel_e: 0.0 + } + ); + assert_eq!( + attributes.target(10.0, 20.0), + PushBlendParameters { + hstr_vel_b: 1.0, + lstr_vel_b: 1.0, + vel_e: 1.0 + } + ); +} +#[test] +fn out_factor_combines_normalized_speed_strength_then_caps_at_stock_limit() { + near(clips().out_factor(2.5, 2.5, 0.4, 0.659), 0.5); + assert_eq!(clips().out_factor(50.0, 50.0, 0.4, 0.659), 0.659); + assert_eq!(clips().out_factor(-20.0, -20.0, 0.4, 0.659), 0.0); +} +#[test] +fn init_push_preserves_out_factor_and_marks_all_blends_uninitialized() { + let mut state = state(); + state.current_push_dv = 4.0; + state.continue_push = true; + state.initialize_push(); + assert_eq!(state.out_factor, 0.375); + assert_eq!(state.current_push_dv, 0.0); + assert!(!state.continue_push); + assert_eq!(state.current, state.target); + assert_eq!(state.current.vel_e, -1.0); +} +#[test] +fn teleport_push_evaluates_previous_held_time_before_advancing() { + let mut first = FirstPushStrength::begin(0.1, 0.5); + let mut state = state(); + first.update(&mut state, &curves(), None, 0.0, 0.25); + assert_eq!(state.current_push_dv, 0.0); + assert_eq!(first.simulated_held_seconds, 0.25); + assert!(!first.first_push && !first.first_update); + first.update(&mut state, &curves(), None, 0.0, 0.25); + near(state.current_push_dv, 1.75); +} +#[test] +fn first_push_release_latches_until_fresh_activation() { + let mut first = FirstPushStrength::begin(0.5, 0.5); + assert!(!first.push_from_teleport); + let mut state = state(); + first.update(&mut state, &curves(), Some(0.25), 0.0, 0.1); + let strength = state.current_push_dv; + first.update(&mut state, &curves(), None, 0.0, 0.1); + first.update(&mut state, &curves(), Some(1.0), 0.0, 0.1); + assert_eq!(state.current_push_dv, strength); +} +#[test] +fn holding_first_cap_can_reduce_strength_but_next_hold_is_uncapped() { + let mut state = state(); + state.current_push_dv = 6.0; + let mut cycle = PushCycle::begin( + &mut state, + &curves(), + PushIntents { + pushing: Some(1.0), + configured_foot: true, + new_push: false, + }, + ); + cycle.update( + &mut state, + &curves(), + PushIntents { + pushing: Some(1.0), + configured_foot: true, + new_push: false, + }, + 0.0, + 4.0, + ); + assert_eq!(state.current_push_dv, 4.0); + cycle.update(&mut state, &curves(), PushIntents::default(), 0.0, 4.0); + assert!(!state.continue_push); + cycle.update( + &mut state, + &curves(), + PushIntents { + pushing: Some(1.0), + configured_foot: true, + new_push: true, + }, + 0.0, + 4.0, + ); + assert_eq!(state.current_push_dv, 7.0); + assert_eq!(cycle.phase, PushCyclePhase::HoldingNext); + state.current_push_dv = 9.0; + cycle.update(&mut state, &curves(), PushIntents::default(), 0.0, 4.0); + assert_eq!(state.current_push_dv, 7.0); + assert_eq!(cycle.phase, PushCyclePhase::NextReleased); + // Release does not clear the continuation set in the previous next-hold. + assert!(state.continue_push); +} +#[test] +fn released_first_and_new_push_checks_can_cascade_in_one_update() { + let mut state = state(); + let mut cycle = PushCycle::begin( + &mut state, + &curves(), + PushIntents { + pushing: Some(0.25), + configured_foot: true, + new_push: false, + }, + ); + cycle.update( + &mut state, + &curves(), + PushIntents { + pushing: None, + configured_foot: true, + new_push: true, + }, + 0.0, + 4.0, + ); + assert_eq!(cycle.phase, PushCyclePhase::HoldingNext); + assert!(state.continue_push); + assert_eq!(cycle.next_push_dv, 0.0); +} +#[test] +fn out_distance_uses_live_foot_and_deck_axes_with_only_z_flipped() { + let mut frame = PushFootFrame { + left_foot: [11.0, 22.0, 33.0, 0.0], + right_foot: [14.0, 25.0, 36.0, 0.0], + deck_position: [10.0, 20.0, 30.0, 0.0], + deck_y: [0.0, 1.0, 0.0, 0.0], + deck_z: [1.0, 0.0, 0.0, 0.0], + skateboard_flipped: false, + }; + assert_eq!(frame.out_distance(false), [1.0, 2.0]); + assert_eq!(frame.out_distance(true), [4.0, 5.0]); + frame.skateboard_flipped = true; + assert_eq!(frame.out_distance(true), [-4.0, 5.0]); +} diff --git a/crates/skate-core/src/riding/tests/speed_and_slope.rs b/crates/skate-core/src/riding/tests/speed_and_slope.rs new file mode 100644 index 00000000..7481700d --- /dev/null +++ b/crates/skate-core/src/riding/tests/speed_and_slope.rs @@ -0,0 +1,31 @@ +use super::*; + +fn linear_settings() -> SpeedAndSlopeSettings { + let keys = [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0]; + SpeedAndSlopeSettings { + turn_torque_vs_speed: PointGraph { x: keys, y: keys }, + turn_torque_vs_slope: PointGraph { x: keys, y: keys }, + heading_adjust_max_speed: 8.0, + } +} + +#[test] +fn coefficient_uses_absolute_forward_speed_and_saturates_its_fraction() { + let settings = linear_settings(); + let half_speed = settings.calculate(0.0, 4.0); + assert!((half_speed - 0.5).abs() < 0.00001); + assert_eq!(settings.calculate(0.0, -4.0), half_speed); + assert!((settings.calculate(0.0, 80.0) - 1.0).abs() < 0.00001); + assert_eq!(settings.calculate(0.0, 0.0), 0.0); +} + +#[test] +fn over_vertical_slope_returns_to_the_first_curve_key() { + let mut settings = linear_settings(); + settings.turn_torque_vs_slope.y[0] = 0.375; + // acos(-1)/(pi/2) exceeds1.2. The native cutoff selects zero, + // so an inverted board uses the first graph value, not its last value. + assert_eq!(settings.calculate(-1.0, 8.0), 0.375); + assert_eq!(settings.calculate(-2.0, 8.0), 0.375); + assert!((settings.calculate(0.0, 8.0) - 1.0).abs() < 0.00001); +} diff --git a/crates/skate-core/src/riding/tests/steering.rs b/crates/skate-core/src/riding/tests/steering.rs new file mode 100644 index 00000000..a6bd1021 --- /dev/null +++ b/crates/skate-core/src/riding/tests/steering.rs @@ -0,0 +1,104 @@ +use super::*; + +fn settings() -> SteeringSettings { + SteeringSettings { + hard_turn_increase: 1.0, + damping: 0.7, + speed_graph_max_speed: 50.0, + push_scalar_increment: 0.05, + push_scalar_decrement: 0.015, + push_scalar_min: 0.5, + manual_scalar: 0.25, + general_scalar: 0.6, + tight_trucks_scalar: 0.7, + tilt_blending: 0.2, + speed_graph: PointGraph { + x: [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 1.0], + y: [1.0; 8], + }, + input_graph: PointGraph { + x: [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 1.0], + y: [1.0; 8], + }, + } +} + +fn input() -> SteeringInput { + SteeringInput { + turn: 0.5, + flipped_controls_scalar: 1.0, + ..Default::default() + } +} + +#[test] +fn hard_turn_uses_turn_sign_including_zero_not_hard_turn_sign() { + let s = settings(); + let mut i = input(); + i.hard_turn = -1.0; + assert_eq!(calculate_tilt(&s, i, None, None), 1.2); + i.turn = -0.5; + assert_eq!(calculate_tilt(&s, i, None, None), -1.2); + i.turn = -0.0; + assert_eq!(calculate_tilt(&s, i, None, None), 1.2); +} + +#[test] +fn optional_state_pointers_and_pushing_recovery_preserve_native_limits() { + let s = settings(); + let mut scalar = 1.0; + let mut turn = 0.0; + let mut i = input(); + i.pushing = true; + calculate_tilt(&s, i, Some(&mut scalar), Some(&mut turn)); + assert_eq!(turn, 0.35); + assert_eq!(scalar, 0.985); + for _ in 0..100 { + calculate_tilt(&s, i, Some(&mut scalar), None); + } + assert_eq!(scalar, 0.5); + i.pushing = false; + for _ in 0..100 { + calculate_tilt(&s, i, Some(&mut scalar), None); + } + assert_eq!(scalar, 1.0); +} + +#[test] +fn manual_tightness_and_flipped_controls_multiply_the_tilt() { + let s = settings(); + let base = calculate_tilt(&s, input(), None, None); + let i = SteeringInput { + balance: 1.0, + truck_tightness: 1.0, + flipped_controls_scalar: -1.0, + ..input() + }; + assert!((calculate_tilt(&s, i, None, None) - base * -0.25 * 0.7).abs() < 1e-7); +} + +#[test] +fn truck_contact_flags_swap_with_stance_and_first_inactive_tick_holds_angle() { + let mut regular = TruckSteeringState::default(); + regular.update(0.6, 0.2, 0, 1 << 27); + assert!(regular.targets[0] > 0.0); + assert_eq!(regular.targets[1], 0.0); + let mut switch = TruckSteeringState::default(); + switch.update(0.6, 0.2, 1 << 20, 1 << 27); + assert_eq!(regular.targets, [switch.targets[1], switch.targets[0]]); + let previous = regular.targets[0]; + regular.update(0.6, 0.2, 0, 0); + assert_eq!(regular.targets[0], previous); + regular.update(0.6, 0.2, 0, 0); + assert_eq!(regular.targets[0], previous * 0.983); +} + +#[test] +fn truck_reactivation_completes_the_native_ramp() { + let mut state = TruckSteeringState::default(); + for _ in 0..11 { + state.update(0.6, 1.0, 0, 3 << 26); + } + assert_eq!(state.targets, [0.6; 2]); + assert!(state.activation_time[0] > 0.165); +} diff --git a/crates/skate-core/src/riding/vector.rs b/crates/skate-core/src/riding/vector.rs new file mode 100644 index 00000000..2198a913 --- /dev/null +++ b/crates/skate-core/src/riding/vector.rs @@ -0,0 +1,40 @@ +use crate::physics::reciprocal_sqrt::estimate; + +pub(super) fn dot3(a: [f32; 4], b: [f32; 4]) -> f32 { + crate::physics::native_arithmetic::dot3(a, b) +} + +/// Inline NormalizeSafe sequence in TU3 82C04F68 / 82D92CF8. +pub(super) fn normalize(v: [f32; 4], threshold: [f32; 4]) -> [f32; 4] { + let squared = dot3(v, v); + 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 length = if squared == 0.0 { + 0.0 + } else { + squared * inverse + }; + core::array::from_fn(|i| { + if length > threshold[i] { + v[i] * inverse + } else { + 0.0 + } + }) +} + +pub(super) fn clamp(value: f32, lower: f32, upper: f32) -> f32 { + let v = if lower - value >= 0.0 { lower } else { value }; + if upper - v >= 0.0 { v } else { upper } +} + +pub(super) fn cross(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + core::array::from_fn(|i| { + let j = if i == 3 { 3 } else { (i + 1) % 3 }; + let k = if i == 3 { 3 } else { (i + 2) % 3 }; + (-a[k]).mul_add(b[j], a[j] * b[k]) + }) +} diff --git a/crates/skate-core/src/trigonometry.rs b/crates/skate-core/src/trigonometry.rs new file mode 100644 index 00000000..232697b4 --- /dev/null +++ b/crates/skate-core/src/trigonometry.rs @@ -0,0 +1,141 @@ +//! Scalar lane of TU3 `XMVectorSinCos`, `0x82473A08`. +//! +//! Coefficients are the executable words at 0x822F97C0..0x822F9860. +//! Arithmetic follows the recovered polynomial structure. Numerical agreement +//! with the original game requires independent validation. +pub fn sin_cos(angle: f32) -> (f32, f32) { + let turns = (angle * f32::from_bits(0x3E22_F983)).round_ties_even(); + let x = (-f32::from_bits(0x40C9_0FDB)).mul_add(turns, angle); + let x2 = x * x; + let x3 = x2 * x; + let x4 = x2 * x2; + let x5 = x3 * x2; + let x6 = x3 * x3; + let x7 = x4 * x3; + let x8 = x4 * x4; + let x9 = x5 * x4; + let x10 = x5 * x5; + let x11 = x6 * x5; + let x12 = x6 * x6; + let x13 = x7 * x6; + let x15 = x8 * x7; + let x14 = x7 * x7; + let x16 = x8 * x8; + let x17 = x9 * x8; + let x19 = x10 * x9; + let x18 = x9 * x9; + let x21 = x11 * x10; + let x20 = x10 * x10; + let x23 = x12 * x11; + let x22 = x11 * x11; + + let sin = f32::from_bits(0xBE2A_AAAB).mul_add(x3, x); + let sin = f32::from_bits(0x3C08_8889).mul_add(x5, sin); + let sin = x7.mul_add(f32::from_bits(0xB950_0D01), sin); + let sin = f32::from_bits(0x3638_EF1D).mul_add(x9, sin); + let sin = f32::from_bits(0xB2D7_322B).mul_add(x11, sin); + let sin = f32::from_bits(0x2F30_9231).mul_add(x13, sin); + let sin = f32::from_bits(0xAB57_3F9F).mul_add(x15, sin); + let sin = f32::from_bits(0x274A_963C).mul_add(x17, sin); + let sin = f32::from_bits(0xA317_A4DA).mul_add(x19, sin); + let sin = f32::from_bits(0x1EB8_DC78).mul_add(x21, sin); + let sin = f32::from_bits(0x9A3B_0DA1).mul_add(x23, sin); + + let cos = (-0.5_f32).mul_add(x2, 1.0); + let cos = f32::from_bits(0x3D2A_AAAB).mul_add(x4, cos); + let cos = f32::from_bits(0xBAB6_0B61).mul_add(x6, cos); + let cos = f32::from_bits(0x37D0_0D01).mul_add(x8, cos); + let cos = f32::from_bits(0xB493_F27E).mul_add(x10, cos); + let cos = f32::from_bits(0x310F_76C8).mul_add(x12, cos); + let cos = f32::from_bits(0xAD49_CBA5).mul_add(x14, cos); + let cos = f32::from_bits(0x2957_3F9F).mul_add(x16, cos); + let cos = f32::from_bits(0xA534_13C3).mul_add(x18, cos); + let cos = f32::from_bits(0x20F2_A15D).mul_add(x20, cos); + let cos = f32::from_bits(0x9C86_71CB).mul_add(x22, cos); + (sin, cos) +} + +/// Standalone TU3 cosine82473930. The even-power tree differs from SinCos; +/// normal gameplay camera roll82BD35B8 calls this function independently. +/// This is the retained readable polynomial also audited for SetTurning. +pub fn cos(angle: f32) -> f32 { + let turns = (angle * f32::from_bits(0x3e22_f983)).round_ties_even(); + let x = (-f32::from_bits(0x40c9_0fdb)).mul_add(turns, angle); + let x2 = x * x; + let x4 = x2 * x2; + let x6 = x4 * x2; + let x8 = x4 * x4; + let x10 = x6 * x4; + let x12 = x6 * x6; + let x14 = x8 * x6; + let x16 = x8 * x8; + let x18 = x10 * x8; + let x22 = x12 * x10; + let x20 = x10 * x10; + let mut value = (-0.5_f32).mul_add(x2, 1.0); + for (power, coefficient) in [ + (x4, 0x3d2a_aaab), (x6, 0xbab6_0b61), (x8, 0x37d0_0d01), + (x10, 0xb493_f27e), (x12, 0x310f_76c8), (x14, 0xad49_cba5), + (x16, 0x2957_3f9f), (x18, 0xa534_13c3), (x20, 0x20f2_a15d), + (x22, 0x9c86_71cb), + ] { value = f32::from_bits(coefficient).mul_add(power, value); } + value +} + +/// TU3 vector sine 824531C8. Its sequential odd powers differ from SinCos's +/// multiplication tree, so callers of the standalone sine must use this path. +pub fn sin(angle: f32) -> f32 { + let turns = (angle * f32::from_bits(0x3E22_F983)).round_ties_even(); + let x = (-f32::from_bits(0x40C9_0FDB)).mul_add(turns, angle); + let x2 = x * x; + let mut power = x * x2; + let mut result = x; + for coefficient in [ + 0xBE2A_AAAB, + 0x3C08_8889, + 0xB950_0D01, + 0x3638_EF1D, + 0xB2D7_322B, + 0x2F30_9231, + 0xAB57_3F9F, + 0x274A_963C, + 0xA317_A4DA, + 0x1EB8_DC78, + 0x9A3B_0DA1, + ] { + result = f32::from_bits(coefficient).mul_add(power, result); + power *= x2; + } + result +} + +/// Scalar lane of TU3 vector acos 82453298. The native radicand constant is +/// 0x3f800001, and its reciprocal square root receives one refinement. +pub fn acos(value: f32) -> f32 { + (f32::from_bits(0x4049_0FDB) * 0.5) - asin(value) +} + +/// Shared inverse-sine polynomial, also inlined by DisablePushBrake82BA5150. +/// Keep this intermediate separate: that caller subtracts degrees from90, +/// whereas vector acos subtracts radians from half pi. +pub fn asin(value: f32) -> f32 { + let a = value.abs(); + let cube = (value * value) * a; + let c = f32::from_bits; + let p0 = c(0x400B_1889).mul_add(a, c(0xC0D1_360E)); + let p1 = c(0x3E66_3246).mul_add(a, c(0xBF98_3F2F)); + let p2 = c(0xBED6_5553).mul_add(a, c(0x4089_80BD)); + let p3 = c(0xBD6D_D42D).mul_add(a, c(0x3F1D_D7B6)); + let p0 = p0.mul_add(a, c(0x40AF_6AD8)); + let p1 = p1.mul_add(a, c(0x3FB5_8485)); + let p2 = p2.mul_add(a, c(0xC08F_6AD9)); + let p3 = p3.mul_add(a, c(0xBFAF_4418)); + let left = p1.mul_add(cube, p0); + let right = p3.mul_add(cube, p2); + let radicand = c(0x3F80_0001) - a; + let mut r = crate::physics::reciprocal_sqrt::estimate(radicand); + let correction = (-(radicand * 0.5)).mul_add(r * r, 0.5); + r = r.mul_add(correction, r); + let residual = (-a).mul_add(value, value) * right; + residual.mul_add(r, value * left) +} diff --git a/crates/skate-core/tests/animation_actor_composition.rs b/crates/skate-core/tests/animation_actor_composition.rs new file mode 100644 index 00000000..7abdcec4 --- /dev/null +++ b/crates/skate-core/tests/animation_actor_composition.rs @@ -0,0 +1,620 @@ +use skate_core::animation::{ + commands::{ + batch::{CompletedSetData, SetDataQueue}, + buffers::{BufferError, PoseBuffers}, + }, + output::{ + actor_packet::{ + self, ActorPacketFields, ActorPublicationEnvironment, ActorPublicationState, + ExternalPhysicsInput, ExternalReset, PlayerPhysicsSettingsSource, + }, + attributes::{AttributeName, MotionGraphAttribute, PacketAttributes}, + intents::{Intent, IntentCapacityExceeded, IntentMap, IntentName}, + motion_graph_packet::{GestureData, MotionGraphPacket, TrickData, TrickIdentifier}, + packet_reset::{AdditionalResetFields, RESET_POSE}, + physics_packet::{ActorPoseBuffers, PhysicsPosePacket, SkaterPublicationState}, + setup_physics::{ + self, AnimationPublication, IntentPublication, MotionGraphPublication, + PhysicsInputPacket, PublicationError, + }, + }, +}; +use std::num::NonZeroU16; + +fn intent(id: u32, value: f32) -> Intent { + Intent { + name: IntentName([id, 0, 0, 0, 0, 0]), + value, + } +} +#[test] +fn intent_assignment_reverses_collisions_and_retains_first_duplicate_value() { + let mut source = IntentMap::default(); + for entry in [ + intent(1, 1.0), + intent(62, 2.0), + intent(123, 3.0), + intent(2, 4.0), + ] { + assert_eq!(source.insert(entry), Ok(true)); + } + assert_eq!(source.insert(intent(62, 99.0)), Ok(false)); + assert_eq!( + source.entries().map(|e| e.name.0[0]).collect::>(), + [123, 62, 1, 2] + ); + let mut destination = IntentMap::default(); + destination.insert(intent(99, 99.0)).unwrap(); + destination.replace_from(&source); + assert_eq!( + destination + .entries() + .map(|e| (e.name.0[0], e.value)) + .collect::>(), + [(1, 1.0), (62, 2.0), (123, 3.0), (2, 4.0)] + ); + assert_eq!( + source.entries().map(|e| e.name.0[0]).collect::>(), + [123, 62, 1, 2] + ); +} +#[test] +fn intent_hash_wraps_all_six_words_and_capacity_allows_existing_keys() { + let mut map = IntentMap::default(); + map.insert(intent(1, 7.0)).unwrap(); + let wrapped = Intent { + name: IntentName([u32::MAX, 0, 0, 0, 0, 2]), + value: -0.0, + }; + map.insert(wrapped).unwrap(); + assert_eq!(map.entries().next().unwrap().name, wrapped.name); + assert_eq!(map.entries().next().unwrap().value.to_bits(), 0x80000000); + for id in 2..64 { + map.insert(intent(id, id as f32)).unwrap(); + } + assert_eq!(map.len(), 64); + assert_eq!(map.insert(intent(1, 8.0)), Ok(false)); + assert_eq!(map.insert(intent(1000, 8.0)), Err(IntentCapacityExceeded)); + assert_eq!(map.len(), 64); + map.clear(); + assert!(map.is_empty()); + assert_eq!(map.insert(wrapped), Ok(true)); +} + +fn graph() -> MotionGraphPacket { + MotionGraphPacket { + trick: TrickData { + identifiers: [TrickIdentifier([7; 6]); 2], + word48: 8, + scalars52_56: [9.0, 10.0], + }, + gesture: GestureData { + gesture: 11, + flag_bytes: [1, 2, 3, 255], + }, + wipeout_gesture: [12.0, 13.0], + } +} +fn packet() -> PhysicsInputPacket { + PhysicsInputPacket { + pose: PhysicsPosePacket { + bone_count: 1, + hierarchy: vec![[[77.0; 4]; 4]], + local: vec![[[88.0; 4]; 4]], + timestep: 0.0, + foot_surface_ids: [42; 2], + flags: u32::MAX, + board_flipped: false, + mirrored: false, + riding_switch: false, + riding_fakie: false, + weight_forwards: false, + regular_stance: false, + air_dismount_revert_frames: 0, + }, + reset: AdditionalResetFields { + compression: 99.0, + foot_ik_influence: [99.0; 2], + next_step_position_valid: true, + actor_flag_1904_bit23: false, + actor_flag_1908_bit2: false, + external_impulse_active: false, + external_physics_input_active: false, + externally_controlled: false, + prevent_manual_respawn: false, + ignore_respawn_reset_button: 0, + force_braking: false, + truck_tightness: 99.0, + wheel_hardness: 99.0, + auxiliary_vectors: [[99.0; 4]; 6], + requested_physics_mode: 99, + }, + motion_graph: graph(), + intents: IntentMap::default(), + attributes: PacketAttributes::default(), + actor: ActorPacketFields { + external_impulse: [0; 4], + external_physics: ExternalPhysicsInput { + vectors: [[0; 4]; 10], + flags: 0x01abcdef, + }, + external_reset: ExternalReset { + transform: [[0; 4]; 4], + byte64: 0, + }, + actor_flag_1904_bit29: false, + }, + } +} +fn actor() -> ActorPublicationState { + ActorPublicationState { + flags1904: (1 << 23) | (1 << 25) | (1 << 27) | (1 << 28) | (1 << 29) | (1 << 30) | 1, + flags1908: 4, + external_controller_present: false, + external_impulse: [0x7fc12345, 0x80000000, 1, 0xff], + external_physics: ExternalPhysicsInput { + vectors: std::array::from_fn(|i| [i as u32; 4]), + flags: 0xaa765432, + }, + external_reset: ExternalReset { + transform: [[0x80000000; 4]; 4], + byte64: 254, + }, + player_index: 3, + } +} +struct Environment { + calls: Vec<&'static str>, + scene: Option, + selector: u8, + missing_scene: bool, + missing_mode: bool, +} +struct Settings { + fixed: bool, + missing_mode: bool, +} +impl PlayerPhysicsSettingsSource for Settings { + type Error = &'static str; + fn truck_tightness(&mut self, index: u32) -> Result { + assert!(!self.fixed); + assert_eq!(index, 3); + Ok(0.2) + } + fn wheel_hardness(&mut self, index: u32) -> Result { + assert!(!self.fixed); + assert_eq!(index, 3); + Ok(0.8) + } + fn requested_physics_mode(&mut self, index: u32) -> Result { + assert_eq!(index, 3); + if self.missing_mode { + Err("mode unavailable") + } else { + Ok(4) + } + } +} +impl ActorPublicationEnvironment for Environment { + type Error = &'static str; + type Settings = Settings; + fn current_scene_mode(&mut self) -> Result, Self::Error> { + self.calls.push("scene"); + if self.missing_scene { + Err("scene service unavailable") + } else { + Ok(self.scene) + } + } + fn ignore_respawn_reset_button(&mut self) -> Result { + self.calls.push("global-byte"); + Ok(0xfe) + } + fn actor_subobject56_slot16( + &mut self, + actor: &mut ActorPublicationState, + ) -> Result { + self.calls.push("actor56"); + assert_eq!(actor.flags1904 & ((1 << 25) | (1 << 28)), 0); + Ok(self.selector) + } + fn capture_physics_settings(&mut self) -> Result { + self.calls.push("settings-base"); + Ok(Settings { + fixed: self.selector != 0, + missing_mode: self.missing_mode, + }) + } +} +fn environment() -> Environment { + Environment { + calls: vec![], + scene: Some(19), + selector: 0, + missing_scene: false, + missing_mode: false, + } +} +#[test] +fn external_input_copy_transfers_each_high_flag_and_preserves_every_low_flag() { + for bit in 0..32 { + let mut destination = ExternalPhysicsInput { + vectors: [[7; 4]; 10], + flags: 1 << bit, + }; + let source = ExternalPhysicsInput { + vectors: [[0x7fc12345; 4]; 10], + flags: 0, + }; + destination.copy_from(&source); + assert_eq!(destination.flags, if bit < 25 { 1 << bit } else { 0 }); + assert_eq!(destination.vectors, source.vectors); + let source = ExternalPhysicsInput { + flags: 1 << bit, + ..source + }; + destination.flags = 0; + destination.copy_from(&source); + assert_eq!(destination.flags, if bit >= 25 { 1 << bit } else { 0 }); + } +} +#[test] +fn actor_publication_copies_masked_payload_consumes_only_two_latches_and_repeats() { + let mut packet = packet(); + let mut actor = actor(); + let initial = actor.flags1904; + let mut environment = environment(); + actor_packet::publish( + &mut actor, + &mut packet.pose, + &mut packet.reset, + &mut packet.actor, + &mut environment, + ) + .unwrap(); + assert_eq!( + environment.calls, + ["scene", "global-byte", "settings-base", "actor56"] + ); + assert_eq!(packet.actor.external_physics.flags, 0xababcdef); + assert_eq!( + packet.actor.external_physics.vectors, + actor.external_physics.vectors + ); + assert_eq!(packet.actor.external_reset, actor.external_reset); + assert_eq!(packet.actor.external_impulse, actor.external_impulse); + assert!(packet.reset.actor_flag_1904_bit23 && packet.reset.actor_flag_1908_bit2); + assert!(packet.reset.external_impulse_active && packet.reset.external_physics_input_active); + assert!(packet.actor.actor_flag_1904_bit29 && packet.reset.prevent_manual_respawn); + assert!(!packet.reset.externally_controlled && !packet.reset.force_braking); + assert_eq!(packet.pose.flags, u32::MAX); + assert_eq!(actor.flags1904, initial & !((1 << 25) | (1 << 28))); + assert_eq!(packet.reset.ignore_respawn_reset_button, 254); + assert_eq!( + (packet.reset.truck_tightness, packet.reset.wheel_hardness), + (0.2, 0.8) + ); + assert_eq!(packet.reset.requested_physics_mode, 4); + actor_packet::publish( + &mut actor, + &mut packet.pose, + &mut packet.reset, + &mut packet.actor, + &mut environment, + ) + .unwrap(); + assert_eq!(packet.pose.flags & (1 << 24), 0); + assert!(!packet.reset.prevent_manual_respawn); + assert!(packet.reset.external_impulse_active && packet.actor.actor_flag_1904_bit29); +} +#[test] +fn scene_query_short_circuits_and_only_modes19_20_enable_flag() { + for controller in [false, true] { + for braking in [false, true] { + for mode in [None, Some(18), Some(19), Some(20), Some(21)] { + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + a.external_controller_present = controller; + if braking { + a.flags1904 |= 1 << 26; + } + e.scene = mode; + e.selector = 128; + actor_packet::publish(&mut a, &mut p.pose, &mut p.reset, &mut p.actor, &mut e) + .unwrap(); + assert_eq!(e.calls.contains(&"scene"), !controller && !braking); + assert_eq!( + p.pose.flags & (1 << 22) != 0, + !controller && !braking && matches!(mode, Some(19 | 20)) + ); + assert_eq!(p.reset.externally_controlled, controller); + assert_eq!(p.reset.force_braking, braking); + assert_eq!(p.reset.truck_tightness.to_bits(), 0x3f333333); + assert_eq!(p.reset.wheel_hardness.to_bits(), 0x3f333333); + assert_eq!(p.reset.requested_physics_mode, 4); + } + } + } +} +#[test] +fn missing_mode_retains_both_branch_scalars_without_reading_unused_settings() { + for fixed in [false, true] { + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + e.selector = u8::from(fixed); + e.missing_mode = true; + assert_eq!( + actor_packet::publish(&mut a, &mut p.pose, &mut p.reset, &mut p.actor, &mut e), + Err("mode unavailable") + ); + let expected = if fixed { (0.7, 0.7) } else { (0.2, 0.8) }; + assert_eq!((p.reset.truck_tightness, p.reset.wheel_hardness), expected); + assert_eq!(p.reset.requested_physics_mode, 99); + assert_eq!( + e.calls, + ["scene", "global-byte", "settings-base", "actor56"] + ); + } +} + +fn animation(initialized: bool) -> (CompletedSetData, ActorPoseBuffers, SkaterPublicationState) { + let mut buffers = PoseBuffers::default(); + let hierarchy = if initialized { + buffers.matrices.initialized([[[1.0; 4]; 4]]) + } else { + buffers.matrices.allocate(1) + }; + let local = buffers.matrices.initialized([[[2.0; 4]; 4]]); + let completed = SetDataQueue::new(buffers, NonZeroU16::new(1).unwrap()) + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + ( + completed, + ActorPoseBuffers { + hierarchy: hierarchy.at(0), + local: local.at(0), + }, + SkaterPublicationState { + orientation_bit31: false, + mirrored: false, + riding_fakie: true, + weight_on_nose: true, + relative_stance: 1, + natural_stance: 0, + request_bit16: true, + request_bit15: true, + air_dismount_revert_requested: true, + air_dismount_revert_frames: 12, + signal: None, + }, + ) +} +#[test] +fn composition_overwrites_reset_poses_then_merges_graph_lists_and_binds_owned_packet() { + let (completed, poses, mut state) = animation(true); + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + let mut mg = graph(); + mg.gesture.gesture = 987; + let mut intents = IntentMap::default(); + intents.insert(intent(1, 3.0)).unwrap(); + let attrs = [MotionGraphAttribute { + name: AttributeName([1; 5]), + value: 4.0, + }]; + let tree = [MotionGraphAttribute { + name: AttributeName([1; 5]), + value: 5.0, + } + .to_animation()]; + let binding = setup_physics::compose( + &mut p, + AnimationPublication { + state: &mut state, + completed: &completed, + poses: &poses, + signal_name: b"signup", + tree_attributes: &tree, + }, + MotionGraphPublication { + state: &mg, + intents: IntentPublication::MotionGraph(&intents), + attributes: &attrs, + }, + &mut a, + &mut e, + ) + .unwrap(); + let published = binding.packet(); + assert_eq!(published.pose.hierarchy, [[[1.0; 4]; 4]]); + assert_eq!(published.pose.local, [[[2.0; 4]; 4]]); + assert_eq!(published.pose.flags, 0x19ffffff); + assert_eq!(published.reset.compression, 0.5); + assert_eq!(published.pose.timestep.to_bits(), 0x3c888889); + assert_eq!(published.motion_graph.gesture.gesture, 987); + assert_eq!( + published + .attributes + .entries() + .iter() + .map(|a| a.payload.0[0]) + .collect::>(), + [Some(4.0f32.to_bits()), Some(5.0f32.to_bits())] + ); + assert_eq!(published.intents.entries().next().unwrap().value, 3.0); + assert!(!state.request_bit16 && !state.request_bit15 && !state.air_dismount_revert_requested); + assert_eq!(mg.gesture.gesture, 987); +} +#[test] +fn composition_runtime_failure_retains_prior_phases_but_does_not_bind() { + let (completed, poses, mut state) = animation(true); + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + let mg = graph(); + p.intents.insert(intent(1, 3.0)).unwrap(); + p.intents.insert(intent(62, 4.0)).unwrap(); + e.missing_scene = true; + let error = setup_physics::compose( + &mut p, + AnimationPublication { + state: &mut state, + completed: &completed, + poses: &poses, + signal_name: b"", + tree_attributes: &[], + }, + MotionGraphPublication { + state: &mg, + intents: IntentPublication::RetainPacket, + attributes: &[], + }, + &mut a, + &mut e, + ) + .err() + .unwrap(); + assert_eq!( + error, + PublicationError::RuntimeService("scene service unavailable") + ); + assert_eq!(e.calls, ["scene"]); + assert_eq!(p.pose.local, [[[2.0; 4]; 4]]); + assert_eq!(p.actor.external_physics.flags, 0xababcdef); + assert_eq!( + p.intents.entries().map(|e| e.name.0[0]).collect::>(), + [62, 1] + ); + assert_ne!(a.flags1904 & ((1 << 25) | (1 << 28)), 0); + assert!(!state.request_bit16); +} +#[test] +fn missing_pose_stops_after_reset_before_graph_or_runtime_phases() { + let (completed, poses, mut state) = animation(false); + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + let mut mg = graph(); + mg.gesture.gesture = 999; + let error = setup_physics::compose( + &mut p, + AnimationPublication { + state: &mut state, + completed: &completed, + poses: &poses, + signal_name: b"", + tree_attributes: &[], + }, + MotionGraphPublication { + state: &mg, + intents: IntentPublication::RetainPacket, + attributes: &[], + }, + &mut a, + &mut e, + ) + .err() + .unwrap(); + assert!(matches!( + error, + PublicationError::Pose(BufferError::UninitializedBone { .. }) + )); + assert_eq!(p.pose.local, [RESET_POSE]); + assert_eq!(p.motion_graph.gesture.gesture, 11); + assert!(e.calls.is_empty()); + assert!(state.request_bit16); +} + +#[test] +fn composed_mg_and_tree_attributes_reach_concrete_skeleton_steering_consumers() { + use skate_core::animation::skeleton_input::{ + name::encode, + scalar_attributes::{self, AnimationControlOutput, ScalarAttributeInputs}, + }; + let (completed, poses, mut state) = animation(true); + let mut p = packet(); + let mut a = actor(); + let mut e = environment(); + let mg = graph(); + let graph_attributes = [ + MotionGraphAttribute { + name: encode(b"Turn"), + value: 0.25, + }, + MotionGraphAttribute { + name: encode(b"PushContact"), + value: 0.0, + }, + ]; + let tree_attributes = [ + MotionGraphAttribute { + name: encode(b"Turn"), + value: -0.75, + } + .to_animation(), + MotionGraphAttribute { + name: encode(b"Spin"), + value: 0.5, + } + .to_animation(), + ]; + let binding = setup_physics::compose( + &mut p, + AnimationPublication { + state: &mut state, + completed: &completed, + poses: &poses, + signal_name: b"signup", + tree_attributes: &tree_attributes, + }, + MotionGraphPublication { + state: &mg, + intents: IntentPublication::RetainPacket, + attributes: &graph_attributes, + }, + &mut a, + &mut e, + ) + .unwrap(); + let mut processed = ScalarAttributeInputs { + flags2468: 0, + flags2472: 0, + flags2476: 0, + flags2484: 0, + flags2488: 0, + board_adjust: 0, + balance: 0.0, + spin: 0.0, + body_spin: 0.0, + brake: 0.0, + turn: 0.0, + turn_scale: 1.0, + magnitude_scale: 1.0, + animation_end_com: [0.0; 4], + animation_translation: [0.0; 4], + animation_time: 0.0, + animation_physics_blend_seconds: 0.0, + cadence_end_percent: 0.0, + raw_turn: 0.0, + hard_turn: 0.0, + slide: 0.0, + }; + let mut animation_output = AnimationControlOutput { + grind_name: AttributeName([0; 5]), + flags: 0, + }; + scalar_attributes::dispatch_supported_packet(&binding, &mut processed, &mut animation_output) + .unwrap(); + assert_eq!(processed.turn, 0.75); // Later tree value wins, then native negation. + assert_eq!(processed.spin, -0.5); + assert_eq!(processed.flags2488, 1 << 29); // Scalar marker, no strength calculation. + assert_eq!(binding.packet().attributes.entries().len(), 4); + assert_eq!( + binding.packet().attributes.entries()[0].payload.0[0], + Some(0.25f32.to_bits()) + ); +} diff --git a/crates/skate-core/tests/animation_commands.rs b/crates/skate-core/tests/animation_commands.rs new file mode 100644 index 00000000..43af0ba4 --- /dev/null +++ b/crates/skate-core/tests/animation_commands.rs @@ -0,0 +1,358 @@ +use skate_core::animation::commands::{ + batch::SetDataQueue, + buffers::{BufferError, PoseBuffers}, + set_data::{CopyCommand, InternalUpdate, SetDataCommand, resolve_set_count}, +}; +use skate_core::animation::output::{NativeMatrix, Sqt}; +use std::num::NonZeroU16; + +fn matrix(value: f32) -> NativeMatrix { + [[value; 4]; 4] +} +fn queue(buffers: PoseBuffers, limit: u16) -> SetDataQueue { + SetDataQueue::new(buffers, NonZeroU16::new(limit).unwrap()) +} +#[test] +fn partial_set_exports_whole_skeleton_and_preserves_tail() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(9.0)]); + let internal = buffers + .matrices + .initialized([matrix(1.0), matrix(2.0), matrix(3.0)]); + let output = buffers.matrices.initialized([matrix(-1.0); 4]); + let mut queue = queue(buffers, 4); + queue + .enqueue( + 3, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: source.at(0), + bone_count: 1, + }, + internal: internal.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + assert!(queue.flush()); + assert!(!queue.flush()); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 4).unwrap(), + [matrix(9.0), matrix(2.0), matrix(3.0), matrix(-1.0)] + ); +} +#[test] +fn alias_writes_feed_later_records_and_final_external_export() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(7.0)]); + let scratch = buffers.matrices.initialized([matrix(0.0); 2]); + let output = buffers.matrices.allocate(1); + let mut queue = queue(buffers, 8); + queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: source.at(0), + bone_count: 1, + }, + internal: scratch.at(0), + external: Some(scratch.at(1)), + }), + &[], + ) + .unwrap(); + queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: scratch.at(1), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 1).unwrap(), + [matrix(7.0)] + ); +} +#[test] +fn uninitialized_tail_does_not_become_a_valid_zero_pose() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(8.0)]); + let internal = buffers.matrices.allocate(2); + let output = buffers.matrices.initialized([matrix(3.0); 2]); + let mut queue = queue(buffers, 8); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: source.at(0), + bone_count: 1, + }, + internal: internal.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 2), + Err(BufferError::UninitializedBone { bone: 1 }) + ); + assert_eq!( + done.buffers().matrices.read(output.at(0), 1).unwrap(), + [matrix(8.0)] + ); +} +#[test] +fn sqt_copy_preserves_every_float_bit_and_nonzero_bone_offset() { + let mut buffers = PoseBuffers::default(); + let value = Sqt { + scale: [f32::from_bits(0x7fc12345), -0.0, 3.0, 4.0], + rotation: [5.0; 4], + translation: [6.0; 4], + }; + let source = buffers.sqt.initialized([value]); + let output = buffers.sqt.allocate(3); + let mut queue = queue(buffers, 1); + queue + .enqueue( + 1, + SetDataCommand::Sqt(CopyCommand { + update: InternalUpdate::Preserve, + internal: source.at(0), + external: Some(output.at(1)), + }), + &[], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let copied = done.buffers().sqt.read(output.at(1), 1).unwrap()[0]; + assert_eq!( + copied.scale.map(f32::to_bits), + value.scale.map(f32::to_bits) + ); + assert_eq!(copied.rotation, value.rotation); + assert_eq!(copied.translation, value.translation); + assert!(done.buffers().sqt.read(output.at(0), 1).is_err()); +} +#[test] +fn capacity_boundary_and_dependency_preserve_producer_before_consumer() { + let mut buffers = PoseBuffers::default(); + let input = buffers.matrices.initialized([matrix(42.0)]); + let intermediate = buffers.matrices.allocate(1); + let output = buffers.matrices.allocate(1); + let mut queue = queue(buffers, 1); + let first = queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: input.at(0), + external: Some(intermediate.at(0)), + }), + &[None], + ) + .unwrap(); + let second = queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: intermediate.at(0), + external: Some(output.at(0)), + }), + &[Some(first), None, Some(first)], + ) + .unwrap(); + assert_ne!(first, second); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 1).unwrap(), + [matrix(42.0)] + ); +} +#[test] +fn prepare_and_cancel_never_execute_copies() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(9.0)]); + let output = buffers.matrices.initialized([matrix(1.0)]); + let mut queue = queue(buffers, 1); + queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: source.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + queue.flush(); + assert_eq!( + queue.cancel().matrices.read(output.at(0), 1).unwrap(), + [matrix(1.0)] + ); +} +#[test] +fn unresolved_partial_overlap_fails_without_undoing_prior_set() { + let mut buffers = PoseBuffers::default(); + let source = buffers.matrices.initialized([matrix(8.0)]); + let internal = buffers.matrices.initialized([matrix(0.0); 3]); + let mut queue = queue(buffers, 1); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: source.at(0), + bone_count: 1, + }, + internal: internal.at(0), + external: Some(internal.at(1)), + }), + &[], + ) + .unwrap(); + let failure = match queue.submit().complete() { + Ok(_) => panic!("overlap accepted"), + Err(e) => e, + }; + assert_eq!(failure.cause, BufferError::UnsupportedPartialOverlap); + assert_eq!( + failure.buffers.matrices.read(internal.at(0), 3).unwrap(), + [matrix(8.0), matrix(0.0), matrix(0.0)] + ); +} +#[test] +fn latest_enqueued_skeleton_count_applies_to_every_command_in_batch() { + let mut buffers = PoseBuffers::default(); + let input = buffers.matrices.initialized([matrix(1.0), matrix(2.0)]); + let output = buffers.matrices.allocate(2); + let mut queue = queue(buffers, 3); + let first = queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: input.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: input.at(0), + external: None, + }), + &[Some(first)], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + assert_eq!( + done.buffers().matrices.read(output.at(0), 2).unwrap(), + [matrix(1.0), matrix(2.0)] + ); + assert_eq!(resolve_set_count(0, 258), 2); + assert_eq!(resolve_set_count(0, 256), 0); + assert_eq!(resolve_set_count(255, 20), 255); +} + +#[test] +fn foreign_buffer_and_job_handles_are_rejected() { + let mut foreign = PoseBuffers::default(); + let foreign_pose = foreign.matrices.initialized([matrix(9.0)]); + let mut foreign_queue = queue(foreign, 2); + let foreign_job = foreign_queue + .enqueue( + 1, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: foreign_pose.at(0), + external: None, + }), + &[], + ) + .unwrap(); + let mut buffers = PoseBuffers::default(); + let local = buffers.matrices.initialized([matrix(1.0)]); + assert_eq!( + buffers.matrices.read(foreign_pose.at(0), 1), + Err(BufferError::UnknownBuffer) + ); + let mut own_queue = queue(buffers, 2); + let command = SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: local.at(0), + external: None, + }); + assert_eq!( + own_queue.enqueue(1, command, &[Some(foreign_job)]), + Err(skate_core::animation::commands::batch::QueueError::UnavailableDependency) + ); + own_queue.enqueue(1, command, &[]).unwrap(); + assert!(own_queue.submit().complete().is_ok()); +} + +#[test] +fn full_export_count_is_not_truncated_to_the_set_byte() { + let mut buffers = PoseBuffers::default(); + let internal = buffers + .matrices + .initialized((0..256).map(|i| matrix(i as f32))); + let empty_source = buffers.matrices.allocate(0); + let output = buffers.matrices.allocate(256); + let mut queue = queue(buffers, 1); + queue + .enqueue( + 256, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Set { + source: empty_source.at(usize::MAX), + bone_count: 0, + }, + internal: internal.at(0), + external: Some(output.at(0)), + }), + &[], + ) + .unwrap(); + let done = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let values = done.buffers().matrices.read(output.at(0), 256).unwrap(); + assert_eq!(values[0], matrix(0.0)); + assert_eq!(values[255], matrix(255.0)); +} diff --git a/crates/skate-core/tests/animation_output_bounds.rs b/crates/skate-core/tests/animation_output_bounds.rs new file mode 100644 index 00000000..37aebf24 --- /dev/null +++ b/crates/skate-core/tests/animation_output_bounds.rs @@ -0,0 +1,23 @@ +//! Independent buffer-validation checks; no generated-code fixtures. +use skate_core::animation::output::{PoseBufferError, Sqt, compose_hierarchy, sqt_to_local}; + +#[test] +fn invalid_buffers_fail_without_partial_publication() { + let sentinel = [[f32::from_bits(0x3f123456); 4]; 4]; + let sqt = Sqt { + scale: [1.0; 4], + rotation: [0.0, 0.0, 0.0, 1.0], + translation: [0.0; 4], + }; + let mut destination = [sentinel; 4]; + assert_eq!( + sqt_to_local(1, 0, &[sqt], &mut destination), + Err(PoseBufferError::ShortInput) + ); + assert_eq!(destination, [sentinel; 4]); + assert_eq!( + compose_hierarchy(4, &[-1, 0, 1, 9], -1, &[sentinel; 4], &mut destination), + Err(PoseBufferError::InvalidParent { bone: 3, parent: 9 }) + ); + assert_eq!(destination, [sentinel; 4]); +} diff --git a/crates/skate-core/tests/animation_packet_merge.rs b/crates/skate-core/tests/animation_packet_merge.rs new file mode 100644 index 00000000..1a975d99 --- /dev/null +++ b/crates/skate-core/tests/animation_packet_merge.rs @@ -0,0 +1,125 @@ +use skate_core::animation::output::{ + attributes::{ + AnimationAttribute, AttributeName, AttributePayload, MotionGraphAttribute, PacketAttributes, + }, + motion_graph_packet::{self, GestureData, MotionGraphPacket, TrickData, TrickIdentifier}, +}; +fn attribute(kind: u8, seed: u32) -> AnimationAttribute { + AnimationAttribute { + payload: AttributePayload(std::array::from_fn(|i| Some(seed + i as u32))), + begin_time: 2.0, + end_time: 3.0, + name: AttributeName([seed; 5]), + status: 9, + kind, + sequence_id: 17, + } +} +#[test] +fn assignment_copies_only_kind_selected_union_lanes_and_all_metadata() { + for kind in [0, 1, 2, 3, 4, 255] { + let source = attribute(kind, 100); + let mut destination = attribute(3, 200); + destination.copy_from(&source); + let count = match kind { + 0 | 2 => 1, + 1 => 4, + 3 => 6, + _ => 0, + }; + for lane in 0..6 { + assert_eq!( + destination.payload.0[lane], + Some(if lane < count { 100 } else { 200 } + lane as u32) + ); + } + assert_eq!(destination.kind, kind); + assert_eq!(destination.name, source.name); + assert_eq!(destination.begin_time, 2.0); + assert_eq!(destination.end_time, 3.0); + assert_eq!(destination.status, 9); + assert_eq!(destination.sequence_id, 17); + } +} +#[test] +fn motion_graph_conversion_preserves_scalar_bits_without_fabricating_other_lanes() { + for bits in [0x80000000, 0x7fc12345, 0x3f800000] { + let source = MotionGraphAttribute { + name: AttributeName([10, 20, 30, 40, 50]), + value: f32::from_bits(bits), + }; + let result = source.to_animation(); + assert_eq!(result.payload.0, [Some(bits), None, None, None, None, None]); + assert_eq!(result.name, source.name); + assert_eq!((result.begin_time, result.end_time), (-1.0, -1.0)); + assert_eq!((result.status, result.kind, result.sequence_id), (6, 0, -1)); + } +} +#[test] +fn merge_resets_active_length_preserves_duplicates_and_keeps_mg_before_tree() { + let name = AttributeName([1, 2, 3, 4, 5]); + let mg = [ + MotionGraphAttribute { name, value: 0.0 }, + MotionGraphAttribute { name, value: 2.0 }, + ]; + let mut tree = [attribute(0, 7), attribute(1, 8)]; + tree[0].name = name; + let mut list = PacketAttributes::default(); + list.append(&attribute(3, 500)); + list.replace_from(&mg, &tree); + assert_eq!(list.entries().len(), 4); + assert_eq!(list.entries()[0].name, name); + assert_eq!(list.entries()[1].name, name); + assert_eq!(list.entries()[2].name, name); + assert_eq!(list.entries()[0].payload.0[0], Some(0)); + assert_eq!(list.entries()[1].payload.0[0], Some(2.0f32.to_bits())); + assert_eq!(list.entries()[2].payload.0[0], Some(7)); + assert_eq!(list.entries()[3].kind, 1); + list.replace_from(&[], &[]); + assert!(list.entries().is_empty()); + list.append(&attribute(0, 900)); + assert_eq!( + list.entries()[0].payload.0, + [ + Some(900), + Some(501), + Some(502), + Some(503), + Some(504), + Some(505) + ] + ); +} +fn motion(seed: u32) -> MotionGraphPacket { + MotionGraphPacket { + trick: TrickData { + identifiers: [TrickIdentifier([seed; 6]), TrickIdentifier([seed + 1; 6])], + word48: seed + 2, + scalars52_56: [f32::from_bits(0x80000000), f32::from_bits(0x7fc12345)], + }, + gesture: GestureData { + gesture: seed + 3, + flag_bytes: [1, 2, 0xaa, 0xbb], + }, + wipeout_gesture: [4.0, -5.0], + } +} +#[test] +fn concrete_motion_graph_publication_preserves_source_including_gesture_and_trick() { + let source = motion(10); + let mut destination = motion(90); + motion_graph_packet::publish(&source, &mut destination); + assert_eq!(destination.trick.identifiers, source.trick.identifiers); + assert_eq!(destination.trick.word48, 12); + assert_eq!( + destination.trick.scalars52_56.map(f32::to_bits), + [0x80000000, 0x7fc12345] + ); + assert_eq!(destination.gesture, source.gesture); + assert_eq!(destination.wipeout_gesture, [4.0, -5.0]); + assert_eq!(source.gesture.gesture, 13); + assert_eq!(source.wipeout_gesture, [4.0, -5.0]); + destination.gesture.gesture = 37; + motion_graph_packet::publish(&source, &mut destination); + assert_eq!(destination.gesture.gesture, 13); +} diff --git a/crates/skate-core/tests/animation_packet_reset.rs b/crates/skate-core/tests/animation_packet_reset.rs new file mode 100644 index 00000000..52127d0e --- /dev/null +++ b/crates/skate-core/tests/animation_packet_reset.rs @@ -0,0 +1,138 @@ +use skate_core::animation::{ + commands::buffers::BufferError, + output::{ + NativeMatrix, + packet_reset::{self, AdditionalResetFields}, + physics_packet::PhysicsPosePacket, + }, +}; +fn filled(value: f32) -> NativeMatrix { + [[value; 4]; 4] +} +fn packet(count: u32) -> PhysicsPosePacket { + PhysicsPosePacket { + bone_count: count, + hierarchy: vec![filled(7.0); 3], + local: vec![filled(8.0); 3], + timestep: 99.0, + foot_surface_ids: [123, 456], + flags: u32::MAX, + board_flipped: true, + mirrored: true, + riding_switch: true, + riding_fakie: true, + weight_forwards: true, + regular_stance: true, + air_dismount_revert_frames: 100, + } +} +fn fields() -> AdditionalResetFields { + AdditionalResetFields { + compression: 9.0, + foot_ik_influence: [4.0, 5.0], + next_step_position_valid: true, + actor_flag_1904_bit23: true, + actor_flag_1908_bit2: true, + external_impulse_active: true, + external_physics_input_active: true, + externally_controlled: true, + prevent_manual_respawn: true, + ignore_respawn_reset_button: 0xff, + force_braking: true, + truck_tightness: 4.0, + wheel_hardness: 5.0, + auxiliary_vectors: [[f32::NAN; 4]; 6], + requested_physics_mode: 4, + } +} +#[test] +fn reset_clears_all_owned_fields_and_uses_packed_basis_with_zero_translation_w() { + let mut packet = packet(2); + let mut fields = fields(); + let hierarchy_allocation = packet.hierarchy.as_ptr(); + let local_allocation = packet.local.as_ptr(); + packet_reset::reset(&mut packet, &mut fields).unwrap(); + let expected = [ + [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], + ]; + assert_eq!(&packet.local[..2], [expected, expected]); + assert_eq!(&packet.hierarchy[..2], [expected, expected]); + assert_eq!(packet.local[2], filled(8.0)); + assert_eq!(packet.hierarchy[2], filled(7.0)); + assert_eq!(packet.hierarchy.as_ptr(), hierarchy_allocation); + assert_eq!(packet.local.as_ptr(), local_allocation); + assert_eq!(packet.bone_count, 2); + assert_eq!(packet.timestep.to_bits(), 0x3c888889); + assert_eq!(packet.flags, 0x013fffff); + assert_eq!(packet.foot_surface_ids, [0, 0]); + assert_eq!(packet.air_dismount_revert_frames, 0); + assert!( + !packet.board_flipped + && !packet.mirrored + && !packet.riding_switch + && !packet.riding_fakie + && !packet.weight_forwards + && !packet.regular_stance + ); + assert_eq!(fields.compression, 0.5); + assert_eq!(fields.foot_ik_influence, [0.0, 0.0]); + assert_eq!(fields.truck_tightness, 0.0); + assert_eq!(fields.wheel_hardness, 0.0); + assert_eq!(fields.ignore_respawn_reset_button, 0); + assert_eq!(fields.requested_physics_mode, 1); + assert!( + !fields.next_step_position_valid + && !fields.actor_flag_1904_bit23 + && !fields.actor_flag_1908_bit2 + && !fields.external_impulse_active + && !fields.external_physics_input_active + && !fields.externally_controlled + && !fields.prevent_manual_respawn + && !fields.force_braking + ); + for vector in fields.auxiliary_vectors { + assert_eq!(vector.map(f32::to_bits), [0; 4]); + } +} +#[test] +fn reset_preserves_every_flag_outside_the_recovered_clear_mask() { + for bit in 0..32 { + let mut packet = packet(0); + let mut fields = fields(); + packet.flags = 1 << bit; + packet_reset::reset(&mut packet, &mut fields).unwrap(); + let preserved = bit <= 21 || bit == 24; + assert_eq!( + packet.flags, + if preserved { 1 << bit } else { 0 }, + "bit {bit}" + ); + } +} +#[test] +fn zero_and_signed_negative_counts_skip_matrices_but_still_reset_scalars() { + for count in [0, 0x80000000, u32::MAX] { + let mut packet = packet(count); + let mut fields = fields(); + packet_reset::reset(&mut packet, &mut fields).unwrap(); + assert_eq!(packet.bone_count, count); + assert_eq!(packet.local, vec![filled(8.0); 3]); + assert_eq!(fields.compression, 0.5); + assert_eq!(packet.flags, 0x013fffff); + } +} +#[test] +fn short_allocation_is_rejected_without_partial_reset() { + let mut packet = packet(4); + let mut fields = fields(); + assert_eq!( + packet_reset::reset(&mut packet, &mut fields), + Err(BufferError::RangeOutsideAllocation) + ); + assert_eq!(packet.flags, u32::MAX); + assert_eq!(fields.requested_physics_mode, 4); + assert_eq!(packet.local, vec![filled(8.0); 3]); +} diff --git a/crates/skate-core/tests/animation_physics_publication.rs b/crates/skate-core/tests/animation_physics_publication.rs new file mode 100644 index 00000000..ab95c171 --- /dev/null +++ b/crates/skate-core/tests/animation_physics_publication.rs @@ -0,0 +1,219 @@ +use skate_core::animation::output::physics_packet; +use skate_core::animation::output::physics_packet::{ + ActorPoseBuffers, AnimationSignal, PhysicsPosePacket, SkaterPublicationState, +}; +use skate_core::animation::{ + commands::{ + batch::SetDataQueue, + buffers::{BufferError, PoseBuffers}, + }, + output::NativeMatrix, +}; +use std::num::NonZeroU16; +fn matrix(value: f32) -> NativeMatrix { + [[value; 4]; 4] +} +fn state() -> SkaterPublicationState { + SkaterPublicationState { + orientation_bit31: false, + mirrored: false, + riding_fakie: false, + weight_on_nose: false, + relative_stance: 0, + natural_stance: 0, + request_bit16: true, + request_bit15: true, + air_dismount_revert_requested: true, + air_dismount_revert_frames: 17, + signal: Some(AnimationSignal { + name_hash: 0, + active: 255, + }), + } +} +fn packet() -> PhysicsPosePacket { + PhysicsPosePacket { + bone_count: 2, + hierarchy: vec![matrix(-1.0); 3], + local: vec![matrix(-2.0); 3], + timestep: 0.0, + foot_surface_ids: [35, 46], + flags: u32::MAX, + board_flipped: false, + mirrored: false, + riding_switch: false, + riding_fakie: false, + weight_forwards: false, + regular_stance: false, + air_dismount_revert_frames: 0, + } +} +#[test] +fn publication_copies_separate_arrays_consumes_latches_and_preserves_unrelated_flags() { + let mut buffers = PoseBuffers::default(); + let hierarchy = buffers.matrices.initialized([matrix(1.0), matrix(2.0)]); + let local = buffers.matrices.initialized([matrix(3.0), matrix(4.0)]); + let completed = SetDataQueue::new(buffers, NonZeroU16::new(1).unwrap()) + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let poses = ActorPoseBuffers { + hierarchy: hierarchy.at(0), + local: local.at(0), + }; + let mut state = state(); + let mut packet = packet(); + assert_eq!( + physics_packet::publish( + &mut state, + &completed, + &poses, + &mut packet, + 1.0 / 60.0, + b"signup" + ), + Ok(17) + ); + assert_eq!(packet.hierarchy, [matrix(1.0), matrix(2.0), matrix(-1.0)]); + assert_eq!(packet.local, [matrix(3.0), matrix(4.0), matrix(-2.0)]); + assert_eq!(packet.flags, u32::MAX & !((1 << 25) | (1 << 26))); + assert_eq!(packet.foot_surface_ids, [0, 0]); + assert_eq!(packet.timestep.to_bits(), (1.0f32 / 60.0).to_bits()); + assert!(!state.request_bit16 && !state.request_bit15 && !state.air_dismount_revert_requested); + assert_eq!(state.signal.as_ref().unwrap().active, 0); + // ASCII shift/add evaluated independently: no overflow fold for six bytes. + assert_eq!(state.signal.as_ref().unwrap().name_hash, 127_919_552); + packet.hierarchy[0] = matrix(100.0); + assert_eq!( + completed + .buffers() + .matrices + .read(hierarchy.at(0), 1) + .unwrap(), + [matrix(1.0)] + ); + physics_packet::publish(&mut state, &completed, &poses, &mut packet, 0.25, b"signup").unwrap(); + assert_eq!(packet.flags & ((1 << 27) | (1 << 23) | (1 << 28)), 0); + assert_eq!(packet.air_dismount_revert_frames, 17); + assert_eq!(state.air_dismount_revert_frames, 17); +} +#[test] +fn concrete_stance_truth_table_keeps_natural_relative_and_fakie_distinct() { + let mut scratch = PoseBuffers::default(); + let empty = scratch.matrices.allocate(0); + let completed = SetDataQueue::new(scratch, NonZeroU16::new(1).unwrap()) + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let poses = ActorPoseBuffers { + hierarchy: empty.at(0), + local: empty.at(0), + }; + for reversed in [false, true] { + for mirrored in [false, true] { + for fakie in [false, true] { + for nose in [false, true] { + let mut state = state(); + state.orientation_bit31 = reversed; + state.mirrored = mirrored; + state.riding_fakie = fakie; + state.weight_on_nose = nose; + state.relative_stance = 1; + state.natural_stance = 2; + state.signal = None; + let mut packet = packet(); + packet.bone_count = 0; + physics_packet::publish(&mut state, &completed, &poses, &mut packet, 0.0, b"") + .unwrap(); + assert_eq!(packet.board_flipped, reversed != mirrored); + assert_eq!(packet.mirrored, mirrored); + assert_eq!(packet.riding_fakie, fakie); + assert_eq!(packet.weight_forwards, fakie != nose); + assert!(packet.riding_switch); + assert!(!packet.regular_stance); + } + } + } + } +} +#[test] +fn unsafe_source_extent_is_rejected_before_consuming_requests() { + let mut buffers = PoseBuffers::default(); + let pose = buffers.matrices.allocate(2); + let completed = SetDataQueue::new(buffers, NonZeroU16::new(1).unwrap()) + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let mut state = state(); + let mut packet = packet(); + let poses = ActorPoseBuffers { + hierarchy: pose.at(0), + local: pose.at(0), + }; + assert_eq!( + physics_packet::publish(&mut state, &completed, &poses, &mut packet, 0.1, b"signup"), + Err(BufferError::UninitializedBone { bone: 0 }) + ); + assert!(state.request_bit16 && state.request_bit15 && state.air_dismount_revert_requested); + assert_eq!(packet.foot_surface_ids, [35, 46]); +} +#[test] +fn signal_hash_uses_signed_bytes_nul_termination_and_high_nibble_fold() { + assert_eq!(physics_packet::signal_hash(b""), 0); + assert_eq!(physics_packet::signal_hash(b"a\0ignored"), 97); + assert_eq!(physics_packet::signal_hash(&[0xff]), 0x0fff_fe1f); + assert_eq!(physics_packet::signal_hash(&[0x80]), 0x0fff_fe60); +} + +#[test] +fn completed_setdata_exports_feed_physics_publication() { + use skate_core::animation::commands::set_data::{CopyCommand, InternalUpdate, SetDataCommand}; + let mut buffers = PoseBuffers::default(); + let hierarchy_input = buffers.matrices.initialized([matrix(12.0), matrix(13.0)]); + let local_input = buffers.matrices.initialized([matrix(22.0), matrix(23.0)]); + let hierarchy_actor = buffers.matrices.allocate(2); + let local_actor = buffers.matrices.allocate(2); + let mut queue = SetDataQueue::new(buffers, NonZeroU16::new(1).unwrap()); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: hierarchy_input.at(0), + external: Some(hierarchy_actor.at(0)), + }), + &[], + ) + .unwrap(); + queue + .enqueue( + 2, + SetDataCommand::Matrix(CopyCommand { + update: InternalUpdate::Preserve, + internal: local_input.at(0), + external: Some(local_actor.at(0)), + }), + &[], + ) + .unwrap(); + let completed = queue + .submit() + .complete() + .unwrap_or_else(|e| panic!("{:?}", e.cause)); + let poses = ActorPoseBuffers { + hierarchy: hierarchy_actor.at(0), + local: local_actor.at(0), + }; + let mut packet = packet(); + physics_packet::publish( + &mut state(), + &completed, + &poses, + &mut packet, + 1.0 / 60.0, + b"signup", + ) + .unwrap(); + assert_eq!(&packet.hierarchy[..2], [matrix(12.0), matrix(13.0)]); + assert_eq!(&packet.local[..2], [matrix(22.0), matrix(23.0)]); +} diff --git a/crates/skate-core/tests/animation_skeleton_attributes.rs b/crates/skate-core/tests/animation_skeleton_attributes.rs new file mode 100644 index 00000000..c01e790d --- /dev/null +++ b/crates/skate-core/tests/animation_skeleton_attributes.rs @@ -0,0 +1,237 @@ +use skate_core::animation::{ + output::attributes::{AnimationAttribute, AttributeName, AttributePayload}, + skeleton_input::{ + catalog::{self, EVENT_COMPARISONS, SCALAR_ATTRIBUTES}, + name::encode, + scalar_attributes::{self, AnimationControlOutput, DispatchError, ScalarAttributeInputs}, + }, +}; + +fn input() -> ScalarAttributeInputs { + ScalarAttributeInputs { + flags2468: 0x1000, + flags2472: 0x1000, + flags2476: 0x1000, + flags2484: 0x1000, + flags2488: 0x1000, + board_adjust: 999, + balance: 2.0, + spin: 3.0, + body_spin: 4.0, + brake: 5.0, + turn: 6.0, + turn_scale: 7.0, + magnitude_scale: 8.0, + animation_end_com: [1.0, 2.0, 3.0, -0.0], + animation_translation: [4.0, 5.0, 6.0, 9.0], + animation_time: 10.0, + animation_physics_blend_seconds: 11.0, + cadence_end_percent: 12.0, + raw_turn: 13.0, + hard_turn: 14.0, + slide: 15.0, + } +} +fn output() -> AnimationControlOutput { + AnimationControlOutput { + grind_name: AttributeName([99; 5]), + flags: 0x1000, + } +} +fn attribute(name: &[u8], value: Option) -> AnimationAttribute { + AnimationAttribute { + name: encode(name), + payload: AttributePayload([value.map(f32::to_bits), None, None, None, None, None]), + begin_time: 123.0, + end_time: -99.0, + status: 0, + kind: 0, + sequence_id: 5, + } +} +fn apply( + name: &[u8], + value: Option, + input: &mut ScalarAttributeInputs, + output: &mut AnimationControlOutput, +) { + scalar_attributes::dispatch_attribute(&attribute(name, value), input, output).unwrap(); +} + +#[test] +fn native_name_encoding_is_base38_case_insensitive_chunked_and_nul_terminated() { + assert_eq!(encode(b"A").0, [11 * 79_235_168, 0, 0, 0, 0]); + assert_eq!( + encode(b"a0_zZz").0[0], + 11 * 79_235_168 + 2_085_136 + 37 * 54_872 + 36 * 1_444 + 36 * 38 + 36 + ); + assert_eq!(encode(b"ABCDEFg").0[1], 17 * 79_235_168); + assert_eq!(encode(b"Turn"), encode(b"tUrN")); + assert_eq!(encode(b"Turn\0Brake"), encode(b"Turn")); + assert_eq!( + encode(b"123456123456123456123456123456ignored"), + encode(b"123456123456123456123456123456") + ); + assert_eq!( + encode(&[255]).0[0], + (-48i32 as u32).wrapping_mul(79_235_168) + ); + assert_ne!(encode(b"push_contact"), encode(b"PushContact")); +} + +#[test] +fn complete_dispatch_identity_catalog_retains_direct_and_event_comparisons() { + assert_eq!(SCALAR_ATTRIBUTES.len(), 151); + assert_eq!(EVENT_COMPARISONS.len(), 5); + assert!( + SCALAR_ATTRIBUTES + .windows(2) + .all(|pair| pair[0].comparison_site < pair[1].comparison_site) + ); + assert_eq!( + catalog::lookup(encode(b"Spin")).unwrap().comparison_site, + 0x82bdb004 + ); + assert_eq!( + catalog::lookup(encode(b"IsWipeoutPushOffImpulse")) + .unwrap() + .comparison_site, + 0x82bdb148 + ); + assert_eq!( + catalog::lookup(encode(b"WantsLeftAirGrab")) + .unwrap() + .comparison_site, + 0x82bdb5fc + ); + assert_eq!( + catalog::lookup(encode(b"WantsRightAirGrab")) + .unwrap() + .comparison_site, + 0x82bdb614 + ); + assert_eq!( + catalog::lookup(encode(b"jump")).unwrap().comparison_site, + 0x82bdb7e8 + ); + assert!(catalog::lookup(encode(b"push_contact")).is_none()); +} + +#[test] +fn steering_and_push_markers_write_their_distinct_consumed_fields_without_thresholds() { + let mut i = input(); + let mut o = output(); + for name in [ + b"NoInput".as_slice(), + b"NewAutoPump", + b"ManualBrake", + b"Carving", + b"PushContact", + b"PushSpeed", + ] { + apply(name, None, &mut i, &mut o); + } + apply(b"Brake", Some(-2.5), &mut i, &mut o); + apply(b"Turn", Some(0.25), &mut i, &mut o); + apply(b"Spin", Some(-0.75), &mut i, &mut o); + assert_eq!((i.brake, i.turn, i.spin), (-2.5, -0.25, 0.75)); + assert_eq!(i.flags2468, 0xa0001000); + assert_eq!(i.flags2472, 0x00801000); + assert_eq!(i.flags2488, 0x20801000); + assert_eq!(i.flags2476, 0x1000); + assert_eq!(o.flags, 0x60001000); + assert_eq!(i.balance, 2.0); + // A false/zero scalar is still a presence marker in these native branches. + apply(b"PushContact", Some(0.0), &mut i, &mut o); + assert_eq!(i.flags2488, 0x20801000); +} + +#[test] +fn repeated_attributes_preserve_list_order_and_do_not_clear_unrelated_state() { + let mut i = input(); + let mut o = output(); + apply(b"SlideBoard", None, &mut i, &mut o); + apply(b"GrindNose", None, &mut i, &mut o); + apply(b"GrindFacingForwards", None, &mut i, &mut o); + apply(b"GrindFacingBackwards", None, &mut i, &mut o); + apply(b"BoardAdjustLeft", None, &mut i, &mut o); + apply(b"BoardAdjustDown", None, &mut i, &mut o); + apply(b"Turn", Some(1.0), &mut i, &mut o); + apply(b"TURN", Some(-0.0), &mut i, &mut o); + assert_eq!(o.grind_name, encode(b"GrindNose")); + assert_eq!(o.flags, 0x1000); + assert_eq!(i.board_adjust, 4); + assert_eq!(i.turn.to_bits(), 0); + assert_eq!(i.brake, 5.0); + assert_eq!(i.flags2468, 0x1000); +} + +#[test] +fn com_translation_and_scale_handlers_preserve_other_lanes_and_float_signs() { + let mut i = input(); + let mut o = output(); + apply(b"AnimEndCOMY", Some(-7.0), &mut i, &mut o); + apply(b"AnimTransZ", Some(8.0), &mut i, &mut o); + apply(b"TurnScale", Some(0.0), &mut i, &mut o); + apply(b"MagScale", Some(-3.0), &mut i, &mut o); + apply(b"RawTurn", Some(f32::from_bits(0x7fc12345)), &mut i, &mut o); + assert_eq!(i.animation_end_com[..3], [1.0, -7.0, 3.0]); + assert_eq!(i.animation_end_com[3].to_bits(), 0x80000000); + assert_eq!(i.animation_translation, [4.0, 5.0, 8.0, 9.0]); + assert_eq!((i.turn_scale, i.magnitude_scale), (0.0, -3.0)); + assert_eq!(i.raw_turn.to_bits(), 0xffc12345); +} + +#[test] +fn scalar_kind_gate_ignores_timing_status_but_never_treats_active_events_as_scalars() { + let mut i = input(); + let mut o = output(); + let original = i; + for kind in [1, 2, 127, 128, 255] { + let mut a = attribute(b"Turn", Some(0.75)); + a.kind = kind; + a.status = 255; + scalar_attributes::dispatch_attribute(&a, &mut i, &mut o).unwrap(); + assert_eq!(i, original); + } + for status in [0, 1, 2, 16, 128] { + let mut a = attribute(b"Turn", None); + a.kind = 3; + a.status = status; + scalar_attributes::dispatch_attribute(&a, &mut i, &mut o).unwrap(); + assert_eq!(i, original); + } + for status in [4, 8, 12, 255] { + let mut a = attribute(b"unknown-event", None); + a.kind = 3; + a.status = status; + assert!(matches!( + scalar_attributes::dispatch_attribute(&a, &mut i, &mut o), + Err(DispatchError::EventConsumerUnavailable { .. }) + )); + } + let mut a = attribute(b"Turn", Some(2.0)); + a.status = 255; + scalar_attributes::dispatch_attribute(&a, &mut i, &mut o).unwrap(); + assert_eq!(i.turn, -2.0); +} + +#[test] +fn unported_known_handlers_and_uninitialized_scalars_fail_without_writes() { + let mut i = input(); + let mut o = output(); + let original = i; + let original_o = o; + assert!( + matches!(scalar_attributes::dispatch_attribute(&attribute(b"MinJump", Some(0.8)), &mut i, &mut o), + Err(DispatchError::KnownScalarUnavailable { attribute }) if attribute.comparison_site == 0x82bdb71c) + ); + assert!(matches!( + scalar_attributes::dispatch_attribute(&attribute(b"Turn", None), &mut i, &mut o), + Err(DispatchError::UninitializedScalar { .. }) + )); + scalar_attributes::dispatch_attribute(&attribute(b"unknown-name", None), &mut i, &mut o) + .unwrap(); + assert_eq!(i, original); + assert_eq!(o, original_o); +} diff --git a/crates/skate-core/tests/constraint_batch.rs b/crates/skate-core/tests/constraint_batch.rs new file mode 100644 index 00000000..509a7fd9 --- /dev/null +++ b/crates/skate-core/tests/constraint_batch.rs @@ -0,0 +1,35 @@ +use skate_core::physics::constraint_batch::compile_active; + +#[test] +fn inactive_constraints_never_reach_compilation_and_survivors_keep_registration_order() { + let records = [ + (0, [0, 8]), + (1, [4, 0]), + (2, [1, 2]), + (3, [0, 4]), + (4, [12, 4]), + ]; + let mut called = Vec::new(); + let batch = compile_active( + records, + |record| record.1, + |(id, _)| { + assert!(![0, 2].contains(&id), "inactive row reached compiler"); + called.push(id); + id * 10 + }, + ); + assert_eq!(called, [1, 3, 4]); + assert_eq!(batch, [10, 30, 40]); +} + +#[test] +fn each_batch_recomputes_eligibility_after_endpoint_state_changes() { + let mut states = [4, 0]; + assert_eq!(compile_active([7], |_| states, |id| id), [7]); + states[0] = 8; + let sleeping: Vec = compile_active([7], |_| states, |_| panic!("sleeping row compiled")); + assert!(sleeping.is_empty()); + states[1] = 4; + assert_eq!(compile_active([7], |_| states, |id| id), [7]); +} diff --git a/crates/skate-core/tests/contact_iteration.rs b/crates/skate-core/tests/contact_iteration.rs new file mode 100644 index 00000000..33498969 --- /dev/null +++ b/crates/skate-core/tests/contact_iteration.rs @@ -0,0 +1,136 @@ +//! Independent analytical cases for compiled rows. These exercise the solver +//! without pretending the still-unavailable contact builder has been validated. +use skate_core::physics::solver::packed::{self, Contact, Reaction}; + +fn row(words: &mut [u32], index: usize, value: [f32; 4]) { + for (i, value) in value.into_iter().enumerate() { + words[index * 4 + i] = value.to_bits(); + } +} +fn read(words: &[u32], index: usize) -> [f32; 4] { + core::array::from_fn(|i| f32::from_bits(words[index * 4 + i])) +} +fn contact(target: [f32; 4], inverse_mass: f32) -> Contact { + let mut words = [0; 64]; + row(&mut words, 2, [1.0, 0.0, 0.0, 0.0]); + row(&mut words, 3, [0.0, 1.0, 0.0, 0.5]); + row(&mut words, 4, [0.0, 0.0, 1.0, 0.25]); + row(&mut words, 6, target); + row(&mut words, 7, [1.0, 0.0, 0.0, 0.0]); + row(&mut words, 10, [0.0, 1.0, 0.0, 0.0]); + row(&mut words, 13, [0.0, 0.0, 1.0, 0.0]); + row(&mut words, 8, [0.0, 0.0, 0.0, inverse_mass]); + Contact { + words, + reaction_a: 0, + reaction_b: 1, + } +} +fn once(contact: &mut Contact, reactions: &mut [Reaction; 2]) { + packed::solve( + core::slice::from_mut(contact), + &mut [], + &mut [], + reactions, + 1, + ); +} + +#[test] +fn first_normal_impulse_does_not_enable_friction_in_the_same_pass() { + let mut contact = contact([2.0, 1.0, -1.0, 0.5], 1.0); + let mut reactions = [[0; 16]; 2]; + once(&mut contact, &mut reactions); + assert_eq!(read(&contact.words, 5), [2.0, 0.0, 0.0, 0.5]); + assert_eq!(&read(&reactions[0], 0)[..3], &[2.0, 0.0, 0.0]); + assert_eq!(&read(&reactions[0], 1)[..3], &[0.5, 0.0, 0.0]); + assert_eq!(reactions[1], [0; 16]); +} + +#[test] +fn static_boundary_is_inclusive_but_sliding_uses_dynamic_limit() { + for (target, expected) in [(1.0, 1.0), (-1.0, -1.0), (1.125, 0.5), (-1.125, -0.5)] { + let mut contact = contact([0.0, target, 0.0, 0.0], 0.0); + row(&mut contact.words, 5, [2.0, 0.0, 0.0, 0.0]); + once(&mut contact, &mut [[0; 16]; 2]); + assert_eq!(read(&contact.words, 5), [2.0, expected, 0.0, 0.0]); + } +} + +#[test] +fn angular_motion_at_contact_feeds_the_normal_row() { + let mut contact = contact([0.0; 4], 0.0); + row(&mut contact.words, 0, [0.0, 1.0, 0.0, 0.0]); + row(&mut contact.words, 8, [0.0, 0.0, -1.0, 0.0]); + let mut reactions = [[0; 16]; 2]; + row(&mut reactions[0], 2, [0.0, 0.0, 2.0, 0.0]); + once(&mut contact, &mut reactions); + assert_eq!(read(&contact.words, 5), [2.0, 0.0, 0.0, 0.0]); + assert_eq!(read(&reactions[0], 2), [0.0; 4]); +} + +#[test] +fn later_contact_reads_the_reaction_from_the_previous_contact() { + let mut contacts = [ + contact([2.0, 0.0, 0.0, 0.0], 1.0), + contact([3.0, 0.0, 0.0, 0.0], 1.0), + ]; + let mut reactions = [[0; 16]; 2]; + packed::solve(&mut contacts, &mut [], &mut [], &mut reactions, 1); + assert_eq!(read(&contacts[0].words, 5)[0], 2.0); + assert_eq!(read(&contacts[1].words, 5)[0], 1.0); + assert_eq!(read(&reactions[0], 0)[0], 3.0); +} + +#[test] +fn body_b_receives_opposite_linear_and_angular_changes() { + let mut contact = contact([2.0, 0.0, 0.0, 0.5], 1.0); + row(&mut contact.words, 9, [0.0, 0.0, 3.0, 0.25]); + let mut reactions = [[0; 16]; 2]; + once(&mut contact, &mut reactions); + assert_eq!(&read(&reactions[1], 0)[..3], &[-0.5, 0.0, 0.0]); + assert_eq!(&read(&reactions[1], 1)[..3], &[-0.125, 0.0, 0.0]); + assert_eq!(&read(&reactions[1], 2)[..3], &[0.0, 0.0, -6.0]); + assert_eq!(&read(&reactions[1], 3)[..3], &[0.0, 0.0, -1.5]); +} + +#[test] +fn ground_contact_reaches_joint_then_drive_in_one_iteration() { + // Three equal-mass bodies at their centers. Ground first gives body0 a + // correction of2. The joint equalizes0/1 to1; the drive then equalizes1/2 + // to0.5. Solving families from independent snapshots cannot produce this. + let mut ground = contact([2.0, 0.0, 0.0, 0.0], 1.0); + ground.reaction_b = 3; + let mut words = [0; 96]; + row(&mut words, 4, [0.5, 0.0, 0.0, 0.0]); + row(&mut words, 12, [1.0, 0.0, 0.0, 0.0]); + row(&mut words, 18, [0.0, 0.0, 0.0, 1.0]); + row(&mut words, 21, [0.0, 0.0, 0.0, 1.0]); + let mut joint = packed::Joint { + words, + reaction_a: 0, + reaction_b: 1, + }; + row(&mut words, 2, [0.0, 0.0, 0.0, 1.0]); + words[6 * 4 + 3] = 10.0f32.to_bits(); + let mut drive = packed::Drive { + words, + reaction_a: 1, + reaction_b: 2, + }; + let mut reactions = [[0; 16]; 4]; + packed::solve( + core::slice::from_mut(&mut ground), + core::slice::from_mut(&mut joint), + core::slice::from_mut(&mut drive), + &mut reactions, + 1, + ); + assert_eq!( + reactions.map(|r| f32::from_bits(r[0])), + [1.0, 0.5, 0.5, 0.0] + ); + assert_eq!(read(&ground.words, 5)[0], 2.0); + assert_eq!(read(&joint.words, 2)[0], -1.0); + assert_eq!(read(&drive.words, 2)[0], -0.5); +} diff --git a/crates/skate-core/tests/physics_rebuild_authored_transforms.rs b/crates/skate-core/tests/physics_rebuild_authored_transforms.rs new file mode 100644 index 00000000..566535de --- /dev/null +++ b/crates/skate-core/tests/physics_rebuild_authored_transforms.rs @@ -0,0 +1,128 @@ +//! Analytic geometry checks using real stock inputs and explicit alternatives. +//! No generated-code outputs or live/settled-pose fixtures are used. +use skate_core::{ + math::Vector3, + physics::drive_frames::{ + AuthoredTransformInputs, RetailAffineTransform, authored_body_pose_records, + authored_body_transforms, default_body_transforms, + }, +}; + +#[test] +fn stock_dimensions_place_all_seven_authored_parts() { + let parts = default_body_transforms(); + // Decoded XML dimensions: mid length .59, wheel offset .095, + // truck longitudinal correction -.052, vertical position -.0565. + let expected = [ + [-0.095, -0.0565, 0.243], + [0.095, -0.0565, 0.243], + [-0.095, -0.0565, -0.243], + [0.095, -0.0565, -0.243], + [0.0, -0.0565, 0.243], + [0.0, -0.0565, -0.243], + [0.0; 3], + ]; + for (part, expected) in parts.iter().zip(expected) { + for (actual, expected) in [part.translation.x, part.translation.y, part.translation.z] + .into_iter() + .zip(expected) + { + assert!((actual - expected).abs() < 0.00000004); + } + } + for part in &parts[..5] { + assert_eq!(part.basis, RetailAffineTransform::IDENTITY.basis); + } + assert_eq!(parts[6], RetailAffineTransform::IDENTITY); +} + +#[test] +fn asymmetric_inputs_keep_native_front_back_and_wheel_signs() { + let parts = authored_body_transforms(AuthoredTransformInputs { + deck_mid_length: 8.0, + wheel_x_distance: 0.5, + truck_z_position_front: -1.0, + truck_z_position_back: -2.0, + truck_y_position: -0.25, + }); + assert_eq!( + parts.map(|p| p.translation), + [ + Vector3::new(-0.5, -0.25, 3.0), + Vector3::new(0.5, -0.25, 3.0), + Vector3::new(-0.5, -0.25, -2.0), + Vector3::new(0.5, -0.25, -2.0), + Vector3::new(0.0, -0.25, 3.0), + Vector3::new(0.0, -0.25, -2.0), + Vector3::ZERO, + ] + ); +} + +#[test] +fn negative_offsets_are_not_clamped_or_reordered() { + let parts = authored_body_transforms(AuthoredTransformInputs { + deck_mid_length: 2.0, + wheel_x_distance: -0.5, + truck_z_position_front: -3.0, + truck_z_position_back: -4.0, + truck_y_position: 0.25, + }); + assert_eq!(parts[0].translation, Vector3::new(0.5, 0.25, -2.0)); + assert_eq!(parts[1].translation, Vector3::new(-0.5, 0.25, -2.0)); + assert_eq!(parts[2].translation, Vector3::new(0.5, 0.25, 3.0)); + assert_eq!(parts[3].translation, Vector3::new(-0.5, 0.25, 3.0)); +} + +#[test] +fn rear_truck_keeps_quarter_turn_polynomial_residual() { + let basis = default_body_transforms()[5].basis.columns; + // Analytic Y quarter-turn axes: -Z, +Y, +X. The literal angle is finite + // pi/2 and the recovered polynomial retains a nonzero cosine residual. + assert_eq!(basis[1], [0.0, 1.0, 0.0]); + assert!(basis[0][0].abs() < 0.000001); + assert_ne!(basis[0][0], 0.0); + assert_eq!(basis[0][0].to_bits(), basis[2][2].to_bits()); + assert_eq!(basis[0][2].to_bits(), (-basis[2][0]).to_bits()); + assert!((basis[0][2] + 1.0).abs() < 0.000001); + assert!((basis[2][0] - 1.0).abs() < 0.000001); + assert_eq!(basis[0][1], 0.0); + assert_eq!(basis[2][1], 0.0); +} + +#[test] +fn reset_records_preserve_authored_axes_and_zero_carry_lanes() { + let records = authored_body_pose_records(AuthoredTransformInputs::STOCK); + let parts = default_body_transforms(); + for (record, part) in records.into_iter().zip(parts) { + assert_eq!([record[3], record[7], record[11], record[15]], [0; 4]); + for axis in 0..3 { + assert_eq!( + [record[axis * 4], record[axis * 4 + 1], record[axis * 4 + 2]], + part.basis.columns[axis].map(f32::to_bits) + ); + } + assert_eq!( + [record[12], record[13], record[14]], + [ + part.translation.x.to_bits(), + part.translation.y.to_bits(), + part.translation.z.to_bits() + ] + ); + } +} + +#[test] +fn dimensions_do_not_change_authored_body_orientations() { + let stock = default_body_transforms(); + let alternate = authored_body_transforms(AuthoredTransformInputs { + deck_mid_length: 1.25, + wheel_x_distance: 0.25, + truck_z_position_front: 0.0, + truck_z_position_back: 0.0, + truck_y_position: -0.125, + }); + assert_eq!(stock.map(|p| p.basis), alternate.map(|p| p.basis)); + assert_ne!(stock[0].translation, alternate[0].translation); +} diff --git a/crates/skate-core/tests/physics_rebuild_constraints.rs b/crates/skate-core/tests/physics_rebuild_constraints.rs new file mode 100644 index 00000000..2cfc0ca7 --- /dev/null +++ b/crates/skate-core/tests/physics_rebuild_constraints.rs @@ -0,0 +1,436 @@ +//! Independently specified mechanics and packed-boundary checks. These rows +//! are explicit test inputs, not generated-code outputs or parity fixtures. +use skate_core::physics::solver::packed::{self, Constraint, Drive, Joint, Reaction}; + +fn column(words: &mut [u32], byte_offset: usize, values: [f32; 4]) { + for (slot, value) in words[byte_offset / 4..byte_offset / 4 + 4] + .iter_mut() + .zip(values) + { + *slot = value.to_bits(); + } +} + +fn read(words: &[u32], byte_offset: usize) -> [f32; 4] { + core::array::from_fn(|i| f32::from_bits(words[byte_offset / 4 + i])) +} + +// Equal unit masses/inertias with origin anchors have inverse effective mass +// one half along each axis. The six impulse axes are the Cartesian basis. +fn origin_constraint() -> Constraint<96> { + let mut words = [0; 96]; + for axis in 0..3 { + let mut basis = [0.0; 4]; + basis[axis] = 1.0; + column(&mut words, 192 + axis * 16, basis); + column(&mut words, 240 + axis * 16, basis); + let mut projection = basis; + projection[axis] = 0.5; + column(&mut words, 64 + axis * 32, projection); + column(&mut words, 80 + axis * 32, projection); + basis[3] = if axis == 0 { 1.0 } else { 0.0 }; + column(&mut words, 288 + axis * 16, basis); + column(&mut words, 336 + axis * 16, basis); + } + Constraint { + words, + reaction_a: 0, + reaction_b: 1, + } +} + +fn drive() -> Drive { + let mut drive = origin_constraint(); + drive.words[11] = 1.0f32.to_bits(); + drive.words[15] = 1.0f32.to_bits(); + drive_limits(&mut drive, [1000.0; 3], [1000.0; 3]); + drive +} + +fn drive_limits(drive: &mut Drive, linear: [f32; 3], angular: [f32; 3]) { + for (offset, value) in [ + (108, linear[0]), + (124, linear[1]), + (172, linear[2]), + (140, angular[0]), + (156, angular[1]), + (188, angular[2]), + ] { + drive.words[offset / 4] = value.to_bits(); + } +} + +fn reactions(linear_b: [f32; 3], angular_b: [f32; 3]) -> [Reaction; 2] { + let mut states = [[0; 16]; 2]; + column( + &mut states[1], + 0, + [linear_b[0], linear_b[1], linear_b[2], 0.0], + ); + column( + &mut states[1], + 32, + [angular_b[0], angular_b[1], angular_b[2], 0.0], + ); + states +} + +fn xyz(words: &[u32], offset: usize) -> [f32; 3] { + read(words, offset)[..3].try_into().unwrap() +} + +#[test] +fn joint_and_drive_equalize_origin_corrections_and_converge() { + for is_drive in [false, true] { + let mut joints: Vec = if is_drive { + vec![] + } else { + vec![origin_constraint()] + }; + let mut drives = if is_drive { vec![drive()] } else { vec![] }; + let mut states = reactions([4.0, -8.0, 12.0], [-6.0, 10.0, -14.0]); + packed::solve(&mut [], &mut joints, &mut drives, &mut states, 1); + for body in states { + assert_eq!(xyz(&body, 0), [2.0, -4.0, 6.0]); + assert_eq!(xyz(&body, 32), [-3.0, 5.0, -7.0]); + } + let first = states; + packed::solve(&mut [], &mut joints, &mut drives, &mut states, 5); + assert_eq!(states, first); + } +} + +#[test] +fn drive_applies_six_independent_symmetric_limits() { + let mut drive = drive(); + drive_limits(&mut drive, [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]); + column(&mut drive.words, 160, [8.0, -8.0, 0.25, 3.0]); + column(&mut drive.words, 176, [-9.0, 9.0, -0.75, 6.0]); + let mut states = reactions([0.0; 3], [0.0; 3]); + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!(read(&drive.words, 32), [1.0, -2.0, 0.25, 1.0]); + assert_eq!(read(&drive.words, 48), [-4.0, 5.0, -0.75, 1.0]); + assert_eq!(xyz(&states[0], 0), [1.0, -2.0, 0.25]); + assert_eq!(xyz(&states[1], 0), [-1.0, 2.0, -0.25]); + assert_eq!(xyz(&states[0], 32), [-4.0, 5.0, -0.75]); + assert_eq!(xyz(&states[1], 32), [4.0, -5.0, 0.75]); +} + +#[test] +fn drive_softness_scales_accumulated_and_projected_response_before_target() { + let mut drive = drive(); + column(&mut drive.words, 32, [2.0, 0.0, 0.0, 0.5]); + column(&mut drive.words, 48, [4.0, 0.0, 0.0, 0.25]); + column(&mut drive.words, 160, [1.0, 0.0, 0.0, 1000.0]); + column(&mut drive.words, 176, [-1.0, 0.0, 0.0, 1000.0]); + let mut states = reactions([2.0, 0.0, 0.0], [8.0, 0.0, 0.0]); + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!(read(&drive.words, 32), [2.5, 0.0, 0.0, 0.5]); + assert_eq!(read(&drive.words, 48), [1.0, 0.0, 0.0, 0.25]); + assert_eq!(states[0][0], 0.5f32.to_bits()); + assert_eq!(states[1][0], 1.5f32.to_bits()); + assert_eq!(states[0][8], (-3.0f32).to_bits()); + assert_eq!(states[1][8], 11.0f32.to_bits()); +} + +#[test] +fn joint_limits_remove_inside_interval_and_correct_both_outside_sides() { + let mut joint = origin_constraint(); + column(&mut joint.words, 160, [-1.0, -2.0, -3.0, -4.0]); + column(&mut joint.words, 176, [1.0, 2.0, 3.0, 4.0]); + for (offset, value) in [(108, -2.0f32), (124, -3.0), (140, 2.0), (156, 3.0)] { + joint.words[offset / 4] = value.to_bits(); + } + let mut states = reactions([6.0, -6.0, 0.0], [8.0, -8.0, 0.0]); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(xyz(&joint.words, 32), [2.0, -1.0, 0.0]); + assert_eq!(xyz(&joint.words, 48), [2.0, -1.0, 0.0]); + + for direction in [-1.0, 1.0] { + let mut joint = joint.clone(); + column(&mut joint.words, 32, [0.0; 4]); + column(&mut joint.words, 48, [0.0; 4]); + let mut states = reactions( + [direction * 2.0, direction * 4.0, direction * 6.0], + [0.0; 3], + ); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(xyz(&joint.words, 32), [0.0; 3]); + } +} + +#[test] +fn off_center_linear_and_angular_candidates_share_incoming_reactions() { + let mut joint = origin_constraint(); + column(&mut joint.words, 0, [0.0, 1.0, 0.0, 0.0]); + column(&mut joint.words, 16, [0.0, -2.0, 0.0, 0.0]); + joint.words[64 / 4] = 0.125f32.to_bits(); + joint.words[348 / 4] = 2.0f32.to_bits(); + let mut states = reactions([0.0; 3], [0.0, 0.0, -1.0]); + states[0][10] = 1.0f32.to_bits(); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(xyz(&joint.words, 32), [-0.125, 0.0, 0.0]); + assert_eq!(xyz(&joint.words, 48), [0.0, 0.0, -1.0]); + assert_eq!(xyz(&states[0], 0), [-0.125, 0.0, 0.0]); + assert_eq!(xyz(&states[1], 0), [0.25, 0.0, 0.0]); + assert_eq!(xyz(&states[0], 32), [0.0, 0.0, 0.125]); + assert_eq!(xyz(&states[1], 32), [0.0, 0.0, 0.25]); +} + +#[test] +fn shared_reactions_use_full_inertia_tensors_and_separate_arms() { + let mut drive = drive(); + column(&mut drive.words, 0, [1.0, 2.0, 3.0, 0.0]); + column(&mut drive.words, 16, [-1.0, 1.0, 2.0, 0.0]); + column(&mut drive.words, 288, [2.0, 0.25, 0.5, 1.0]); + column(&mut drive.words, 304, [0.25, 3.0, 0.75, 0.0]); + column(&mut drive.words, 320, [0.5, 0.75, 4.0, 0.0]); + column(&mut drive.words, 336, [1.0, -0.5, 0.0, 1.0]); + column(&mut drive.words, 352, [-0.5, 2.0, 0.25, 0.0]); + column(&mut drive.words, 368, [0.0, 0.25, 3.0, 0.0]); + column(&mut drive.words, 160, [2.0, 3.0, 4.0, 1000.0]); + column(&mut drive.words, 176, [5.0, 6.0, 7.0, 1000.0]); + let mut states = reactions([0.0; 3], [0.0; 3]); + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!(xyz(&states[0], 32), [13.0, 29.5, 32.0]); + assert_eq!(xyz(&states[1], 32), [4.0, -27.0, -9.5]); +} + +#[test] +fn packed_carry_lanes_update_but_pose_only_blocks_and_records_survive() { + let mut drive = drive(); + for (offset, carry) in [ + (204, 7.0f32), + (220, 11.0), + (236, 13.0), + (300, 2.0), + (316, 5.0), + (332, 7.0), + (348, 3.0), + (364, 13.0), + (380, 17.0), + ] { + drive.words[offset / 4] = carry.to_bits(); + } + column(&mut drive.words, 160, [2.0, 3.0, 4.0, 1000.0]); + column(&mut drive.words, 176, [5.0, 6.0, 7.0, 1000.0]); + let original = drive.words; + let mut states = reactions([0.0; 3], [0.0; 3]); + states[0][3] = 17.0f32.to_bits(); + states[1][3] = 19.0f32.to_bits(); + states[0][11] = 11.0f32.to_bits(); + states[1][11] = 23.0f32.to_bits(); + for state in &mut states { + state[4..8].copy_from_slice(&[0x12345678; 4]); + state[12..16].copy_from_slice(&[0x87654321; 4]); + } + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!(states[0][3], 215.0f32.to_bits()); + assert_eq!(states[1][3], (-278.0f32).to_bits()); + assert_eq!(states[0][11], 100.0f32.to_bits()); + assert_eq!(states[1][11], (-189.0f32).to_bits()); + for state in states { + assert_eq!(&state[4..8], &[0x12345678; 4]); + assert_eq!(&state[12..16], &[0x87654321; 4]); + } + assert_eq!(&drive.words[..8], &original[..8]); + assert_eq!(&drive.words[16..], &original[16..]); +} + +#[test] +fn zero_strength_drive_and_static_body_response_are_distinct() { + let mut drive = drive(); + drive_limits(&mut drive, [0.0; 3], [0.0; 3]); + let mut states = reactions([8.0; 3], [6.0; 3]); + let before = states; + packed::solve( + &mut [], + &mut [], + std::slice::from_mut(&mut drive), + &mut states, + 2, + ); + assert_eq!(states, before); + + let mut drive = origin_constraint(); + drive.words[84..96].fill(0); // Body B supplies no dynamic response. + let mut states = reactions([8.0, 0.0, 0.0], [0.0; 3]); + let fixed = states[1]; + packed::solve( + &mut [], + std::slice::from_mut(&mut drive), + &mut [], + &mut states, + 1, + ); + assert_eq!(states[1], fixed); + assert_eq!(states[0][0], 4.0f32.to_bits()); +} + +#[test] +fn family_order_and_body_indices_share_reactions_between_constraints() { + let mut joint = origin_constraint(); + let mut drive = drive(); + drive.reaction_a = 1; + drive.reaction_b = 2; + let mut states = [[0; 16]; 3]; + states[1][0] = 4.0f32.to_bits(); + states[2][0] = 8.0f32.to_bits(); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + std::slice::from_mut(&mut drive), + &mut states, + 1, + ); + assert_eq!( + [states[0][0], states[1][0], states[2][0]], + [2.0f32, 5.0, 5.0].map(f32::to_bits) + ); + assert_eq!(drive.words[8], 3.0f32.to_bits()); + + let mut reversed = origin_constraint(); + reversed.reaction_a = 1; + reversed.reaction_b = 0; + let mut states = reactions([4.0, 0.0, 0.0], [0.0; 3]); + packed::solve( + &mut [], + std::slice::from_mut(&mut reversed), + &mut [], + &mut states, + 1, + ); + assert_eq!(states[0][0], 2.0f32.to_bits()); + assert_eq!(states[1][0], 2.0f32.to_bits()); + assert_eq!(reversed.words[8], (-2.0f32).to_bits()); +} + +#[test] +fn zero_iterations_preserve_all_packed_words() { + let mut joints = [origin_constraint()]; + let mut drives = [drive()]; + let original_joint = joints[0].words; + let original_drive = drives[0].words; + let mut states = reactions([2.0, 3.0, 4.0], [5.0, 6.0, 7.0]); + let original_states = states; + packed::solve(&mut [], &mut joints, &mut drives, &mut states, 0); + assert_eq!(states, original_states); + assert_eq!(joints[0].words, original_joint); + assert_eq!(drives[0].words, original_drive); +} + +#[test] +fn nonorthogonal_angular_axes_and_rotated_linear_axes_keep_column_direction() { + let mut joint = origin_constraint(); + for (axis, values) in [ + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [1.0, 0.0, 0.0, 0.0], + ] + .into_iter() + .enumerate() + { + column(&mut joint.words, 192 + axis * 16, values); + } + column(&mut joint.words, 64, [0.0, 0.0, 0.5, 0.0]); + column(&mut joint.words, 96, [0.5, 0.0, 0.0, 0.0]); + column(&mut joint.words, 128, [0.0, 0.5, 0.0, 0.0]); + column(&mut joint.words, 240, [1.0, 0.0, 0.0, 0.0]); + column(&mut joint.words, 256, [1.0, 1.0, 0.0, 0.0]); + column(&mut joint.words, 272, [0.0, 1.0, 1.0, 0.0]); + column(&mut joint.words, 80, [0.5, 0.25, 0.0, 0.0]); + column(&mut joint.words, 112, [0.0, 0.25, 0.25, 0.0]); + column(&mut joint.words, 144, [0.0, 0.0, 0.25, 0.0]); + let mut states = reactions([6.0, 2.0, 4.0], [2.0, 4.0, 6.0]); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(xyz(&joint.words, 32), [1.0, 2.0, 3.0]); + assert_eq!(xyz(&joint.words, 48), [1.0, 1.5, 2.5]); + assert_eq!(xyz(&states[0], 0), [3.0, 1.0, 2.0]); + assert_eq!(xyz(&states[0], 32), [2.5, 4.0, 2.5]); + assert_eq!(xyz(&states[1], 32), [-0.5, 0.0, 3.5]); +} + +#[test] +fn fused_projection_retains_exact_product_cancellation() { + let mut joint = origin_constraint(); + // (1 + 2^-23) * (1 - 2^-23) - 1 = -2^-46. A rounded multiply + // followed by addition would erase this independently specified residual. + joint.words[16] = f32::from_bits(0x3f800001).to_bits(); + joint.words[8] = (-1.0f32).to_bits(); + let mut states = reactions([f32::from_bits(0x3f7ffffe), 0.0, 0.0], [0.0; 3]); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(joint.words[8], 0xa8800000); +} + +#[test] +fn joint_accumulator_carry_is_projected_instead_of_preserved_as_softness() { + let mut joint = origin_constraint(); + column(&mut joint.words, 32, [0.0, 0.0, 0.0, 2.0]); + column(&mut joint.words, 48, [0.0, 0.0, 0.0, 3.0]); + column(&mut joint.words, 160, [0.0, 0.0, 0.0, -4.0]); + column(&mut joint.words, 176, [0.0, 0.0, 0.0, 4.0]); + let mut states = reactions([0.0; 3], [0.0; 3]); + packed::solve( + &mut [], + std::slice::from_mut(&mut joint), + &mut [], + &mut states, + 1, + ); + assert_eq!(read(&joint.words, 32), [0.0; 4]); + assert_eq!(read(&joint.words, 48), [0.0; 4]); +} diff --git a/crates/skate-core/tests/physics_rebuild_contact_build.rs b/crates/skate-core/tests/physics_rebuild_contact_build.rs new file mode 100644 index 00000000..5842b77f --- /dev/null +++ b/crates/skate-core/tests/physics_rebuild_contact_build.rs @@ -0,0 +1,273 @@ +//! Independently specified geometry and branch cases. Supplied response scales +//! are algebraic test inputs, never native hardware estimates or parity fixtures. +use skate_core::math::Vector3; +use skate_core::physics::solver::contact_build::{ + ContactBuildError, ContactMassResponse, UnavailableContactMassResponse, build_with_response, + prepare_contact, +}; + +fn row(words: &mut [u32; 64], row: usize, values: [f32; 4]) { + words[row * 4..row * 4 + 4].copy_from_slice(&values.map(f32::to_bits)); +} + +fn read(words: &[u32; 64], row: usize) -> [f32; 4] { + core::array::from_fn(|i| f32::from_bits(words[row * 4 + i])) +} + +fn origin() -> [u32; 64] { + let mut words = [0; 64]; + row(&mut words, 2, [1.0, 0.0, 0.0, 0.5]); + row(&mut words, 3, [0.0, 1.0, 0.0, 0.75]); + row(&mut words, 4, [0.0, 0.0, 1.0, 0.25]); + for body in 0..2 { + row(&mut words, 8 + body, [1.0, 0.0, 0.0, 1.0]); + row(&mut words, 10 + body, [1.0, 1.0, 0.0, 0.0]); + words[(10 + body) * 4 + 3] = 4; + } + words +} + +struct ExpectedResponse { + masses: [f32; 3], + scales: [f32; 3], + calls: usize, +} +impl ContactMassResponse for ExpectedResponse { + fn inverse_effective_mass(&mut self, masses: [f32; 3]) -> Result<[f32; 3], ContactBuildError> { + assert_eq!( + masses, self.masses, + "builder must pass its actual effective masses" + ); + self.calls += 1; + Ok(self.scales) + } +} + +fn compile(words: &mut [u32; 64], dt: f32, masses: [f32; 3], scales: [f32; 3]) { + let mut response = ExpectedResponse { + masses, + scales, + calls: 0, + }; + build_with_response(words, dt, &mut response).unwrap(); + assert_eq!(response.calls, 1); +} + +#[test] +fn origin_masses_and_complete_metadata_publication() { + let mut words = origin(); + words[3] = 0xaabbccdd; + words[7] = 0x11223344; + words[27] = 0x55667788; + words[31] = 0x99aabbcc; + words[23] = 0x12345678; + words[43] = 4 | 8 | 0x80; + let prepared = prepare_contact(&words, 0.25); + assert_eq!(prepared.effective_mass, [2.0; 3]); + assert_eq!(prepared.arms, [Vector3::ZERO; 2]); + compile(&mut words, 0.25, [2.0; 3], [0.5; 3]); + let mut expected = [0; 64]; + expected[3] = 0x55667788; + expected[7] = 0x99aabbcc; + row(&mut expected, 2, [0.5, 0.0, 0.0, 0.0]); + expected[11] = 8; + row(&mut expected, 3, [0.0, 0.5, 0.0, 0.75]); + row(&mut expected, 4, [0.0, 0.0, 0.5, 0.25]); + row(&mut expected, 7, [1.0, 0.0, 0.0, 0.0]); + expected[31] = 0xaabbccdd; + row(&mut expected, 8, [0.0, 0.0, 0.0, 1.0]); + row(&mut expected, 9, [0.0, 0.0, 0.0, 1.0]); + row(&mut expected, 10, [0.0, 1.0, 0.0, 0.0]); + expected[43] = 0x11223344; + row(&mut expected, 13, [0.0, 0.0, 1.0, 0.0]); + expected[55] = 0x12345678; + assert_eq!(words, expected); +} + +#[test] +fn off_center_inertia_and_tangent_carry_are_retained() { + let mut words = origin(); + row(&mut words, 0, [0.0, 2.0, 0.0, 0.0]); + row(&mut words, 1, [0.0, 0.0, 3.0, 0.0]); + // A normal torque is -2Z; B normal torque is +3Y. + let p = prepare_contact(&words, 0.0); + assert_eq!(p.effective_mass, [15.0, 11.0, 6.0]); + assert_eq!(p.angular_response_a[0], [0.0, 0.0, -2.0, -2.0]); + assert_eq!(p.angular_response_b[0], [0.0, 3.0, 0.0, 3.0]); + compile(&mut words, 0.0, [15.0, 11.0, 6.0], [0.125, 0.25, 0.5]); + assert_eq!(read(&words, 8), [0.0, 0.0, -2.0, 1.0]); + assert_eq!(read(&words, 9), [0.0, 3.0, 0.0, 1.0]); + assert_eq!(read(&words, 12), [-3.0, 0.0, 0.0, -3.0]); + assert_eq!(read(&words, 14), [2.0, 0.0, 0.0, 2.0]); +} + +#[test] +fn full_symmetric_inertia_changes_response_and_effective_mass() { + let mut words = origin(); + row(&mut words, 0, [0.0, 2.0, 3.0, 0.0]); + row(&mut words, 8, [4.0, 1.0, 2.0, 2.0]); + row(&mut words, 10, [6.0, 5.0, 3.0, 0.0]); + words[43] = 4; + let p = prepare_contact(&words, 0.0); + // I * (0,3,-2) = (-1,9,-3), torque work 33 + masses 3. + assert_eq!(p.angular_response_a[0], [-1.0, 9.0, -3.0, 2.0]); + assert_eq!(p.effective_mass, [36.0, 39.0, 19.0]); + compile(&mut words, 0.0, [36.0, 39.0, 19.0], [0.125; 3]); +} + +#[test] +fn each_active_state_gates_mass_inertia_and_point_acceleration() { + for (a, b, masses) in [ + (false, false, [0.0; 3]), + (true, false, [5.0, 1.0, 5.0]), + (false, true, [10.0, 10.0, 1.0]), + (true, true, [15.0, 11.0, 6.0]), + ] { + let mut words = origin(); + row(&mut words, 0, [0.0, 2.0, 0.0, 0.0]); + row(&mut words, 1, [0.0, 0.0, 3.0, 0.0]); + row(&mut words, 12, [1.0, 2.0, 3.0, 0.0]); + row(&mut words, 13, [4.0, 5.0, 6.0, 0.0]); + words[43] = if a { 4 } else { 8 }; + words[47] = if b { 4 } else { 0 }; + let p = prepare_contact(&words, 0.5); + assert_eq!(p.active, [a, b]); + assert_eq!(p.effective_mass, masses); + assert_eq!( + p.point_acceleration[0], + if a { + Vector3::new(1.0, 2.0, 3.0) + } else { + Vector3::ZERO + } + ); + assert_eq!( + p.point_acceleration[1], + if b { + Vector3::new(4.0, 5.0, 6.0) + } else { + Vector3::ZERO + } + ); + compile(&mut words, 0.5, masses, [0.0; 3]); + assert_eq!(words[11], if a { 0 } else { 8 }); + } +} + +#[test] +fn force_torque_velocity_and_squared_timestep_predict_contact_separation() { + let mut words = origin(); + row(&mut words, 0, [1.0, 2.0, 3.0, 0.0]); + row(&mut words, 1, [4.0, 6.0, 8.0, 0.0]); + row(&mut words, 7, [4.0, 6.0, 8.0, 0.0]); + row(&mut words, 12, [10.0, 20.0, 30.0, 0.0]); + row(&mut words, 14, [2.0, 3.0, 5.0, 0.0]); + row(&mut words, 13, [1.0, 2.0, 3.0, 0.0]); + row(&mut words, 5, [2.0, 4.0, 6.0, 0.0]); + let p = prepare_contact(&words, 0.5); + assert_eq!( + p.point_acceleration, + // (2,3,5) cross (1,2,3) = (-1,-1,+1). + [Vector3::new(9.0, 19.0, 31.0), Vector3::new(1.0, 2.0, 3.0)] + ); + assert_eq!(p.separation_projection, [3.0, 4.0, 5.0]); + assert_eq!(p.restitution_projection, [-0.5, -1.0, -1.5]); + assert_eq!(p.predicted_separation_projection, [2.0, 1.75, 1.0]); + compile(&mut words, 0.5, [15.0, 12.0, 7.0], [0.5; 3]); + assert_eq!(read(&words, 6), [1.25, 0.875, 0.5, 1.5]); +} + +#[test] +fn normal_target_ordered_sign_branches_and_zero_boundaries() { + // (separation, velocity, forceB, restitution, expected normal target). + for (sep, vel, force, bounce, expected) in [ + (-2.0, -2.0, 8.0, 0.5_f32, 4.0), // negative separation, positive restitution + (-2.0, 4.0, 0.0, 0.5, 6.0), // both negative: subtract their sum + (2.0, 2.0, 0.0, 0.5, 5.0), // positive separation: restitution only + (2.0, -1.0, 0.0, 0.5, 0.5), + (2.0, 2.0, -5.0, 0.5, -1.0), // nonpositive prediction clears adjustment + (2.0, 2.0, -4.0, 0.5, 0.0), + (0.0, 2.0, 0.0, 0.5, 3.0), // equality takes >= separation branch + (-2.0, 3.0, 0.0, 0.0, 1.0), // zero restitution takes >= branch + ] { + let mut words = origin(); + row(&mut words, 1, [sep, 0.0, 0.0, 0.0]); + row(&mut words, 7, [sep, 0.0, 0.0, 0.0]); + row(&mut words, 5, [vel, 2.0, -3.0, 0.0]); + row(&mut words, 13, [force, 0.0, 0.0, 0.0]); + words[11] = bounce.to_bits(); + compile(&mut words, 1.0, [2.0; 3], [1.0; 3]); + assert_eq!( + read(&words, 6), + [expected, 2.0, -3.0, sep], + "sep {sep}, velocity {vel}, force {force}" + ); + } +} + +#[test] +fn axes_project_without_normalization_and_scales_apply_by_constraint() { + let mut words = origin(); + row(&mut words, 2, [2.0, 1.0, 0.0, 0.0]); + row(&mut words, 3, [0.0, 3.0, 1.0, 0.75]); + row(&mut words, 4, [1.0, 0.0, 4.0, 0.25]); + row(&mut words, 5, [1.0, 2.0, 3.0, 0.0]); + compile(&mut words, 1.0, [2.0; 3], [0.5, 0.25, 0.125]); + assert_eq!(read(&words, 2), [1.0, 0.0, 0.125, 0.0]); + assert_eq!(read(&words, 3), [0.5, 0.75, 0.0, 0.75]); + assert_eq!(read(&words, 4), [0.0, 0.25, 0.5, 0.25]); + assert_eq!(read(&words, 6), [2.0, 2.25, 1.625, 0.0]); +} + +#[test] +fn unavailable_estimate_preserves_every_input_word() { + let mut words = origin(); + words[23] = 0xfedcba98; + row(&mut words, 5, [3.0, -2.0, 1.0, 0.0]); + let before = words; + let result = build_with_response(&mut words, 0.5, &mut UnavailableContactMassResponse); + assert_eq!( + result, + Err(ContactBuildError::ReciprocalEstimateUnavailable { + effective_mass: [2.0; 3] + }) + ); + assert_eq!(words, before); +} + +#[test] +fn prepared_rotated_contact_drives_shared_solver_reactions() { + use skate_core::physics::solver::packed::{self, Contact}; + let mut words = origin(); + // Normal Y, tangents Z/X. Only A has a lever arm, +X. + row(&mut words, 0, [1.0, 0.0, 0.0, 0.0]); + row(&mut words, 1, [1.0, 2.0, 0.0, 0.0]); + row(&mut words, 7, [1.0, 2.0, 0.0, 0.0]); + row(&mut words, 2, [0.0, 1.0, 0.0, 0.0]); + row(&mut words, 3, [0.0, 0.0, 1.0, 0.75]); + row(&mut words, 4, [1.0, 0.0, 0.0, 0.25]); + row(&mut words, 5, [8.0, 0.0, 4.0, 0.0]); + // Deliberately explicit algebraic scales, not a reciprocal approximation. + compile(&mut words, 1.0, [3.0, 3.0, 2.0], [0.25, 0.5, 0.125]); + let mut contacts = [Contact { + words, + reaction_a: 0, + reaction_b: 1, + }]; + let mut reactions = [[0; 16]; 2]; + packed::solve(&mut contacts, &mut [], &mut [], &mut reactions, 2); + // Pass one gives normal .5 and no friction. On pass two the normal + // correction is -.25; friction uses old normal .5 and dynamic limit .125. + assert_eq!(read(&contacts[0].words, 5), [0.25, 0.125, 0.125, 0.625]); + let read_reaction = |body: usize, row: usize| -> [f32; 4] { + core::array::from_fn(|lane| f32::from_bits(reactions[body][row * 4 + lane])) + }; + assert_eq!(read_reaction(0, 0), [0.125, 0.25, 0.125, 0.0]); + assert_eq!(read_reaction(1, 0), [-0.125, -0.25, -0.125, 0.0]); + assert_eq!(read_reaction(0, 1), [0.0, 0.625, 0.0, 0.0]); + assert_eq!(read_reaction(1, 1), [0.0, -0.625, 0.0, 0.0]); + assert_eq!(read_reaction(0, 2), [0.0, -0.125, 0.25, 0.125]); + assert_eq!(read_reaction(1, 2), [0.0, 0.0, 0.0, -0.25]); + assert_eq!(read_reaction(0, 3), [0.0, 0.0, 0.625, 0.625]); + assert_eq!(read_reaction(1, 3), [0.0, 0.0, 0.0, -0.625]); +} diff --git a/crates/skate-core/tests/physics_rebuild_mass.rs b/crates/skate-core/tests/physics_rebuild_mass.rs new file mode 100644 index 00000000..70ca885a --- /dev/null +++ b/crates/skate-core/tests/physics_rebuild_mass.rs @@ -0,0 +1,297 @@ +//! Independent geometric/constructor checks; no generated execution fixtures. +use skate_core::{ + math::Vector3, + physics::mass::{ + MassShape, PartMassInput, TruckMassSettings, WheelMassSettings, forward_mass_properties, + primitive_mass, truck_mass_input, wheel_mass_input, + }, +}; + +fn close(actual: f32, expected: f64) { + assert!( + (f64::from(actual) - expected).abs() <= expected.abs().max(1e-25) * 3e-6, + "actual {actual:?}, expected {expected:?}" + ); +} + +#[test] +fn sphere_obeys_solid_sphere_moments_and_volume() { + let mass = primitive_mass(MassShape::Sphere { radius: 2.0 }).unwrap(); + for moment in [ + mass.moments_per_unit_mass.x, + mass.moments_per_unit_mass.y, + mass.moments_per_unit_mass.z, + ] { + close(moment, 8.0 / 5.0); + } + close(mass.volume, 32.0 * std::f64::consts::PI / 3.0); +} + +#[test] +fn capsule_uses_native_longitudinal_axis_and_dimension_floor() { + let mass = primitive_mass(MassShape::Capsule { + radius: 1.0, + half_length: 2.0, + }) + .unwrap(); + close(mass.moments_per_unit_mass.x, 271.0 / 100.0); + assert_eq!(mass.moments_per_unit_mass.x, mass.moments_per_unit_mass.y); + close(mass.moments_per_unit_mass.z, 2.0 / 5.0); + close(mass.volume, 16.0 * std::f64::consts::PI / 3.0); + + let floor = primitive_mass(MassShape::Capsule { + radius: 1.0, + half_length: 20.0, + }); + for radius in [-2.0, 0.0, 0.5, 1.0] { + assert_eq!( + primitive_mass(MassShape::Capsule { + radius, + half_length: 20.0 + }), + floor + ); + } + assert_ne!( + primitive_mass(MassShape::Capsule { + radius: 1.5, + half_length: 20.0 + }), + floor + ); +} + +#[test] +fn sphere_radius_floor_includes_negative_zero_and_unordered_input() { + let minimum = f32::from_bits(0x33d6_bf95); + let floor = primitive_mass(MassShape::Sphere { radius: minimum }); + for radius in [0.0, -0.0, -10.0, minimum * 0.5, f32::NAN, f32::NEG_INFINITY] { + assert_eq!(primitive_mass(MassShape::Sphere { radius }), floor); + } + assert_ne!( + primitive_mass(MassShape::Sphere { + radius: minimum * 2.0 + }), + floor + ); +} + +#[test] +fn box_without_rounding_has_cuboid_properties() { + let mass = primitive_mass(MassShape::RoundedBox { + half_extents: Vector3::new(1.0, 2.0, 3.0), + radius: 0.0, + }) + .unwrap(); + close(mass.moments_per_unit_mass.x, 13.0 / 3.0); + close(mass.moments_per_unit_mass.y, 10.0 / 3.0); + close(mass.moments_per_unit_mass.z, 5.0 / 3.0); + assert_eq!(mass.volume, 48.0); +} + +#[test] +fn rounded_box_includes_edge_and_corner_volume() { + let mass = primitive_mass(MassShape::RoundedBox { + half_extents: Vector3::new(1.0, 2.0, 3.0), + radius: 0.5, + }) + .unwrap(); + close(mass.volume, 92.0 + 19.0 * std::f64::consts::PI / 6.0); + close(mass.moments_per_unit_mass.x, 49.0 / 8.0); + close(mass.moments_per_unit_mass.y, 115.0 / 24.0); + close(mass.moments_per_unit_mass.z, 67.0 / 24.0); +} + +#[test] +fn box_floor_depends_on_largest_padded_dimension() { + for half_extents in [ + Vector3::new(0.0, 0.0, 20.0), + Vector3::new(0.0, 20.0, 0.0), + Vector3::new(20.0, 0.0, 0.0), + ] { + let mass = primitive_mass(MassShape::RoundedBox { + half_extents, + radius: 0.0, + }) + .unwrap(); + assert_eq!(mass.volume, 160.0); + } + let thin = primitive_mass(MassShape::RoundedBox { + half_extents: Vector3::new(-3.0, 0.0, 20.0), + radius: 0.5, + }); + let floored = primitive_mass(MassShape::RoundedBox { + half_extents: Vector3::new(0.525, 0.525, 20.0), + radius: 0.5, + }); + assert_eq!(thin, floored); +} + +#[test] +fn cylinder_padding_expands_radius_and_half_length() { + let bare = primitive_mass(MassShape::Cylinder { + radius: 3.0, + half_length: 2.0, + padding: 0.0, + }) + .unwrap(); + let padded = primitive_mass(MassShape::Cylinder { + radius: 2.5, + half_length: 1.5, + padding: 0.5, + }) + .unwrap(); + assert_eq!(bare, padded); + close(bare.moments_per_unit_mass.x, 43.0 / 12.0); + assert_eq!(bare.moments_per_unit_mass.z, 4.5); + close(bare.volume, 36.0 * std::f64::consts::PI); +} + +#[test] +fn geometric_scaling_is_quadratic_for_moments_and_cubic_for_volume() { + let shapes = [ + ( + MassShape::Sphere { radius: 0.75 }, + MassShape::Sphere { radius: 1.5 }, + ), + ( + MassShape::Capsule { + radius: 0.75, + half_length: 1.25, + }, + MassShape::Capsule { + radius: 1.5, + half_length: 2.5, + }, + ), + ( + MassShape::RoundedBox { + half_extents: Vector3::new(1.0, 2.0, 3.0), + radius: 0.25, + }, + MassShape::RoundedBox { + half_extents: Vector3::new(2.0, 4.0, 6.0), + radius: 0.5, + }, + ), + ( + MassShape::Cylinder { + radius: 0.75, + half_length: 1.25, + padding: 0.25, + }, + MassShape::Cylinder { + radius: 1.5, + half_length: 2.5, + padding: 0.5, + }, + ), + ]; + for (small, large) in shapes { + let small = primitive_mass(small).unwrap(); + let large = primitive_mass(large).unwrap(); + assert_eq!(large.volume, small.volume * 8.0); + assert_eq!( + large.moments_per_unit_mass, + Vector3::new( + small.moments_per_unit_mass.x * 4.0, + small.moments_per_unit_mass.y * 4.0, + small.moments_per_unit_mass.z * 4.0, + ) + ); + } +} + +#[test] +fn constructors_derive_geometry_and_scale_requested_mass() { + let wheel = wheel_mass_input(WheelMassSettings { + radius: 0.125, + mass: 0.25, + mass_factor: 2.0, + }); + assert_eq!( + wheel, + PartMassInput { + shape: MassShape::Sphere { radius: 0.125 }, + requested_mass: 0.5 + } + ); + let truck = truck_mass_input(TruckMassSettings { + wheel_radius: 0.125, + wheel_x_distance: 0.25, + radius_scalar: 2.0, + half_height_scalar: 3.0, + mass: 0.5, + mass_factor: 4.0, + }); + assert_eq!( + truck, + PartMassInput { + shape: MassShape::Capsule { + radius: 0.125, + half_length: 0.375 + }, + requested_mass: 2.0, + } + ); + let stock = forward_mass_properties(wheel_mass_input(WheelMassSettings::STOCK)).unwrap(); + assert_eq!(stock.mass.to_bits(), 0x3ed4_7ae1); // 0.415 + close(stock.principal_moments.x, 0.4 * 0.031_f64.powi(2) * 0.415); + let truck = truck_mass_input(TruckMassSettings::STOCK); + if let MassShape::Capsule { + radius, + half_length, + } = truck.shape + { + close(radius, 0.00833125); + close(half_length, 0.064125); + } else { + panic!("native truck producer must use capsule"); + } + assert_eq!(truck.requested_mass.to_bits(), 0x3ff9_9999); // 1.95 +} + +#[test] +fn mass_fallback_is_strictly_below_minimum_normal_and_preserves_nan() { + let shape = MassShape::Sphere { radius: 1.0 }; + let volume = primitive_mass(shape).unwrap().volume; + for requested_mass in [0.0, -1.0, f32::from_bits(1), f32::MIN_POSITIVE * 0.5] { + assert_eq!( + forward_mass_properties(PartMassInput { + shape, + requested_mass + }) + .unwrap() + .mass, + volume + ); + } + for requested_mass in [f32::MIN_POSITIVE, 2.0, f32::INFINITY] { + assert_eq!( + forward_mass_properties(PartMassInput { + shape, + requested_mass + }) + .unwrap() + .mass, + requested_mass + ); + } + assert!( + forward_mass_properties(PartMassInput { + shape, + requested_mass: f32::NAN + }) + .unwrap() + .mass + .is_nan() + ); + assert_eq!(primitive_mass(MassShape::Unsupported), None); + assert_eq!( + forward_mass_properties(PartMassInput { + shape: MassShape::Unsupported, + requested_mass: 1.0 + }), + None + ); +} diff --git a/crates/skate-core/tests/point_graph.rs b/crates/skate-core/tests/point_graph.rs new file mode 100644 index 00000000..5648feea --- /dev/null +++ b/crates/skate-core/tests/point_graph.rs @@ -0,0 +1,45 @@ +//! Expected branch outcomes are specified directly from TU3 82481E10's +//! fcmpu/blt control flow, not generated-code execution. +use skate_core::point_graph::PointGraph; + +#[test] +fn unordered_endpoint_comparison_selects_the_final_value() { + let graph = PointGraph { + x: [0.0, 1.0, 2.0], + y: [3.0, 5.0, 7.0], + }; + assert_eq!(graph.evaluate(f32::NAN), 7.0); + let last_key_nan = PointGraph { + x: [0.0, 1.0, f32::NAN], + y: graph.y, + }; + assert_eq!(last_key_nan.evaluate(0.5), 7.0); +} + +#[test] +fn unordered_interior_key_does_not_select_an_interval() { + let graph = PointGraph { + x: [0.0, f32::NAN, 1.0, 2.0], + y: [0.0, 99.0, 10.0, 20.0], + }; + assert_eq!(graph.evaluate(1.5), 15.0); +} + +#[test] +fn finite_endpoints_and_interpolation_remain_unchanged() { + let graph = PointGraph { + x: [0.0, 1.0, 2.0], + y: [0.0, 2.0, 8.0], + }; + for (input, expected) in [ + (-1.0, 0.0), + (0.0, 0.0), + (0.5, 1.0), + (1.0, 2.0), + (1.5, 5.0), + (2.0, 8.0), + (3.0, 8.0), + ] { + assert_eq!(graph.evaluate(input), expected); + } +} diff --git a/crates/skate-core/tests/riding_grounded_propulsion.rs b/crates/skate-core/tests/riding_grounded_propulsion.rs new file mode 100644 index 00000000..08250422 --- /dev/null +++ b/crates/skate-core/tests/riding_grounded_propulsion.rs @@ -0,0 +1,382 @@ +//! Independent caller/branch checks; no generated executable fixtures. +use skate_core::{ + math::Vector3, + physics::{ + force_queue::{BoardForceQueue, QueuedPointForce}, + manual::controller::ManualEffect, + rigid_body::RetailInertiaDynamics, + }, + riding::{ + braking::{BrakeSettings, LinearDragSettings}, + grounded::{ + drag::{ + BodyInertias, DRAG_FREQUENCY, DragBindingError, DragSelection, GroundDragInput, + }, + propulsion::{ + self, GroundPropulsionInput, GroundPropulsionSettings, PropulsionSubmission, + }, + }, + }, +}; + +fn input() -> GroundPropulsionInput { + GroundPropulsionInput { + flags_2468: (1 << 30) | (1 << 25), + flags_2472: 0, + target_speed: 10., + signed_speed: 4., + absolute_body_speed: 4., + scalar_2660: 2., + timestep: 0.25, + brake_input: 0.5, + push_direction: Vector3::new(0., 2., 3.), + brake_direction: Vector3::new(1., 0., 0.), + surface_braking_factor: 0.2, + } +} +fn settings() -> GroundPropulsionSettings { + GroundPropulsionSettings { + braking: BrakeSettings { + input_force: 10., + override_force: 20., + minimum_speed: 0.5, + }, + maximum_pushable_speed: 8., + mode_speed_changes: [2., 1.], + } +} +fn manual() -> ManualEffect { + ManualEffect { + angular_displacement: [0.; 4], + force_world: [0.; 4], + force_point_body: [0.; 4], + corrective_force_world: [11., 12., 13., 14.], + corrective_point_body: [21., 22., 23., 24.], + correction_active: false, + opposing_motion_without_correction: false, + } +} +fn inertia() -> RetailInertiaDynamics { + RetailInertiaDynamics { + inverse_tensor: Vector3::new(2., 3., 4.), + inverse_mass: 5., + spherical: 6., + maximum_linear_velocity: 7., + maximum_angular_velocity: 8., + linear_drag: 9., + angular_drag: 10., + } +} + +#[test] +fn live_processed_scale_time_and_distinct_axes_reach_brake_then_push_records() { + let mut suppressed = 255; + let forces = propulsion::calculate(input(), settings(), &mut suppressed); + assert_eq!(suppressed, 0); + assert_eq!(forces.braking.force_world, Vector3::new(-1., -0., -0.)); + assert_eq!(forces.push.vector, Vector3::new(0., 24., 36.)); + let mut queue = BoardForceQueue::default(); + assert_eq!( + forces.submit(&manual(), &mut queue), + PropulsionSubmission::BrakingAndPush([true, true]) + ); + assert_eq!( + queue.entries().iter().map(|f| f.tag).collect::>(), + [2, 3] + ); + assert_eq!(queue.entries()[0].point_body, Vector3::ZERO); + assert_eq!(queue.entries()[1].point_body, Vector3::ZERO); + let doubled = propulsion::calculate( + GroundPropulsionInput { + scalar_2660: 4., + ..input() + }, + settings(), + &mut suppressed, + ); + assert_eq!(doubled.push.vector, Vector3::new(0., 48., 72.)); +} + +#[test] +fn suppression_byte_is_republished_and_zero_forces_are_still_queued() { + let mut suppressed = 0; + let forces = propulsion::calculate( + GroundPropulsionInput { + flags_2472: 1 << 9, + ..input() + }, + settings(), + &mut suppressed, + ); + assert_eq!(suppressed, 1); + assert_eq!(forces.push.vector, Vector3::ZERO); + let inactive = propulsion::calculate( + GroundPropulsionInput { + flags_2468: 0, + flags_2472: 1 << 9, + ..input() + }, + settings(), + &mut suppressed, + ); + assert_eq!(suppressed, 0); + let mut queue = BoardForceQueue::default(); + inactive.submit(&manual(), &mut queue); + assert_eq!(queue.entries().len(), 2); + assert_eq!(queue.entries()[0].force_world, Vector3::ZERO); + assert_eq!(queue.entries()[1].force_world, Vector3::ZERO); +} + +#[test] +fn only_manual_output80_selects_alternate_tag7_payload() { + let forces = propulsion::calculate(input(), settings(), &mut 0); + let mut queue = BoardForceQueue::default(); + let manual = ManualEffect { + correction_active: true, + ..manual() + }; + assert_eq!( + forces.submit(&manual, &mut queue), + PropulsionSubmission::ManualCorrection(true) + ); + assert_eq!( + queue.entries(), + [QueuedPointForce { + tag: 7, + force_world: Vector3::new(11., 12., 13.), + point_body: Vector3::new(21., 22., 23.), + }] + ); + queue.clear(); + let only81 = ManualEffect { + correction_active: false, + opposing_motion_without_correction: true, + ..manual + }; + assert_eq!( + forces.submit(&only81, &mut queue), + PropulsionSubmission::BrakingAndPush([true, true]) + ); +} + +#[test] +fn capacity_drops_later_push_without_reordering_or_clearing_prior_forces() { + let mut queue = BoardForceQueue::default(); + for tag in 100..120 { + queue.append(QueuedPointForce { + tag, + ..QueuedPointForce::default() + }); + } + let forces = propulsion::calculate(input(), settings(), &mut 0); + assert_eq!( + forces.submit(&manual(), &mut queue), + PropulsionSubmission::BrakingAndPush([true, false]) + ); + assert_eq!(queue.entries().len(), 21); + assert_eq!(queue.entries()[0].tag, 100); + assert_eq!(queue.entries()[20].tag, 2); +} + +#[test] +fn braking_override_reverse_and_unordered_cutoff_follow_direct_branches() { + let mut sample = input(); + sample.flags_2468 |= 1 << 29; + sample.signed_speed = -4.; + let positive = propulsion::calculate(sample, settings(), &mut 0); + assert_eq!(positive.braking.force_world.x, 4.); + sample.absolute_body_speed = 0.25; + assert_eq!( + propulsion::calculate(sample, settings(), &mut 0) + .braking + .force_world, + Vector3::ZERO + ); + sample.absolute_body_speed = f32::NAN; + assert_eq!( + propulsion::calculate(sample, settings(), &mut 0) + .braking + .force_world + .x, + 4. + ); + sample.signed_speed = f32::NAN; + assert_eq!( + propulsion::calculate(sample, settings(), &mut 0) + .braking + .force_world + .x, + -4. + ); +} + +#[test] +fn push_unordered_ratio_selects_upper_fraction_before_both_limit_tests() { + // Negative limits are not sanitized by the source. They make the two + // independent pre-clamp comparisons visible even when the input gap is0. + let sample = GroundPropulsionInput { + signed_speed: f32::NAN, + absolute_body_speed: 0., + ..input() + }; + let settings = GroundPropulsionSettings { + mode_speed_changes: [2., -2.], + ..settings() + }; + let forces = propulsion::calculate(sample, settings, &mut 0); + assert_eq!(forces.push.vector, Vector3::new(0., 32., 48.)); +} + +fn drag_input() -> GroundDragInput { + GroundDragInput { + flags_2468: 1 << 30, + absolute_body_speed_2616: 0.5, + balance_2720: 0., + scalar_2724: 0., + ground_normal_y: 0.75, + } +} +fn drag_settings() -> LinearDragSettings { + LinearDragSettings { + brake_speed: 1., + balance_speed: 2., + comparison_threshold: 0.5, + balance_drag: 0.25, + } +} + +#[test] +fn drag_reads_normal_y_and_preserves_strict_thresholds_and_exclusions() { + let settings = drag_settings(); + assert_eq!(drag_input().calculate(settings), 1.); + assert_eq!( + GroundDragInput { + scalar_2724: 0.1, + ..drag_input() + } + .calculate(settings), + 0. + ); + assert_eq!( + GroundDragInput { + absolute_body_speed_2616: 2., + ..drag_input() + } + .calculate(settings), + 0. + ); + assert_eq!( + GroundDragInput { + balance_2720: 1., + ..drag_input() + } + .calculate(settings), + 0.25 + ); + assert_eq!( + GroundDragInput { + balance_2720: 1., + ground_normal_y: 0.5, + ..drag_input() + } + .calculate(settings), + 0. + ); + assert_eq!( + GroundDragInput { + balance_2720: 1., + ground_normal_y: f32::NAN, + ..drag_input() + } + .calculate(settings), + 0. + ); + assert_eq!( + GroundDragInput { + absolute_body_speed_2616: f32::NAN, + ..drag_input() + } + .calculate(settings), + 0. + ); +} + +#[test] +fn drag_publication_changes_only_linear_drag_of_bound_inertias() { + let mut storage = [inertia(); 8]; + let bindings = [6, 1, 1, 5, 3, 2, 0]; + let drag = GroundDragInput { + balance_2720: 1., + ..drag_input() + } + .calculate(drag_settings()); + BodyInertias { + part_inertia_indices: &bindings, + inertias: &mut storage, + } + .apply_ground_drag(drag) + .unwrap(); + for index in [0, 1, 2, 3, 5, 6] { + assert_eq!( + storage[index], + RetailInertiaDynamics { + linear_drag: f32::from_bits(0x416f_ffff), + ..inertia() + } + ); + } + assert_eq!(storage[4], inertia()); + assert_eq!(storage[7], inertia()); + assert_eq!(DRAG_FREQUENCY.to_bits(), 0x426f_ffff); +} + +#[test] +fn drag_all_part_count_boundaries_single_selection_and_signed_zero() { + for count in [0, 1, 3, 4, 7, 8] { + let mut storage = [inertia(); 8]; + let indices: Vec<_> = (0..count).collect(); + BodyInertias { + part_inertia_indices: &indices, + inertias: &mut storage, + } + .set_linear_drag(-0., DragSelection::AllParts) + .unwrap(); + for (index, value) in storage.iter().enumerate() { + assert_eq!( + value.linear_drag.to_bits(), + if index < count { + 0x8000_0000 + } else { + 9f32.to_bits() + } + ); + } + } + let mut storage = [inertia(); 2]; + BodyInertias { + part_inertia_indices: &[1, 0], + inertias: &mut storage, + } + .set_linear_drag(1., DragSelection::Part(0)) + .unwrap(); + assert_eq!(storage[0], inertia()); + assert_eq!(storage[1].linear_drag.to_bits(), 0x426f_ffff); +} + +#[test] +fn invalid_host_bindings_fail_before_mutation() { + let mut storage = [inertia(); 2]; + let mut bodies = BodyInertias { + part_inertia_indices: &[0, 3], + inertias: &mut storage, + }; + assert_eq!( + bodies.apply_ground_drag(1.), + Err(DragBindingError::InertiaOutsideStorage) + ); + assert_eq!( + bodies.set_linear_drag(1., DragSelection::Part(usize::MAX)), + Err(DragBindingError::PartOutsideAssembly) + ); + assert_eq!(storage, [inertia(); 2]); +} diff --git a/crates/skate-core/tests/riding_manual.rs b/crates/skate-core/tests/riding_manual.rs new file mode 100644 index 00000000..359e14fa --- /dev/null +++ b/crates/skate-core/tests/riding_manual.rs @@ -0,0 +1,374 @@ +//! Independent manual control-flow checks; supplied angle measurements are not +//! a replacement implementation or numerical validation of 8296EC98. +use skate_core::{ + physics::manual::{ + angle::{AngleError, normalize}, + controller::{ManualAngleMeasurement, ManualError, ManualInput, calculate}, + settings::{ManualGains, ManualMode, ManualSettings}, + state::{ManualEntryContinuation, ManualState}, + }, + point_graph::PointGraph, +}; + +fn state() -> ManualState { + ManualState { + filtered_angle_error: 0.0, + target_angle: 0.0, + measured_angle: 0.0, + angular_correction: 0.0, + elapsed: 0.0, + } +} +fn gains() -> ManualGains { + ManualGains { + proportional: 0.0, + integral: 0.0, + derivative: 0.0, + } +} +fn settings() -> ManualSettings { + ManualSettings { + noise_vs_speed: PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [1.0; 8], + }, + torque_scale_without_contact: 0.25, + torque_bleed_without_contact: 0.5, + start_torque_scale: 0.0, + procedural_noise_scale: 0.0, + procedural_noise_frequency: 1.0, + powerslide: gains(), + manual: gains(), + maximum_tilt_degrees: 0.0, + maximum_angle_error: 1.0, + derivative_limit: 0.25, + brake_tilt_degrees: 0.0, + animation_noise_scale: 0.0, + } +} +fn mode() -> ManualMode { + ManualMode { + correction_angular_speed_threshold: 4.0, + corrective_force_enabled: true, + } +} +fn input() -> ManualInput { + ManualInput { + balance: 1.0, + flipped_controls: 1.0, + procedural_noise_time: 0.0, + absolute_speed: 0.0, + animation_noise: 0.0, + timestep: 0.125, + powersliding: false, + braking: false, + positive_balance_contact: true, + negative_balance_contact: true, + reversed_point_selection: false, + reference_x: [1.0, 0.0, 0.0, 0.0], + reference_z: [0.0, 0.0, 1.0, 0.0], + deck_z: [0.0, 0.0, 1.0, 0.0], + velocity_frame_z: [0.0, 0.0, 1.0, 0.0], + angular_velocity_world: [0.0, 0.0, -5.0, 0.0], + correction_point_7888: [1.0, 2.0, 3.0, 4.0], + correction_point_7952: [5.0, 6.0, 7.0, 8.0], + } +} +struct Angle { + value: f32, + calls: usize, + fail: bool, +} +impl Angle { + fn new(value: f32) -> Self { + Self { + value, + calls: 0, + fail: false, + } + } +} +impl ManualAngleMeasurement for Angle { + type Error = &'static str; + fn angle_between( + &mut self, + _deck: [f32; 4], + _z: [f32; 4], + _x: [f32; 4], + ) -> Result { + self.calls += 1; + if self.fail { + Err("angle measurement unavailable") + } else { + Ok(self.value) + } + } +} + +#[test] +fn zero_balance_resets_five_fields_without_querying_geometry() { + let mut state = ManualState { + filtered_angle_error: 1., + target_angle: 2., + measured_angle: 3., + angular_correction: 4., + elapsed: 5., + }; + let mut input = input(); + input.balance = -0.0; + let mut angle = Angle::new(0.0); + angle.fail = true; + let effect = calculate(&mut state, &settings(), mode(), &input, &mut angle).unwrap(); + assert_eq!(state, crate::state()); + assert_eq!(angle.calls, 0); + assert_eq!(effect.angular_displacement, [0.0; 4]); + assert!(!effect.correction_active); + assert_eq!(effect.corrective_force_world, [0.0; 4]); + assert!(!effect.opposing_motion_without_correction); +} + +#[test] +fn ground_entry_preserves_only_the_native_ground_history_branch() { + let original = ManualState { + filtered_angle_error: 2., + target_angle: 3., + measured_angle: 4., + angular_correction: 6., + elapsed: 7., + }; + let mut state = original; + assert_eq!( + state.enter_ground(100, 0.25), + ManualEntryContinuation::Continue + ); + assert_eq!(state.filtered_angle_error, 0.5); + assert_eq!(state.angular_correction, 1.5); + assert_eq!(state.target_angle, 3.0); + assert_eq!(state.measured_angle, 4.0); + assert_eq!(state.elapsed, 7.0); + assert_eq!( + state.enter_ground(200, 0.25), + ManualEntryContinuation::RemoveVelocityIntoGround + ); + assert_eq!(state, crate::state()); +} + +#[test] +fn derivative_is_suppressed_only_on_first_active_tick_and_then_clamped() { + let mut state = state(); + state.measured_angle = -2.0; + let mut settings = settings(); + settings.manual.derivative = 2.0; + calculate( + &mut state, + &settings, + mode(), + &input(), + &mut Angle::new(0.5), + ) + .unwrap(); + assert_eq!(state.angular_correction, 0.0); + assert_eq!(state.elapsed, 0.125); + let result = calculate( + &mut state, + &settings, + mode(), + &input(), + &mut Angle::new(1.0), + ) + .unwrap(); + assert_eq!(state.angular_correction, 0.25); + assert_eq!(result.angular_displacement[0], -0.25); + assert_eq!(state.elapsed, 0.25); +} + +#[test] +fn wrong_contact_bleeds_state_and_separately_scales_output() { + let mut state = state(); + state.elapsed = 1.0; + state.angular_correction = 8.0; + let mut input = input(); + input.positive_balance_contact = false; + let result = calculate( + &mut state, + &settings(), + mode(), + &input, + &mut Angle::new(0.0), + ) + .unwrap(); + assert_eq!(state.angular_correction, 2.0); // bleed0.5 * one-axle factor0.5 + assert_eq!(result.angular_displacement[0], -0.25); // output0.25 * one-axle factor0.5 + input.balance = -1.0; + calculate( + &mut state, + &settings(), + mode(), + &input, + &mut Angle::new(0.0), + ) + .unwrap(); + assert_eq!(state.angular_correction, 2.0); // negative balance contact exists +} + +#[test] +fn powerslide_argument_selects_distinct_controller_gains() { + let mut settings = settings(); + settings.manual.proportional = 2.0; + settings.powerslide.proportional = 4.0; + for (powersliding, expected) in [(false, -1.0), (true, -2.0)] { + let mut input = input(); + input.powersliding = powersliding; + let mut state = state(); + calculate(&mut state, &settings, mode(), &input, &mut Angle::new(0.5)).unwrap(); + assert_eq!(state.angular_correction, expected); + assert_eq!(state.filtered_angle_error, -0.025); + } +} + +#[test] +fn brake_target_ignores_noise_but_retains_signed_target_mapping() { + let mut settings = settings(); + settings.maximum_tilt_degrees = 24.0; + settings.brake_tilt_degrees = 30.0; + settings.procedural_noise_scale = 100.0; + settings.animation_noise_scale = 100.0; + let mut input = input(); + input.braking = true; + input.balance = -0.5; + input.flipped_controls = -1.0; + input.animation_noise = 1.0; + input.procedural_noise_time = 0.25; + let mut state = state(); + calculate(&mut state, &settings, mode(), &input, &mut Angle::new(0.0)).unwrap(); + let expected = 18.75 * f32::from_bits(0x3c8efa35); // target magnitude .625 *30deg + assert_eq!(state.target_angle, expected); +} + +#[test] +fn procedural_noise_uses_processed_time_instead_of_elapsed_timer() { + let mut settings = settings(); + settings.procedural_noise_scale = 0.25; + let mut input = input(); + input.procedural_noise_time = 0.25; + let mut state = state(); + state.elapsed = 20.0; + calculate(&mut state, &settings, mode(), &input, &mut Angle::new(0.0)).unwrap(); + assert!((state.target_angle - 0.25).abs() < 0.000001); + assert_eq!(state.elapsed, 20.125); +} + +#[test] +fn correction_requires_opposing_motion_speed_and_alignment_and_selects_point() { + let mut input = input(); + input.braking = true; + for (reversed, point) in [ + (false, input.correction_point_7888), + (true, input.correction_point_7952), + ] { + input.reversed_point_selection = reversed; + let result = calculate( + &mut state(), + &settings(), + mode(), + &input, + &mut Angle::new(0.0), + ) + .unwrap(); + assert!(result.correction_active); + assert_eq!(result.corrective_force_world, [0.0, 0.0, 2000.0, 0.0]); + assert_eq!(result.corrective_point_body, point); + assert!(!result.opposing_motion_without_correction); + } + input.angular_velocity_world[2] = -4.0; // threshold equality does not activate + assert!( + !calculate( + &mut state(), + &settings(), + mode(), + &input, + &mut Angle::new(0.0) + ) + .unwrap() + .correction_active + ); + input.angular_velocity_world[2] = -5.0; + assert!( + !calculate( + &mut state(), + &settings(), + mode(), + &input, + &mut Angle::new(0.1) + ) + .unwrap() + .correction_active + ); + input.angular_velocity_world[2] = 5.0; + assert!( + !calculate( + &mut state(), + &settings(), + mode(), + &input, + &mut Angle::new(0.0) + ) + .unwrap() + .correction_active + ); +} + +#[test] +fn disabled_mode_reports_opposing_motion_before_speed_and_alignment_gates() { + let mut input = input(); + input.braking = true; + input.angular_velocity_world[2] = -0.1; + let mut mode = mode(); + mode.corrective_force_enabled = false; + let result = calculate( + &mut state(), + &settings(), + mode, + &input, + &mut Angle::new(0.5), + ) + .unwrap(); + assert!(result.opposing_motion_without_correction); + assert!(!result.correction_active); + assert_eq!(result.corrective_force_world, [0.0; 4]); +} + +#[test] +fn measurement_failure_does_not_return_partial_effect_or_advance_timer() { + let mut state = state(); + let mut angle = Angle::new(0.0); + angle.fail = true; + assert_eq!( + calculate(&mut state, &settings(), mode(), &input(), &mut angle), + Err(ManualError::Measurement("angle measurement unavailable")) + ); + assert_eq!(state.elapsed, 0.0); +} + +#[test] +fn finite_angle_wrap_has_native_interval_and_explicit_exception_boundary() { + let pi = f32::from_bits(0x40490fdb); + let tau = f32::from_bits(0x40c90fdb); + assert_eq!(normalize(pi), Ok(-pi)); + assert_eq!(normalize(-pi), Ok(-pi)); + assert_eq!(normalize(0.25), Ok(0.25)); + assert_eq!(normalize(tau).unwrap(), 0.0); + assert_eq!(normalize(tau).unwrap().to_bits(), (-0.0_f32).to_bits()); + assert_eq!( + normalize(f32::NAN), + Err(AngleError::IntegerConversionUnavailable) + ); + assert_eq!( + normalize(f32::INFINITY), + Err(AngleError::IntegerConversionUnavailable) + ); + assert_eq!( + normalize(f32::MAX), + Err(AngleError::IntegerConversionUnavailable) + ); +} diff --git a/crates/skate-core/tests/riding_manual_entry.rs b/crates/skate-core/tests/riding_manual_entry.rs new file mode 100644 index 00000000..f61c9d80 --- /dev/null +++ b/crates/skate-core/tests/riding_manual_entry.rs @@ -0,0 +1,248 @@ +//! Independent branch/order checks with supplied projection measurements. +//! These do not validate the missing native three-component dot arithmetic. +use skate_core::{ + physics::manual::state::ManualState, + riding::grounded::manual_entry::{ + ManualGroundBodies, ManualGroundInput, ManualGroundProjection, enter_ground, + remove_velocity_into_ground, + }, +}; + +struct Bodies { + velocities: [[f32; 4]; 7], + reads: Vec, + writes: Vec, + alias: bool, +} +impl Bodies { + fn new() -> Self { + Self { + velocities: [[2., 3., 4., f32::from_bits(0x7fc0_0123)]; 7], + reads: vec![], + writes: vec![], + alias: false, + } + } +} +impl ManualGroundBodies for Bodies { + fn linear_velocity(&mut self, part: usize) -> [f32; 4] { + self.reads.push(part); + self.velocities[if self.alias { 0 } else { part }] + } + fn set_linear_velocity(&mut self, part: usize, velocity: [f32; 4]) { + self.writes.push(part); + self.velocities[if self.alias { 0 } else { part }] = velocity; + } +} +struct Projection { + speed: f32, + samples: Vec<([f32; 4], [f32; 4])>, + fail_at: Option, +} +impl Projection { + fn new(speed: f32) -> Self { + Self { + speed, + samples: vec![], + fail_at: None, + } + } +} +impl ManualGroundProjection for Projection { + type Error = &'static str; + fn normal_speed(&mut self, normal: [f32; 4], velocity: [f32; 4]) -> Result { + self.samples.push((normal, velocity)); + if self.fail_at == Some(self.samples.len()) { + Err("projection unavailable") + } else { + Ok(self.speed) + } + } +} +fn input() -> ManualGroundInput { + ManualGroundInput { + balance: 1., + ground_normal: [0., 1., 0., 55.], + flags_2468: 0, + flags_2472: 0, + } +} +fn state() -> ManualState { + ManualState { + filtered_angle_error: 2., + target_angle: 3., + measured_angle: 4., + angular_correction: 5., + elapsed: 6., + } +} + +#[test] +fn signed_zero_bypasses_every_read_but_unordered_balance_enters() { + for balance in [0., -0.] { + let mut bodies = Bodies::new(); + let mut projection = Projection::new(3.); + remove_velocity_into_ground( + ManualGroundInput { balance, ..input() }, + &mut bodies, + &mut projection, + ) + .unwrap(); + assert!(bodies.reads.is_empty()); + assert!(projection.samples.is_empty()); + } + let mut bodies = Bodies::new(); + remove_velocity_into_ground( + ManualGroundInput { + balance: f32::NAN, + ..input() + }, + &mut bodies, + &mut Projection::new(3.), + ) + .unwrap(); + assert_eq!(bodies.writes, [6]); +} + +#[test] +fn contact_bits_and_reversal_select_exact_groups_in_native_order() { + for (contacts, reversed, expected) in [ + (0, false, vec![6]), + (1 << 27, false, vec![6, 0, 1, 4]), + (1 << 26, false, vec![6, 2, 3, 5]), + (1 << 27, true, vec![6, 2, 3, 5]), + (1 << 26, true, vec![6, 0, 1, 4]), + ((1 << 26) | (1 << 27), false, vec![6, 0, 1, 4, 2, 3, 5]), + ((1 << 26) | (1 << 27), true, vec![6, 0, 1, 4, 2, 3, 5]), + ] { + let mut bodies = Bodies::new(); + remove_velocity_into_ground( + ManualGroundInput { + flags_2468: if reversed { 1 << 20 } else { 0 }, + flags_2472: contacts, + ..input() + }, + &mut bodies, + &mut Projection::new(3.), + ) + .unwrap(); + assert_eq!(bodies.reads, expected); + assert_eq!(bodies.writes, expected); + } +} + +#[test] +fn projection_is_not_clamped_or_normalized_and_preserves_w_bits() { + for (speed, expected_y) in [(3., -3.), (-3., 9.)] { + let mut bodies = Bodies::new(); + let mut projection = Projection::new(speed); + let normal = [0., 2., 0., 55.]; + remove_velocity_into_ground( + ManualGroundInput { + ground_normal: normal, + ..input() + }, + &mut bodies, + &mut projection, + ) + .unwrap(); + assert_eq!(projection.samples[0].0, normal); + assert_eq!(bodies.velocities[6][..3], [2., expected_y, 4.]); + assert_eq!(bodies.velocities[6][3].to_bits(), 0x7fc0_0123); + } +} + +#[test] +fn multiply_and_subtract_have_separate_rounding() { + let mut bodies = Bodies::new(); + bodies.velocities[6][0] = 1.; + let normal_x = f32::from_bits(0x3f80_0001); + let speed = f32::from_bits(0x3f7f_fffe); + remove_velocity_into_ground( + ManualGroundInput { + ground_normal: [normal_x, 0., 0., 0.], + ..input() + }, + &mut bodies, + &mut Projection::new(speed), + ) + .unwrap(); + assert_eq!(bodies.velocities[6][0].to_bits(), 0); + assert_ne!((-normal_x).mul_add(speed, 1.).to_bits(), 0); +} + +#[test] +fn later_parts_read_prior_writes_when_bodies_alias() { + let mut bodies = Bodies::new(); + bodies.alias = true; + let mut projection = Projection::new(1.); + remove_velocity_into_ground( + ManualGroundInput { + flags_2472: 1 << 27, + ..input() + }, + &mut bodies, + &mut projection, + ) + .unwrap(); + let sampled_y: Vec<_> = projection + .samples + .iter() + .map(|sample| sample.1[1]) + .collect(); + assert_eq!(sampled_y, [3., 2., 1., 0.]); + assert_eq!(bodies.velocities[0][1], -1.); +} + +#[test] +fn category100_scales_preserved_state_and_skips_velocity_work() { + let mut state = state(); + let mut bodies = Bodies::new(); + let mut projection = Projection::new(3.); + enter_ground(&mut state, 100, 0.5, input(), &mut bodies, &mut projection).unwrap(); + assert_eq!( + state, + ManualState { + filtered_angle_error: 1., + angular_correction: 2.5, + ..crate::state() + } + ); + assert!(bodies.reads.is_empty()); + assert!(projection.samples.is_empty()); +} + +#[test] +fn other_category_resets_then_runs_velocity_continuation_and_retains_error_prefix() { + let mut state = state(); + let mut bodies = Bodies::new(); + let mut projection = Projection::new(3.); + projection.fail_at = Some(3); + let result = enter_ground( + &mut state, + 200, + 77., + ManualGroundInput { + flags_2472: 1 << 27, + ..input() + }, + &mut bodies, + &mut projection, + ); + assert_eq!(result, Err("projection unavailable")); + assert_eq!( + state, + ManualState { + filtered_angle_error: 0., + target_angle: 0., + measured_angle: 0., + angular_correction: 0., + elapsed: 0., + } + ); + assert_eq!(bodies.reads, [6, 0, 1]); + assert_eq!(bodies.writes, [6, 0]); + assert_eq!(bodies.velocities[6][1], 0.); + assert_eq!(bodies.velocities[0][1], 0.); + assert_eq!(bodies.velocities[1][1], 3.); +} diff --git a/crates/skate-core/tests/riding_mode_binding.rs b/crates/skate-core/tests/riding_mode_binding.rs new file mode 100644 index 00000000..04b53434 --- /dev/null +++ b/crates/skate-core/tests/riding_mode_binding.rs @@ -0,0 +1,19 @@ +use skate_core::riding::grounded::mode::ProcessedMode; + +#[test] +fn unsupported_mode_retains_last_valid_attributes_but_publishes_request() { + let attributes = ["zero", "one", "two", "three", "four"]; + let mut mode = ProcessedMode { + requested_mode: 99, + selected_attributes: "external initial binding", + }; + mode.update(u32::MAX, &attributes); + assert_eq!(mode.selected_attributes, "external initial binding"); + for (index, selected) in attributes.iter().enumerate() { + mode.update(index as u32, &attributes); + assert_eq!(mode.selected_attributes, *selected); + mode.update(5, &attributes); + assert_eq!(mode.requested_mode, 5); + assert_eq!(mode.selected_attributes, *selected); + } +} diff --git a/crates/skate-core/tests/riding_pumping_controller.rs b/crates/skate-core/tests/riding_pumping_controller.rs new file mode 100644 index 00000000..fc9262b9 --- /dev/null +++ b/crates/skate-core/tests/riding_pumping_controller.rs @@ -0,0 +1,338 @@ +//! Independent control-flow checks with supplied geometric measurements. +//! These do not implement or certify the missing native geometry operations. +use skate_core::{ + point_graph::PointGraph, + riding::pumping::{ + controller::{ + GROUND_PUMPING_TIMESTEP, PumpingGeometry, PumpingSample, calculate, update, + update_ground, + }, + settings::{PumpingMode, PumpingSettings}, + state::PumpingState, + }, +}; + +fn constant(value: f32) -> PointGraph<8> { + PointGraph { + x: [0., 1., 2., 3., 4., 5., 6., 7.], + y: [value; 8], + } +} +fn settings() -> PumpingSettings { + PumpingSettings { + pump_vs_speed: constant(2.0), + pump_vs_time: constant(1.0), + min_crouch_vs_ground_angle: constant(0.25), + compression_vs_ground_angle: constant(0.5), + compression_vs_deck_angle: constant(0.75), + height_change_damping: 1.0, + minimum_height_change: 0.1, + maximum_height_change: 0.5, + ground_compression_scale: -4.0, + deck_compression_scale: -8.0, + angular_speed_damping: 1.0, + } +} +fn mode() -> PumpingMode { + PumpingMode { + maximum_absorption_per_second: 100.0, + maximum_acceleration_per_second: 100.0, + absorption_factor: 3.0, + acceleration_factor: 5.0, + } +} +fn sample() -> PumpingSample { + PumpingSample { + position: [4., 5., 6., 9.], + normal: [0., 1., 0., 7.], + com_to_deck_world: [1., 2., 3., 0.], + deck_angle: 0.0, + intentional_pumping: 1, + } +} + +struct Measurements { + height: f32, + angular: f32, + calls: Vec<&'static str>, + times: Vec, + inclination_input: Option, + fail_speed: bool, +} +impl Measurements { + fn new(height: f32, angular: f32) -> Self { + Self { + height, + angular, + calls: vec![], + times: vec![], + inclination_input: None, + fail_speed: false, + } + } +} +impl PumpingGeometry for Measurements { + type Error = &'static str; + fn height(&mut self, normal: [f32; 4], com: [f32; 4]) -> Result { + self.calls.push("height"); + assert_eq!(normal, sample().normal); + assert_eq!(com, sample().com_to_deck_world); + Ok(self.height) + } + fn speed(&mut self, _old: [f32; 4], _now: [f32; 4], dt: f32) -> Result { + self.calls.push("speed"); + self.times.push(dt); + if self.fail_speed { + Err("native speed unavailable") + } else { + Ok(4.0) + } + } + fn angular_speed( + &mut self, + _position: [f32; 4], + _normal: [f32; 4], + _sample: &PumpingSample, + dt: f32, + ) -> Result { + self.calls.push("angular"); + self.times.push(dt); + Ok(self.angular) + } + fn inclination_radians(&mut self, input: f32) -> Result { + self.calls.push("inclination"); + self.inclination_input = Some(input); + Ok(0.0) + } +} +fn seeded() -> PumpingState { + let mut state = PumpingState::reset_state(); + state.record_valid = true; + state.previous_normal = [0., 0.5, 0., 0.]; + state +} + +#[test] +fn first_update_only_records_history_and_clears_force() { + let mut state = PumpingState::reset_state(); + state.pump_acceleration = 42.0; + state.absorption = 3.0; + state.reset_only_scalar = 11.0; + state.intentional_pumping = 7; + let mut geometry = Measurements::new(2.0, 99.0); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut geometry, + ) + .unwrap(); + assert_eq!(geometry.calls, ["height"]); + assert_eq!(state.previous_position, sample().position); + assert_eq!(state.previous_normal, sample().normal); + assert_eq!(state.previous_height, 2.0); + assert!(state.record_valid); + assert_eq!(state.pump_acceleration, 0.0); + assert_eq!(state.absorption, 3.0); + assert_eq!(state.reset_only_scalar, 11.0); + assert_eq!(state.intentional_pumping, 7); +} + +#[test] +fn positive_pumping_caps_height_before_mode_scaling_and_updates_outputs() { + let mut state = seeded(); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut Measurements::new(2.0, 2.0), + ) + .unwrap(); + assert_eq!(state.smoothed_height_change, 2.0); + assert_eq!(state.pumping_time, 0.25); + assert_eq!(state.pumping, 4.0); + assert_eq!(state.pump_acceleration, 10.0); // 4 * 5 * capped 0.5 + assert_eq!(state.absorption, -8.0); + assert_eq!(state.ground_normal_absorption, -6.0); // greater squared deck magnitude + assert_eq!(state.minimum_crouch, 0.25); + assert_eq!(state.physics_output().compression, 0.0); + assert_eq!(state.physics_output().intentional_pumping, 1); +} + +#[test] +fn absorption_and_acceleration_use_distinct_factors_and_time_scaled_limits() { + let mut limits = mode(); + limits.maximum_absorption_per_second = 2.0; + limits.maximum_acceleration_per_second = 4.0; + for (angular, expected) in [(-2.0, -0.5), (2.0, 1.0)] { + let mut state = seeded(); + update( + &mut state, + &settings(), + limits, + sample(), + 0.25, + &mut Measurements::new(2.0, angular), + ) + .unwrap(); + assert_eq!(state.pump_acceleration, expected); + } + let mut state = seeded(); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut Measurements::new(2.0, -2.0), + ) + .unwrap(); + assert_eq!(state.pump_acceleration, -6.0); // -4 * absorption factor 3 * 0.5 +} + +#[test] +fn falling_height_does_not_pump_but_geometry_and_compression_still_update() { + let mut state = seeded(); + state.previous_height = 5.0; + state.pumping_time = 9.0; + let mut geometry = Measurements::new(2.0, 2.0); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut geometry, + ) + .unwrap(); + assert_eq!(state.pumping_time, 0.0); + assert_eq!(state.pump_acceleration, 0.0); + assert_eq!(state.absorption, -8.0); + assert_eq!( + geometry.calls, + ["height", "speed", "angular", "inclination"] + ); + assert_eq!(geometry.inclination_input, Some(0.5)); // previous normal, not this tick's normal +} + +#[test] +fn ground_pumping_uses_its_native_fixed_step() { + let mut state = seeded(); + let mut geometry = Measurements::new(0.25, 2.0); + update_ground(&mut state, &settings(), mode(), sample(), &mut geometry).unwrap(); + assert_eq!(state.pumping_time.to_bits(), 0x3c88_8889); + assert_eq!(geometry.times, [GROUND_PUMPING_TIMESTEP; 2]); +} + +#[test] +fn reset_invalidates_history_and_all_owned_outputs() { + let mut state = seeded(); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut Measurements::new(2.0, 2.0), + ) + .unwrap(); + state.reset_only_scalar = 7.0; + state.reset(); + assert_eq!(state, PumpingState::reset_state()); + let mut geometry = Measurements::new(2.0, 2.0); + update_ground(&mut state, &settings(), mode(), sample(), &mut geometry).unwrap(); + assert_eq!(geometry.calls, ["height"]); + assert_eq!(state.intentional_pumping, 0); +} + +#[test] +fn missing_geometry_aborts_before_cache_commit_or_force_publication() { + let mut state = seeded(); + state.previous_position = [8.0; 4]; + state.pump_acceleration = 12.0; + let mut geometry = Measurements::new(2.0, 2.0); + geometry.fail_speed = true; + assert_eq!( + update_ground(&mut state, &settings(), mode(), sample(), &mut geometry), + Err("native speed unavailable") + ); + assert_eq!(state.previous_position, [8.0; 4]); + assert_eq!(state.previous_height, 0.0); + assert_eq!(state.pump_acceleration, 0.0); + assert_eq!(geometry.calls, ["height", "speed"]); +} + +#[test] +fn calculate_preserves_update_owned_history_and_equal_compression_magnitude() { + let mut state = seeded(); + state.previous_normal[1] = -2.0; + state.pump_acceleration = 11.0; + state.pumping_time = 4.0; + let mut settings = settings(); + settings.deck_compression_scale = 8.0 / 3.0; + let mut geometry = Measurements::new(1.0, 2.0); + assert_eq!( + calculate(&mut state, &settings, &sample(), 0.25, &mut geometry).unwrap(), + 4.0 + ); + assert_eq!(state.ground_normal_absorption, -2.0); // tie does not select positive deck compression + assert_eq!(geometry.inclination_input, Some(0.0)); + assert_eq!(state.pump_acceleration, 11.0); + assert_eq!(state.pumping_time, 4.0); +} + +#[test] +fn threshold_equality_and_negative_history_keep_native_signed_comparisons() { + let mut config = settings(); + config.minimum_height_change = 0.25; + let mut state = seeded(); + update( + &mut state, + &config, + mode(), + sample(), + 0.25, + &mut Measurements::new(0.25, 2.0), + ) + .unwrap(); + assert_eq!(state.pump_acceleration, 5.0); // equality is active, not discarded + config.height_change_damping = 0.0; + config.minimum_height_change = -3.0; + state.smoothed_height_change = -2.0; + update( + &mut state, + &config, + mode(), + sample(), + 0.25, + &mut Measurements::new(0.25, 2.0), + ) + .unwrap(); + assert_eq!(state.pump_acceleration, -6.0); + assert_eq!(state.pumping_time, 0.0); +} + +#[test] +fn unordered_history_and_normal_follow_native_compare_and_select_paths() { + let mut state = seeded(); + state.smoothed_height_change = f32::NAN; + state.previous_normal[1] = f32::NAN; + let mut geometry = Measurements::new(0.25, 2.0); + update( + &mut state, + &settings(), + mode(), + sample(), + 0.25, + &mut geometry, + ) + .unwrap(); + assert!(state.smoothed_height_change.is_nan()); + assert_eq!(state.pumping_time, 0.0); + assert_eq!(state.pump_acceleration, 25.0); // upper fsel selects bound for unordered difference + assert_eq!(geometry.inclination_input, Some(1.0)); +} diff --git a/crates/skate-core/tests/wheel_ground_solver.rs b/crates/skate-core/tests/wheel_ground_solver.rs new file mode 100644 index 00000000..b9c5ea80 --- /dev/null +++ b/crates/skate-core/tests/wheel_ground_solver.rs @@ -0,0 +1,190 @@ +//! Wheel/ground response through the production shared solver. These tests +//! query the production sphere/triangle path before compiling the contact. +//! Wheel mass/radius/inertia come from the recovered stock assembly. +use skate_core::{ + math::Vector3, + physics::{ + board::RETAIL_WHEEL_RADIUS, + collision::{ + Sphere, Triangle, TriangleFeature, WorldContactSettings, sphere_triangle_world_contact, + }, + contact::{RetailContactBodyState, RetailContactInput, generate_contact}, + contact_solver::{RetailContactJacobian, build_contact_jacobian}, + mass::retail_wheel_mass_properties, + rigid_body::RetailReactionCorrections, + solver::solve_constraints, + }, +}; + +const DT: f32 = 1.0 / 60.0; +const GRAVITY: f32 = 9.8; + +fn contact(speed: f32, spin: f32, force: f32, friction: (f32, f32)) -> RetailContactJacobian { + let inertia = retail_wheel_mass_properties().dynamics; + let world = RetailContactBodyState { + contact_body_id: u32::MAX, + reaction_id: 1, + state: 0, + center_of_mass: Vector3::ZERO, + inverse_inertia_full: Vector3::ZERO, + inverse_inertia_split: Vector3::ZERO, + inverse_mass: 0.0, + force_acceleration: Vector3::ZERO, + torque_acceleration: Vector3::ZERO, + linear_velocity: Vector3::ZERO, + angular_velocity: Vector3::ZERO, + kinetic_energy: 0.0, + cool_down: 0, + }; + let wheel = RetailContactBodyState { + contact_body_id: 0, + reaction_id: 0, + state: 4, + center_of_mass: Vector3::new(0.0, RETAIL_WHEEL_RADIUS, 0.0), + inverse_inertia_full: Vector3::new(inertia.inverse_tensor.x, 0.0, 0.0), + inverse_inertia_split: Vector3::new( + inertia.inverse_tensor.z, + inertia.inverse_tensor.y, + 0.0, + ), + inverse_mass: inertia.inverse_mass, + force_acceleration: Vector3::new(0.0, -GRAVITY, force * inertia.inverse_mass), + linear_velocity: Vector3::new(0.0, 0.0, speed), + angular_velocity: Vector3::new(spin, 0.0, 0.0), + ..world + }; + let pair = sphere_triangle_world_contact( + Sphere { + center: wheel.center_of_mass, + radius: RETAIL_WHEEL_RADIUS, + }, + Triangle { + vertices: [ + Vector3::new(-2.0, 0.0, -2.0), + Vector3::new(2.0, 0.0, -2.0), + Vector3::new(-2.0, 0.0, 2.0), + ], + feature: TriangleFeature { + normal: Vector3::new(0.0, 1.0, 0.0), + edges: [ + Vector3::new(0.0, 0.0, 1.0), + Vector3::new( + std::f32::consts::FRAC_1_SQRT_2, + 0.0, + -std::f32::consts::FRAC_1_SQRT_2, + ), + Vector3::new(-1.0, 0.0, 0.0), + ], + flags: 0x1F0, + edge_cosines: [1.0; 3], + }, + edge_lengths: [4.0, 4.0 * 2.0_f32.sqrt(), 4.0], + fatness: 0.0, + }, + wheel.linear_velocity, + WorldContactSettings { + volume_padding: 0.0, + maximum_separating_distance: 0.0, + edge_cos_bend_normal_threshold: -1.0, + convexity_epsilon: 0.0, + is_object: false, + }, + ) + .expect("wheel sphere reaches the triangle"); + build_contact_jacobian( + generate_contact( + RetailContactInput { + position_on_a: pair.points.a, + position_on_b: pair.points.b, + normal: pair.normal, + static_friction: friction.0, + dynamic_friction: friction.1, + restitution: 0.0, + tag: 0, + }, + wheel, + world, + ), + DT, + ) +} + +fn solve( + speed: f32, + spin: f32, + force: f32, + friction: (f32, f32), + iterations: u32, +) -> (f32, f32, RetailContactJacobian) { + let mut contacts = [contact(speed, spin, force, friction)]; + let mut reactions = [RetailReactionCorrections::default(); 2]; + solve_constraints(&mut contacts, &mut [], &mut [], &mut reactions, iterations); + let r = reactions[0]; + let inv_mass = retail_wheel_mass_properties().dynamics.inverse_mass; + let velocity = speed + force * inv_mass * DT + r.linear_displacement.z / DT; + let angular = spin + r.angular_displacement.x / DT; + assert_eq!( + reactions[1], + RetailReactionCorrections::default(), + "static world must not move" + ); + if iterations > 0 { + assert!( + (-GRAVITY * DT + r.linear_displacement.y / DT).abs() < 1e-6, + "ground supports the wheel" + ); + } + (velocity, angular, contacts[0]) +} + +#[test] +fn ground_friction_spins_a_slipping_wheel_in_both_directions() { + for speed in [-1.0, 1.0] { + let (velocity, angular, _) = solve(speed, 0.0, 0.0, (0.8, 0.7), 25); + assert!(velocity.abs() < speed.abs()); + assert!(angular * speed > 0.0); + assert!((velocity - angular * RETAIL_WHEEL_RADIUS).abs() < speed.abs()); + let inertia = retail_wheel_mass_properties().dynamics; + let linear_impulse = (velocity - speed) / inertia.inverse_mass; + let angular_impulse = angular / inertia.inverse_tensor.x; + assert!((angular_impulse + RETAIL_WHEEL_RADIUS * linear_impulse).abs() < 1e-7); + } +} + +#[test] +fn a_rolling_wheel_does_not_receive_artificial_drag() { + for speed in [-3.0, 3.0] { + let spin = speed / RETAIL_WHEEL_RADIUS; + let (velocity, angular, _) = solve(speed, spin, 0.0, (0.8, 0.7), 25); + assert!((velocity - speed).abs() < 1e-6); + assert!((angular - spin).abs() < 1e-5); + } +} + +#[test] +fn frictionless_ground_supports_without_spinning_or_slowing_the_wheel() { + let (velocity, angular, _) = solve(2.0, 0.0, 0.0, (0.0, 0.0), 25); + assert_eq!(velocity, 2.0); + assert_eq!(angular, 0.0); +} + +#[test] +fn static_grip_transitions_to_dynamic_friction_under_larger_load() { + let (velocity, angular, _) = solve(0.0, 0.0, 0.1, (0.8, 0.7), 25); + assert!(velocity > 0.0 && angular > 0.0); + assert!((velocity - angular * RETAIL_WHEEL_RADIUS).abs() < 1e-6); + let (velocity, angular, contact) = solve(0.0, 0.0, 30.0, (0.8, 0.7), 25); + assert!(velocity - angular * RETAIL_WHEEL_RADIUS > 0.1); + let tangential = contact.accumulated_impulse()[1] + .abs() + .max(contact.accumulated_impulse()[2].abs()); + assert!((tangential - 0.7 * contact.accumulated_impulse()[0]).abs() < 1e-8); +} + +#[test] +fn first_contact_pass_uses_previous_normal_impulse_for_friction() { + let (_, first_spin, _) = solve(1.0, 0.0, 0.0, (0.8, 0.7), 1); + let (_, second_spin, _) = solve(1.0, 0.0, 0.0, (0.8, 0.7), 2); + assert_eq!(first_spin, 0.0); + assert!(second_spin > 0.0); +} diff --git a/crates/skate-data/Cargo.toml b/crates/skate-data/Cargo.toml new file mode 100644 index 00000000..c811da07 --- /dev/null +++ b/crates/skate-data/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "skate-data" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +skate-core = { path = "../skate-core" } +serde.workspace = true +serde_json.workspace = true diff --git a/crates/skate-data/src/abin/bank.rs b/crates/skate-data/src/abin/bank.rs new file mode 100644 index 00000000..0e989ff9 --- /dev/null +++ b/crates/skate-data/src/abin/bank.rs @@ -0,0 +1,316 @@ +//! Ordered bank indexing and lossless header decoding. Codec math is separate. +use super::*; +use std::{collections::BTreeMap, fs, path::Path, sync::Arc}; + +pub struct Bank { + bytes: Arc<[u8]>, + pub wrapper_bytes: usize, + pub trailer: Range, + records: Vec, + offsets: BTreeMap, + animations: BTreeMap, + clips: BTreeMap, + poses: BTreeMap, + hierarchy: Option, +} +impl Bank { + pub fn load(path: &Path) -> Result { + let bytes = + fs::read(path).map_err(|e| Error::new(0, format!("{}: {e}", path.display())))?; + Self::parse(bytes) + } + pub fn parse(bytes: Vec) -> Result { + let reader = Reader::new(&bytes); + let end = reader.u32(0)? as usize; + if reader.u32(4)? != 1 || end < 48 || end > bytes.len() { + return Err(Error::new(0, "invalid type1 bank wrapper")); + } + let mut records = Vec::new(); + let mut offsets = BTreeMap::new(); + let mut animations = BTreeMap::new(); + let mut clips = BTreeMap::new(); + let mut poses = BTreeMap::new(); + let mut hierarchy = None; + let mut at = 48; + while at < end { + let size = reader.u32(at)? as usize; + if size < 48 || size % 16 != 0 || size > end - at { + return Err(Error::new(at, "invalid record size/alignment")); + } + let r = reader.bounded(at..at + size)?; + let header = RecordHeader { + name: r.name(at + 16, 6)?, + name_words: r.words(at + 16)?, + offset: at, + size, + payload_offset: (at + 55) & !15, + type_id: r.u32(at + 4)?, + codec_word: r.u32(at + 8)?, + guid: r.u32(at + 12)?, + }; + let data = match header.type_id { + 2 => RecordData::Clip(read_clip(r, &header)?), + 3 => RecordData::Pose(Pose { + parts: read_parts( + r, + header.payload_offset, + 4, + r.u32(header.payload_offset)? as usize, + false, + )?, + }), + 4 => RecordData::Hierarchy(read_hierarchy(r, header.payload_offset)?), + 5 => { + let n = r.u32(header.payload_offset)?; + let size = (n as usize) + .checked_mul(112) + .ok_or_else(|| Error::new(at, "physics bone size overflow"))?; + RecordData::PhysicsPose(PhysicsPose { + bone_count: n, + records: r.relative(header.payload_offset, 16, size)?, + }) + } + _ => RecordData::Opaque, + }; + let index = records.len(); + // SetDBContent82D1B428..45C overwrites map entries in file order. + // Clips also participate in the animation-tree map, poses do not. + match header.type_id { + 2 => { + clips.insert(header.name.clone(), index); + animations.insert(header.name.clone(), index); + } + 3 => { + poses.insert(header.name.clone(), index); + } + 4 => { + hierarchy = Some(index); + } + 6..=11 => { + animations.insert(header.name.clone(), index); + } + _ => {} + } + offsets.insert(at, index); + records.push(Record { header, data }); + at += size; + } + let file_size = bytes.len(); + Ok(Self { + bytes: bytes.into(), + wrapper_bytes: end, + trailer: end..file_size, + records, + offsets, + animations, + clips, + poses, + hierarchy, + }) + } + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + pub fn records(&self) -> &[Record] { + &self.records + } + pub fn record_at(&self, offset: usize) -> Option<&Record> { + self.offsets.get(&offset).map(|&i| &self.records[i]) + } + pub fn animation(&self, name: &str) -> Option<&Record> { + self.animations + .get(&name.to_ascii_uppercase()) + .map(|&i| &self.records[i]) + } + pub fn clip(&self, name: &str) -> Option<(&RecordHeader, &Clip)> { + let record = &self.records[*self.clips.get(&name.to_ascii_uppercase())?]; + if let RecordData::Clip(clip) = &record.data { + Some((&record.header, clip)) + } else { + None + } + } + pub fn pose(&self, name: &str) -> Option<(&RecordHeader, &Pose)> { + let record = &self.records[*self.poses.get(&name.to_ascii_uppercase())?]; + if let RecordData::Pose(pose) = &record.data { + Some((&record.header, pose)) + } else { + None + } + } + pub fn hierarchy(&self) -> Option<&Hierarchy> { + match &self.records[self.hierarchy?].data { + RecordData::Hierarchy(v) => Some(v), + _ => None, + } + } + ///The stock OnBoard/OffBoard VBR banks carry all eight parts in hierarchy + ///order. Their encoded ids are all zero; source82D20608/82D20924 advances + ///its equal-id cursor one entry at a time, preserving this ordinal mapping. + ///Call before using positional part association; partial banks may require + ///the original named/id association and are not silently treated as full. + pub fn validate_positional_parts(&self) -> Result<()> { + let hierarchy = self + .hierarchy() + .ok_or_else(|| Error::new(0, "missing hierarchy"))?; + for record in &self.records { + let parts = match &record.data { + RecordData::Clip(v) => &v.parts, + RecordData::Pose(v) => &v.parts, + _ => continue, + }; + if parts.len() != hierarchy.parts.len() { + return Err(Error::new( + record.header.offset, + "part table differs from full hierarchy", + )); + } + for (entry, layout) in parts.iter().zip(&hierarchy.parts) { + if let Some(part) = &entry.part { + if part.channel_count as u32 != layout.bone_count { + return Err(Error::new( + part.offset, + "part channel count differs from hierarchy", + )); + } + } + } + } + Ok(()) + } +} + +fn read_clip(r: Reader<'_>, h: &RecordHeader) -> Result { + let p = h.payload_offset; + r.take(p, 56)?; + let flags = r.u32(p + 52)?; + let attribute_count = flags as u8; + let attribute_offset = r.relative(p, r.u32(p + 44)?, 0)?.start; + Ok(Clip { + fps_bits: r.u32(p + 32)?, + frame_count_bits: r.u32(p + 36)?, + base_speed_bits: r.u32(p + 40)?, + flags, + loop_translation_words: r.words(p)?, + loop_rotation_words: r.words(p + 16)?, + attribute_offset, + attribute_count, + parts: read_parts( + r, + p, + 56, + ((flags >> 8) & 31) as usize, + flags & 0x2000_0000 != 0, + )?, + }) +} +fn read_parts( + r: Reader<'_>, + base: usize, + table: usize, + count: usize, + channel: bool, +) -> Result> { + let size = count + .checked_mul(4) + .ok_or_else(|| Error::new(base, "part table size overflow"))?; + r.take(base + table, size)?; + let mut parts = Vec::with_capacity(count); + for i in 0..count { + let table_offset = base + table + 4 * i; + let word = r.u32(table_offset)?; + let relative = word & 0x00ff_ffff; + let part = if relative == 0 { + None + } else { + let offset = r.relative(base, relative, 16)?.start; + let header_word = r.u32(offset)?; + let compressed_size = r.u32(offset + 4)?; + let compression_header_relative = r.u32(offset + 8)?; + let compressed_data_relative = r.u32(offset + 12)?; + let channel_count = (((header_word >> 6) & 63) + 1) as u16; + let compression_header = r.relative( + offset, + compression_header_relative, + (header_word >> 12) as usize, + )?; + let compressed_data = + r.relative(offset, compressed_data_relative, compressed_size as usize)?; + let channel_weights = if channel { + Some(r.relative(offset, 16, 4 * channel_count as usize)?) + } else { + None + }; + Some(AnimationPart { + offset, + header_word, + compressed_size, + compression_header_relative, + compressed_data_relative, + channel_count, + compression_header, + compressed_data, + channel_weights, + }) + }; + parts.push(PartEntry { + table_index: i, + table_offset, + raw_word: word, + encoded_id: (word >> 24) as u8, + part, + }); + } + Ok(parts) +} +fn read_hierarchy(r: Reader<'_>, p: usize) -> Result { + let bone_count = r.u16(p)?; + let n = bone_count as usize; + let count = r.u32(p + 4)? as usize; + let part_bytes = count + .checked_mul(36) + .ok_or_else(|| Error::new(p, "hierarchy part count overflow"))?; + let part_base = p + 8 + 8 * n; + r.take(p + 8, 8 * n)?; + r.take(part_base, part_bytes)?; + let name_base = part_base + part_bytes; + r.take(name_base, 20 * n)?; + let mut parents = Vec::with_capacity(n); + let mut mirrors = Vec::with_capacity(n); + let mut bone_names = Vec::with_capacity(n); + for i in 0..n { + let parent = r.i32(p + 8 + 4 * i)?; + let mirror = r.i32(p + 8 + 4 * n + 4 * i)?; + if parent < -1 || parent >= i as i32 || mirror < -1 || mirror >= n as i32 { + return Err(Error::new(p + 8 + 4 * i, "invalid parent or mirror index")); + } + parents.push(parent); + mirrors.push(mirror); + bone_names.push(r.name(name_base + 20 * i, 5)?); + } + let mut parts = Vec::with_capacity(count); + for i in 0..count { + let at = part_base + i * 36; + let bone_count = r.u32(at + 24)?; + let sqt_offset = r.i32(at + 28)?; + if sqt_offset < 0 || sqt_offset as usize + bone_count as usize > n { + return Err(Error::new(at, "part extends outside skeleton output")); + } + parts.push(HierarchyPart { + name: r.name(at, 6)?, + bone_count, + sqt_offset, + flags: r.u16(at + 32)?, + encoded_id: r.u16(at + 34)?, + }); + } + Ok(Hierarchy { + offset: p, + bone_count, + has_trajectory: r.u16(p + 2)? != 0, + parents, + mirrors, + bone_names, + parts, + }) +} diff --git a/crates/skate-data/src/abin/mod.rs b/crates/skate-data/src/abin/mod.rs new file mode 100644 index 00000000..506da948 --- /dev/null +++ b/crates/skate-data/src/abin/mod.rs @@ -0,0 +1,168 @@ +//! Direct stock Andale bank storage and format descriptions. +//! +//! Source: TU3 DataBase::SetDBContent82D1B340, Clip::Init827B8AB0, +//! FetchSys::BatchFetch82D20560/82D20788 and codec identifier82D17A60. +//! This module owns bytes and bounds checks; codec arithmetic belongs to core. +mod bank; +mod reader; +pub use bank::Bank; +pub use reader::Reader; +use std::{fmt, ops::Range}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Error { + pub offset: usize, + pub message: String, +} +impl Error { + pub fn new(offset: usize, message: impl Into) -> Self { + Self { + offset, + message: message.into(), + } + } +} +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ABIN at {:#x}: {}", self.offset, self.message) + } +} +impl std::error::Error for Error {} +pub type Result = std::result::Result; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Codec { + Raw, + Vbr, + Other(u32), +} +impl Codec { + ///82D17A60 reverses the disk word and compares RD\0\0 and VBR\0. + pub fn from_word(word: u32) -> Self { + match word { + 0x0000_4452 => Self::Raw, + 0x0052_4256 => Self::Vbr, + v => Self::Other(v), + } + } +} +#[derive(Clone, Debug)] +pub struct RecordHeader { + pub name: String, + pub name_words: [u32; 6], + pub offset: usize, + pub size: usize, + pub payload_offset: usize, + pub type_id: u32, + pub codec_word: u32, + pub guid: u32, +} +impl RecordHeader { + pub fn codec(&self) -> Codec { + Codec::from_word(self.codec_word) + } + pub fn range(&self) -> Range { + self.offset..self.offset + self.size + } +} +#[derive(Clone, Debug)] +pub struct Record { + pub header: RecordHeader, + pub data: RecordData, +} +#[derive(Clone, Debug)] +pub enum RecordData { + Clip(Clip), + Pose(Pose), + Hierarchy(Hierarchy), + PhysicsPose(PhysicsPose), + Opaque, +} +#[derive(Clone, Debug)] +pub struct Clip { + pub fps_bits: u32, + pub frame_count_bits: u32, + pub base_speed_bits: u32, + pub flags: u32, + pub loop_translation_words: [u32; 4], + pub loop_rotation_words: [u32; 4], + pub attribute_offset: usize, + pub attribute_count: u8, + pub parts: Vec, +} +impl Clip { + pub fn looping(&self) -> bool { + self.flags & 0x1000_0000 != 0 + } + pub fn channel_animation(&self) -> bool { + self.flags & 0x2000_0000 != 0 + } + pub fn phase_controlled(&self) -> bool { + self.flags & 0x4000_0000 != 0 + } +} +#[derive(Clone, Debug)] +pub struct Pose { + pub parts: Vec, +} +#[derive(Clone, Debug)] +pub struct PhysicsPose { + pub bone_count: u32, + ///24 authored112-byte PhysicsParamBoneData records in the stock banks. + pub records: Range, +} +#[derive(Clone, Debug)] +pub struct PartEntry { + pub table_index: usize, + pub table_offset: usize, + pub raw_word: u32, + pub encoded_id: u8, + ///An authored zero offset remains an empty entry; no table compaction. + pub part: Option, +} +/// Common original AnimationPart header for RAW and VBR. Relative words and +/// absolute byte ranges coexist so consumers never accidentally mix bases. +#[derive(Clone, Debug)] +pub struct AnimationPart { + pub offset: usize, + pub header_word: u32, + pub compressed_size: u32, + pub compression_header_relative: u32, + pub compressed_data_relative: u32, + ///Header bits6..11 plus1, verified against all stock hierarchy parts. + pub channel_count: u16, + ///Packed header bits12..31 give the byte count at compression_header_relative. + pub compression_header: Range, + pub compressed_data: Range, + ///Original channel-animation weight words begin at part+16. + pub channel_weights: Option>, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HierarchyPart { + pub name: String, + pub bone_count: u32, + pub sqt_offset: i32, + ///Retained original words; no runtime part-id rewriting is performed. + pub flags: u16, + pub encoded_id: u16, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Hierarchy { + pub offset: usize, + pub bone_count: u16, + pub has_trajectory: bool, + pub parents: Vec, + pub mirrors: Vec, + pub bone_names: Vec, + pub parts: Vec, +} +impl Hierarchy { + pub fn compatible_with(&self, other: &Self) -> bool { + self.bone_count == other.bone_count + && self.has_trajectory == other.has_trajectory + && self.parents == other.parents + && self.mirrors == other.mirrors + && self.bone_names == other.bone_names + && self.parts == other.parts + } +} diff --git a/crates/skate-data/src/abin/reader.rs b/crates/skate-data/src/abin/reader.rs new file mode 100644 index 00000000..9d6684b2 --- /dev/null +++ b/crates/skate-data/src/abin/reader.rs @@ -0,0 +1,113 @@ +//! Checked big-endian reads with absolute offsets retained through subrecords. +use super::{Error, Result}; +use std::ops::Range; + +#[derive(Clone, Copy)] +pub struct Reader<'a> { + bytes: &'a [u8], + begin: usize, + end: usize, +} +impl<'a> Reader<'a> { + pub fn new(bytes: &'a [u8]) -> Self { + Self { + bytes, + begin: 0, + end: bytes.len(), + } + } + pub fn bounded(self, range: Range) -> Result { + self.take( + range.start, + range + .end + .checked_sub(range.start) + .ok_or_else(|| Error::new(range.start, "reversed record range"))?, + )?; + Ok(Self { + bytes: self.bytes, + begin: range.start, + end: range.end, + }) + } + pub fn take(self, offset: usize, size: usize) -> Result<&'a [u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::new(offset, "read overflow"))?; + if offset < self.begin || end > self.end { + return Err(Error::new( + offset, + format!( + "read size{size} exceeds record {:#x}..{:#x}", + self.begin, self.end + ), + )); + } + Ok(&self.bytes[offset..end]) + } + pub fn u8(self, offset: usize) -> Result { + Ok(self.take(offset, 1)?[0]) + } + pub fn u16(self, offset: usize) -> Result { + Ok(u16::from_be_bytes( + self.take(offset, 2)?.try_into().unwrap(), + )) + } + pub fn u32(self, offset: usize) -> Result { + Ok(u32::from_be_bytes( + self.take(offset, 4)?.try_into().unwrap(), + )) + } + pub fn i32(self, offset: usize) -> Result { + Ok(self.u32(offset)? as i32) + } + pub fn words(self, offset: usize) -> Result<[u32; N]> { + let raw = self.take( + offset, + N.checked_mul(4) + .ok_or_else(|| Error::new(offset, "word count overflow"))?, + )?; + Ok(core::array::from_fn(|i| { + u32::from_be_bytes(raw[4 * i..4 * i + 4].try_into().unwrap()) + })) + } + ///Five words encode FastString30, six encode FastString36. Names stay + ///uppercase as authored; padding remains word-local, matching stock names. + pub fn name(self, offset: usize, words: usize) -> Result { + let raw = self.take( + offset, + words + .checked_mul(4) + .ok_or_else(|| Error::new(offset, "name size overflow"))?, + )?; + const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_"; + let mut result = String::new(); + for raw_word in raw.chunks_exact(4) { + let mut value = u32::from_be_bytes(raw_word.try_into().unwrap()); + let mut divisor = 38u32.pow(5); + for _ in 0..6 { + let digit = value / divisor; + value %= divisor; + if digit == 0 { + if value != 0 { + return Err(Error::new(offset, "invalid FastString padding")); + } + break; + } + let character = ALPHABET + .get(digit as usize - 1) + .ok_or_else(|| Error::new(offset, "invalid FastString digit"))?; + result.push(*character as char); + divisor /= 38; + } + } + Ok(result) + } + pub(crate) fn relative(self, base: usize, offset: u32, size: usize) -> Result> { + let start = base + .checked_add(offset as usize) + .ok_or_else(|| Error::new(base, "relative offset overflow"))?; + self.take(start, size)?; + Ok(start..start + size) + } +} diff --git a/crates/skate-data/src/animation_banks.rs b/crates/skate-data/src/animation_banks.rs new file mode 100644 index 00000000..d04c4761 --- /dev/null +++ b/crates/skate-data/src/animation_banks.rs @@ -0,0 +1,46 @@ +//! Own both stock skater banks once. Clip/tree names are disjoint; poses retain +//! their bank identity because the banks share several reference-pose names. +use crate::{abin::Bank, animation_metadata::AnimationMetadata, sha256}; +use std::{path::Path, sync::Arc}; + +pub struct AnimationBanks { + pub banks: Vec>, + pub identities: Vec, +} +impl AnimationBanks { + pub fn load(asset_root: &Path) -> Result { + let mut banks = Vec::new(); + let mut identities = Vec::new(); + for name in ["OnBoard", "OffBoard"] { + let bank = Bank::load(&asset_root.join(format!("private/stock/data/anim/{name}.abin"))) + .map_err(|e| format!("{name}: {e}"))?; + bank.validate_positional_parts() + .map_err(|e| e.to_string())?; + identities.push(sha256::digest(bank.bytes())); + banks.push(Arc::new(bank)); + } + let first = banks[0] + .hierarchy() + .ok_or("OnBoard has no animation hierarchy")?; + let second = banks[1] + .hierarchy() + .ok_or("OffBoard has no animation hierarchy")?; + if !first.compatible_with(second) { + return Err("Skater animation bank hierarchies differ".into()); + } + Ok(Self { banks, identities }) + } + pub fn metadata(&self) -> Result { + let mut result = AnimationMetadata::from_bank( + &self.banks[0], + "OnBoard.abin".into(), + self.identities[0].clone(), + )?; + result.merge(AnimationMetadata::from_bank( + &self.banks[1], + "OffBoard.abin".into(), + self.identities[1].clone(), + )?)?; + Ok(result) + } +} diff --git a/crates/skate-data/src/animation_frames.rs b/crates/skate-data/src/animation_frames.rs new file mode 100644 index 00000000..0070f415 --- /dev/null +++ b/crates/skate-data/src/animation_frames.rs @@ -0,0 +1,252 @@ +//! Decoded stock samples. Playback/interpolation and reference composition are +//! separate core operations; this loader never repairs missing bone channels. +use crate::animation_metadata::AnimationMetadata; +use serde::Deserialize; +use std::{collections::BTreeMap, fs, path::Path}; +mod native; + +/// Scale xyz, quaternion xyzw, translation xyz; each component is an f32 word. +pub type SampleWords = [u32; 10]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClipFrames { + pub name: String, + pub source_offset: u64, + pub fps_bits: u32, + pub loop_translation_bits: [u32; 3], + pub loop_rotation_bits: [u32; 4], + pub channel_animation: bool, + pub channel_weights: Vec, + pub frames: Vec>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReferencePose { + pub name: String, + pub source_offset: u64, + pub samples: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + version: u32, + source_sha256: String, + decoder_sha256: String, + decoder_hardware_verified: bool, + parents: Vec, + bone_names: Vec, + mirror_indices: Vec, + has_trajectory: bool, + clips: Vec, + reference_poses: Vec, +} + +pub struct AnimationFrames { + pub source_sha256: String, + pub decoder_sha256: String, + pub decoder_hardware_verified: bool, + pub parents: Vec, + pub bone_names: Vec, + pub mirror_indices: Vec, + pub has_trajectory: bool, + clips: BTreeMap, + reference_poses: BTreeMap, + native: Option, +} + +impl AnimationFrames { + pub fn from_banks(banks: &crate::animation_banks::AnimationBanks) -> Result { + let hierarchy = banks.banks[0] + .hierarchy() + .ok_or("Missing animation hierarchy")?; + Ok(Self { + source_sha256: banks.identities[0].clone(), + decoder_sha256: String::new(), + decoder_hardware_verified: false, + parents: hierarchy.parents.clone(), + bone_names: hierarchy.bone_names.clone(), + mirror_indices: hierarchy.mirrors.clone(), + has_trajectory: hierarchy.has_trajectory, + clips: BTreeMap::new(), + reference_poses: BTreeMap::new(), + native: Some(native::NativeFrames::new(banks)?), + }) + } + pub fn load(path: &Path, metadata: &AnimationMetadata) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("Cannot read {}: {error}", path.display()))?; + Self::parse(&text, metadata).map_err(|error| format!("{}: {error}", path.display())) + } + + pub fn parse(text: &str, metadata: &AnimationMetadata) -> Result { + let file: File = serde_json::from_str(text).map_err(|error| error.to_string())?; + if file.version != 3 || file.source_sha256 != metadata.source_sha256 { + return Err( + "Animation sample format or stock bank identity differs from metadata".into(), + ); + } + if file.decoder_sha256.len() != 64 + || !file.decoder_sha256.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err("Animation sample decoder identity is invalid".into()); + } + let bones = file.parents.len(); + if bones == 0 || bones > 255 { + return Err("Animation sample hierarchy has invalid bone count".into()); + } + for (index, &parent) in file.parents.iter().enumerate() { + if parent < -1 || parent >= index as i32 { + return Err(format!("Animation bone{index} has invalid parent{parent}")); + } + } + if file.bone_names.len() != bones || file.mirror_indices.len() != bones { + return Err("Animation hierarchy names/mirrors are incomplete".into()); + } + let mut names = std::collections::BTreeSet::new(); + for (i, (&partner, name)) in file.mirror_indices.iter().zip(&file.bone_names).enumerate() { + if name.is_empty() + || !names.insert(name) + || partner < -1 + || partner >= bones as i32 + || (partner >= 0 && file.mirror_indices[partner as usize] != i as i32) + { + return Err(format!( + "Animation bone{i} has an invalid name/mirror mapping" + )); + } + } + let mut clips = BTreeMap::new(); + for clip in file.clips { + let source = metadata.clip(&clip.name)?; + if source.source_offset != clip.source_offset + || source.fps_bits != clip.fps_bits + || f32::from_bits(source.frames_bits) != clip.frames.len() as f32 + || clip.channel_animation != (source.flags_word & (1 << 29) != 0) + { + return Err(format!( + "{}: decoded samples disagree with stock clip header", + clip.name + )); + } + if clip.channel_weights.len() != bones + || clip + .channel_weights + .iter() + .any(|&word| !f32::from_bits(word).is_finite()) + || clip + .loop_translation_bits + .iter() + .chain(&clip.loop_rotation_bits) + .any(|&word| !f32::from_bits(word).is_finite()) + { + return Err(format!( + "{}: invalid loop transform or missing channel weights", + clip.name + )); + } + for frame in &clip.frames { + validate_samples(frame, bones)?; + } + let name = clip.name.clone(); + if clips.insert(name.clone(), clip).is_some() { + return Err(format!("Duplicate decoded clip{name}")); + } + } + let mut reference_poses = BTreeMap::new(); + for pose in file.reference_poses { + validate_samples(&pose.samples, bones)?; + if !matches!( + pose.name.as_str(), + "RIG_TPOSE" | "BOARD_BACKWARDS" | "BOARD_BACKWARDS_IK" + ) || reference_poses.insert(pose.source_offset, pose).is_some() + { + return Err("Invalid or duplicated reference-pose source record".into()); + } + } + Ok(Self { + source_sha256: file.source_sha256, + decoder_sha256: file.decoder_sha256, + decoder_hardware_verified: file.decoder_hardware_verified, + parents: file.parents, + bone_names: file.bone_names, + mirror_indices: file.mirror_indices, + has_trajectory: file.has_trajectory, + clips, + reference_poses, + native: None, + }) + } + + pub fn clip(&self, name: &str) -> Result<&ClipFrames, String> { + if let Some(native) = &self.native { + return native.clip(name); + } + self.clips + .get(&name.to_ascii_uppercase()) + .ok_or_else(|| format!("Required decoded stock clip is absent: {name}")) + } + + /// Explicit source-record lookup, useful when auditing original bank data. + pub fn reference_pose(&self, source_offset: u64) -> Result<&ReferencePose, String> { + if let Some(native) = &self.native { + return native.reference_pose(0, source_offset); + } + self.reference_poses + .get(&source_offset) + .ok_or_else(|| format!("Required reference-pose record{source_offset:#x} is absent")) + } + + /// SetDBContent82D1B45C assigns into the selected database's name map, + /// replacing earlier same-name records in that bank. + pub fn named_pose(&self, name: &str) -> Result<&ReferencePose, String> { + self.named_pose_in(0, name) + } + pub fn named_pose_in(&self, bank: usize, name: &str) -> Result<&ReferencePose, String> { + if let Some(native) = &self.native { + return native.named_pose(bank, name); + } + if bank != 0 { + return Err("Converted animation data contains only one bank".into()); + } + self.reference_poses + .values() + .rev() + .find(|pose| pose.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("Required stock reference pose is absent: {name}")) + } + + pub fn clip_count(&self) -> usize { + if let Some(native) = &self.native { + return native.clip_count(); + } + self.clips.len() + } + + pub fn clip_names(&self) -> Box + '_> { + if let Some(native) = &self.native { + return Box::new(native.clip_names()); + } + Box::new(self.clips.keys().map(String::as_str)) + } + pub fn clip_bank(&self, name: &str) -> Result { + if let Some(native) = &self.native { + return native.clip_bank(name); + } + self.clip(name).map(|_| 0) + } +} + +fn validate_samples(samples: &[SampleWords], bones: usize) -> Result<(), String> { + if samples.len() != bones + || samples + .iter() + .flatten() + .any(|&word| !f32::from_bits(word).is_finite()) + { + return Err("Decoded stock frame is incomplete or nonfinite".into()); + } + Ok(()) +} diff --git a/crates/skate-data/src/animation_frames/native.rs b/crates/skate-data/src/animation_frames/native.rs new file mode 100644 index 00000000..30db4f1d --- /dev/null +++ b/crates/skate-data/src/animation_frames/native.rs @@ -0,0 +1,231 @@ +//! Bank-backed lazy animation samples. Each requested clip is decoded once; +//! registration indexes every record without allocating every decoded frame. +use super::{ClipFrames, ReferencePose, SampleWords, validate_samples}; +use crate::{ + abin::{Bank, Codec, PartEntry, Reader, RecordData}, + animation_banks::AnimationBanks, +}; +use skate_core::animation::vbr::VbrDecoder; +use std::{ + collections::BTreeMap, + sync::{Arc, OnceLock}, +}; + +struct Entry { + bank: usize, + offset: usize, + decoded: OnceLock>, +} +impl Entry { + fn new(bank: usize, offset: usize) -> Self { + Self { + bank, + offset, + decoded: OnceLock::new(), + } + } +} +pub(super) struct NativeFrames { + banks: Vec>, + clips: BTreeMap>, + poses: BTreeMap<(usize, u64), Entry>, + pose_names: BTreeMap<(usize, String), u64>, +} +impl NativeFrames { + pub(super) fn new(banks: &AnimationBanks) -> Result { + let mut clips: BTreeMap> = BTreeMap::new(); + let mut poses = BTreeMap::new(); + let mut pose_names = BTreeMap::new(); + for (bank_index, bank) in banks.banks.iter().enumerate() { + for record in bank.records() { + let name = &record.header.name; + let at = record.header.offset; + match &record.data { + RecordData::Clip(_) => { + if clips + .get(name) + .is_some_and(|entry| entry.bank != bank_index) + { + return Err(format!( + "Ambiguous clip {name} in multiple animation banks" + )); + } + clips.insert(name.clone(), Entry::new(bank_index, at)); + } + RecordData::Pose(_) => { + poses.insert((bank_index, at as u64), Entry::new(bank_index, at)); + pose_names.insert((bank_index, name.clone()), at as u64); + } + _ => {} + } + } + } + Ok(Self { + banks: banks.banks.clone(), + clips, + poses, + pose_names, + }) + } + pub(super) fn clip_count(&self) -> usize { + self.clips.len() + } + pub(super) fn clip_names(&self) -> impl Iterator { + self.clips.keys().map(String::as_str) + } + pub(super) fn clip_bank(&self, name: &str) -> Result { + self.clips + .get(&name.to_ascii_uppercase()) + .map(|e| e.bank) + .ok_or_else(|| format!("Missing stock animation clip {name}")) + } + pub(super) fn clip(&self, name: &str) -> Result<&ClipFrames, String> { + let entry = self + .clips + .get(&name.to_ascii_uppercase()) + .ok_or_else(|| format!("Missing stock animation clip {name}"))?; + entry + .decoded + .get_or_init(|| decode_clip(&self.banks[entry.bank], entry.offset)) + .as_ref() + .map_err(Clone::clone) + } + pub(super) fn named_pose(&self, bank: usize, name: &str) -> Result<&ReferencePose, String> { + let at = self + .pose_names + .get(&(bank, name.to_ascii_uppercase())) + .ok_or_else(|| format!("Missing reference pose {name} in bank {bank}"))?; + self.reference_pose(bank, *at) + } + pub(super) fn reference_pose( + &self, + bank: usize, + offset: u64, + ) -> Result<&ReferencePose, String> { + let entry = self + .poses + .get(&(bank, offset)) + .ok_or_else(|| format!("Missing reference pose {bank}:{offset:#x}"))?; + entry + .decoded + .get_or_init(|| decode_pose(&self.banks[bank], entry.offset)) + .as_ref() + .map_err(Clone::clone) + } +} + +fn decode_clip(bank: &Bank, offset: usize) -> Result { + let record = bank.record_at(offset).ok_or("Missing clip source record")?; + let RecordData::Clip(clip) = &record.data else { + return Err("Source record is not a clip".into()); + }; + require_vbr(record.header.codec())?; + let count = f32::from_bits(clip.frame_count_bits); + if !count.is_finite() || count < 1.0 || count.fract() != 0.0 { + return Err(format!("{}: invalid frame count", record.header.name)); + } + let frames = decode_parts(bank, &clip.parts, count as usize) + .map_err(|e| format!("{}: {e}", record.header.name))?; + let bones = bank.hierarchy().ok_or("No bank hierarchy")?.bone_count as usize; + let mut weights = vec![1.0f32.to_bits(); bones]; + if clip.channel_animation() { + let reader = Reader::new(bank.bytes()); + for (entry, layout) in clip.parts.iter().zip(&bank.hierarchy().unwrap().parts) { + let part = entry + .part + .as_ref() + .ok_or("Channel animation part is absent")?; + let range = part + .channel_weights + .as_ref() + .ok_or("Channel animation weights are absent")?; + for lane in 0..layout.bone_count as usize { + let word = reader + .u32(range.start + lane * 4) + .map_err(|e| e.to_string())?; + if !f32::from_bits(word).is_finite() { + return Err("Nonfinite channel weight".into()); + } + weights[layout.sqt_offset as usize + lane] = word; + } + } + } + Ok(ClipFrames { + name: record.header.name.clone(), + source_offset: offset as u64, + fps_bits: clip.fps_bits, + loop_translation_bits: clip.loop_translation_words[..3].try_into().unwrap(), + loop_rotation_bits: clip.loop_rotation_words, + channel_animation: clip.channel_animation(), + channel_weights: weights, + frames, + }) +} +fn decode_pose(bank: &Bank, offset: usize) -> Result { + let record = bank.record_at(offset).ok_or("Missing pose source record")?; + let RecordData::Pose(pose) = &record.data else { + return Err("Source record is not a pose".into()); + }; + require_vbr(record.header.codec())?; + let mut frames = + decode_parts(bank, &pose.parts, 1).map_err(|e| format!("{}: {e}", record.header.name))?; + Ok(ReferencePose { + name: record.header.name.clone(), + source_offset: offset as u64, + samples: frames.remove(0), + }) +} +fn require_vbr(codec: Codec) -> Result<(), String> { + if codec != Codec::Vbr { + return Err(format!("Unsupported native animation codec {codec:?}")); + } + Ok(()) +} +fn decode_parts( + bank: &Bank, + parts: &[PartEntry], + count: usize, +) -> Result>, String> { + let hierarchy = bank.hierarchy().ok_or("Missing stock hierarchy")?; + let bones = hierarchy.bone_count as usize; + if parts.len() != hierarchy.parts.len() { + return Err("Animation part count differs from hierarchy".into()); + } + let mut frames = vec![vec![[0; 10]; bones]; count]; + let mut assigned = vec![false; bones]; + for (entry, layout) in parts.iter().zip(&hierarchy.parts) { + let part = entry + .part + .as_ref() + .ok_or("Required stock animation part is absent")?; + let mut decoder = VbrDecoder::new( + bank.bytes(), + part.offset, + part.compression_header_relative as usize, + part.compressed_data_relative as usize, + )?; + if decoder.frame_count() != count || decoder.channel_count() != layout.bone_count as usize { + return Err(format!( + "{}: codec frame/channel count disagrees with bank header", + layout.name + )); + } + let start = + usize::try_from(layout.sqt_offset).map_err(|_| "Negative animation part offset")?; + let end = start + layout.bone_count as usize; + if end > bones || assigned[start..end].iter().any(|b| *b) { + return Err("Overlapping animation parts".into()); + } + assigned[start..end].fill(true); + for (index, frame) in frames.iter_mut().enumerate() { + frame[start..end].copy_from_slice(&decoder.decode_frame(index)?); + } + } + if assigned.iter().any(|v| !*v) { + return Err("Incomplete animation hierarchy channels".into()); + } + for frame in &frames { + validate_samples(frame, bones)?; + } + Ok(frames) +} diff --git a/crates/skate-data/src/animation_metadata.rs b/crates/skate-data/src/animation_metadata.rs new file mode 100644 index 00000000..11aa78ba --- /dev/null +++ b/crates/skate-data/src/animation_metadata.rs @@ -0,0 +1,376 @@ +//! Runtime clip metadata read directly from the stock Andale bank. +//! Clock/attribute evaluation belongs to core/game; this loader preserves bits +//! and record order and rejects malformed or ambiguous source data. +use serde::Deserialize; +use std::{collections::BTreeMap, fs, path::Path}; +mod native; +mod selection_space; +pub use selection_space::{ + SelectionCandidateMetadata, SelectionParameterMetadata, SelectionSpaceMetadata, +}; + +/// Bank-relative record offsets are meaningful only with this source identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BankSource { + pub source_bank: String, + pub source_sha256: String, + pub source_bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClipAttribute { + pub name: String, + pub type_id: u8, + pub begin_bits: u32, + pub end_bits: u32, + /// Raw aligned payload, including opaque curve data and padding. Consumers + /// select the lanes written by Attribute::Init for this type. + pub payload_words: Vec, + pub source_offset: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClipMetadata { + pub name: String, + pub source_offset: u64, + pub fps_bits: u32, + pub frames_bits: u32, + pub base_speed_bits: u32, + pub flags_word: u32, + pub attributes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhaseBlendMetadata { + pub name: String, + pub source_offset: u64, + pub parameter: String, + pub children: Vec, +} +pub enum TreeMetadata<'a> { + Clip(&'a ClipMetadata), + PhaseBlend(&'a PhaseBlendMetadata), + Selector(&'a SelectorMetadata), + SelectionSpace(&'a SelectionSpaceMetadata), +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectorMetadata { + pub name: String, + pub source_offset: u64, + pub parameter: String, + pub default: String, + pub children: Vec, + pub values: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct UnsupportedTree { + name: String, + source_offset: u64, + type_id: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + version: u32, + source_bank: String, + source_sha256: String, + source_bytes: u64, + clips: Vec, + #[serde(default)] + phase_blends: Vec, + #[serde(default)] + selectors: Vec, + #[serde(default)] + selection_spaces: Vec, + unsupported_trees: Vec, +} + +#[derive(Debug)] +pub struct AnimationMetadata { + pub source_bank: String, + pub source_sha256: String, + clips: BTreeMap>, + phase_blends: BTreeMap>, + selectors: BTreeMap>, + selection_spaces: BTreeMap>, + unsupported: BTreeMap>, + sources: Vec, + origins: BTreeMap, +} + +impl AnimationMetadata { + pub fn load(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|e| format!("Cannot read animation metadata {}: {e}", path.display()))?; + Self::parse(&text).map_err(|e| format!("{}: {e}", path.display())) + } + + pub fn parse(text: &str) -> Result { + let file: File = serde_json::from_str(text).map_err(|e| e.to_string())?; + Self::from_file(file) + } + + fn from_file(file: File) -> Result { + if file.version != 1 { + return Err(format!( + "Unsupported animation metadata version {}", + file.version + )); + } + if file.source_bank.is_empty() + || file.source_sha256.len() != 64 + || !file.source_sha256.bytes().all(|c| c.is_ascii_hexdigit()) + || file.source_bytes < 48 + { + return Err("Invalid animation bank source identity".into()); + } + let mut clips: BTreeMap> = BTreeMap::new(); + for clip in file.clips { + validate_name(&clip.name, 36)?; + let fps = f32::from_bits(clip.fps_bits); + let frames = f32::from_bits(clip.frames_bits); + let base = f32::from_bits(clip.base_speed_bits); + if !fps.is_finite() + || fps <= 0.0 + || !frames.is_finite() + || frames < 1.0 + || !base.is_finite() + || base <= 0.0 + || clip.source_offset >= file.source_bytes + { + return Err(format!("{}: invalid clip header", clip.name)); + } + let mut previous = clip.source_offset; + for attribute in &clip.attributes { + validate_name(&attribute.name, 30)?; + if !f32::from_bits(attribute.begin_bits).is_finite() + || !f32::from_bits(attribute.end_bits).is_finite() + || attribute.source_offset <= previous + || attribute.source_offset >= file.source_bytes + { + return Err(format!("{}: invalid attribute record/order", clip.name)); + } + let minimum = match attribute.type_id { + 0 => 1, + 1 => 4, + 3 => 6, + _ => 0, + }; + if attribute.payload_words.len() < minimum { + return Err(format!( + "{}: truncated {} payload", + clip.name, attribute.name + )); + } + previous = attribute.source_offset; + } + clips.entry(clip.name.clone()).or_default().push(clip); + } + let mut phase_blends: BTreeMap> = BTreeMap::new(); + for tree in file.phase_blends { + validate_name(&tree.name, 36)?; + validate_name(&tree.parameter, 30)?; + if tree.children.len() < 2 { + return Err(format!("{}: PhaseBlend needs two children", tree.name)); + } + if tree.source_offset >= file.source_bytes { + return Err(format!("{}: tree offset outside bank", tree.name)); + } + for child in &tree.children { + validate_name(child, 36)?; + } + phase_blends + .entry(tree.name.clone()) + .or_default() + .push(tree); + } + let mut selectors: BTreeMap> = BTreeMap::new(); + for tree in file.selectors { + validate_name(&tree.name, 36)?; + validate_name(&tree.parameter, 30)?; + validate_name(&tree.default, 36)?; + if tree.children.len() != tree.values.len() { + return Err(format!("{}: selector key/child count mismatch", tree.name)); + } + if tree.source_offset >= file.source_bytes { + return Err(format!("{}: tree offset outside bank", tree.name)); + } + for child in &tree.children { + validate_name(child, 36)?; + } + for value in &tree.values { + validate_name(value, 30)?; + } + selectors.entry(tree.name.clone()).or_default().push(tree); + } + let mut selection_spaces: BTreeMap> = BTreeMap::new(); + for tree in file.selection_spaces { + tree.validate(file.source_bytes)?; + selection_spaces + .entry(tree.name.clone()) + .or_default() + .push(tree); + } + let mut unsupported: BTreeMap> = BTreeMap::new(); + for tree in file.unsupported_trees { + validate_name(&tree.name, 36)?; + if tree.source_offset >= file.source_bytes { + return Err(format!("{}: tree offset outside bank", tree.name)); + } + unsupported + .entry(tree.name) + .or_default() + .push((tree.source_offset, tree.type_id)); + } + let origins = clips + .keys() + .chain(phase_blends.keys()) + .chain(selectors.keys()) + .chain(selection_spaces.keys()) + .chain(unsupported.keys()) + .map(|name| (name.clone(), 0)) + .collect(); + let sources = vec![BankSource { + source_bank: file.source_bank.clone(), + source_sha256: file.source_sha256.clone(), + source_bytes: file.source_bytes, + }]; + Ok(Self { + source_bank: file.source_bank, + source_sha256: file.source_sha256, + clips, + phase_blends, + selectors, + selection_spaces, + unsupported, + sources, + origins, + }) + } + + /// Combine disjoint animation namespaces without comparing offsets from + /// different files. Same-bank duplicates still obey native last-record wins. + /// All collision checks precede mutation; the primary rig identity remains. + pub fn merge(&mut self, other: Self) -> Result<(), String> { + if let Some(name) = other + .origins + .keys() + .find(|name| self.origins.contains_key(*name)) + { + return Err(format!("Animation bank merge collides at {name}")); + } + let base = self.sources.len(); + self.origins.extend( + other + .origins + .into_iter() + .map(|(name, index)| (name, base + index)), + ); + self.sources.extend(other.sources); + self.clips.extend(other.clips); + self.phase_blends.extend(other.phase_blends); + self.selectors.extend(other.selectors); + self.selection_spaces.extend(other.selection_spaces); + self.unsupported.extend(other.unsupported); + Ok(()) + } + + pub fn sources(&self) -> &[BankSource] { + &self.sources + } + + pub fn source_for(&self, name: &str) -> Option<&BankSource> { + self.origins + .get(&name.to_ascii_uppercase()) + .map(|&index| &self.sources[index]) + } + + /// The serialized names use FastString's canonical uppercase alphabet. + /// The API accepts only that alphabet plus lowercase ASCII, so uppercase + /// lookup is equivalent to native FastString encoding for accepted inputs. + pub fn clip(&self, name: &str) -> Result<&ClipMetadata, String> { + let name = name.to_ascii_uppercase(); + validate_name(&name, 36)?; + // SetDBContent82D1B428..434 updates its separate clip map on every + // clip record; repeated keys overwrite with the later bank record. + self.clips + .get(&name) + .and_then(|clips| clips.iter().max_by_key(|c| c.source_offset)) + .ok_or_else(|| format!("Missing direct clip {name}")) + } + pub fn tree(&self, name: &str) -> Result, String> { + let name = name.to_ascii_uppercase(); + validate_name(&name, 36)?; + let clips = self.clips.get(&name).map(Vec::as_slice).unwrap_or(&[]); + let trees = self + .unsupported + .get(&name) + .map(Vec::as_slice) + .unwrap_or(&[]); + let phase_blends = self + .phase_blends + .get(&name) + .map(Vec::as_slice) + .unwrap_or(&[]); + let selectors = self.selectors.get(&name).map(Vec::as_slice).unwrap_or(&[]); + let selection_spaces = self + .selection_spaces + .get(&name) + .map(Vec::as_slice) + .unwrap_or(&[]); + // TU382D1B438..45C inserts into the tree map in file order and writes + // the record pointer even when map[]82D27320 finds an existing key. + let last = clips + .iter() + .map(|c| c.source_offset) + .chain(phase_blends.iter().map(|t| t.source_offset)) + .chain(selectors.iter().map(|t| t.source_offset)) + .chain(selection_spaces.iter().map(|t| t.source_offset)) + .chain(trees.iter().map(|t| t.0)) + .max(); + if let Some(clip) = clips.iter().find(|c| Some(c.source_offset) == last) { + return Ok(TreeMetadata::Clip(clip)); + } + if let Some(tree) = phase_blends.iter().find(|t| Some(t.source_offset) == last) { + return Ok(TreeMetadata::PhaseBlend(tree)); + } + if let Some(tree) = selectors.iter().find(|t| Some(t.source_offset) == last) { + return Ok(TreeMetadata::Selector(tree)); + } + if let Some(tree) = selection_spaces + .iter() + .find(|t| Some(t.source_offset) == last) + { + return Ok(TreeMetadata::SelectionSpace(tree)); + } + if let Some((_, kind)) = trees.iter().find(|t| Some(t.0) == last) { + return Err(format!( + "Animation {name} is tree type {kind}; tree evaluation is required" + )); + } + Err(format!("Missing animation {name} in {}", self.source_bank)) + } + + pub fn clip_count(&self) -> usize { + self.clips.values().map(Vec::len).sum() + } +} + +fn validate_name(name: &str, length: usize) -> Result<(), String> { + if name.is_empty() + || name.len() > length + || !name + .bytes() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_') + { + return Err(format!("Invalid canonical FastString name {name:?}")); + } + Ok(()) +} diff --git a/crates/skate-data/src/animation_metadata/native.rs b/crates/skate-data/src/animation_metadata/native.rs new file mode 100644 index 00000000..cc914528 --- /dev/null +++ b/crates/skate-data/src/animation_metadata/native.rs @@ -0,0 +1,153 @@ +//! Lossless original ABIN metadata layouts. No pose codec or numerical decoder. +//! Clip header827B8AB0, attribute82D164F8, tree lookup82D1B5B8. +use super::{ + AnimationMetadata, ClipAttribute, ClipMetadata, File, PhaseBlendMetadata, SelectorMetadata, + UnsupportedTree, +}; +use crate::abin::{Bank, Clip, Error, Reader, RecordData, RecordHeader, Result}; + +impl AnimationMetadata { + /// Read the retained bytes without constructing or evaluating animation + /// trees. Caller supplies the independently calculated bank SHA-256. + pub fn from_bank( + bank: &Bank, + source_bank: String, + source_sha256: String, + ) -> std::result::Result { + let mut file = File { + version: 1, + source_bank, + source_sha256, + source_bytes: bank.bytes().len() as u64, + clips: Vec::new(), + phase_blends: Vec::new(), + selectors: Vec::new(), + selection_spaces: Vec::new(), + unsupported_trees: Vec::new(), + }; + let reader = Reader::new(bank.bytes()); + for record in bank.records() { + let h = &record.header; + let r = reader.bounded(h.range()).map_err(|e| e.to_string())?; + match &record.data { + RecordData::Clip(clip) => file + .clips + .push(read_clip(r, h, clip).map_err(|e| e.to_string())?), + RecordData::Pose(_) | RecordData::Hierarchy(_) | RecordData::PhysicsPose(_) => {} + RecordData::Opaque => match h.type_id { + 7 => file + .phase_blends + .push(read_phase_blend(r, h).map_err(|e| e.to_string())?), + 8 => file + .selectors + .push(read_selector(r, h).map_err(|e| e.to_string())?), + 11 => file + .selection_spaces + .push(super::selection_space::read(r, h).map_err(|e| e.to_string())?), + kind => file.unsupported_trees.push(UnsupportedTree { + name: h.name.clone(), + source_offset: h.offset as u64, + type_id: kind, + }), + }, + } + } + Self::from_file(file) + } +} + +fn read_clip(r: Reader<'_>, h: &RecordHeader, clip: &Clip) -> Result { + let mut at = clip.attribute_offset; + let mut attributes = Vec::with_capacity(clip.attribute_count as usize); + for _ in 0..clip.attribute_count { + // Original GetAttribute82D25CA0 advances by the record's first word. + let size = r.u32(at)? as usize; + if size < 48 { + return Err(Error::new(at, "attribute record smaller than header")); + } + let end = at + .checked_add(size) + .ok_or_else(|| Error::new(at, "attribute size overflow"))?; + let a = r.bounded(at..end)?; + let data = at + .checked_add(51) + .ok_or_else(|| Error::new(at, "attribute alignment overflow"))? + & !15; + let payload = a.take( + data, + end.checked_sub(data) + .ok_or_else(|| Error::new(at, "attribute payload outside record"))?, + )?; + if payload.len() % 4 != 0 { + return Err(Error::new(at, "unaligned attribute payload")); + } + attributes.push(ClipAttribute { + name: a.name(at + 12, 5)?, + type_id: a.u8(at + 32)?, + begin_bits: a.u32(at + 4)?, + end_bits: a.u32(at + 8)?, + payload_words: payload + .chunks_exact(4) + .map(|word| u32::from_be_bytes(word.try_into().unwrap())) + .collect(), + source_offset: at as u64, + }); + at = end; + } + Ok(ClipMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + fps_bits: clip.fps_bits, + frames_bits: clip.frame_count_bits, + base_speed_bits: clip.base_speed_bits, + flags_word: clip.flags, + attributes, + }) +} + +fn read_phase_blend(r: Reader<'_>, h: &RecordHeader) -> Result { + // Type7 construction82D1B5B8: count+8, parameter+16, child names+36. + let p = h.payload_offset; + let count = r.u32(p + 8)? as usize; + let size = count + .checked_mul(24) + .ok_or_else(|| Error::new(p, "PhaseBlend child count overflow"))?; + r.take(p + 36, size)?; + let children = (0..count) + .map(|i| r.name(p + 36 + 24 * i, 6)) + .collect::>>()?; + Ok(PhaseBlendMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + parameter: r.name(p + 16, 5)?, + children, + }) +} + +fn read_selector(r: Reader<'_>, h: &RecordHeader) -> Result { + // Type8 construction82D1B5B8 keeps the first name as authored default. + // Child/value order matters: runtime chooses the first matching value. + let p = h.payload_offset; + let count = r.u32(p + 28)? as usize; + let names_size = count + .max(1) + .checked_mul(24) + .ok_or_else(|| Error::new(p, "Selector child count overflow"))?; + let keys_size = count + .checked_mul(20) + .ok_or_else(|| Error::new(p, "Selector key count overflow"))?; + let names = r.relative(p, r.u32(p + 20)?, names_size)?.start; + let keys = r.relative(p, r.u32(p + 24)?, keys_size)?.start; + Ok(SelectorMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + parameter: r.name(p, 5)?, + default: r.name(names, 6)?, + children: (0..count) + .map(|i| r.name(names + 24 * i, 6)) + .collect::>>()?, + values: (0..count) + .map(|i| r.name(keys + 20 * i, 5)) + .collect::>>()?, + }) +} diff --git a/crates/skate-data/src/animation_metadata/selection_space.rs b/crates/skate-data/src/animation_metadata/selection_space.rs new file mode 100644 index 00000000..cdc204ad --- /dev/null +++ b/crates/skate-data/src/animation_metadata/selection_space.rs @@ -0,0 +1,111 @@ +//! Type11 layout from original 82D1BEA0, 82D26CC0 and 82D26FF8. +use super::validate_name; +use crate::abin::{Error, Reader, RecordHeader, Result}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectionParameterMetadata { + pub name: String, + pub mode: u32, + pub weight_bits: u32, + pub minimum_bits: u32, + pub maximum_bits: u32, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectionCandidateMetadata { + pub child: String, + pub value_bits: Vec, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectionSpaceMetadata { + pub name: String, + pub source_offset: u64, + pub parameters: Vec, + pub candidates: Vec, +} +impl SelectionSpaceMetadata { + pub(super) fn validate(&self, bytes: u64) -> std::result::Result<(), String> { + validate_name(&self.name, 36)?; + if self.source_offset >= bytes || self.parameters.len() > 10 || self.candidates.is_empty() { + return Err(format!("{}: invalid selection space dimensions", self.name)); + } + for p in &self.parameters { + validate_name(&p.name, 30)?; + if [p.weight_bits, p.minimum_bits, p.maximum_bits] + .into_iter() + .any(|v| !f32::from_bits(v).is_finite()) + { + return Err(format!("{}: nonfinite selection metric", self.name)); + } + } + for c in &self.candidates { + validate_name(&c.child, 36)?; + if c.value_bits.len() != self.parameters.len() + || c.value_bits.iter().any(|v| !f32::from_bits(*v).is_finite()) + { + return Err(format!("{}: invalid selection candidate", self.name)); + } + } + Ok(()) + } +} +pub(super) fn read(r: Reader<'_>, h: &RecordHeader) -> Result { + let p = h.payload_offset; + let count = r.u32(p)? as usize; + if count > 10 { + return Err(Error::new( + p, + "selection parameter array exceeds native capacity", + )); + } + let parameters_at = r.relative(p, r.u32(p + 4)?, count * 40)?.start; + let candidates_count = r.u32(p + 8)? as usize; + let candidates_at = r.relative(p, r.u32(p + 12)?, 4)?.start; + let stride = r.u32(candidates_at)? as usize; + let size = stride + .checked_mul(candidates_count) + .ok_or_else(|| Error::new(p, "selection candidate size overflow"))?; + r.take(candidates_at, size)?; + if stride < 32 { + return Err(Error::new( + candidates_at, + "selection candidate header too small", + )); + } + let parameters = (0..count) + .map(|i| { + let at = parameters_at + i * 40; + Ok(SelectionParameterMetadata { + name: r.name(at, 6)?, + mode: r.u32(at + 24)?, + weight_bits: r.u32(at + 28)?, + minimum_bits: r.u32(at + 32)?, + maximum_bits: r.u32(at + 36)?, + }) + }) + .collect::>()?; + let candidates = (0..candidates_count) + .map(|i| { + let at = candidates_at + i * stride; + let child_reader = r.bounded(at..at + stride)?; + let values = child_reader + .relative(at, child_reader.u32(at + 28)?, count * 4)? + .start; + Ok(SelectionCandidateMetadata { + child: child_reader.name(at + 4, 6)?, + value_bits: (0..count) + .map(|j| child_reader.u32(values + j * 4)) + .collect::>()?, + }) + }) + .collect::>()?; + Ok(SelectionSpaceMetadata { + name: h.name.clone(), + source_offset: h.offset as u64, + parameters, + candidates, + }) +} diff --git a/crates/skate-data/src/collections.rs b/crates/skate-data/src/collections.rs new file mode 100644 index 00000000..9d45c95c --- /dev/null +++ b/crates/skate-data/src/collections.rs @@ -0,0 +1,137 @@ +//! Typed access to converted stock skater XML values, without engine objects. +use serde::Deserialize; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Debug, Deserialize)] +pub struct Field { + #[serde(rename = "type")] + pub type_name: String, + pub data: String, +} + +#[derive(Debug, Deserialize)] +pub struct Collection { + #[serde(rename = "class")] + pub class_name: String, + pub key: String, + pub parent: String, + pub fields: BTreeMap, + pub source: String, + pub sha256: String, +} + +#[derive(Debug, Deserialize)] +pub struct Collections { + version: u32, + collections: Vec, +} + +impl Collections { + pub fn entries(&self) -> &[Collection] { + &self.collections + } + + pub fn load(asset_root: &Path) -> Result { + let path = asset_root.join("private/stock/skater-collections.json"); + let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let data: Self = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if data.version != 1 { + return Err(format!( + "Unsupported skater collections version {}", + data.version + )); + } + let mut identities = std::collections::BTreeSet::new(); + for item in &data.collections { + if !identities.insert((&item.class_name, &item.key)) { + return Err(format!( + "Duplicate collection {}/{}", + item.class_name, item.key + )); + } + } + Ok(data) + } + + pub fn field(&self, class: &str, key: &str, name: &str) -> Result<&Field, String> { + let mut current = key; + for _ in 0..=self.collections.len() { + let item = self + .collections + .iter() + .find(|c| c.class_name == class && c.key == current) + .ok_or_else(|| format!("Missing stock collection {class}/{current}"))?; + if let Some(field) = item.fields.get(name) { + return Ok(field); + } + if item.parent.is_empty() { + return Err(format!("Missing stock field {class}/{key}/{name}")); + } + current = &item.parent; + } + Err(format!("Cyclic stock collection inheritance {class}/{key}")) + } + + pub fn float(&self, class: &str, key: &str, name: &str) -> Result { + let field = self.field(class, key, name)?; + if field.type_name != "EA::Reflection::Float" { + return Err(format!("Expected float at {class}/{key}/{name}")); + } + let words = decode_words::<1>(&field.data)?; + let value = f32::from_bits(words[0]); + if !value.is_finite() { + return Err(format!("Non-finite stock float {class}/{key}/{name}")); + } + Ok(value) + } + + pub fn integer(&self, class: &str, key: &str, name: &str) -> Result { + let field = self.field(class, key, name)?; + if !matches!( + field.type_name.as_str(), + "EA::Reflection::Int32" | "EA::Reflection::UInt32" + ) { + return Err(format!("Expected integer at {class}/{key}/{name}")); + } + Ok(decode_words::<1>(&field.data)?[0]) + } + + pub fn boolean(&self, class: &str, key: &str, name: &str) -> Result { + let field = self.field(class, key, name)?; + if field.type_name != "EA::Reflection::Bool" { + return Err(format!("Expected boolean at {class}/{key}/{name}")); + } + // Attributes store the byte followed by padding; layout bools may + // contain only the byte. Reading a big-endian u32 gives the wrong bit. + match field.data.get(..2) { + Some("00") => Ok(false), + Some("01") => Ok(true), + _ => Err(format!("Invalid stock boolean {class}/{key}/{name}")), + } + } + + pub fn words( + &self, + class: &str, + key: &str, + name: &str, + ) -> Result<[u32; N], String> { + decode_words(&self.field(class, key, name)?.data) + } +} + +fn decode_words(text: &str) -> Result<[u32; N], String> { + let hex: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + if hex.len() != N * 8 || !hex.is_ascii() { + return Err(format!( + "Expected {N} big-endian words, found {} bytes of hex", + hex.len() + )); + } + let mut words = [0; N]; + for (i, word) in words.iter_mut().enumerate() { + *word = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16) + .map_err(|e| format!("Invalid collection payload: {e}"))?; + } + Ok(words) +} diff --git a/crates/skate-data/src/input_config.rs b/crates/skate-data/src/input_config.rs new file mode 100644 index 00000000..be919b2f --- /dev/null +++ b/crates/skate-data/src/input_config.rs @@ -0,0 +1,100 @@ +//! Compatibility check for the stock gameplay expressions specialized by +//! skate-core's GameplayActions. This is not the full native expression VM. +use crate::AssetError; +use std::{collections::BTreeMap, fs, path::Path}; + +pub const STOCK_INPUT_PATH: &str = "private/stock/data/config/input.cfg"; + +pub struct StockGameplayConfig; + +impl StockGameplayConfig { + pub fn load(asset_root: &Path) -> Result { + let path = asset_root.join(STOCK_INPUT_PATH); + let source = fs::read_to_string(&path) + .map_err(|error| AssetError(format!("Cannot read {}: {error}", path.display())))?; + Self::validate(&source) + .map_err(|error| AssetError(format!("Input configuration {}: {error}", path.display()))) + } + + /// Require the actual file to agree with the supported native mapping. + /// A modified expression fails explicitly instead of silently running the + /// stock specialization against different user data. + pub fn validate(source: &str) -> Result { + let source: String = source + .lines() + .map(|line| line.split("//").next().unwrap_or_default()) + .collect::>() + .join("\n"); + let mut definitions = BTreeMap::new(); + for statement in source.split(';') { + let Some((name, expression)) = statement.split_once('=') else { + continue; + }; + let name = name.trim(); + if BUTTON_NAMES.contains(&name) || GAMEPLAY.iter().any(|(key, _)| *key == name) { + let expression: String = + expression.chars().filter(|c| !c.is_whitespace()).collect(); + if definitions.insert(name, expression).is_some() { + return Err(AssetError(format!( + "Duplicate gameplay input definition {name}" + ))); + } + } + } + for (index, name) in BUTTON_NAMES.iter().enumerate() { + require(&definitions, name, &format!("Button{index}"))?; + } + for (name, expression) in GAMEPLAY { + require(&definitions, name, expression)?; + } + Ok(Self) + } +} + +fn require( + definitions: &BTreeMap<&str, String>, + name: &str, + expected: &str, +) -> Result<(), AssetError> { + match definitions.get(name) { + Some(actual) if actual == expected => Ok(()), + Some(actual) => Err(AssetError(format!( + "Unsupported {name} = {actual}; the implemented stock mapping requires {expected}" + ))), + None => Err(AssetError(format!( + "Missing gameplay input definition {name}" + ))), + } +} + +const BUTTON_NAMES: [&str; 24] = [ + "DPadU", "DPadD", "DPadL", "DPadR", "Start", "Back", "LStick", "RStick", "LBumper", "RBumper", + "LTrigger", "RTrigger", "A", "B", "X", "Y", "LStickR", "LStickL", "LStickU", "LStickD", + "RStickR", "RStickL", "RStickU", "RStickD", +]; + +// TU3 82697740 registers these eighteen actions at indices 64..81. +const GAMEPLAY: [(&str, &str); 18] = [ + ("GP_LStickX", "LStickR-LStickL"), + ("GP_LStickY", "LStickU-LStickD"), + ("GP_LStickIn", "LStick"), + ("GP_RStickX", "RStickR-RStickL"), + ("GP_RStickY", "RStickU-RStickD"), + ("GP_RStickIn", "RStick"), + ("GP_LTrigger", "LTrigger"), + ("GP_RTrigger", "RTrigger"), + ("GP_LBumper", "LBumper"), + ("GP_RBumper", "RBumper"), + ("GP_UDPad", "DPadU"), + ("GP_DDPad", "DPadD"), + ("GP_LDPad", "DPadL"), + ("GP_RDPad", "DPadR"), + ("GP_XFace", "X"), + ("GP_YFace", "Y"), + ("GP_AFace", "A"), + ("GP_BFace", "B"), +]; + +#[cfg(test)] +#[path = "tests/input_config.rs"] +mod tests; diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs new file mode 100644 index 00000000..562e5065 --- /dev/null +++ b/crates/skate-data/src/lib.rs @@ -0,0 +1,13 @@ +//! Runtime stock data, validation and bank storage. Stateless codec math is in core. +#![forbid(unsafe_code)] +mod manifest; +pub use manifest::{AssetError, GameAssets}; +pub mod abin; +pub mod animation_banks; +pub mod animation_frames; +pub mod animation_metadata; +pub mod collections; +pub mod input_config; +pub mod physics_skeleton; +mod sha256; +pub mod state_graph; diff --git a/crates/skate-data/src/manifest.rs b/crates/skate-data/src/manifest.rs new file mode 100644 index 00000000..5c0f2681 --- /dev/null +++ b/crates/skate-data/src/manifest.rs @@ -0,0 +1,79 @@ +use serde::Deserialize; +use std::{ + fmt, fs, + path::{Component, Path, PathBuf}, +}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GameAssets { + pub version: u32, + pub character_scene: String, + pub initial_animation: String, + pub action_graph: String, + pub motion_graph: String, +} + +#[derive(Debug)] +pub struct AssetError(pub String); +impl fmt::Display for AssetError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for AssetError {} + +impl GameAssets { + pub fn load(root: &Path) -> Result { + let file = root.join("private/game.json"); + let json = fs::read_to_string(&file).map_err(|e| AssetError(format!("Cannot read {}: {e}. Run pipeline/Prepare-Assets.ps1 or select the correct --assets directory.", file.display())))?; + let manifest = Self::parse(&json)?; + for (kind, relative) in [ + ("Character scene", manifest.character_scene.as_str()), + ("ActionGraph", manifest.action_graph.as_str()), + ("MotionGraph", manifest.motion_graph.as_str()), + ] { + let path = root.join(relative); + if !path.is_file() { + return Err(AssetError(format!("{kind} missing: {}", path.display()))); + } + } + Ok(manifest) + } + + pub fn parse(json: &str) -> Result { + let manifest: Self = serde_json::from_str(json) + .map_err(|e| AssetError(format!("Invalid game asset manifest: {e}")))?; + if manifest.version != 1 { + return Err(AssetError(format!( + "Unsupported asset manifest version {}", + manifest.version + ))); + } + validate_path(&manifest.character_scene, "character_scene", "glb")?; + validate_path(&manifest.action_graph, "action_graph", "stategraph")?; + validate_path(&manifest.motion_graph, "motion_graph", "stategraph")?; + if manifest.initial_animation.trim().is_empty() { + return Err(AssetError( + "initial_animation must name a stock animation".into(), + )); + } + Ok(manifest) + } +} + +fn validate_path(value: &str, field: &str, extension: &str) -> Result<(), AssetError> { + let path = PathBuf::from(value); + if path.as_os_str().is_empty() + || !path.components().all(|c| matches!(c, Component::Normal(_))) + || value.contains([':', '\\', '#']) + { + return Err(AssetError(format!( + "{field} must be a relative asset path using forward slashes" + ))); + } + if path.extension().and_then(|x| x.to_str()) != Some(extension) { + return Err(AssetError(format!("{field} must name a .{extension} file"))); + } + Ok(()) +} diff --git a/crates/skate-data/src/physics_skeleton.rs b/crates/skate-data/src/physics_skeleton.rs new file mode 100644 index 00000000..7e022edc --- /dev/null +++ b/crates/skate-data/src/physics_skeleton.rs @@ -0,0 +1,64 @@ +//! Lossless stock physics-bone records. Core consumes typed values derived here. +use serde::Deserialize; +use std::{collections::BTreeSet, path::Path}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicsBone { + pub name: String, + pub source_offset: u64, + pub words: [u32; 28], +} +impl PhysicsBone { + pub fn size(&self) -> [f32; 3] { + [self.words[8], self.words[9], self.words[10]].map(f32::from_bits) + } + pub fn rotation(&self) -> [f32; 4] { + [self.words[12], self.words[13], self.words[14], self.words[15]].map(f32::from_bits) + } + pub fn translation(&self) -> [f32; 3] { + [self.words[16], self.words[17], self.words[18]].map(f32::from_bits) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicsSkeleton { + pub name: String, + pub source_offset: u64, + pub bones: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + version: u32, + source_sha256: String, + skeletons: Vec, +} + +impl PhysicsSkeleton { + pub fn load(path: &Path, bank_sha256: &str, name: &str) -> Result { + let bytes = std::fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?; + let file: File = serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + if file.version != 1 || file.source_sha256 != bank_sha256 { + return Err("Physics skeleton bank identity or format differs from animation data".into()); + } + let mut identities = BTreeSet::new(); + if file.skeletons.iter().any(|s| !identities.insert(&s.name)) { + return Err("Converted physics skeleton has unresolved duplicate names".into()); + } + let selected = file.skeletons.into_iter().find(|s| s.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("Required stock physics skeleton {name} is absent"))?; + let mut names = BTreeSet::new(); + if selected.bones.is_empty() || selected.bones.len() > 255 { + return Err("Invalid stock physical bone count".into()); + } + for bone in &selected.bones { + if !names.insert(&bone.name) || bone.size().iter().chain(&bone.rotation()).chain(&bone.translation()).any(|v| !v.is_finite()) { + return Err(format!("Invalid or repeated stock physics bone {}", bone.name)); + } + } + Ok(selected) + } +} diff --git a/crates/skate-data/src/sha256.rs b/crates/skate-data/src/sha256.rs new file mode 100644 index 00000000..7ee929b9 --- /dev/null +++ b/crates/skate-data/src/sha256.rs @@ -0,0 +1,79 @@ +//! SHA-256 source identity for private bank bytes (FIPS 180-4). +//! This is file integrity bookkeeping, separate from game arithmetic. +pub(crate) fn digest(bytes: &[u8]) -> String { + let mut state = [ + 0x6a09e667u32, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19, + ]; + let mut blocks = bytes.chunks_exact(64); + for block in &mut blocks { + compress(&mut state, block); + } + let remainder = blocks.remainder(); + let mut tail = [0u8; 128]; + tail[..remainder.len()].copy_from_slice(remainder); + tail[remainder.len()] = 0x80; + let end = if remainder.len() < 56 { 64 } else { 128 }; + tail[end - 8..end].copy_from_slice(&((bytes.len() as u64).wrapping_mul(8)).to_be_bytes()); + for block in tail[..end].chunks_exact(64) { + compress(&mut state, block); + } + state.iter().map(|v| format!("{v:08x}")).collect() +} +fn compress(state: &mut [u32; 8], block: &[u8]) { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + let mut w = [0u32; 64]; + for (out, bytes) in w.iter_mut().zip(block.chunks_exact(4)) { + *out = u32::from_be_bytes(bytes.try_into().unwrap()); + } + for i in 16..64 { + let x = w[i - 15]; + let y = w[i - 2]; + let s0 = x.rotate_right(7) ^ x.rotate_right(18) ^ (x >> 3); + let s1 = y.rotate_right(17) ^ y.rotate_right(19) ^ (y >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choice = (e & f) ^ (!e & g); + let t1 = h + .wrapping_add(s1) + .wrapping_add(choice) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let t2 = s0.wrapping_add((a & b) ^ (a & c) ^ (b & c)); + h = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (s, v) in state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *s = s.wrapping_add(v); + } +} diff --git a/crates/skate-data/src/state_graph.rs b/crates/skate-data/src/state_graph.rs new file mode 100644 index 00000000..4b250d3e --- /dev/null +++ b/crates/skate-data/src/state_graph.rs @@ -0,0 +1,143 @@ +//! Stock TU3 `.stategraph` decoding, independent of graph execution. +//! +//! ReadElement 82C15DE0 reads a NUL-terminated tag, BinaryAttributeMap +//! 82C14F38, a big-endian child count, then children in file order. +//! Each attribute stores two strings, a float word and a boolean byte. +//! Keep all three representations: parsing the string again loses stock data. +use crate::AssetError; +use std::{fs, path::Path}; + +pub mod attributes; +pub mod binding; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphAttribute { + pub name: String, + pub text: String, + pub float_bits: u32, + pub boolean_byte: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphElement { + /// Byte offset of the tag in the original file, for binding diagnostics. + pub source_offset: usize, + pub tag: String, + /// File order, including duplicate names. Native hash-map binding is a + /// separate operation; the decoder must not silently discard attributes. + pub attributes: Vec, + /// Indices into StateGraph::elements, in original child order. + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StateGraph { + /// Preorder arena. Element zero is the single file root. + pub elements: Vec, +} + +impl StateGraph { + pub fn load(path: &Path) -> Result { + let bytes = fs::read(path) + .map_err(|e| AssetError(format!("Cannot read graph {}: {e}", path.display())))?; + Self::decode(&bytes) + .map_err(|e| AssetError(format!("Invalid graph {}: {e}", path.display()))) + } + + pub fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader { bytes, offset: 0 }; + let (root, children) = reader.element()?; + let mut elements = vec![root]; + let mut parents = vec![(0_usize, children)]; + // The native reader recurses. An explicit stack preserves preorder and + // permits deeply nested input without depending on the host stack size. + while let Some((parent, remaining)) = parents.last_mut() { + if *remaining == 0 { + parents.pop(); + continue; + } + *remaining -= 1; + let (element, children) = reader.element()?; + let index = elements.len(); + elements[*parent].children.push(index); + elements.push(element); + parents.push((index, children)); + } + if reader.offset != bytes.len() { + return Err(reader.error("trailing bytes after the root element")); + } + Ok(Self { elements }) + } +} + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl Reader<'_> { + fn error(&self, message: &str) -> AssetError { + AssetError(format!("stategraph byte 0x{:X}: {message}", self.offset)) + } + + fn take(&mut self, count: usize) -> Result<&[u8], AssetError> { + if count > self.bytes.len() - self.offset { + return Err(self.error("truncated record")); + } + let begin = self.offset; + self.offset += count; + Ok(&self.bytes[begin..self.offset]) + } + + fn word(&mut self) -> Result { + Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) + } + + fn string(&mut self) -> Result { + let length = self.bytes[self.offset..] + .iter() + .position(|b| *b == 0) + .ok_or_else(|| self.error("unterminated string"))?; + let offset = self.offset; + let string = self.take(length + 1)?; + // Native storage is bytes. Unsupported encodings fail explicitly; + // lossy conversion could change node names or binding keys. + std::str::from_utf8(&string[..length]) + .map(str::to_owned) + .map_err(|_| AssetError(format!("stategraph byte 0x{offset:X}: non-UTF-8 string"))) + } + + fn element(&mut self) -> Result<(GraphElement, u32), AssetError> { + let source_offset = self.offset; + let tag = self.string()?; + let attribute_count = self.word()?; + // Each attribute requires at least two terminators, four bytes and + // one byte. Bound allocations by actual input, not a guessed graph cap. + if attribute_count as usize > (self.bytes.len() - self.offset) / 7 { + return Err(self.error("attribute count exceeds remaining data")); + } + let mut attributes = Vec::with_capacity(attribute_count as usize); + for _ in 0..attribute_count { + attributes.push(GraphAttribute { + name: self.string()?, + text: self.string()?, + float_bits: self.word()?, + boolean_byte: self.take(1)?[0], + }); + } + let children = self.word()?; + // A minimal child is one terminator and two four-byte counts. + if children as usize > (self.bytes.len() - self.offset) / 9 { + return Err(self.error("child count exceeds remaining data")); + } + Ok(( + GraphElement { + source_offset, + tag, + attributes, + children: Vec::new(), + }, + children, + )) + } +} diff --git a/crates/skate-data/src/state_graph/attributes.rs b/crates/skate-data/src/state_graph/attributes.rs new file mode 100644 index 00000000..4223be63 --- /dev/null +++ b/crates/skate-data/src/state_graph/attributes.rs @@ -0,0 +1,84 @@ +//! BinaryAttributeMap keys (82B92B98) and lookup/insert semantics. +//! This is the graph's 32-bit hash, not Attribulator's 64-bit field hash. +use super::GraphAttribute; +use std::collections::BTreeMap; + +/// Attribute/tag callers use zero for missing or empty strings. The underlying +/// 82B92B98 byte hash has a different, nonzero result for a zero-length buffer. +pub fn key_hash(text: &str) -> u32 { + if text.is_empty() { + 0 + } else { + byte_hash(text.as_bytes()) + } +} + +/// Complete 82B92B98, including each tail length and wrapping 32-bit arithmetic. +pub fn byte_hash(bytes: &[u8]) -> u32 { + let mut a = 0x9e37_79b9_u32; + let mut b = a; + let mut c = 0xabcd_ef00_u32; + let mut chunks = bytes.chunks_exact(12); + for chunk in &mut chunks { + a = a.wrapping_add(u32::from_le_bytes(chunk[0..4].try_into().unwrap())); + b = b.wrapping_add(u32::from_le_bytes(chunk[4..8].try_into().unwrap())); + c = c.wrapping_add(u32::from_le_bytes(chunk[8..12].try_into().unwrap())); + mix(&mut a, &mut b, &mut c); + } + c = c.wrapping_add(bytes.len() as u32); + for (i, &byte) in chunks.remainder().iter().enumerate() { + match i { + 0..=3 => a = a.wrapping_add((byte as u32) << (8 * i)), + 4..=7 => b = b.wrapping_add((byte as u32) << (8 * (i - 4))), + _ => c = c.wrapping_add((byte as u32) << (8 * (i - 7))), + } + } + mix(&mut a, &mut b, &mut c); + c +} + +fn mix(a: &mut u32, b: &mut u32, c: &mut u32) { + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 13); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 8); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 13); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 12); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 16); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 5); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 3); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 10); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 15); +} + +/// Native 82C19BB0 is unique insertion by the hashed key: the first record wins +/// for duplicate keys, including collisions between differently spelled keys. +/// Original record order and all encodings remain available in GraphElement. +pub struct Attributes<'a> { + entries: BTreeMap, +} + +impl<'a> Attributes<'a> { + pub fn new(attributes: &'a [GraphAttribute]) -> Self { + let mut entries = BTreeMap::new(); + for attribute in attributes { + entries + .entry(key_hash(&attribute.name)) + .or_insert(attribute); + } + Self { entries } + } + pub fn get(&self, name: &str) -> Option<&'a GraphAttribute> { + self.entries.get(&key_hash(name)).copied() + } + /// 82C14E10. A present empty string is not the default. + pub fn text(&self, name: &str) -> Option<&'a str> { + self.get(name).map(|v| v.text.as_str()) + } + /// 82C151B0 returns the stored byte without boolean normalization. + pub fn boolean_byte(&self, name: &str, default: u8) -> u8 { + self.get(name).map_or(default, |v| v.boolean_byte) + } + /// 82C15308; callers of 82C15258 supply the native zero default. + pub fn float_bits(&self, name: &str, default: u32) -> u32 { + self.get(name).map_or(default, |v| v.float_bits) + } +} diff --git a/crates/skate-data/src/state_graph/binding.rs b/crates/skate-data/src/state_graph/binding.rs new file mode 100644 index 00000000..a22b7d99 --- /dev/null +++ b/crates/skate-data/src/state_graph/binding.rs @@ -0,0 +1,426 @@ +//! Dynamic graph object construction and name binding from TU3 82C15F50, +//! 82C11930, 82C16708, 82C169B8, 82C16D68, 82C16FF0 and 82C16C10. +//! Concrete operations are created through the native factory boundary and +//! receive each nested parameter in authored order. +use super::{ + StateGraph, + attributes::{Attributes, key_hash}, +}; +use crate::AssetError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Node { + State(usize), + Transition(usize), + Expression(usize), + Operation(usize), +} + +#[derive(Clone, Debug)] +pub struct State { + pub element: usize, + pub name: String, + pub parent: Option, + pub children: Vec, + pub behaviors: Vec, + pub transitions: Vec, + pub expression: Option, + pub enabled: u8, + pub active: u8, + pub interruptibility: u32, + pub interrupt_ancestor: Option, +} + +#[derive(Clone, Debug)] +pub struct Transition { + pub element: usize, + pub owner: usize, + pub target: Option, + pub enabled: u8, + pub priority: u32, + pub expression: Option, + pub hooks: Vec, +} + +#[derive(Clone, Debug)] +pub struct Expression { + pub element: usize, + pub enabled: u8, + pub operator: u32, + /// Ordered nested expressions and concrete condition operations. + pub children: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperationKind { + Behavior, + Condition, + Hook, +} + +#[derive(Clone, Debug)] +pub struct Operation { + pub element: usize, + pub parent: Node, + pub kind: OperationKind, + pub name: String, + pub enabled: u8, + /// Condition::mMask from 82C12CB0/82C12BB0. Other operation kinds have no + /// condition mask. + pub condition_mask: Option, + /// Ordered `param` elements, consumed by the concrete virtual40 handler. + pub parameters: Vec, +} + +#[derive(Clone, Debug)] +pub struct Binding { + pub states: Vec, + pub transitions: Vec, + pub expressions: Vec, + pub operations: Vec, + pub root: usize, +} + +#[derive(Clone, Debug)] +pub struct OperationInstances { + /// Same indices as `Binding::operations`. + pub operations: Vec, +} + +/// Readable boundary for 8241BFF0 (factory create) and the parser's virtual+40 +/// parameter dispatch in 82C15F50. A missing registration is represented by +/// `Ok(None)` and is rejected by `instantiate_operations` with source context. +pub trait OperationFactory { + type Instance; + type Error: std::fmt::Display; + + fn create( + &mut self, + kind: OperationKind, + parent: Node, + attributes: &Attributes<'_>, + ) -> Result, Self::Error>; + + fn add_parameter( + &mut self, + instance: &mut Self::Instance, + attributes: &Attributes<'_>, + ) -> Result<(), Self::Error>; +} + +impl Binding { + /// Binds the compiled dynamic graph structure, not its registered gameplay + /// operations. The source graph remains the owner of raw typed attributes. + pub fn from_graph(source: &StateGraph) -> Result { + let mut result = Self { + states: Vec::new(), + transitions: Vec::new(), + expressions: Vec::new(), + operations: Vec::new(), + root: 0, + }; + let mut parents = vec![None; source.elements.len()]; + let mut nodes = vec![None; source.elements.len()]; + for (i, element) in source.elements.iter().enumerate() { + for &child in &element.children { + if child <= i || child >= parents.len() || parents[child].replace(i).is_some() { + return Err(error(source, i, "invalid preorder ownership")); + } + } + let parent = parents[i].and_then(|p| nodes[p]); + let attrs = Attributes::new(&element.attributes); + let enabled = if parent.is_some_and(|p| result.enabled(p) == 0) { + 0 + } else { + attrs.boolean_byte("enabled", 1) + }; + let name = attrs.text("name").unwrap_or("__unknown__").to_owned(); + let tag = key_hash(&element.tag); + let node = if tag == key_hash("state") { + let parent = match parent { + None if i == 0 => None, + Some(Node::State(id)) => Some(id), + _ => return Err(error(source, i, "state requires a state parent")), + }; + let interruptibility = match attrs.text("interruptable").unwrap_or("true") { + "true" => 1, + "false" => 0, + _ => 2, + }; + let id = result.states.len(); + result.states.push(State { + element: i, + name, + parent, + children: Vec::new(), + behaviors: Vec::new(), + transitions: Vec::new(), + expression: None, + enabled, + active: attrs.boolean_byte("active", 1), + interruptibility, + interrupt_ancestor: None, + }); + if let Some(parent) = parent { + result.states[parent].children.push(id); + } + Node::State(id) + } else if tag == key_hash("transition") { + let Some(Node::State(owner)) = parent else { + return Err(error(source, i, "transition requires a state parent")); + }; + let id = result.transitions.len(); + let priority = match attrs.text("priority") { + Some("med") => 1, + Some("high") => 2, + Some("urgent") => 3, + _ => 0, + }; + result.transitions.push(Transition { + element: i, + owner, + target: None, + enabled, + priority, + expression: None, + hooks: Vec::new(), + }); + result.states[owner].transitions.push(id); + Node::Transition(id) + } else if tag == key_hash("expression") { + let id = result.expressions.len(); + let operator = match attrs.text("op") { + Some("and") => 1, + Some("or") => 2, + Some("not") => 3, + _ => 0, + }; + result.expressions.push(Expression { + element: i, + enabled, + operator, + children: Vec::new(), + }); + match parent { + Some(Node::State(owner)) => result.states[owner].expression = Some(id), + Some(Node::Transition(owner)) => { + result.transitions[owner].expression = Some(id) + } + Some(Node::Expression(owner)) => result.expressions[owner] + .children + .push(Node::Expression(id)), + _ => { + return Err(error( + source, + i, + "expression requires state, transition or expression parent", + )); + } + } + Node::Expression(id) + } else if tag == key_hash("param") { + let Some(Node::Operation(owner)) = parent else { + return Err(error(source, i, "unbound parameter handler")); + }; + result.operations[owner].parameters.push(i); + if !element.children.is_empty() { + return Err(error( + source, + i, + "parameter children have no native object parent", + )); + } + continue; + } else { + let kind = if tag == key_hash("behaviour") { + OperationKind::Behavior + } else if tag == key_hash("condition") { + OperationKind::Condition + } else if tag == key_hash("hook") { + OperationKind::Hook + } else { + return Err(error(source, i, "unsupported graph element")); + }; + let Some(parent) = parent else { + return Err(error(source, i, "operation requires a parent")); + }; + let id = result.operations.len(); + match (kind, parent) { + (OperationKind::Behavior, Node::State(owner)) => { + result.states[owner].behaviors.push(id) + } + (OperationKind::Condition, Node::Expression(owner)) => { + result.expressions[owner].children.push(Node::Operation(id)) + } + (OperationKind::Hook, Node::Transition(owner)) => { + result.transitions[owner].hooks.push(id) + } + _ => return Err(error(source, i, "unsupported operation parent")), + } + result.operations.push(Operation { + element: i, + parent, + kind, + name, + enabled, + condition_mask: (kind == OperationKind::Condition) + .then(|| parse_condition_mask(attrs.text("mask"))), + parameters: Vec::new(), + }); + Node::Operation(id) + }; + nodes[i] = Some(node); + } + if result.states.is_empty() { + return Err(AssetError("Graph has no root state".into())); + } + result.process_names(source)?; + Ok(result) + } + + /// Instantiates every concrete operation from its complete attribute map, + /// then passes its `param` maps one at a time in stock child order. + pub fn instantiate_operations( + &self, + source: &StateGraph, + factory: &mut F, + ) -> Result, AssetError> { + let mut instances = Vec::with_capacity(self.operations.len()); + for operation in &self.operations { + let attributes = Attributes::new(&source.elements[operation.element].attributes); + let mut instance = factory + .create(operation.kind, operation.parent, &attributes) + .map_err(|e| operation_error(source, operation, &e.to_string()))? + .ok_or_else(|| operation_error(source, operation, "unregistered operation"))?; + for &element in &operation.parameters { + let attributes = Attributes::new(&source.elements[element].attributes); + factory + .add_parameter(&mut instance, &attributes) + .map_err(|e| operation_error(source, operation, &e.to_string()))?; + } + instances.push(instance); + } + Ok(OperationInstances { + operations: instances, + }) + } + + fn enabled(&self, node: Node) -> u8 { + match node { + Node::State(i) => self.states[i].enabled, + Node::Transition(i) => self.transitions[i].enabled, + Node::Expression(i) => self.expressions[i].enabled, + Node::Operation(i) => self.operations[i].enabled, + } + } + + fn process_names(&mut self, source: &StateGraph) -> Result<(), AssetError> { + for id in 0..self.states.len() { + let state = &self.states[id]; + if state.interruptibility == 2 { + let attrs = Attributes::new(&source.elements[state.element].attributes); + let name = attrs.text("interruptable").unwrap(); + let target = self + .find_state(id, name, true) + .ok_or_else(|| error(source, state.element, "unresolved interrupt ancestor"))?; + let mut ancestor = Some(id); + while ancestor.is_some() && ancestor != Some(target) { + ancestor = self.states[ancestor.unwrap()].parent; + } + if ancestor.is_none() { + return Err(error( + source, + state.element, + "interrupt target is not an ancestor", + )); + } + self.states[id].interrupt_ancestor = Some(target); + } + } + for id in 0..self.transitions.len() { + let transition = &self.transitions[id]; + let attrs = Attributes::new(&source.elements[transition.element].attributes); + let target = + self.find_state(transition.owner, attrs.text("target").unwrap_or(""), true); + self.transitions[id].target = target; + // 82C16FF0 disables unresolved/disabled targets; no invented fallback. + if target.is_none_or(|i| self.states[i].enabled == 0) { + self.transitions[id].enabled = 0; + } + } + Ok(()) + } + + /// Complete 82C16C10 search: first direct child, then self/ancestors if + /// allowed. Dotted paths search their first component with ascent enabled, + /// even if the caller disabled ascent, then resolve the suffix locally. + pub fn find_state(&self, start: usize, name: &str, ascend: bool) -> Option { + if let Some((first, rest)) = name.split_once('.') { + let found = self.find_state(start, first, true)?; + return self.find_state(found, rest, false); + } + let key = name_hash(name); + let mut state = start; + loop { + for &child in &self.states[state].children { + if name_hash(&self.states[child].name) == key { + return Some(child); + } + } + if !ascend { + return None; + } + if name_hash(&self.states[state].name) == key { + return Some(state); + } + state = self.states[state].parent?; + } + } +} + +/// Native interned-name comparison 82C118A0 uses FNV-1, without string equality +/// after the hash comparison. This differs from BinaryAttributeMap's key hash. +pub fn name_hash(text: &str) -> u32 { + text.bytes().fold(0x811c9dc5_u32, |hash, byte| { + hash.wrapping_mul(0x01000193) ^ u32::from(byte) + }) +} + +/// StateGraph::ParseMask 82C12BB0. Comparison is case-sensitive. TU3 returns +/// the precondition mask for a missing or unrecognized value. +pub fn parse_condition_mask(value: Option<&str>) -> u32 { + match value { + Some("sustain") => 2, + Some("always") => 3, + Some("postcond") => 4, + _ => 1, + } +} + +fn error(source: &StateGraph, element: usize, message: &str) -> AssetError { + let element = &source.elements[element]; + AssetError(format!( + "Graph {} at byte {}: {message}", + element.tag, element.source_offset + )) +} + +fn operation_error(source: &StateGraph, operation: &Operation, message: &str) -> AssetError { + error( + source, + operation.element, + &format!( + "{} `{}`: {message}", + operation_kind(operation.kind), + operation.name + ), + ) +} + +fn operation_kind(kind: OperationKind) -> &'static str { + match kind { + OperationKind::Behavior => "behaviour", + OperationKind::Condition => "condition", + OperationKind::Hook => "hook", + } +} diff --git a/crates/skate-data/src/tests/input_config.rs b/crates/skate-data/src/tests/input_config.rs new file mode 100644 index 00000000..692ba1ce --- /dev/null +++ b/crates/skate-data/src/tests/input_config.rs @@ -0,0 +1,36 @@ +use super::*; + +fn source() -> String { + let buttons = BUTTON_NAMES + .iter() + .enumerate() + .map(|(i, name)| format!("{name} = Button{i};\n")); + let gameplay = GAMEPLAY + .iter() + .map(|(name, value)| format!("{name} = {value};\n")); + buttons.chain(gameplay).collect() +} + +#[test] +fn accepts_spacing_comments_and_unrelated_menu_expressions() { + let source = format!( + "// controller mapping\n{}\nMenuKey = A.released ? 1 : -1;", + source().replace("LStickR-LStickL", "LStickR - LStickL // same expression\n") + ); + assert!(StockGameplayConfig::validate(&source).is_ok()); +} + +#[test] +fn rejects_changed_axis_or_button_instead_of_using_incompatible_specialization() { + for changed in [ + source().replace( + "GP_LStickX = LStickR-LStickL", + "GP_LStickX = LStickL-LStickR", + ), + source().replace("A = Button12", "A = Button13"), + source().replace("GP_AFace = A;", ""), + format!("{}GP_AFace = B;", source()), + ] { + assert!(StockGameplayConfig::validate(&changed).is_err()); + } +} diff --git a/crates/skate-data/tests/graph_attributes.rs b/crates/skate-data/tests/graph_attributes.rs new file mode 100644 index 00000000..becb233d --- /dev/null +++ b/crates/skate-data/tests/graph_attributes.rs @@ -0,0 +1,27 @@ +use skate_data::state_graph::GraphAttribute; +use skate_data::state_graph::attributes::Attributes; + +#[test] +fn binary_attributes_keep_first_record_and_separate_native_encodings() { + let records = vec![ + GraphAttribute { + name: "value".into(), + text: "".into(), + float_bits: 0x80000000, + boolean_byte: 255, + }, + GraphAttribute { + name: "value".into(), + text: "replacement".into(), + float_bits: 0x3f800000, + boolean_byte: 0, + }, + ]; + let attributes = Attributes::new(&records); + assert_eq!(attributes.text("value"), Some("")); + assert_eq!(attributes.float_bits("value", 0), 0x80000000); + assert_eq!(attributes.boolean_byte("value", 0), 255); + assert_eq!(attributes.text("missing"), None); + assert_eq!(attributes.float_bits("missing", 0x3f800000), 0x3f800000); + assert_eq!(attributes.boolean_byte("missing", 2), 2); +} diff --git a/crates/skate-data/tests/graph_binding.rs b/crates/skate-data/tests/graph_binding.rs new file mode 100644 index 00000000..090bd90a --- /dev/null +++ b/crates/skate-data/tests/graph_binding.rs @@ -0,0 +1,111 @@ +use skate_data::state_graph::{ + GraphAttribute, GraphElement, StateGraph, + binding::{Binding, parse_condition_mask}, +}; + +fn attr(name: &str, text: &str, byte: u8) -> GraphAttribute { + GraphAttribute { + name: name.into(), + text: text.into(), + float_bits: 0, + boolean_byte: byte, + } +} +fn element(tag: &str, attributes: Vec, children: Vec) -> GraphElement { + GraphElement { + source_offset: 0, + tag: tag.into(), + attributes, + children, + } +} +#[test] +fn constructor_flags_priorities_expressions_and_target_resolution() { + let source = StateGraph { + elements: vec![ + element("state", vec![attr("name", "Root", 0)], vec![1, 3, 4, 6]), + element( + "state", + vec![attr("name", "Off", 0), attr("enabled", "true", 0)], + vec![2], + ), + element( + "behaviour", + vec![attr("name", "Handler", 0), attr("enabled", "true", 255)], + vec![], + ), + element( + "transition", + vec![attr("target", "Off", 0), attr("priority", "urgent", 0)], + vec![], + ), + element( + "state", + vec![attr("name", "On", 0), attr("interruptable", "Root", 0)], + vec![5], + ), + element( + "transition", + vec![attr("target", "Root.On", 0), attr("priority", "med", 0)], + vec![], + ), + element("transition", vec![attr("target", "Absent", 0)], vec![7]), + element("expression", vec![attr("op", "or", 0)], vec![8]), + element("condition", vec![attr("name", "Condition", 0)], vec![]), + ], + }; + let binding = Binding::from_graph(&source).unwrap(); + assert_eq!(binding.states[0].children, vec![1, 2]); + assert_eq!(binding.states[1].enabled, 0); + assert_eq!(binding.operations[0].enabled, 0); // Inherited disable wins. + assert_eq!(binding.states[2].interruptibility, 2); + assert_eq!(binding.states[2].interrupt_ancestor, Some(0)); + assert_eq!(binding.transitions[0].target, Some(1)); + assert_eq!(binding.transitions[0].enabled, 0); // Target disables transition. + assert_eq!(binding.transitions[0].priority, 3); + assert_eq!(binding.transitions[1].target, Some(2)); + assert_eq!(binding.transitions[1].priority, 1); + assert_eq!(binding.transitions[2].target, None); + assert_eq!(binding.transitions[2].enabled, 0); + assert_eq!(binding.expressions[0].operator, 2); + assert_eq!(binding.operations[1].condition_mask, Some(1)); + // Constructor inheritance precedes Process disabling an unresolved target. + assert_eq!(binding.expressions[0].enabled, 1); +} + +#[test] +fn condition_masks_match_tu3_parse_mask_ordinals() { + assert_eq!(parse_condition_mask(None), 1); + assert_eq!(parse_condition_mask(Some("precond")), 1); + assert_eq!(parse_condition_mask(Some("sustain")), 2); + assert_eq!(parse_condition_mask(Some("always")), 3); + assert_eq!(parse_condition_mask(Some("postcond")), 4); + assert_eq!(parse_condition_mask(Some("PRECOND")), 1); + assert_eq!(parse_condition_mask(Some("unknown")), 1); +} + +#[test] +fn missing_native_operations_and_invalid_ancestry_are_explicit() { + let graph = StateGraph { + elements: vec![element("include", vec![], vec![])], + }; + assert!( + Binding::from_graph(&graph) + .unwrap_err() + .0 + .contains("unsupported graph element") + ); + let graph = StateGraph { + elements: vec![element( + "state", + vec![attr("interruptable", "Missing", 0)], + vec![], + )], + }; + assert!( + Binding::from_graph(&graph) + .unwrap_err() + .0 + .contains("unresolved interrupt ancestor") + ); +} diff --git a/crates/skate-data/tests/graph_operations.rs b/crates/skate-data/tests/graph_operations.rs new file mode 100644 index 00000000..f443607f --- /dev/null +++ b/crates/skate-data/tests/graph_operations.rs @@ -0,0 +1,122 @@ +use skate_data::state_graph::{ + GraphAttribute, GraphElement, StateGraph, + attributes::Attributes, + binding::{Binding, Node, OperationFactory, OperationKind}, +}; + +fn attr(name: &str, text: &str, bits: u32, byte: u8) -> GraphAttribute { + GraphAttribute { + name: name.into(), + text: text.into(), + float_bits: bits, + boolean_byte: byte, + } +} + +fn element(tag: &str, attributes: Vec, children: Vec) -> GraphElement { + GraphElement { + source_offset: 0x40, + tag: tag.into(), + attributes, + children, + } +} + +#[derive(Default)] +struct Factory { + events: Vec, + registered: bool, +} + +impl OperationFactory for Factory { + type Instance = String; + type Error = &'static str; + + fn create( + &mut self, + kind: OperationKind, + parent: Node, + attributes: &Attributes<'_>, + ) -> Result, Self::Error> { + let name = attributes.text("name").unwrap().to_owned(); + self.events + .push(format!("create:{kind:?}:{parent:?}:{name}")); + Ok(self.registered.then_some(name)) + } + + fn add_parameter( + &mut self, + instance: &mut Self::Instance, + attributes: &Attributes<'_>, + ) -> Result<(), Self::Error> { + self.events.push(format!( + "param:{instance}:{}:{:08X}:{}", + attributes.text("name").unwrap(), + attributes.float_bits("value", 0), + attributes.boolean_byte("value", 0) + )); + Ok(()) + } +} + +fn source() -> StateGraph { + StateGraph { + elements: vec![ + element("state", vec![attr("name", "Root", 0, 0)], vec![1]), + element("behaviour", vec![attr("name", "Drive", 0, 0)], vec![2, 3]), + element( + "param", + vec![ + attr("name", "first", 0, 0), + attr("value", "one", 0x3F80_0000, 7), + ], + vec![], + ), + element( + "param", + vec![ + attr("name", "second", 0, 0), + attr("value", "two", 0x4000_0000, 9), + ], + vec![], + ), + ], + } +} + +#[test] +fn factory_receives_complete_operation_then_ordered_typed_parameters() { + let source = source(); + let binding = Binding::from_graph(&source).unwrap(); + let mut factory = Factory { + registered: true, + ..Default::default() + }; + let instances = binding + .instantiate_operations(&source, &mut factory) + .unwrap(); + assert_eq!(instances.operations, ["Drive"]); + assert_eq!( + factory.events, + [ + "create:Behavior:State(0):Drive", + "param:Drive:first:3F800000:7", + "param:Drive:second:40000000:9", + ] + ); +} + +#[test] +fn missing_registration_is_a_source_located_error() { + let source = source(); + let binding = Binding::from_graph(&source).unwrap(); + let error = binding + .instantiate_operations(&source, &mut Factory::default()) + .unwrap_err(); + assert!(error.0.contains("byte 64")); + assert!( + error + .0 + .contains("behaviour `Drive`: unregistered operation") + ); +} diff --git a/crates/skate-data/tests/manifest.rs b/crates/skate-data/tests/manifest.rs new file mode 100644 index 00000000..07af5f3b --- /dev/null +++ b/crates/skate-data/tests/manifest.rs @@ -0,0 +1,61 @@ +use skate_data::GameAssets; + +fn manifest(path: &str, version: u32) -> String { + serde_json::json!({ + "version": version, + "character_scene": path, + "initial_animation": "R_IDLE_HCOM_000", + "action_graph": "private/stock/data/state/ActionGraph_OnBoard.stategraph", + "motion_graph": "private/stock/data/state/MotionGraph_OnBoard.stategraph" + }) + .to_string() +} +#[test] +fn validates_contract_without_loading_private_assets() { + assert_eq!( + GameAssets::parse(&manifest("private/skater.glb", 1)) + .unwrap() + .initial_animation, + "R_IDLE_HCOM_000" + ); +} +#[test] +fn rejects_incompatible_versions_and_ambiguous_paths() { + assert!(GameAssets::parse(&manifest("private/skater.glb", 2)).is_err()); + for path in [ + "../skater.glb", + "/skater.glb", + "C:/skater.glb", + "private\\skater.glb", + "private/skater.glb#Scene0", + "private/skater.json", + "", + ] { + assert!( + GameAssets::parse(&manifest(path, 1)).is_err(), + "accepted {path}" + ); + } +} + +#[test] +fn requires_relative_compiled_stock_graph_paths() { + let mut value: serde_json::Value = + serde_json::from_str(&manifest("private/skater.glb", 1)).unwrap(); + for field in ["action_graph", "motion_graph"] { + for path in ["../graph.stategraph", "/graph.stategraph", "graph.xml", ""] { + value[field] = path.into(); + assert!( + GameAssets::parse(&serde_json::to_string(&value).unwrap()).is_err(), + "accepted {field}={path}" + ); + } + value[field] = format!("private/{field}.stategraph").into(); + } +} +#[test] +fn reports_missing_data_as_an_error() { + let root = std::env::temp_dir().join(format!("skate-missing-assets-{}", std::process::id())); + let error = GameAssets::load(&root).unwrap_err().to_string(); + assert!(error.contains("private") && error.contains("Prepare-Assets")); +} diff --git a/crates/skate-data/tests/state_graph.rs b/crates/skate-data/tests/state_graph.rs new file mode 100644 index 00000000..f25dd508 --- /dev/null +++ b/crates/skate-data/tests/state_graph.rs @@ -0,0 +1,76 @@ +use skate_data::state_graph::StateGraph; + +fn element(tag: &str, attributes: &[(&str, &str, u32, u8)], children: &[Vec]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend(tag.bytes().chain([0])); + bytes.extend((attributes.len() as u32).to_be_bytes()); + for (key, text, bits, flag) in attributes { + bytes.extend(key.bytes().chain([0])); + bytes.extend(text.bytes().chain([0])); + bytes.extend(bits.to_be_bytes()); + bytes.push(*flag); + } + bytes.extend((children.len() as u32).to_be_bytes()); + for child in children { + bytes.extend(child); + } + bytes +} + +#[test] +fn preserves_tree_order_duplicate_attributes_and_encoded_values() { + let grandchild = element("condition", &[], &[]); + let first = element("expression", &[("op", "and", 0, 0)], &[grandchild]); + let second = element( + "behaviour", + &[("value", "0", 0x80000000, 2), ("value", "1", 0x7fc00001, 0)], + &[], + ); + let bytes = element("state", &[("name", "Root", 0, 0)], &[first, second]); + let graph = StateGraph::decode(&bytes).unwrap(); + assert_eq!( + graph + .elements + .iter() + .map(|e| e.tag.as_str()) + .collect::>(), + ["state", "expression", "condition", "behaviour"] + ); + assert_eq!(graph.elements[0].children, [1, 3]); + assert_eq!(graph.elements[1].children, [2]); + assert_eq!(graph.elements[3].attributes[0].float_bits, 0x80000000); + assert_eq!(graph.elements[3].attributes[0].boolean_byte, 2); + assert_eq!(graph.elements[3].attributes[1].float_bits, 0x7fc00001); + assert_eq!(graph.elements[3].attributes[1].text, "1"); + assert!( + graph + .elements + .windows(2) + .all(|e| e[0].source_offset < e[1].source_offset) + ); +} + +#[test] +fn rejects_truncation_and_trailing_data_without_partial_graph() { + let bytes = element( + "state", + &[("name", "Root", 0, 0)], + &[element("behaviour", &[], &[])], + ); + for end in 0..bytes.len() { + assert!( + StateGraph::decode(&bytes[..end]).is_err(), + "accepted truncation at {end}" + ); + } + let mut extra = bytes.clone(); + extra.push(0); + assert!( + StateGraph::decode(&extra) + .unwrap_err() + .to_string() + .contains("trailing") + ); + let excessive = b"state\0\xff\xff\xff\xff"; + assert!(StateGraph::decode(excessive).is_err()); +} diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml new file mode 100644 index 00000000..59ff6260 --- /dev/null +++ b/crates/skate-game/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "skate-game" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +bevy.workspace = true +skate-core.workspace = true +skate-data.workspace = true diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs new file mode 100644 index 00000000..7c9cc80c --- /dev/null +++ b/crates/skate-game/src/animation.rs @@ -0,0 +1,141 @@ +//! Render the same stock skeleton that animation and physics update. +//! The GLB supplies the mesh, skin weights and hierarchy; it does not play a +//! separate idle clip or own the skater's pose. +use crate::{app::FrameSet, assets::AssetStatus, physics::SkaterRuntime, world::PlayerRoot}; +use bevy::{mesh::skinning::SkinnedMesh, prelude::*}; +use skate_core::animation::output::NativeMatrix; + +#[derive(Resource, Default)] +pub(crate) struct AnimationStatus { + pub ready: bool, + bindings: Vec, +} +struct BoneBinding { + entity: Entity, + bone: usize, + parent_bone: Option, +} +pub(crate) struct AnimationPlugin; +impl Plugin for AnimationPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, (bind, present).chain().in_set(FrameSet::Animation)); + } +} +fn bind( + status: Res, + skater: Res, + mut animation: ResMut, + skins: Query<(Entity, &SkinnedMesh)>, + nodes: Query<(&Name, &Transform)>, + parents: Query<&ChildOf>, + roots: Query>, + mut exit: MessageWriter, +) { + if *status != AssetStatus::Ready || animation.ready { + return; + } + let names = &skater.animation.evaluator.frames.bone_names; + let result = (|| -> Result>, String> { + for (entity, skin) in &skins { + if !parents.iter_ancestors(entity).any(|e| roots.contains(e)) { + continue; + } + let mut binding = Vec::with_capacity(skin.joints.len()); + for &joint in &skin.joints { + let (name, _) = nodes + .get(joint) + .map_err(|_| "Skater skin joint is missing its name or transform")?; + let bone = names + .iter() + .position(|n| n.eq_ignore_ascii_case(name.as_str())) + .ok_or_else(|| { + format!("Skater skin bone {name} is absent from the stock rig") + })?; + let mut parent_bone = None; + for parent in parents.iter_ancestors(joint) { + if roots.contains(parent) { + break; + } + if let Ok((name, transform)) = nodes.get(parent) { + if let Some(index) = names + .iter() + .position(|n| n.eq_ignore_ascii_case(name.as_str())) + { + parent_bone = Some(index); + break; + } + // The supplied GLB's armature node is identity. Reject + // another import convention rather than double a pose. + if !transform.to_matrix().abs_diff_eq(Mat4::IDENTITY, 0.00001) { + return Err(format!( + "Skater armature ancestor {name} has an unsupported transform" + )); + } + } + } + binding.push(BoneBinding { + entity: joint, + bone, + parent_bone, + }); + } + // All ten imported mesh primitives share these joint entities and + // their authored inverse-bind matrices. Bind the joints once and + // retain that mesh reference: the stock initialization pose is an + // animation pose, not a replacement skin bind pose. + return Ok(Some(binding)); + } + Ok(None) + })(); + match result { + Ok(Some(binding)) => { + animation.bindings = binding; + animation.ready = true; + info!( + "GAME_CHARACTER_READY bones={} source=physical_stock_pose", + animation.bindings.len() + ); + } + Ok(None) => (), + Err(message) => { + error!("{message}"); + exit.write(AppExit::error()); + } + } +} +fn present( + skater: Res, + animation: Res, + mut nodes: Query<&mut Transform>, +) { + if !animation.ready { + return; + } + // Existing GLB was exported through Blender: its bone-local axes are + // rotated -90 degrees about X relative to the native frames. Both files' + // world positions are Y-up. This is a skin basis change, not a physics turn. + let basis = render_basis(); + for binding in &animation.bindings { + let global = native_matrix(skater.render_pose[binding.bone]) * basis; + let local = if let Some(parent) = binding.parent_bone { + (native_matrix(skater.render_pose[parent]) * basis).inverse() * global + } else { + global + }; + if let Ok(mut transform) = nodes.get_mut(binding.entity) { + *transform = Transform::from_matrix(local); + } + } +} +fn render_basis() -> Mat4 { + Mat4::from_cols(Vec4::X, -Vec4::Z, Vec4::Y, Vec4::W) +} +pub(crate) fn native_matrix(matrix: NativeMatrix) -> Mat4 { + Mat4::from_cols( + Vec3::from_array(matrix[0][..3].try_into().unwrap()).extend(0.0), + Vec3::from_array(matrix[1][..3].try_into().unwrap()).extend(0.0), + Vec3::from_array(matrix[2][..3].try_into().unwrap()).extend(0.0), + Vec3::from_array(matrix[3][..3].try_into().unwrap()).extend(1.0), + ) +} diff --git a/crates/skate-game/src/animation_pose.rs b/crates/skate-game/src/animation_pose.rs new file mode 100644 index 00000000..5faa0a84 --- /dev/null +++ b/crates/skate-game/src/animation_pose.rs @@ -0,0 +1,195 @@ +//! Production animation command evaluator. File IO and buffer ownership are +//! host services; frame selection, trajectory and pose math live in core. +use skate_core::animation::{ + output::{self, NativeMatrix, Sqt}, + playback_tree::PoseCommand, + pose_add, pose_blend, pose_mirror, + pose_sample::{sample_key_vbr, select_frames}, + pose_trajectory::{self, LoopTransform}, +}; +use skate_data::{ + animation_banks::AnimationBanks, + animation_frames::{AnimationFrames, ClipFrames, SampleWords}, +}; +use std::path::Path; + +pub(crate) struct PoseEvaluator { + pub frames: AnimationFrames, +} + +impl PoseEvaluator { + pub fn load(asset_root: &Path) -> Result { + Self::from_banks(&AnimationBanks::load(asset_root)?) + } + pub fn from_banks(banks: &AnimationBanks) -> Result { + Ok(Self { + frames: AnimationFrames::from_banks(banks)?, + }) + } + + /// Executes the ordered stock tree. This uses the native immediate ACS + /// arithmetic; the host does not recreate packed animation job commands. + pub fn evaluate(&self, commands: &[PoseCommand]) -> Result, String> { + let mut stack: Vec> = Vec::new(); + for command in commands { + match command { + PoseCommand::Pose { name } => { + // Init82B97E38 retains these references from the actor's + // initial database. AddBindPose82B98118 reuses them even + // when a later motion clip comes from another bank. + stack.push( + self.frames + .named_pose(name)? + .samples + .iter() + .copied() + .map(sqt) + .collect(), + ); + } + PoseCommand::Add { motion_is_a } => { + let other = stack + .pop() + .ok_or("Animation add has no reference subtree")?; + let motion = stack + .last_mut() + .ok_or("Animation add has no motion subtree")?; + if motion.len() != other.len() { + return Err("Animation add bone counts differ".into()); + } + for (motion, other) in motion.iter_mut().zip(other) { + *motion = if *motion_is_a { + pose_add::add(*motion, other, true) + } else { + pose_add::add(other, *motion, false) + }; + } + } + PoseCommand::Mirror { trajectory_mode } => { + let pose = stack.last_mut().ok_or("Animation mirror has no subtree")?; + pose_mirror::mirror( + pose, + &self.frames.parents, + &self.frames.mirror_indices, + *trajectory_mode, + )?; + } + PoseCommand::Clip { + name, + time, + previous_time, + loops, + } => { + let clip = self.frames.clip(name)?; + let mut pose = sample_clip(clip, *time)?; + if self.frames.has_trajectory { + let previous = sample_bone(clip, *previous_time, 0)?; + pose[0] = pose_trajectory::delta( + pose[0], + previous, + (*loops != 0).then_some(LoopTransform { + rotation: clip.loop_rotation_bits.map(f32::from_bits), + translation: clip.loop_translation_bits.map(f32::from_bits), + }), + ); + } + stack.push(pose); + } + PoseCommand::Blend { weight } | PoseCommand::ChannelBlend { weight, .. } => { + let second = stack.pop().ok_or("Animation blend has no second subtree")?; + let first = stack + .last_mut() + .ok_or("Animation blend has no first subtree")?; + if first.len() != second.len() { + return Err("Animation subtree bone counts differ".into()); + } + for (a, b) in first.iter_mut().zip(second) { + *a = match command { + PoseCommand::ChannelBlend { + use_channels_from_weights, + .. + } => pose_blend::channel_blend_sample( + *a, + b, + *weight, + *use_channels_from_weights, + ), + _ => pose_blend::blend_sample(*a, b, *weight), + }; + } + } + } + } + if stack.len() != 1 { + return Err(format!("Animation evaluation left{} poses", stack.len())); + } + Ok(stack.pop().unwrap()) + } + + /// TU3828D3800 then828D3B58. NewACS824744B8 initializes the + /// detached parent to0, so trajectory deltas do not move the local rig. + pub fn hierarchy(&self, pose: &[Sqt]) -> Result, String> { + if pose.len() != self.frames.parents.len() { + return Err("Animation pose and hierarchy bone counts differ".into()); + } + let mut globals: Vec<_> = pose.iter().copied().map(output::sqt_to_matrix).collect(); + output::compose_hierarchy_in_place( + globals.len() as i32, + &self.frames.parents, + 0, + &mut globals, + ) + .map_err(|e| format!("Invalid stock animation hierarchy: {e:?}"))?; + Ok(globals) + } +} + +fn sample_clip(clip: &ClipFrames, time: f32) -> Result, String> { + let selection = select_frames( + time, + f32::from_bits(clip.fps_bits), + clip.frames.len(), + true, + 0.0, + )?; + Ok(clip.frames[selection.first] + .iter() + .zip(&clip.frames[selection.second]) + .enumerate() + .map(|(bone, (&first, &second))| { + let mut sample = sample_key_vbr(sqt(first), sqt(second), selection); + sample.translation[3] = f32::from_bits(clip.channel_weights[bone]); + sample + }) + .collect()) +} + +fn sample_bone(clip: &ClipFrames, time: f32, bone: usize) -> Result { + let selection = select_frames( + time, + f32::from_bits(clip.fps_bits), + clip.frames.len(), + true, + 0.0, + )?; + let mut sample = sample_key_vbr( + sqt(clip.frames[selection.first][bone]), + sqt(clip.frames[selection.second][bone]), + selection, + ); + sample.translation[3] = f32::from_bits(clip.channel_weights[bone]); + Ok(sample) +} + +fn sqt(words: SampleWords) -> Sqt { + let [sx, sy, sz, x, y, z, w, tx, ty, tz] = words.map(f32::from_bits); + Sqt { + scale: [sx, sy, sz, 1.0], + rotation: [x, y, z, w], + translation: [tx, ty, tz, 1.0], + } +} + +#[cfg(test)] +#[path = "tests/animation_pose.rs"] +mod tests; diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs new file mode 100644 index 00000000..f19c2b3d --- /dev/null +++ b/crates/skate-game/src/app.rs @@ -0,0 +1,100 @@ +use crate::{ + animation, assets, camera, + config::Config, + graph_runtime::StockGraphs, + input, + physics::{GamePhysics, PhysicsPlugin, SkaterRuntime}, + verification, world, +}; +use bevy::{ + prelude::*, + render::{ + RenderPlugin, + settings::{Backends, InstanceFlags, RenderCreation, WgpuSettings}, + }, +}; +use skate_data::GameAssets; + +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +pub(crate) enum FrameSet { + Assets, + Physics, + Animation, + Verification, +} + +#[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] +pub(crate) enum SimulationSet { + Input, + Controls, + Physics, +} + +pub(crate) fn build( + config: Config, + manifest: GameAssets, + graphs: StockGraphs, + physics: GamePhysics, + skater: SkaterRuntime, +) -> App { + let mut app = App::new(); + app.add_plugins( + DefaultPlugins + .set(AssetPlugin { + file_path: config.asset_root.to_string_lossy().into_owned(), + ..default() + }) + .set(WindowPlugin { + primary_window: Some(Window { + title: "Skate 3".into(), + resolution: (1280, 800).into(), + ..default() + }), + ..default() + }) + .set(RenderPlugin { + render_creation: RenderCreation::Automatic(WgpuSettings { + backends: Some(Backends::VULKAN), + // Existing machine's validation layer rejects wgpu atomic shaders. + // This workaround belongs only to the rendering adapter. + instance_flags: InstanceFlags::empty(), + ..default() + }), + ..default() + }), + ) + .insert_resource(config) + .insert_resource(assets::AssetManifest(manifest)) + .insert_resource(graphs) + .insert_resource(physics) + .insert_resource(skater) + .configure_sets( + FixedUpdate, + ( + SimulationSet::Input, + SimulationSet::Controls, + SimulationSet::Physics, + ) + .chain(), + ) + .configure_sets( + Update, + ( + FrameSet::Assets, + FrameSet::Physics, + FrameSet::Animation, + FrameSet::Verification, + ) + .chain(), + ) + .add_plugins(( + input::InputPlugin, + PhysicsPlugin, + assets::GameAssetsPlugin, + animation::AnimationPlugin, + world::WorldPlugin, + camera::CameraPlugin, + verification::VerificationPlugin, + )); + app +} diff --git a/crates/skate-game/src/assets.rs b/crates/skate-game/src/assets.rs new file mode 100644 index 00000000..792e9f9f --- /dev/null +++ b/crates/skate-game/src/assets.rs @@ -0,0 +1,46 @@ +use crate::app::FrameSet; +use bevy::{asset::LoadState, gltf::Gltf, prelude::*}; +use skate_data::GameAssets; + +#[derive(Resource)] +pub(crate) struct AssetManifest(pub GameAssets); +#[derive(Resource)] +pub(crate) struct CharacterAsset(pub Handle); +#[derive(Resource, Default, PartialEq, Eq)] +pub(crate) enum AssetStatus { + #[default] + Loading, + Ready, + Failed, +} + +pub(crate) struct GameAssetsPlugin; +impl Plugin for GameAssetsPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, load) + .add_systems(Update, check.in_set(FrameSet::Assets)); + } +} +fn load(mut commands: Commands, server: Res, manifest: Res) { + commands.insert_resource(CharacterAsset( + server.load(manifest.0.character_scene.clone()), + )); +} +fn check( + server: Res, + character: Res, + mut status: ResMut, + mut exit: MessageWriter, +) { + if *status != AssetStatus::Loading { + return; + } + if let Some(LoadState::Failed(error)) = server.get_load_state(character.0.id()) { + error!("Character asset failed to load: {error}"); + *status = AssetStatus::Failed; + exit.write(AppExit::error()); + } else if server.is_loaded_with_dependencies(character.0.id()) { + *status = AssetStatus::Ready; + } +} diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs new file mode 100644 index 00000000..ff5fac5b --- /dev/null +++ b/crates/skate-game/src/camera.rs @@ -0,0 +1,67 @@ +//! Normal gameplay camera rendering endpoint. Simulation owns the cadence of +//! CameraRuntime::advance after its complete physical/animation publication. +mod settings; +mod collision; +mod world_query; +mod shot_data; +mod stock_names; +mod shake_data; +mod trajectory; +mod subject; +mod graph_subject; +mod graph_conditions; +mod graph; +mod runtime; +mod publication; +pub(crate) use publication::{ + snapshot as publish_camera_subject, CameraPublicationInputs, CameraStateOutput, + CameraAnimationOutput, CameraAirOutput, CameraOffboardOutput, CameraGrindOutput, + CameraEventsOutput, CameraPreferences, +}; +pub(crate) use subject::{CameraSubjectFrame, CameraSubjectSnapshot}; +pub(crate) use graph_subject::{CameraGraphEnvironment, CameraGraphSubject}; +pub(crate) use runtime::CameraRuntime; +use bevy::prelude::*; +use crate::{app::FrameSet, config::Config}; + +#[derive(Component)] +struct GameplayCamera; + +pub(crate) struct CameraPlugin; +impl Plugin for CameraPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, spawn) + .add_systems(Update, present.after(FrameSet::Animation).before(FrameSet::Verification)); + } +} +fn spawn(mut commands: Commands, config: Res) { + let runtime = CameraRuntime::load(&config.asset_root) + .unwrap_or_else(|error| panic!("Cannot initialize normal gameplay camera: {error}")); + commands.insert_resource(runtime); + commands.spawn(( + GameplayCamera, + Camera3d::default(), + Camera { is_active: false, ..default() }, + Transform::default(), + )); +} + +fn present(mut runtime: ResMut, windows: Query<&Window>, + mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With>) { + if let Ok(window) = windows.single() { + runtime.set_aspect_ratio(window.width() / window.height()); + } + let Some(frame) = runtime.frame else { return; }; + for (mut camera, mut transform, mut projection) in &mut cameras { + let [right, up, at] = frame.basis.columns.map(Vec3::from_array); + // World rendering retains nativeXYZ. Native camera At points forward; + // Bevy camera looks along local-Z, hence the two-axis basis conversion. + transform.rotation = Quat::from_mat3(&Mat3::from_cols(-right, up, -at)); + transform.translation = Vec3::new(frame.position[0], frame.position[1], frame.position[2]); + if let Projection::Perspective(p) = &mut *projection { + p.fov = frame.field_of_view_degrees.to_radians(); + } + camera.is_active = true; + } +} diff --git a/crates/skate-game/src/camera/collision.rs b/crates/skate-game/src/camera/collision.rs new file mode 100644 index 00000000..71863c9d --- /dev/null +++ b/crates/skate-game/src/camera/collision.rs @@ -0,0 +1,66 @@ +//! Host camera query adapter. The six source fat lines are preserved, and each +//! candidate uses the shared recovered TU3 thin/fat triangle dispatcher. +use skate_core::{ + camera::{DropCollisionProvider, FatLine, FatLineResult, PositionerCollisionProvider}, + physics::board_world::BoardWorld, +}; + +pub(super) struct CameraCollision<'a> { + world: &'a BoardWorld, + results: [FatLineResult; 6], + pub error: Option, +} + +impl DropCollisionProvider for CameraCollision<'_> { + fn query( + &mut self, + lines: [FatLine; 10], + _context: u32, + ) -> Result<[FatLineResult; 10], String> { + let mut output = [no_hit(); 10]; + for (result, line) in output.iter_mut().zip(lines) { + *result = super::world_query::line(self.world, line.start, line.end, line.radius)?; + } + Ok(output) + } +} + +impl<'a> CameraCollision<'a> { + pub fn new(world: &'a BoardWorld) -> Self { + Self { + world, + results: [no_hit(); 6], + error: None, + } + } +} + +impl PositionerCollisionProvider for CameraCollision<'_> { + fn submit(&mut self, lines: [FatLine; 6], _context: u32) { + // Context excludes the subject actor from native world queries. The + // clone's BoardWorld contains world triangles only, never actor bodies. + self.results = [no_hit(); 6]; + for (result, line) in self.results.iter_mut().zip(lines) { + match super::world_query::line(self.world, line.start, line.end, line.radius) { + Ok(hit) => *result = hit, + Err(error) => { + self.error = Some(error.into()); + } + } + } + } + fn results(&mut self) -> [FatLineResult; 6] { + self.results + } +} + +// 82DF3290 initializes both vectors, fraction and surface to zero on a miss. +fn no_hit() -> FatLineResult { + FatLineResult { + position: [0.0; 4], + normal: [0.0; 4], + fraction: 0.0, + hit: 0, + surface: 0, + } +} diff --git a/crates/skate-game/src/camera/graph.rs b/crates/skate-game/src/camera/graph.rs new file mode 100644 index 00000000..55dc7ad9 --- /dev/null +++ b/crates/skate-game/src/camera/graph.rs @@ -0,0 +1,117 @@ +//! Stock default camera graph, using the same recovered dynamic controller as +//! the action and motion graphs. Shot behavior side effects occur at Begin. +use std::path::Path; +use skate_core::{camera::{CameraMan, ManagerSubject, SlowMotionController, SlowMotionSettings, + SimulationRateRequest}, graph::{activation::ConditionHost, + controller::{Controller, Frame, Host}}}; +use skate_data::state_graph::{StateGraph, attributes::Attributes, binding::Binding}; +use crate::graph_runtime::CompiledGraph; +use super::{graph_conditions::{Condition, shot_names}, graph_subject::{CameraGraphEnvironment, + CameraGraphSubject}, shot_data::StockShots}; + +pub(super) struct CameraGraph { + program: CompiledGraph, + controller: Controller, + conditions: Vec, + behaviors: Vec, + slow_motion: Vec>, + slow_motion_settings: SlowMotionSettings, +} +enum Behavior { + Choose { names: Vec, incoming: f32, outgoing: f32 }, + Print(String), + SlowMotion, +} +impl CameraGraph { + pub fn load(path: &Path, slow_motion_settings: SlowMotionSettings) -> Result { + let source = StateGraph::load(path).map_err(|e| e.to_string())?; + let binding = Binding::from_graph(&source).map_err(|e| e.to_string())?; + let program = CompiledGraph::from_binding(&binding).map_err(|e| e.to_string())?; + let conditions = program.operations.conditions.iter().map(|id| { + let node = &source.elements[binding.operations[*id].element]; + Condition::parse(&Attributes::new(&node.attributes)) + }).collect::, _>>()?; + let behaviors = program.operations.behaviors.iter().map(|id| { + let operation = &binding.operations[*id]; + let node = &source.elements[operation.element]; + let a = Attributes::new(&node.attributes); + Ok(match operation.name.as_str() { + "CameraChooseShot" => Behavior::Choose { names: shot_names(&a), + incoming: f32::from_bits(a.float_bits("transitionIn", (-1.0_f32).to_bits())), + outgoing: f32::from_bits(a.float_bits("transitionOut", (-1.0_f32).to_bits())) }, + "PrintText2D" => Behavior::Print(a.text("text").unwrap_or("").into()), + "SlowMotionController" => Behavior::SlowMotion, + name => return Err(format!("Unimplemented camera behavior {name}")), + }) + }).collect::, String>>()?; + if !program.operations.hooks.is_empty() { + return Err("Camera graph has unimplemented transition hooks".into()); + } + let controller = Controller::new(binding.states.len()); + let slow_motion = vec![None; behaviors.len()]; + Ok(Self { program, controller, conditions, behaviors, slow_motion, slow_motion_settings }) + } + + pub fn update(&mut self, dt: f32, manager: &mut CameraMan, subject: &ManagerSubject, + physical: CameraGraphSubject, world: &CameraGraphEnvironment, database: &StockShots) + -> Result, String> { + let mut host = CameraHost { manager, subject, physical, world, database, + conditions: &self.conditions, behaviors: &self.behaviors, error: None, + slow_motion: &mut self.slow_motion, slow_motion_settings: self.slow_motion_settings, + rates: Vec::new() }; + self.controller.update(&self.program.program, dt, &mut host); + if let Some(error) = host.error { Err(error) } else { Ok(host.rates) } + } +} +struct CameraHost<'a> { + manager: &'a mut CameraMan, + subject: &'a ManagerSubject, + physical: CameraGraphSubject, + world: &'a CameraGraphEnvironment, + database: &'a StockShots, + conditions: &'a [Condition], + behaviors: &'a [Behavior], + error: Option, + slow_motion: &'a mut [Option], + slow_motion_settings: SlowMotionSettings, + rates: Vec, +} +impl ConditionHost for CameraHost<'_> { + fn condition_activation(&mut self, id: usize, _: &Frame) -> u32 { + u32::from(self.conditions[id].evaluate(self.manager, self.subject, self.physical, self.world)) + } +} +impl Host for CameraHost<'_> { + fn context(&self) -> [u32; 6] { [0; 6] } + fn allocate(&mut self, _: usize, _: &Frame) -> u32 { 0 } + fn begin(&mut self, id: usize, _: [u32; 6], _: &Frame) { + match &self.behaviors[id] { + Behavior::Choose { names, incoming, outgoing } => { + if let Some(name) = names.first() { + if let Err(error) = self.manager.set_shot(name, false, self.subject, self.database) { + self.error = Some(error); return; + } + self.manager.shots.apply_graph_transition(*incoming, *outgoing); + } + } + Behavior::Print(text) => bevy::log::warn!("Stock camera graph: {text}"), + Behavior::SlowMotion => { + let (instance, request) = SlowMotionController::begin(self.slow_motion_settings); + self.slow_motion[id] = Some(instance); + self.rates.push(request); + } + } + } + // CameraChooseShot Update/End both resolve to TU382B61BB8 (return). + fn update(&mut self, id: usize, _: [u32; 6], frame: &Frame) { + if let Some(instance) = &mut self.slow_motion[id] { + self.rates.push(instance.update(frame.dt, self.physical.slow_motion_air_duration, + self.slow_motion_settings)); + } + } + fn end(&mut self, id: usize, _: [u32; 6], _: &Frame) { + if self.slow_motion[id].take().is_some() { self.rates.push(SlowMotionController::end()); } + } + fn hook(&mut self, _: usize, _: &Frame) { unreachable!("camera hooks rejected during load") } + fn release(&mut self, _: u32) {} +} diff --git a/crates/skate-game/src/camera/graph_conditions.rs b/crates/skate-game/src/camera/graph_conditions.rs new file mode 100644 index 00000000..b0d1fe10 --- /dev/null +++ b/crates/skate-game/src/camera/graph_conditions.rs @@ -0,0 +1,131 @@ +//! Camera condition leaves82DF4A90..82DF61DC, bound to the stock binary graph. +use skate_core::{camera::{CameraMan, ManagerSubject}, graph::conditions::NumericCondition}; +use skate_data::state_graph::attributes::{Attributes, key_hash}; +use super::graph_subject::{CameraGraphEnvironment, CameraGraphSubject}; + +#[derive(Clone, Debug)] +pub(super) enum Condition { + Type(u32), Previous(Vec), Tweak(u32), Volume(String), + Boolean(Boolean), Numeric(Value, NumericCondition), +} +#[derive(Clone, Copy, Debug)] +pub(super) enum Boolean { + Observer, WipingOut, BrokenBone, Offboard, OffboardAir, OnboardAir, + FixedInAir, Preparing, Manual, DroppingIn, MovingObject, Skitching, + Footplant, Handplant, Boneless, HippyJump, HippyHurdle, Grinding, + WasGrinding, OnGround, Reentry, Road, LedgeLeft, LedgeRight, + WorldPainter, Threat, BadCollision, +} +#[derive(Clone, Copy, Debug)] +pub(super) enum Value { + Speed, SpeedY, Acceleration, AirTime, LandingHeight, MaxHeight, HeightRatio, + FrontsideAngle, TransferAngle, LaunchIncline, LandingIncline, GrindIncline, + Incline, AbsoluteIncline, CentredTime, TurningTime, TimeSinceInput, +} +impl Condition { + pub fn parse(a: &Attributes<'_>) -> Result { + use Boolean as B; + use Value as V; + let name = a.text("name").unwrap_or(""); + let boolean = match name { + "IsInObserverMode" => Some(B::Observer), "IsWipingOut" => Some(B::WipingOut), + "IsBrokenBoneSlowMo" => Some(B::BrokenBone), "IsOffboard" => Some(B::Offboard), + "IsAirOffboard" => Some(B::OffboardAir), "IsInOnBoardAir" => Some(B::OnboardAir), + "IsFixedInAir" => Some(B::FixedInAir), "IsPreparingToJump" => Some(B::Preparing), + "IsInManual" => Some(B::Manual), "SkaterIsDroppingIn" => Some(B::DroppingIn), + "SkaterIsMovingObject" => Some(B::MovingObject), "SkaterIsSkitching" => Some(B::Skitching), + "SkaterIsPerformingFootPlant" => Some(B::Footplant), + "SkaterIsPerformingHandPlant" => Some(B::Handplant), + "SkaterIsPerformingBoneless" => Some(B::Boneless), + "SkaterIsPerformingHippyJump" => Some(B::HippyJump), + "SkaterIsPerformingHippyHurdle" => Some(B::HippyHurdle), + "SkaterIsGrinding" => Some(B::Grinding), "SkaterWasGrinding" => Some(B::WasGrinding), + "IsPlayerSkateboardOnGround" => Some(B::OnGround), "IsReentry" => Some(B::Reentry), + "SkaterIsOnRoad" => Some(B::Road), "IsGrindLedgeLeft" => Some(B::LedgeLeft), + "IsGrindLedgeRight" => Some(B::LedgeRight), "IsInWorldPainterRegion" => Some(B::WorldPainter), + "SkaterIsUnderThreat" => Some(B::Threat), "IsCameraInBadCollision" => Some(B::BadCollision), + _ => None, + }; + if let Some(value) = boolean { return Ok(Self::Boolean(value)); } + let value = match name { + "CameraSubjectSpeed" => Some(V::Speed), "CameraSubjectSpeedY" => Some(V::SpeedY), + "CameraSubjectAcceleration" => Some(V::Acceleration), "PredictedAirTime" => Some(V::AirTime), + "PredictedLandingHeight" => Some(V::LandingHeight), "PredictedMaxHeight" => Some(V::MaxHeight), + "PredictedHeightDistanceRatio" => Some(V::HeightRatio), "FrontsideAngle" => Some(V::FrontsideAngle), + "TransferAngle" => Some(V::TransferAngle), "LaunchInclineAngle" => Some(V::LaunchIncline), + "LandingInclineAngle" => Some(V::LandingIncline), "GrindInclineAngle" => Some(V::GrindIncline), + "InclineAngle" => Some(V::Incline), "InclineAngleAbs" => Some(V::AbsoluteIncline), + "TurningCentredTime" => Some(V::CentredTime), "TurningTime" => Some(V::TurningTime), + "TimeSinceLastPlayerInput" => Some(V::TimeSinceInput), _ => None, + }; + if let Some(value) = value { + return Ok(Self::Numeric(value, crate::graph_host::parse_numeric_condition(a))); + } + Ok(match name { + "IsCameraTypeActive" => Self::Type(a.text("type").unwrap_or("0").parse() + .map_err(|_| "Invalid normal camera type")?), + "IsPreviousShot" => Self::Previous(shot_names(a).iter().map(|v| key_hash(v)).collect()), + "IsInVolume" => Self::Volume(a.text("volume").unwrap_or("").to_owned()), + // Constructor82DF5588 uses case-sensitive comparison and keeps0 + // for both Freefall and an unrecognized tweak name. + "IsWipeoutBodyTweak" => Self::Tweak(match a.text("tweak").unwrap_or("") { + "CannonBall" => 1, "JudoKick" => 2, "SwanDive" => 3, "Torpedo" => 4, _ => 0 }), + _ => return Err(format!("Unimplemented stock camera condition {name}")), + }) + } + + pub fn evaluate(&self, m: &CameraMan, s: &ManagerSubject, g: CameraGraphSubject, + world: &CameraGraphEnvironment) -> bool { + use Boolean as B; + use Value as V; + let f = &m.rig.fields; + let state = &m.state; + const DEGREES: f32 = f32::from_bits(0x42652ee1); + match self { + Self::Type(value) => *value == world.camera_type, + Self::Previous(names) => names.contains(&key_hash(&m.shots.current().name)), + Self::Tweak(value) => *value == g.wipeout_tweak, + //82DF90C0 compares exactly strlen(requested) bytes with82990158. + Self::Volume(name) => world.volumes.iter() + .any(|v| v.as_bytes().starts_with(name.as_bytes())), + Self::Boolean(value) => match value { + B::Observer => s.special_effect != 0, B::WipingOut => s.rig.wiping_out != 0, + B::BrokenBone => s.rig.broken_bone_slowmo != 0, + B::Offboard => s.rig.off_board != 0 || g.running_out, + B::OffboardAir => g.offboard_air, B::OnboardAir => g.onboard_air, + B::FixedInAir => s.rig.air_flag_452 != 0, B::Preparing => g.preparing_to_jump, + B::Manual => g.manual, B::DroppingIn => g.dropping_in, B::MovingObject => g.moving_object, + B::Skitching => g.skitching, B::Footplant => g.footplant, B::Handplant => g.handplant, + B::Boneless => g.boneless, B::HippyJump => g.hippy_jump, B::HippyHurdle => g.hippy_hurdle, + B::Grinding => f.height_mode == 2, B::WasGrinding => f.previous_height_mode == 2, + B::OnGround => f.height_mode == 0, B::Reentry => state.flags & 0x10 != 0, + B::Road => world.on_road, B::LedgeLeft => world.ledge_left, B::LedgeRight => world.ledge_right, + // TU382DF5050 returns false. AI is intentionally absent from this game. + B::WorldPainter | B::Threat => false, + B::BadCollision => m.rig.positioner.flags & 0x80 != 0 + && m.rig.positioner.distance < f.distance * 0.75, + }, + Self::Numeric(value, condition) => condition.matches(match value { + //82E03060: signed velocity projected on the subject's forward axis. + V::Speed => { let n = s.rig.transform[2]; let v = f.velocity; + (v[0] * n[0] + v[1] * n[1]) + v[2] * n[2] }, + V::SpeedY => f.velocity[1], V::Acceleration => f.smoothed_acceleration, + V::AirTime => s.valid_trajectory_duration(), V::LandingHeight => state.landing_height_delta, + V::MaxHeight => state.apex_height, V::HeightRatio => state.apex_height_ratio, + V::FrontsideAngle => state.frontside_angle, + V::TransferAngle => state.launch_landing_heading_delta * DEGREES, + V::LaunchIncline => state.launch_incline * DEGREES, + V::LandingIncline => state.landing_incline * DEGREES, + V::GrindIncline => state.direction_incline * DEGREES, + V::Incline => state.velocity_incline * DEGREES, + V::AbsoluteIncline => (state.absolute_velocity_incline * DEGREES).abs(), + V::CentredTime => state.centred_time, V::TurningTime => state.steering_time, + V::TimeSinceInput => g.time_since_player_input, + }), + } + } +} + +pub(super) fn shot_names(a: &Attributes<'_>) -> Vec { + (1..).map(|i| format!("shot{i}")).map_while(|key| a.text(&key).map(str::to_owned)).collect() +} diff --git a/crates/skate-game/src/camera/graph_subject.rs b/crates/skate-game/src/camera/graph_subject.rs new file mode 100644 index 00000000..79c8c4e2 --- /dev/null +++ b/crates/skate-game/src/camera/graph_subject.rs @@ -0,0 +1,34 @@ +//! Remaining physical subject getters consumed by the stock camera graph. +//! These are publications from physics/animation, not deductions from the shot. +#[derive(Clone, Copy, Debug)] +pub(crate) struct CameraGraphSubject { + pub time_since_player_input: f32, // getter520 + pub wipeout_tweak: u32, //536 + pub onboard_air: bool, //572 + pub preparing_to_jump: bool, //576 + pub manual: bool, //580 + pub offboard_air: bool, //612 + pub running_out: bool, //616 + pub footplant: bool, //620 + pub handplant: bool, //624 + pub hippy_jump: bool, //628 + pub hippy_hurdle: bool, //632 + pub boneless: bool, //636 + pub moving_object: bool, //644 + pub skitching: bool, //648 + pub dropping_in: bool, //664 + /// Physical output bundle2+184, read directly by82BB1EE0. + pub slow_motion_air_duration: f32, +} + +/// Engine-owned camera preferences and authored world camera-region membership. +/// An empty custom world has no road/ledge/volume camera annotations. +#[derive(Clone, Debug)] +pub(crate) struct CameraGraphEnvironment { + /// Normal stock camera type0=Low, type1=High (graph-authored selection). + pub camera_type: u32, + pub on_road: bool, + pub ledge_left: bool, + pub ledge_right: bool, + pub volumes: Vec, +} diff --git a/crates/skate-game/src/camera/publication.rs b/crates/skate-game/src/camera/publication.rs new file mode 100644 index 00000000..4f43d032 --- /dev/null +++ b/crates/skate-game/src/camera/publication.rs @@ -0,0 +1,343 @@ +//! Game owners -> normal camera subject82DF69C0. Call after physical output. +//! Missing publications are explicit inputs, never inferred from render pose. +use super::{graph_subject::CameraGraphSubject, subject::CameraSubjectSnapshot}; +use crate::physics::{GamePhysics, SkaterRuntime}; +use skate_core::{ + camera::{ + AnchorInputs, Anchors, CompassPoseInputs, ManagerSubject, ReferencePointInputs, Subject, + SubjectPoseInputs, + }, + physics::{board::BodyId, skeleton_animation_record::AnimationPartTransform as Transform}, +}; + +/// State record fields absent from the input phase's smaller CurrentStateFields. +/// The same output/reset owner must publish these; there is no camera default. +pub(crate) struct CameraStateOutput { + pub height_32: f32, + pub physically_pushing_55: u8, + pub wiping_out_59: u8, + pub manual_60: u8, + pub reset_62: u8, + pub use_skeleton_root_75: u8, + pub flag_79: u8, + pub flag_81: u8, +} + +pub(crate) struct CameraAnimationOutput { + /// PhysOutAnimation32..60 from the existing turn conditioner, once per tick. + pub conditioned_turn: [f32; 8], + pub input_turn_64: f32, + pub input_kickturn_68: f32, + pub time_since_input_128: f32, + pub wipeout_tweak_148: u32, + pub stance_155: u8, + pub running_out_160: u8, + /// Actor's actual SkaterAnim component virtual28, copied at82DF6EA4. + pub skater_animation_stance: u8, +} + +/// Air output2. The ordinary Ground cancellation/reset owns its retained +/// values and validity; a false validity flag does not erase these vectors. +pub(crate) struct CameraAirOutput { + pub apex_0: [f32; 4], + pub landing_position_16: [f32; 4], + pub landing_normal_32: [f32; 4], + pub launch_position_48: [f32; 4], + pub heading_80: [f32; 4], + pub time_176: f32, + pub duration_180: f32, + pub apex_time_196: f32, + pub flag_440: u8, +} + +/// The alternate trajectory and event fields read from OffBoard output18. +pub(crate) struct CameraOffboardOutput { + pub duration_92: f32, + pub time_152: f32, + pub apex_time_156: f32, + pub launch_normal_160: [f32; 4], + pub launch_position_176: [f32; 4], + pub landing_normal_192: [f32; 4], + pub landing_position_208: [f32; 4], + pub heading_224: [f32; 4], + pub apex_240: [f32; 4], + pub object_held_304: u8, + pub hurdle_317: u8, + pub use_trajectory_331: u8, + pub dropping_in_334: u8, +} + +/// Grinds output4, distinct from Ground output8 (whose316 is wall-ride exit). +pub(crate) struct CameraGrindOutput { + pub direction_0: [f32; 4], + pub camera_target_96: [f32; 4], + pub grinding_316: u8, +} + +pub(crate) struct CameraEventsOutput { + pub intent_51: u8, + pub preparing_52: u8, + pub dropping_in_63: u8, + pub trick_125: u8, + pub hippy_jump_322: u8, + pub broken_bone_duration_200: f32, + pub capabilities_204: u32, +} + +/// Engine-owned preference values copied by the original subject publisher. +pub(crate) struct CameraPreferences { + pub invert_look: [bool; 2], + pub shake_variant: u8, + pub value_32: f32, +} + +/// Remaining real output producers needed alongside the existing game owners. +/// This structure deliberately has no Default: its callers must publish the +/// actual native reset/state values even when air/offboard/grinds are inactive. +pub(crate) struct CameraPublicationInputs { + pub state: CameraStateOutput, + pub animation: CameraAnimationOutput, + pub air: CameraAirOutput, + pub offboard: CameraOffboardOutput, + pub grinds: CameraGrindOutput, + pub events: CameraEventsOutput, + /// PhysOutSystemReckoning80, after its own physical output conditioner. + pub damped_com_80: [f32; 4], + /// PhysOutGround80 and288.80 differs from dynamic up64 and wheel normal96. + pub ground_up_80: [f32; 4], + pub ground_scalar_288: f32, + /// PhysOutSkeleton552/556 and PhysOutCollision64, respectively. + pub look_552_556: [f32; 2], + pub collision_look_target_64: [f32; 4], + pub preferences: CameraPreferences, + /// Our stable player/query identity replaces the native actor address. + pub context: u32, +} + +/// Publish from the very same physical bodies and completed output records +/// used by the animation phase. This neither advances physics nor conditions +/// the steering twice. SubjectPublisher owns the subsequent camera histories. +pub(crate) fn snapshot( + physics: &GamePhysics, + skater: &SkaterRuntime, + input: &CameraPublicationInputs, +) -> Result { + let p = &skater.player_input.physical; + let processed = &skater.player_input.processed; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Camera publication requires the completed player input toolkit")?; + let ground = skater + .ground + .output(processed, &skater.animation_input, toolkit); + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let mut physical_transform = [[0.0; 4]; 4]; + for (column, axis) in physics + .riding + .motion + .effective_basis + .columns + .iter() + .enumerate() + { + physical_transform[column][..3].copy_from_slice(axis); + } + physical_transform[3] = [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ]; + //82BE3650 -> PhysOutSkeleton0/432 at82BE20F4/2100. Its stance flag is + //Processed2476 bit2, distinct from the board's effective-frame flag2468. + let skeleton_root = effective_skeleton_root( + skater.animated_skeleton.roots.animation_to_world, + processed.flags_2476, + ); + let record = &skater.skeleton.record; + let com = p.reckoning.vector_64.map(f32::from_bits); + let up = p.reckoning.vector_96.map(f32::from_bits); + let velocity = p.skateboard.vector_80.map(f32::from_bits); + //The source getter396 is bound to Motion64, despite its camera name + //"acceleration". Motion64 is the published deck angular velocity. + let acceleration = p.skateboard.vector_64.map(f32::from_bits); + let last_ground_up = p.ground.vector_96.map(f32::from_bits); + let category = p.state.category_12; + let state = p.state.state_16; + let off = &input.offboard; + let air = &input.air; + let alternate = off.use_trajectory_331 != 0; + let ( + launch_position, + launch_normal, + landing_position, + landing_normal, + apex_position, + heading, + trajectory_time, + trajectory_duration, + apex_time, + ) = if alternate { + ( + off.launch_position_176, + off.launch_normal_160, + off.landing_position_208, + off.landing_normal_192, + off.apex_240, + off.heading_224, + off.time_152, + off.duration_92, + off.apex_time_156, + ) + } else { + ( + air.launch_position_48, + last_ground_up, + air.landing_position_16, + air.landing_normal_32, + air.apex_0, + air.heading_80, + air.time_176, + air.duration_180, + air.apex_time_196, + ) + }; + let grinding = input.grinds.grinding_316; + let look = std::array::from_fn(|i| { + if input.preferences.invert_look[i] { + -input.look_552_556[i] + } else { + input.look_552_556[i] + } + }); + let subject = ManagerSubject { + rig: Subject { + //Overwritten by the stock subject's one-frame publication history. + transform: physical_transform, + skeleton_root, + hips_position: record.pose[23][3], + last_valid_ground_up: last_ground_up, + reference_positions: [[0.0; 4]; 10], + context: input.context, + pumping_acceleration: skater.ground.pumping.pump_acceleration, + state_height_32: input.state.height_32, + in_ground_physics: u8::from(category == 100), + grinding, + trajectory_valid: if alternate { 1 } else { p.air.flag_441 }, + wiping_out: input.state.wiping_out_59, + physically_pushing: input.state.physically_pushing_55, + at_pushable_speed: u8::from(ground.skateboard_motion_4.is_at_pushable_speed), + off_board: u8::from(category == 500), + air_flag_452: p.air.use_air_reckoning_452, + state_flag_81: input.state.flag_81, + broken_bone_slowmo: u8::from( + input.events.broken_bone_duration_200 > 0.0 + && input.events.capabilities_204 & 4 != 0, + ), + subject_flag_328: 0, //82DF726C clears this every publication. + }, + anchors: Anchors::new().entries, //filled by the real anchor histories. + compass: [0.0; 9], //filled by the real Compass owner. + board_offset_direction: up, + ground_normal: input.ground_up_80, + launch_position, + launch_normal, + landing_position, + landing_normal, + apex_position, + direction_424: input.grinds.direction_0, + look, + steering: [ + input.animation.conditioned_turn[4], + input.animation.conditioned_turn[3], + input.animation.input_turn_64, + input.animation.input_kickturn_68, + ], + trajectory_time, + trajectory_duration, + apex_time, + value_512: input.preferences.value_32, + value_516: input.ground_scalar_288, + reset: input.state.reset_62, + flag_556: air.flag_440, + stance_560: input.animation.stance_155, + stance_592: input.animation.skater_animation_stance, + flag_652: input.state.flag_79, + shake_variant: input.preferences.shake_variant, + special_effect: 0, //82DF6A44..6A60 clears observer mode for this subject. + flag_684: off.object_held_304, + }; + Ok(CameraSubjectSnapshot { + subject, + pose: SubjectPoseInputs { + physical_transform, + skeleton_root, + center_of_mass: com, + reckoned_center_of_mass: input.damped_com_80, + wiping_out: input.state.wiping_out_59 != 0, + state_flag_75: input.state.use_skeleton_root_75 != 0, + }, + anchors: AnchorInputs { + board_position: physical_transform[3], + board_velocity: velocity, + board_acceleration: acceleration, + board_offset_direction: up, + center_of_mass: com, + damped_center_of_mass: input.damped_com_80, + skeleton_root_up: skeleton_root[1], + grind_point: input.grinds.camera_target_96, + grinding: grinding != 0, + reset: input.state.reset_62 != 0, + }, + reference_points: ReferencePointInputs { + //82BE1E64..1ED4 copies these actual physical volume positions. + head: record.pose[1][3], + hips: record.pose[23][3], + left_foot: record.pose[15][3], + right_foot: record.pose[19][3], + board: skeleton_root[3], + centre_of_mass: com, + damped_centre_of_mass: input.damped_com_80, + grind_position: input.grinds.camera_target_96, + tracked_anchor: [0.0; 4], + incline_normal: [0.0; 4], + grinding, + }, + compass: CompassPoseInputs { + current_physical_transform: physical_transform, + skeleton_direction: skeleton_root[2], + look_target: input.collision_look_target_64, + board_velocity: velocity, + trajectory_direction: heading, + state_103: state == 103, + }, + graph: CameraGraphSubject { + time_since_player_input: input.animation.time_since_input_128, + wipeout_tweak: input.animation.wipeout_tweak_148, + onboard_air: category == 200, + preparing_to_jump: input.events.preparing_52 != 0, + manual: input.state.manual_60 != 0 && input.events.intent_51 == 0, + offboard_air: state == 501, + running_out: input.animation.running_out_160 != 0, + footplant: state == 601, + handplant: state == 600, + hippy_jump: input.events.hippy_jump_322 != 0, + hippy_hurdle: off.hurdle_317 != 0, + boneless: input.events.trick_125 != 0, + moving_object: state == 502, + skitching: state == 104, + dropping_in: input.events.dropping_in_63 != 0 && off.dropping_in_334 != 0, + slow_motion_air_duration: p.air.scalar_184, + }, + }) +} + +fn effective_skeleton_root(mut root: Transform, flags_2476: u32) -> Transform { + if flags_2476 & 4 != 0 { + root[0] = root[0].map(|v| -v); + root[2] = root[2].map(|v| -v); + } + root +} diff --git a/crates/skate-game/src/camera/runtime.rs b/crates/skate-game/src/camera/runtime.rs new file mode 100644 index 00000000..49d39c57 --- /dev/null +++ b/crates/skate-game/src/camera/runtime.rs @@ -0,0 +1,91 @@ +//! Complete normal camera owner: subject publications, stock graph, CameraMan, +//! swept world queries and retained query history. The simulation calls advance +//! once per completed physical/animation publication; rendering only presents it. +use std::path::Path; +use bevy::prelude::Resource; +use skate_core::{camera::{CameraFrame, CameraMan, CompassSettings, ManagerSettings, + MovingObstacleProvider, ShakeSamples, SimulationRateRequest, SlowMotionSettings}, + physics::board_world::BoardWorld, point_graph::PointGraph}; +use skate_data::collections::Collections; +use super::{collision::CameraCollision, graph::CameraGraph, graph_subject::CameraGraphEnvironment, + settings, shake_data, shot_data::StockShots, subject::{CameraSubjectSnapshot, SubjectPublisher}, + trajectory::{CameraTrajectory, TrajectoryResult}}; + +#[derive(Resource)] +pub(crate) struct CameraRuntime { + manager: CameraMan, + subject: SubjectPublisher, + graph: CameraGraph, + shots: StockShots, + settings: ManagerSettings, + compass_settings: CompassSettings, + shakes: [ShakeSamples; 2], + trajectories: [TrajectoryResult; 3], + pub frame: Option, + /// Native message order, including End followed by Begin in one graph tick. + /// The simulation schedule drains these after advance. + pub simulation_rate_requests: Vec, +} + +#[cfg(test)] +#[path = "tests/runtime.rs"] +mod tests; +impl CameraRuntime { + pub fn load(root: &Path) -> Result { + let data = Collections::load(root)?; + let values = data.words::<32>("slowmotion_controller", "default", "timescale")? + .map(f32::from_bits); + let slow_motion = SlowMotionSettings { + timescale: PointGraph { + x: core::array::from_fn(|i| values[i]), + y: core::array::from_fn(|i| values[i + 16]), + }, + fps_at_scale_one: data.float("slowmotion_controller", "default", "fps_at_scale_one")?, + }; + let graph = CameraGraph::load(&root.join( + "private/stock/data/script/camera/Default_cameragraph.stategraph"), slow_motion)?; + let samples = |name: &str| -> Result { + let path = root.join("private/stock/data/camera").join(name); + shake_data::parse(&std::fs::read_to_string(&path) + .map_err(|e| format!("{}: {e}", path.display()))?) + }; + Ok(Self { manager: CameraMan::new(), subject: SubjectPublisher::new(), graph, + shots: StockShots::from_collections(&data)?, settings: settings::manager_settings(&data)?, + compass_settings: settings::compass_settings(&data)?, shakes: [samples("1.shk")?, samples("2.shk")?], + trajectories: core::array::from_fn(|_| TrajectoryResult::new()), frame: None, + simulation_rate_requests: Vec::new() }) + } + + pub fn set_aspect_ratio(&mut self, value: f32) { + self.manager.state.aspect_ratio = value; + } + pub fn selected_shot(&self) -> &str { &self.manager.shots.current().name } + + pub fn advance(&mut self, dt: f32, snapshot: CameraSubjectSnapshot, + world: &BoardWorld, query_gravity: [f32; 4], environment: &CameraGraphEnvironment, + moving: &mut impl MovingObstacleProvider) -> Result { + let mut subject = self.subject.publish(snapshot, &self.manager, self.compass_settings); + self.manager.prepare(&subject, self.settings); + let requests = self.graph.update(dt, &mut self.manager, &subject, + snapshot.graph, environment, &self.shots)?; + self.simulation_rate_requests.extend(requests); + let [a, b, c] = &mut self.trajectories; + let mut trajectories = [a, b, c].map(|result| CameraTrajectory { + world, gravity: query_gravity, result, + }); + let mut collision = CameraCollision::new(world); + let frame = self.manager.update(dt, &mut subject, self.settings, + [&self.shakes[0], &self.shakes[1]], &mut trajectories, moving, &mut collision)?; + if let Some(error) = collision.error { return Err(error); } + if let Some(error) = self.trajectories.iter().find_map(|v| v.error.as_ref()) { + return Err(error.clone()); + } + if !frame.position.iter().all(|v| v.is_finite()) + || !frame.basis.columns.iter().flatten().all(|v| v.is_finite()) + || !frame.field_of_view_degrees.is_finite() { + return Err("Normal gameplay camera produced a non-finite frame".into()); + } + self.frame = Some(frame); + Ok(frame) + } +} diff --git a/crates/skate-game/src/camera/settings.rs b/crates/skate-game/src/camera/settings.rs new file mode 100644 index 00000000..b7ab7144 --- /dev/null +++ b/crates/skate-game/src/camera/settings.rs @@ -0,0 +1,207 @@ +//! Stock normal-camera rig settings, bound by TU3 8252E288/8252EDD0. +//! Runtime offsets in camera kernels are relative to settings-object +16. +use skate_core::{ + camera::{ + AnchorTrackingSettings, AngleTrackingSettings, AvoidanceSettings, DistanceTrackingSettings, + DropSettings, FrameSettings, LookSettings, OrientationSettings, OrientationTrackerSettings, + RigPositioningSettings, RigSettings, ShakeSettings, ManagerSettings, CompassSettings, + }, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct RigSettingsSource { + heading: AngleTrackingSettings, + elevation: AngleTrackingSettings, + anchor: AnchorTrackingSettings, + avoidance: AvoidanceSettings, + positioning: RigPositioningSettings, + collision_hold_duration: f32, +} + +pub(crate) fn manager_settings(data: &Collections) -> Result { + Ok(ManagerSettings { + rig: RigSettingsSource::load(data)?.bind(), orientation: orientation_settings(data)?, + framing: frame_settings(data)?, drop: drop_settings(data)?, look: look_settings(data)?, + shake: shake_settings(data)?, + steering_threshold: data.float("camera", "dynamics", "TurningCentredDeadzone")?, + }) +} + +pub(crate) fn compass_settings(data: &Collections) -> Result { + let f = |field| data.float("camera_compass", "default", field); + Ok(CompassSettings { + heading_response: data.float("camera", "dynamics", "HeadingChangeResponseSpeed")?, + time_before_lineup: f("TimeBeforeAutoLineup")?, lineup_speed: f("AutoLineupSpeed")?, + minimum_deadzone_speed: f("MinDeadzoneSpeed")?, maximum_deadzone_speed: f("MaxDeadzoneSpeed")?, + maximum_deadzone_size: f("MaxDeadzoneSize")?, deadzone_smoothing: f("DeadzoneSizeSmoothing")?, + }) +} + +pub(crate) fn drop_settings(data: &Collections) -> Result { + // Runtime parameter652/656/660/668 correspond to settings object + //668/672/676/684, bound from droppredictor layout208/204/220/228. + Ok(DropSettings { + minimum_test_distance: data.float("camera_droppredictor", "default", "MinTestDistance")?, + total_test_time: data.float("camera_droppredictor", "default", "TotalTestTime")?, + maximum_test_distance: data.float( + "camera_droppredictor", + "default", + "Hash_FB5A1024D47298A1", + )?, + maximum_drop_distance: data.float("camera_droppredictor", "default", "MaxDropDistance")?, + }) +} + +pub(crate) fn look_settings(data: &Collections) -> Result { + // Settings object952/960/964/972 -> parameter936/944/948/956. + Ok(LookSettings { + heading_offset_degrees: data.float("camera", "freecam", "FreeCamHeadingOffset")?, + heading_speed: data.float("camera", "freecam", "FreeCamHeadingSpeed")?, + elevation_offset_degrees: data.float("camera", "freecam", "FreeCamElevationOffset")?, + elevation_speed: data.float("camera", "freecam", "FreeCamElevationSpeed")?, + }) +} + +pub(crate) fn shake_settings(data:&Collections)->Result { + let f=|field|data.float("camera_shake","default",field); + let matrix=|field|->Result<[[f32;4];4],String> { + let words=data.words::<16>("camera_shake","default",field)?; + Ok(core::array::from_fn(|row|core::array::from_fn(|i|f32::from_bits(words[row*4+i])))) + }; + Ok(ShakeSettings { + amplitude_curve:matrix("AmplitudeCurve")?,frequency_curve:matrix("FrequencyCurve")?, + impulse_magnitude:f("OneShotMagnitude")?,impulse_minimum_velocity:f("OneShotMinVelocity")?, + impulse_maximum_velocity:f("OneShotMaxVelocity")?,impulse_frequency:f("OneShotFrequency")?, + impulse_decay:f("OneShotDecay")?,amplitude_minimum:f("AmplitudeMin")?, + amplitude_maximum:f("AmplitudeMax")?,amplitude_top_speed:f("AmplitudeTopSkaterSpeed")?, + frequency_minimum:f("FrequencyMin")?,frequency_maximum:f("FrequencyMax")?, + frequency_top_speed:f("FrequencyTopSkaterSpeed")?,data_frames_per_second:f("DataFPS")?, + translation_multiplier:f("TranslationMultiplier")?,rotation_multiplier:f("RotationMultiplier")?, + dutch_multiplier:f("DutchMultiplier")?, + }) +} + +impl RigSettingsSource { + pub fn load(data: &Collections) -> Result { + let f = |class, key, field| data.float(class, key, field); + // 8252E6CC binds heading; 8252E680 binds elevation. These are + // settings for the normal camera's angular trackers, in degrees. + let angle = |key| -> Result { + Ok(AngleTrackingSettings { + speed_clamp_degrees: f("camera_tracker", key, "SpeedClamp")?, + acceleration_min_degrees: f("camera_tracker", key, "AccelerationClampMin")?, + acceleration_max_degrees: f("camera_tracker", key, "AccelerationClampMax")?, + delta_umbra_degrees: f("camera_tracker", key, "DeltaUmbra")?, + delta_penumbra_degrees: f("camera_tracker", key, "DeltaPenumbra")?, + }) + }; + Ok(Self { + heading: angle("heading")?, + elevation: angle("elevation")?, + anchor: AnchorTrackingSettings { + latch_curve: curve(data, "camera_lag_profile", "push", "Curve")?, + input_curve: curve(data, "camera_lag_profile", "pump", "Curve")?, + speed_clamp: f("camera", "subject_tracker", "SubjectTrackerMaxSpeed")?, + acceleration_clamp: f( + "camera", + "subject_tracker", + "SubjectTrackerMaxAcceleration", + )?, + latch_scale: f("camera_lag_profile", "push", "MaxSmoothing")?, + input_threshold: f("camera", "dynamics", "PumpingMinAcceleration")?, + input_scale: f("camera_lag_profile", "pump", "MaxSmoothing")?, + }, + avoidance: AvoidanceSettings { + horizon: f("camera", "dynamics", "AvoidanceLookahead")?, + heading_smoothing: f("camera", "dynamics", "AvoidanceHeadingSmoothing")?, + elevation_smoothing: f("camera", "dynamics", "AvoidanceElevationSmoothing")?, + ease_out_time: f("camera", "dynamics", "AvoidanceEaseOut")?, + radius_padding: f("camera_positioner", "default", "AvoidanceCollisionOffset")?, + }, + positioning: RigPositioningSettings { + normal: DistanceTrackingSettings { + speed_clamp: f("camera_tracker", "distance", "SpeedClamp")?, + smoothing: f("camera_tracker", "distance", "SmoothingConstant")?, + acceleration_clamp: f("camera_tracker", "distance", "AccelerationClampMax")?, + }, + alternate: DistanceTrackingSettings { + speed_clamp: f("camera_tracker", "distance", "FastSpeedClamp")?, + // Native layout +12 has no recovered spelling. Its complete + // key is preserved by the stock collection conversion. + smoothing: f("camera_tracker", "distance", "Hash_E2D9D0FAAB8CB7F3")?, + acceleration_clamp: f( + "camera_tracker", + "distance", + "FastAccelerationClampMax", + )?, + }, + minimum_distance: f("camera_positioner", "default", "MinDistance")?, + alternate_minimum_distance: f( + "camera_positioner", + "default", + "MinDistanceDuringWipeout", + )?, + }, + collision_hold_duration: f("camera", "dynamics", "CollisionInterpolationDuration")?, + }) + } + + pub fn bind(self) -> RigSettings { + RigSettings { + heading: self.heading, + elevation: self.elevation, + anchor: self.anchor, + avoidance: self.avoidance, + positioning: self.positioning, + collision_hold_duration: self.collision_hold_duration, + // 82F826F8 loads82181A88 with lvlx128, splats lane0, and stores + // all four lanes at830BD350. This is not mapped BSS zero data. + normalization_threshold: [f32::from_bits(0x358637bd); 4], + } + } +} + +pub(crate) fn orientation_settings(data: &Collections) -> Result { + let f = |class, key, field| data.float(class, key, field); + let tracker = |key| -> Result { + Ok(OrientationTrackerSettings { + acceleration_min_degrees: f("camera_tracker", key, "AccelerationClampMin")?, + acceleration_max_degrees: f("camera_tracker", key, "AccelerationClampMax")?, + // 8252F448/8252F508 attribute lookup low wordD1CA9582. + smoothing_min: f("camera_tracker", key, "SmoothingMin")?, + delta_umbra_degrees: f("camera_tracker", key, "DeltaUmbra")?, + delta_penumbra_degrees: f("camera_tracker", key, "DeltaPenumbra")?, + }) + }; + Ok(OrientationSettings { + pan_umbra_degrees: f("camera", "rotation", "PanUmbra")?, + pan_penumbra_degrees: f("camera", "rotation", "PanPenumbra")?, + pan_smoothing_curve: curve(data, "camera", "rotation", "PanSmoothingCurve")?, + pan_minimum_smoothing: f("camera", "rotation", "PanMinSmoothing")?, + pan: tracker("pan")?, + tilt: tracker("tilt")?, + }) +} + +pub(crate) fn frame_settings(data: &Collections) -> Result { + // 8252EE80/8252EE90 read camera/dynamics layout624/576 into + // settings-object660/628, hence parameter644/612. + Ok(FrameSettings { + maximum_pitch_degrees: data.float("camera", "dynamics", "MaxPitch")?, + roll_response: data.float("camera", "dynamics", "DutchInterpolationSpeed")?, + }) +} + +fn curve(data: &Collections, class: &str, key: &str, name: &str) -> Result, String> { + let words = data.words::<16>(class, key, name)?; + let values = words.map(f32::from_bits); + if values.iter().any(|value| !value.is_finite()) { + return Err(format!("Non-finite camera curve {class}/{key}/{name}")); + } + Ok(PointGraph { + x: core::array::from_fn(|i| values[i]), + y: core::array::from_fn(|i| values[i + 8]), + }) +} diff --git a/crates/skate-game/src/camera/shake_data.rs b/crates/skate-game/src/camera/shake_data.rs new file mode 100644 index 00000000..2bb76b03 --- /dev/null +++ b/crates/skate-game/src/camera/shake_data.rs @@ -0,0 +1,19 @@ +//! Owned .shk parser; numerical sequence centering follows82E05DC0 in core. +use skate_core::camera::ShakeSamples; + +pub(crate) fn parse(text:&str)->Result { + let mut tokens=text.split_whitespace(); + let count=tokens.next().ok_or("Empty camera .shk file")? + .parse::().map_err(|e|format!("Invalid camera shake sample count: {e}"))?; + let mut rows=Vec::with_capacity(count); + for index in 0..count { + let mut row=[0.0_f32;8]; + for field in &mut row { + *field=tokens.next().ok_or_else(||format!("Truncated camera shake sample {index}"))? + .parse::().map_err(|e|format!("Invalid camera shake sample {index}: {e}"))?; + } + rows.push(row); + } + if tokens.next().is_some() {return Err("Camera shake has extra sample data".into());} + ShakeSamples::from_rows(&rows) +} diff --git a/crates/skate-game/src/camera/shot_data.rs b/crates/skate-game/src/camera/shot_data.rs new file mode 100644 index 00000000..c6b8345f --- /dev/null +++ b/crates/skate-game/src/camera/shot_data.rs @@ -0,0 +1,178 @@ +//! Immutable stock shot definitions. Field order/units follow TU382CA4660. +use skate_core::camera::{Shot, ShotDatabase, ShotDefinition}; +use skate_data::collections::Collections; +use std::collections::BTreeMap; + +const CLASS: &str = "camera_shots"; +const CLASS_HASH: u64 = 0xf27dd93e059ef6cb; +const RADIANS: f32 = f32::from_bits(0x3c8efa35); + +pub(crate) struct StockShots(BTreeMap); + +impl StockShots { + pub fn from_collections(data: &Collections) -> Result { + let mut names = BTreeMap::new(); + for entry in data.entries().iter().filter(|c| c.class_name == CLASS) { + let text = data.field(CLASS, &entry.key, "CollectionName")?; + if text.type_name != "EA::Reflection::Text" { + return Err(format!("Invalid camera collection name {}", entry.key)); + } + if names + .insert( + super::stock_names::lookup8(entry.key.as_bytes()), + text.data.clone(), + ) + .is_some() + { + return Err("Duplicate camera collection hash".into()); + } + } + let mut definitions = BTreeMap::new(); + for entry in data.entries().iter().filter(|c| c.class_name == CLASS) { + let key = entry.key.as_str(); + let float = |field: &str| data.float(CLASS, key, field); + let enumeration = + |field| -> Result { Ok(data.words::<1>(CLASS, key, field)?[0]) }; + let boolean = + |field| -> Result { Ok(u8::from(data.boolean(CLASS, key, field)?)) }; + let floats = |fields: &[&str]| -> Result, String> { + fields.iter().map(|field| float(field)).collect() + }; + let child = |field| -> Result, String> { + let words = data.words::<6>(CLASS, key, field)?; + let class = (u64::from(words[0]) << 32) | u64::from(words[1]); + let hash = (u64::from(words[2]) << 32) | u64::from(words[3]); + if hash == 0 { + return Ok(None); + } + if class != CLASS_HASH { + return Err(format!("Non-shot reference {key}/{field}")); + } + let name = names + .get(&hash) + .ok_or_else(|| format!("Missing camera child {key}/{field}: {hash:016X}"))?; + Ok((!name.is_empty()).then(|| name.to_ascii_lowercase())) + }; + let shot = Shot { + distance: float("PositionDistance")?, + lens_length: float("FramingLensLength")?, + smoothing: floats(&[ + "SmoothingDirection", + "SmoothingElevation", + "SmoothingYaw", + "SmoothingPitch", + ])? + .try_into() + .unwrap(), + reference_weights: floats(&[ + "ReferenceWeightHead", + "ReferenceWeightHips", + "ReferenceWeightGrind", + "ReferenceWeightCOM", + "ReferenceWeightBoard", + "ReferenceWeightTrajectory", + "ReferenceWeightAnchor", + "ReferenceWeightFeet", + "ReferenceWeightDampedCOM", + "Hash_D70C95EDC16A7DFC", + ])? + .try_into() + .unwrap(), + board_offset: float("ReferenceBoardOffset")?, + position_heading: float("PositionHeading")? * RADIANS, + position_elevation: float("PositionElevation")? * RADIANS, + framing: [ + float("FramingRoll")? * RADIANS, + float("FramingYaw")? * RADIANS, + float("FramingPitch")? * RADIANS, + ], + follow_subject_in_air: boolean("OptionFollowSubjectInAir")?, + mirror_for_stance: boolean("OptionMirrorForStance")?, + snap_to_reference_point: boolean("OptionSnapToReferencePoint")?, + use_previous_shot: boolean("OptionUsePreviousShot")?, + use_drop_predictor: boolean("OptionUseDropPredictor")?, + use_free_camera_stick: boolean("OptionUseFreeCamStick")?, + // The sole dynamic attribute has the native missing-value zero. + // All layout fields above are required in the converted data. + avoidance_override: if has_field(data, key, "OptionAvoidanceOverride")? { + boolean("OptionAvoidanceOverride")? + } else { + 0 + }, + blur: float("FXBlurShot")?, + transition_blur: float("FXBlurTransition")?, + subject_opacity: float("FXSubjectOpacity")?, + collision_hint: enumeration("OptionCollisionType")?, + anchor: enumeration("OptionAnchor")?, + compass_north: enumeration("OptionCompassNorth")?, + world_heading: float("WorldHeading")? * RADIANS, + ..Shot::new() + }; + let definition = ShotDefinition { + name: key.to_ascii_lowercase(), + shot_type: enumeration("ShotType")?, + shot, + transition_time: float("TransitionTime")?, + transition_units: enumeration("TransitionTimeUnits")?, + children: [ + child("BlendShot1")?, + child("BlendShot2")?, + child("BlendShot3")?, + ], + blend_points: [ + float("BlendPoint1")?, + float("BlendPoint2")?, + float("BlendPoint3")?, + ], + blend_value: float("BlendValue")?, + blend_type: enumeration("BlendType")?, + blend_smoothing: float("SmoothingBlendValue")?, + }; + if definitions + .insert(definition.name.clone(), definition) + .is_some() + { + return Err(format!("Duplicate lowercase camera shot {key}")); + } + } + for definition in definitions.values() { + for name in definition.children.iter().flatten() { + if !definitions.contains_key(name) { + return Err(format!( + "Camera shot {} references missing named shot {name}", + definition.name + )); + } + } + } + Ok(Self(definitions)) + } +} + +impl ShotDatabase for StockShots { + fn load(&self, name: &str) -> Result { + self.0 + .get(&name.to_ascii_lowercase()) + .cloned() + .ok_or_else(|| format!("Missing stock camera shot {name}")) + } +} + +fn has_field(data: &Collections, key: &str, name: &str) -> Result { + let mut current = key; + for _ in 0..=data.entries().len() { + let entry = data + .entries() + .iter() + .find(|c| c.class_name == CLASS && c.key == current) + .ok_or_else(|| format!("Missing camera collection {current}"))?; + if entry.fields.contains_key(name) { + return Ok(true); + } + if entry.parent.is_empty() { + return Ok(false); + } + current = &entry.parent; + } + Err(format!("Cyclic camera collection inheritance {key}")) +} diff --git a/crates/skate-game/src/camera/stock_names.rs b/crates/skate-game/src/camera/stock_names.rs new file mode 100644 index 00000000..dacaba6c --- /dev/null +++ b/crates/skate-game/src/camera/stock_names.rs @@ -0,0 +1,42 @@ +//! Host decoding of the collection-name hash stored in stock RefSpec values. +//! Same lookup8 byte format as the existing private XML extraction tools. +pub(super) fn lookup8(bytes: &[u8]) -> u64 { + if bytes.is_empty() { + return 0; + } + let mut a = 0xabcdef0011223344_u64; + let mut b = a; + let mut c = 0x9e3779b97f4a7c13_u64; + let mut blocks = bytes.chunks_exact(24); + for block in &mut blocks { + a = a.wrapping_add(u64::from_le_bytes(block[..8].try_into().unwrap())); + b = b.wrapping_add(u64::from_le_bytes(block[8..16].try_into().unwrap())); + c = c.wrapping_add(u64::from_le_bytes(block[16..24].try_into().unwrap())); + mix(&mut a, &mut b, &mut c); + } + c = c.wrapping_add(bytes.len() as u64); + for (i, byte) in blocks.remainder().iter().enumerate() { + match i { + 0..=7 => a = a.wrapping_add(u64::from(*byte) << (8 * i)), + 8..=15 => b = b.wrapping_add(u64::from(*byte) << (8 * (i - 8))), + _ => c = c.wrapping_add(u64::from(*byte) << (8 * (i - 15))), + } + } + mix(&mut a, &mut b, &mut c); + c +} + +fn mix(a: &mut u64, b: &mut u64, c: &mut u64) { + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 43); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 9); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 8); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 38); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 23); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 5); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 35); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 49); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 11); + *a = a.wrapping_sub(*b).wrapping_sub(*c) ^ (*c >> 12); + *b = b.wrapping_sub(*c).wrapping_sub(*a) ^ (*a << 18); + *c = c.wrapping_sub(*a).wrapping_sub(*b) ^ (*b >> 22); +} diff --git a/crates/skate-game/src/camera/subject.rs b/crates/skate-game/src/camera/subject.rs new file mode 100644 index 00000000..9370826b --- /dev/null +++ b/crates/skate-game/src/camera/subject.rs @@ -0,0 +1,62 @@ +//! Typed physics/animation publication boundary for normal subject82DF69C0. +//! The simulation supplies current physical outputs; camera history stays here. +use bevy::prelude::Resource; +use skate_core::camera::{ + AnchorInputs, Anchors, CameraMan, Compass, CompassPoseInputs, CompassSettings, + ManagerSubject, ReferencePointInputs, SubjectPoseInputs, SubjectPosePublisher, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct CameraSubjectSnapshot { + /// Physical flags, trajectory, steering, root and other direct outputs. + /// The camera overwrites this value's transform, reference_positions, + /// anchors and compass with the source publishers below before use. + pub subject: ManagerSubject, + pub pose: SubjectPoseInputs, + pub anchors: AnchorInputs, + /// Bone positions and raw COM; tracked_anchor, incline_normal and damped + /// COM are filled by their camera owners immediately before publication. + pub reference_points: ReferencePointInputs, + pub compass: CompassPoseInputs, + pub graph: super::graph_subject::CameraGraphSubject, +} + +/// Root's simulation publisher replaces this after a complete physical/pose +/// update. None means there is no valid gameplay subject yet. +#[derive(Resource, Default)] +pub(crate) struct CameraSubjectFrame { + pub snapshot: Option, + pub generation: u64, +} + +pub(super) struct SubjectPublisher { + pose: SubjectPosePublisher, + anchors: Anchors, + compass: Compass, +} +impl SubjectPublisher { + pub fn new() -> Self { + Self { pose: SubjectPosePublisher::new(), anchors: Anchors::new(), compass: Compass::new() } + } + + pub fn publish(&mut self, mut input: CameraSubjectSnapshot, manager: &CameraMan, + settings: CompassSettings) -> ManagerSubject { + let pose = self.pose.publish(input.pose); + input.subject.rig.transform = pose.transform; + input.subject.rig.skeleton_root = input.pose.skeleton_root; + input.anchors.damped_center_of_mass = pose.damped_center_of_mass; + self.anchors.update(input.anchors); + input.subject.anchors = self.anchors.entries; + input.reference_points.damped_centre_of_mass = pose.damped_center_of_mass; + input.reference_points.tracked_anchor = manager.rig.anchor.position; + input.reference_points.incline_normal = manager.state.incline_normal; + input.subject.rig.reference_positions = input.reference_points.positions(); + let selected = if manager.shots.current().name.is_empty() { 5 } + else { manager.shots.current().shot.compass_north }; + let compass_input = input.compass.bind(&input.subject, manager.frame.position, selected); + input.subject.compass = self.compass.update( + if input.subject.reset != 0 { 0.0 } else { f32::from_bits(0x3c88_8889) }, + compass_input, settings); + input.subject + } +} diff --git a/crates/skate-game/src/camera/tests/runtime.rs b/crates/skate-game/src/camera/tests/runtime.rs new file mode 100644 index 00000000..0a28b2be --- /dev/null +++ b/crates/skate-game/src/camera/tests/runtime.rs @@ -0,0 +1,10 @@ +use super::*; + +#[test] +#[ignore = "requires the user's converted stock camera and graph assets"] +fn private_stock_camera_loads_every_graph_operation_and_shot_dependency() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let camera = CameraRuntime::load(root).expect("complete normal stock camera assets"); + assert!(camera.frame.is_none()); +} diff --git a/crates/skate-game/src/camera/trajectory.rs b/crates/skate-game/src/camera/trajectory.rs new file mode 100644 index 00000000..c7390349 --- /dev/null +++ b/crates/skate-game/src/camera/trajectory.rs @@ -0,0 +1,47 @@ +//! Camera trajectory requests82DF9270/82DF9338/82DF9340. Rust executes the +//! world work synchronously; PathEvaluator still consumes it on its next poll. +use skate_core::{ + camera::{PredictionPath, TrajectoryCollisionRequest, TrajectoryQuery}, + physics::board_world::BoardWorld, +}; + +#[derive(Clone, Debug)] +pub(super) struct TrajectoryResult { + ready: bool, + time: f32, + pub error: Option, +} +impl TrajectoryResult { + pub fn new() -> Self { Self { ready: false, time: -1.0, error: None } } +} + +pub(super) struct CameraTrajectory<'a> { + pub world: &'a BoardWorld, + pub gravity: [f32; 4], + pub result: &'a mut TrajectoryResult, +} +impl TrajectoryCollisionRequest for CameraTrajectory<'_> { + fn submit(&mut self, path: PredictionPath, _context: u32, acceleration_flag: u8) { + self.result.ready = false; + self.result.error = None; + let query = TrajectoryQuery { + position: path.position, velocity: path.velocity, + gravity: if acceleration_flag != 0 { self.gravity } else { [0.0; 4] }, + duration: path.horizon, radius: path.radius, + // 82DF92BC/82DF9304 set both maximum-error fractions to1. + start_error: 1.0, end_error: 1.0, + }; + match query.collision_time(|start, end, radius| world_line(self.world, start, end, radius)) { + Ok(time) => { self.result.time = time; self.result.ready = true; } + Err(error) => self.result.error = Some(error), + } + } + fn is_ready(&mut self) -> bool { self.result.ready } + fn collision_time(&mut self) -> f32 { self.result.time } +} + +fn world_line(world: &BoardWorld, start: [f32; 4], end: [f32; 4], radius: f32) + -> Result, String> { + let hit = super::world_query::line(world, start, end, radius)?; + Ok((hit.hit != 0).then_some(hit.position)) +} diff --git a/crates/skate-game/src/camera/world_query.rs b/crates/skate-game/src/camera/world_query.rs new file mode 100644 index 00000000..d6e3b460 --- /dev/null +++ b/crates/skate-game/src/camera/world_query.rs @@ -0,0 +1,37 @@ +//! Camera triangle leaf82771D08 stores the authored face normal, clamps every +//! fraction and retains the first equal-distance hit in world traversal order. +use skate_core::{camera::FatLineResult, math::Vector3, + physics::{board_world::BoardWorld, triangle_query::{TriangleLineHit, triangle_segment}}}; + +pub(super) fn line(world: &BoardWorld, start: [f32; 4], end: [f32; 4], radius: f32) + -> Result { + if !radius.is_finite() || radius < 0.0 { + return Err("Invalid stock camera collision radius".into()); + } + let start = vector(start); + let delta = Vector3::new(end[0] - start.x, end[1] - start.y, end[2] - start.z); + let mut fraction = f32::MAX; + let mut result = no_hit(); + for entry in world.triangles() { + let mut hit = TriangleLineHit { position: Vector3::ZERO, normal: Vector3::ZERO, + fraction: 0.0, volume_parameter: [0.0; 3] }; + // Zero triangle fatness and zero material exclusion mask are native. + // Subject actor exclusion is preserved by BoardWorld's world-only data. + if triangle_segment(&mut hit, start, delta, entry.triangle.vertices, radius, 0.0) { + let lower = if -hit.fraction >= 0.0 { 0.0 } else { hit.fraction }; + let candidate = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + if candidate < fraction { + fraction = candidate; + result = FatLineResult { position: lanes(hit.position), + normal: lanes(entry.triangle.feature.normal), fraction: candidate, + hit: 1, surface: entry.tag }; + } + } + } + Ok(result) +} +pub(super) fn no_hit() -> FatLineResult { + FatLineResult { position: [0.0; 4], normal: [0.0; 4], fraction: 0.0, hit: 0, surface: 0 } +} +fn vector(v: [f32; 4]) -> Vector3 { Vector3::new(v[0], v[1], v[2]) } +fn lanes(v: Vector3) -> [f32; 4] { [v.x, v.y, v.z, 0.0] } diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs new file mode 100644 index 00000000..05903dc4 --- /dev/null +++ b/crates/skate-game/src/config.rs @@ -0,0 +1,55 @@ +use bevy::prelude::Resource; +use std::path::PathBuf; + +#[derive(Resource)] +pub(crate) struct Config { + pub asset_root: PathBuf, + pub verification_capture: Option, +} + +impl Config { + pub fn from_env() -> Result { + let mut config = Self { + asset_root: PathBuf::from("assets"), + verification_capture: None, + }; + let mut args = std::env::args_os().skip(1); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--assets") => { + config.asset_root = args.next().ok_or("--assets requires a directory")?.into() + } + Some("--verify") => { + config.verification_capture = Some( + args.next() + .ok_or("--verify requires an output PNG path")? + .into(), + ) + } + _ => { + return Err(format!( + "Unknown argument {arg:?}. Usage: skate-game [--assets DIRECTORY] [--verify CAPTURE.png]" + )); + } + } + } + config.asset_root = config + .asset_root + .canonicalize() + .map_err(|e| format!("Asset root {}: {e}", config.asset_root.display()))?; + if let Some(path) = &mut config.verification_capture { + if path.extension().and_then(|x| x.to_str()) != Some("png") { + return Err("--verify output must be a PNG file".into()); + } + if !path.is_absolute() { + *path = std::env::current_dir() + .map_err(|e| e.to_string())? + .join(&path); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + } + Ok(config) + } +} diff --git a/crates/skate-game/src/graph_host/action.rs b/crates/skate-game/src/graph_host/action.rs new file mode 100644 index 00000000..ec91cc67 --- /dev/null +++ b/crates/skate-game/src/graph_host/action.rs @@ -0,0 +1,349 @@ +//! Typed ActionGraph registration and persistent operation instances. +//! The schedule and concrete gameplay sinks are owned by the caller. + +use super::action_nodes::{ActionFactory, ActionInstance, ActionInstances, ActionOperation}; +use crate::graph_runtime::{LoadedGraph, OperationRemap}; +use skate_core::graph::conditions::ConditionInputs; +use skate_core::graph::intent_handlers::{ConstMgIntent, TimeMgIntent}; +use skate_core::graph::intents::IntentMap; +use skate_core::graph::{ + activation::ConditionHost, + controller::{BehaviorId, Frame, HookId, Host}, +}; +use skate_core::input::body_flip_signal; +use skate_core::input::graph_intents::{CreateMgIntent, IntentMutation}; +use skate_data::collections::Collections; + +/// Original State output consumed by82BA1680/82BA16F0. +#[derive(Clone, Copy, Debug)] +pub struct PhysicalConditions { + pub requests_dismount: bool, + pub state: u32, +} + +/// Runtime owner for the recovered ActionGraph intent path. Conditions read +/// current published physics and intent values when the graph evaluates them. +pub struct ActionHost { + pub instances: ActionInstances, + pub action_intents: IntentMap, + pub motion_intents: IntentMap, + pub condition_inputs: ConditionInputs, + pub physical_conditions: Option, + pub gameplay_conditions: Option, + pub animation_attributes: Vec, + pub is_tricking: Option, + pub stance: Option<(bool, bool)>, + pub errors: Vec, + remap: OperationRemap, + state_parents: Vec>, + next_instance: u32, + created: Vec, + const_handlers: Vec, + time_handlers: Vec, + body_flip: Vec, + body_flip_settings: Option, + /// JuiceHook instance+8 starts with the native null intent name. Its + /// update removes that pending key and resets it,82BA39A8. + juice_pending: Vec, +} + +impl ActionHost { + pub fn from_graph(graph: &LoadedGraph, data: &Collections) -> Result { + let operations = graph + .binding + .instantiate_operations(&graph.source, &mut ActionFactory) + .map_err(|error| error.to_string())?; + let mut instances = ActionInstances::new(operations.operations); + super::condition_nodes::bind_current_states(graph, &mut instances); + let mut host = Self::new(instances, graph.runtime.operations.clone()); + host.body_flip_settings = Some(body_flip_signal::Settings { + //82BA35E0/3790 read Globals296;8289F8F0 binds hash + //3FC308E69AEA6385 (body_flip), independently checked in stock data. + gesture_window: data.float("anim_motion", "body_flip", "extend_bodyflip_gesture")?, + takeoff_window: data.float("anim_motion", "body_flip", "extend_takeoff_point")?, + }); + host.state_parents = graph + .binding + .states + .iter() + .map(|state| state.parent) + .collect(); + Ok(host) + } + + pub(crate) fn new(instances: ActionInstances, remap: OperationRemap) -> Self { + let count = remap.behaviors.len(); + let const_handlers = remap + .behaviors + .iter() + .map(|&operation| { + let instance = &instances.operations[operation]; + ConstMgIntent::new( + instance + .config + .float_bits + .map(f32::from_bits) + .unwrap_or(0.0), + instance.config.on_update, + ) + }) + .collect(); + let time_handlers = vec![TimeMgIntent::default(); count]; + Self { + instances, + action_intents: IntentMap::new(), + motion_intents: IntentMap::new(), + condition_inputs: ConditionInputs::default(), + physical_conditions: None, + gameplay_conditions: None, + animation_attributes: Vec::new(), + is_tricking: None, + state_parents: Vec::new(), + remap, + stance: None, + errors: Vec::new(), + next_instance: 1, + created: vec![false; count], + const_handlers, + time_handlers, + body_flip: vec![body_flip_signal::State::default(); count], + body_flip_settings: None, + juice_pending: vec![String::new(); count], + } + } + + pub fn apply(&mut self, name: impl Into, mutation: IntentMutation) { + let name = name.into(); + match mutation { + IntentMutation::None => {} + IntentMutation::Set(value) => { + self.motion_intents.insert(&name, value); + } + IntentMutation::Remove => { + self.motion_intents.remove(&name); + } + } + } + + fn operation(&self, id: BehaviorId) -> Option<&ActionInstance> { + self.remap + .behaviors + .get(id) + .and_then(|&operation| self.instances.get(operation)) + } + + fn unsupported(&mut self, id: BehaviorId, instance: &ActionInstance) { + if let Some(error) = instance.unsupported() { + self.errors.push(format!( + "ActionGraph behavior {id}: {:?} `{}`", + error.kind, error.name + )); + } + } +} + +impl ConditionHost for ActionHost { + fn condition_activation(&mut self, condition: usize, frame: &Frame) -> u32 { + let result = self + .remap + .conditions + .get(condition) + .and_then(|&id| self.instances.get(id)) + .ok_or_else(|| format!("ActionGraph condition {condition} is unbound")) + .and_then(|instance| match &instance.operation { + ActionOperation::ExtraCondition(condition) => condition.evaluate(self, frame), + ActionOperation::PhysicsRequestsDismount => self + .physical_conditions + .map(|p| p.requests_dismount) + .ok_or_else(|| "PhysicsRequestsDismount requires published State77".into()), + ActionOperation::IsLandingOnBoard => self + .physical_conditions + .map(|p| p.state == 503) + .ok_or_else(|| "IsLandingOnBoard requires actual State16".into()), + ActionOperation::Condition(operation) => operation + .evaluate( + &self.condition_inputs, + &self.action_intents, + frame.current, + &self.state_parents, + ) + .map_err(|error| format!("ActionGraph condition {condition}: {error}")), + _ => Err(format!( + "Unsupported ActionGraph condition {condition}: {:?}", + instance.config.name + )), + }); + match result { + Ok(value) => u32::from(value), + Err(error) => { + self.errors.push(error); + 0 + } + } + } +} + +impl Host for ActionHost { + fn context(&self) -> [u32; 6] { + [0; 6] + } + fn allocate(&mut self, behavior: BehaviorId, _frame: &Frame) -> u32 { + // Every activation owns fresh behavior state. Re-entry must not reuse + // the old pushing clock, even if the source operation ID is unchanged. + self.created[behavior] = false; + self.time_handlers[behavior] = TimeMgIntent::default(); + self.body_flip[behavior] = body_flip_signal::State::default(); + self.juice_pending[behavior].clear(); + let result = self.next_instance; + self.next_instance = self.next_instance.wrapping_add(1).max(1); + result + } + fn begin(&mut self, behavior: BehaviorId, _context: [u32; 6], _frame: &Frame) { + let Some(instance) = self.operation(behavior).cloned() else { + return; + }; + if instance.unsupported().is_some() { + self.unsupported(behavior, &instance); + return; + } + if let ActionOperation::PrintText(text) = &instance.operation { + bevy::log::info!("ActionGraph: {text}"); + return; + } + if let ActionOperation::BodyFlippingSignal = instance.operation { + match self.body_flip_settings { + Some(settings) => self.body_flip[behavior].begin(settings), + None => self + .errors + .push("BodyFlippingSignal requires stock anim_motion settings".into()), + } + return; + } + if let ActionOperation::CreateConstMgIntent = instance.operation { + if let Some(name) = instance.config.mg_intent.clone() { + let mutation = self.const_handlers[behavior].begin(); + self.apply(name, mutation); + } + } + if let ActionOperation::CreateMgIntent = instance.operation { + if let (Some(mg), Some(ag)) = ( + instance.config.mg_intent.clone(), + instance.config.ag_intent.as_deref(), + ) { + let handler = CreateMgIntent { + on_update: instance.config.on_update, + default_value: instance.config.default_value, + scale: instance.config.scale.unwrap_or(1.0), + filters: instance.config.filters, + }; + let value = self.action_intents.get(ag).copied(); + let mutation = handler.enter(&mut self.created[behavior], value, self.stance); + self.apply(mg, mutation); + } else { + self.errors.push(format!( + "ActionGraph behavior {behavior}: missing AGIntent/MGIntent" + )); + } + } + } + fn update(&mut self, behavior: BehaviorId, _context: [u32; 6], frame: &Frame) { + let Some(instance) = self.operation(behavior).cloned() else { + return; + }; + if instance.unsupported().is_some() { + self.unsupported(behavior, &instance); + return; + } + if let ActionOperation::JuiceHook = instance.operation { + self.motion_intents.remove(&self.juice_pending[behavior]); + self.juice_pending[behavior].clear(); + return; + } + if let ActionOperation::BodyFlippingSignal = instance.operation { + let Some(settings) = self.body_flip_settings else { + self.errors + .push("BodyFlippingSignal requires stock anim_motion settings".into()); + return; + }; + let Some(physical) = &self.condition_inputs.physical_state else { + self.errors + .push("BodyFlippingSignal requires published filtered state".into()); + return; + }; + // TU3 initializers82F84DA8/82F84DC0 bind these exact keys. + let names = ["FrontFlip", "BackFlip"]; + let present = names.map(|name| self.action_intents.contains_key(name)); + match self.body_flip[behavior].update(present, physical.category, frame.dt, settings) { + Some(index) => { + self.motion_intents.insert(names[index], 1.0); + } + None => { + for name in names { + self.motion_intents.remove(name); + } + } + } + return; + } + if let ActionOperation::CreateConstMgIntent = instance.operation { + if let Some(name) = instance.config.mg_intent.as_deref() { + let mutation = self.const_handlers[behavior].update(); + self.apply(name.to_owned(), mutation); + } + return; + } + let Some(ag_name) = instance.config.ag_intent.as_deref() else { + return; + }; + let Some(mg_name) = instance.config.mg_intent.as_deref() else { + return; + }; + let ag = self.action_intents.get(ag_name).copied(); + let mutation = match instance.operation { + ActionOperation::CreateMgIntent => { + let handler = CreateMgIntent { + on_update: instance.config.on_update, + default_value: instance.config.default_value, + scale: instance.config.scale.unwrap_or(1.0), + filters: instance.config.filters, + }; + handler.update(&mut self.created[behavior], ag, self.stance) + } + ActionOperation::CreateMgTimeIntent => { + self.time_handlers[behavior].update(ag, frame.dt) + } + _ => return, + }; + self.apply(mg_name.to_owned(), mutation); + } + fn end(&mut self, behavior: BehaviorId, _context: [u32; 6], _frame: &Frame) { + let Some(instance) = self.operation(behavior).cloned() else { + return; + }; + if let Some(name) = instance.config.mg_intent.as_deref() { + let mutation = match instance.operation { + ActionOperation::CreateConstMgIntent => self.const_handlers[behavior].end(), + ActionOperation::CreateMgTimeIntent => self.time_handlers[behavior].end(), + _ => IntentMutation::Remove, + }; + self.apply(name.to_owned(), mutation); + } + } + fn hook(&mut self, hook: HookId, _frame: &Frame) { + let Some(instance) = self + .remap + .hooks + .get(hook) + .and_then(|&operation| self.instances.get(operation)) + .cloned() + else { + self.errors + .push(format!("ActionGraph hook index {hook} is unbound")); + return; + }; + if instance.unsupported().is_some() { + self.unsupported(hook, &instance); + } + } + fn release(&mut self, _instance: u32) {} +} diff --git a/crates/skate-game/src/graph_host/action_conditions.rs b/crates/skate-game/src/graph_host/action_conditions.rs new file mode 100644 index 00000000..8e2be6e5 --- /dev/null +++ b/crates/skate-game/src/graph_host/action_conditions.rs @@ -0,0 +1,88 @@ +//! Original ActionGraph leaves that consume the prior animation/state output. +use super::{action::ActionHost, motion_gameplay_conditions::GameplayCondition}; +use skate_core::{ + animation::{output::attributes::AttributeName, skeleton_input::name::encode}, + graph::{conditions::NumericCondition, controller::Frame}, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum ActionCondition { + Gesture(crate::input::gesture_catalog::Group), + AnimationAttribute { + name: AttributeName, + sequence_id: i32, + numeric: NumericCondition, + }, + ParentTime { + target: Option, + numeric: NumericCondition, + }, + Physical(GameplayCondition), + Tricking, + InLocomotion, + DisableDismount, +} +impl ActionCondition { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + Ok(Some(match a.text("name").unwrap_or("") { + "HasGestureIntent" => Self::Gesture(crate::input::gesture_catalog::Group::parse( + a.text("group").unwrap_or(""), + )?), + "HasAnimAttribute" => Self::AnimationAttribute { + name: encode(a.text("attribute").unwrap_or("").as_bytes()), + sequence_id: f32::from_bits(a.float_bits("sequenceid", (-1.0f32).to_bits())) as i32, + numeric: super::condition_nodes::numeric(a), + }, + "InParentStateForTime" => Self::ParentTime { + target: None, + numeric: super::condition_nodes::numeric(a), + }, + "IsGrabbingObject" | "IsHandPlanting" => Self::Physical(GameplayCondition::parse(a)?), + "IsTricking" => Self::Tricking, + "IsInLocomotion" => Self::InLocomotion, + "DisableDismount" => Self::DisableDismount, + _ => return Ok(None), + })) + } + pub fn evaluate(&self, host: &ActionHost, frame: &Frame) -> Result { + Ok(match self { + Self::Gesture(group) => group.has_intent(&host.action_intents), + Self::AnimationAttribute { + name, + sequence_id, + numeric, + } => super::condition_nodes::animation_attribute( + &host.animation_attributes, + *name, + *sequence_id, + *numeric, + ), + Self::ParentTime { target, numeric } => { + super::condition_nodes::state_time(frame, *target, *numeric) + } + Self::Physical(condition) => condition + .evaluate_physical( + host.gameplay_conditions + .as_ref() + .ok_or("ActionGraph requires actual physical condition outputs")?, + ) + .ok_or("Condition is not a physical-only original leaf")?, + //82BA56B8 uses specific MotionGraph virtual100=8258F8D8 bit27. + Self::Tricking => host + .is_tricking + .ok_or("IsTricking requires actual MotionGraph flag27")?, + //Factory82BC3F68 installs8231ED5C; virtual48=8274CA90 is li r3,0;blr. + Self::InLocomotion => false, + Self::DisableDismount => { + //82BA5368:822F8C9C > Ground80.Y OR Skeleton598. + let p = host + .condition_inputs + .push_brake + .as_ref() + .ok_or("DisableDismount requires actual ground axis and skeleton598")?; + f32::from_bits(0x3f23d70a) > p.ground_axis_y || p.skeleton_disables_push_brake + } + }) + } +} diff --git a/crates/skate-game/src/graph_host/action_nodes.rs b/crates/skate-game/src/graph_host/action_nodes.rs new file mode 100644 index 00000000..36d8085b --- /dev/null +++ b/crates/skate-game/src/graph_host/action_nodes.rs @@ -0,0 +1,225 @@ +//! Authored ActionGraph intent behavior configuration. +use skate_data::state_graph::{ + attributes::Attributes, + binding::{Node, OperationFactory, OperationKind}, +}; + +#[derive(Clone, Debug, PartialEq)] +pub struct Parameter { + pub name: Option, + pub mg_intent: Option, + pub ag_intent: Option, + pub text: Option, + pub float_bits: Option, + pub boolean_byte: Option, + pub default_value: Option, + pub scale: Option, + pub on_update: bool, + pub filters: [u32; 4], +} + +impl Parameter { + fn from_attributes(attributes: &Attributes<'_>) -> Self { + Self { + name: attributes.text("name").map(str::to_owned), + mg_intent: attributes.text("MGIntent").map(str::to_owned), + ag_intent: attributes.text("AGIntent").map(str::to_owned), + text: attributes.text("value").map(str::to_owned), + float_bits: attributes.get("value").map(|value| value.float_bits), + boolean_byte: attributes.get("value").map(|value| value.boolean_byte), + default_value: attributes + .get("defaultValue") + .map(|value| f32::from_bits(value.float_bits)), + scale: attributes + .get("scale") + .map(|value| f32::from_bits(value.float_bits)), + // Both TU3 constructors82BA23C0/82BA1768 default this to true. + on_update: attributes.boolean_byte("onUpdate", 1) != 0, + filters: [ + filter( + attributes + .text("filter1") + .or_else(|| attributes.text("filter")), + ), + filter(attributes.text("filter2")), + filter(attributes.text("fakieFilter")), + filter(attributes.text("mirrorFilter")), + ], + } + } +} + +fn filter(value: Option<&str>) -> u32 { + // TU382F847C0..82F84850 initialize the names consumed by82BA0A48. + match value { + Some("negate") => 1, + Some("abs") => 2, + Some("oneMinus") => 3, + Some("clamp") => 4, + Some("angleFlip") => 5, + Some("angleRot90") => 6, + Some("angleRotN90") => 7, + _ => 0, + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ActionOperation { + Condition(skate_core::graph::conditions::ActionCondition), + ExtraCondition(super::action_conditions::ActionCondition), + PhysicsRequestsDismount, + IsLandingOnBoard, + CreateMgIntent, + CreateMgTimeIntent, + CreateConstMgIntent, + BoardAdjust, + CreateTrickIntentFromGesture, + JuiceHook, + BodyFlippingSignal, + /// Diagnostic presentation is owned by this host and has no intent effect. + PrintText(String), + Unsupported { + kind: OperationKind, + name: String, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ActionInstance { + pub operation: ActionOperation, + pub config: Parameter, + pub parameters: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActionFactoryError(pub String); + +impl std::fmt::Display for ActionFactoryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Default)] +pub struct ActionFactory; + +impl ActionFactory { + fn operation(kind: OperationKind, name: &str) -> ActionOperation { + match (kind, name) { + (OperationKind::Condition, "PhysicsRequestsDismount") => { + ActionOperation::PhysicsRequestsDismount + } + (OperationKind::Condition, "IsLandingOnBoard") => ActionOperation::IsLandingOnBoard, + (OperationKind::Behavior, "CreateMGIntentFromAGIntent") => { + ActionOperation::CreateMgIntent + } + (OperationKind::Behavior, "CreateMGTimeIntentFromAGIntent") => { + ActionOperation::CreateMgTimeIntent + } + (OperationKind::Behavior, "CreateConstMGIntent") => { + ActionOperation::CreateConstMgIntent + } + (OperationKind::Behavior, "BoardAdjust") => ActionOperation::BoardAdjust, + (OperationKind::Behavior, "CreateTrickIntentFromGesture") => { + ActionOperation::CreateTrickIntentFromGesture + } + (OperationKind::Behavior, "JuiceHook") => ActionOperation::JuiceHook, + (OperationKind::Behavior, "BodyFlippingSignal") => ActionOperation::BodyFlippingSignal, + _ => ActionOperation::Unsupported { + kind, + name: name.to_owned(), + }, + } + } +} + +impl OperationFactory for ActionFactory { + type Instance = ActionInstance; + type Error = ActionFactoryError; + + fn create( + &mut self, + kind: OperationKind, + _parent: Node, + attributes: &Attributes<'_>, + ) -> Result, Self::Error> { + let Some(name) = attributes.text("name") else { + return Err(ActionFactoryError("operation has no name attribute".into())); + }; + Ok(Some(ActionInstance { + operation: if kind == OperationKind::Condition { + if let Some(condition) = + super::action_conditions::ActionCondition::parse(attributes) + .map_err(ActionFactoryError)? + { + ActionOperation::ExtraCondition(condition) + } else { + super::condition_nodes::parse(attributes) + .map_err(ActionFactoryError)? + .map(ActionOperation::Condition) + .unwrap_or_else(|| Self::operation(kind, name)) + } + } else { + if kind == OperationKind::Behavior && name == "PrintText2D" { + ActionOperation::PrintText(attributes.text("text").unwrap_or("").to_owned()) + } else { + Self::operation(kind, name) + } + }, + config: Parameter::from_attributes(attributes), + parameters: Vec::new(), + })) + } + + fn add_parameter( + &mut self, + instance: &mut Self::Instance, + attributes: &Attributes<'_>, + ) -> Result<(), Self::Error> { + instance + .parameters + .push(Parameter::from_attributes(attributes)); + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnsupportedOperation { + pub kind: OperationKind, + pub name: String, +} + +impl ActionInstance { + pub fn unsupported(&self) -> Option { + match &self.operation { + ActionOperation::Unsupported { kind, name } => Some(UnsupportedOperation { + kind: *kind, + name: name.clone(), + }), + ActionOperation::BoardAdjust => Some(UnsupportedOperation { + kind: OperationKind::Behavior, + name: "BoardAdjust".into(), + }), + ActionOperation::CreateTrickIntentFromGesture => Some(UnsupportedOperation { + kind: OperationKind::Behavior, + name: "CreateTrickIntentFromGesture".into(), + }), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +pub struct ActionInstances { + pub operations: Vec, +} + +impl ActionInstances { + pub fn new(operations: Vec) -> Self { + Self { operations } + } + + pub fn get(&self, operation: usize) -> Option<&ActionInstance> { + self.operations.get(operation) + } +} diff --git a/crates/skate-game/src/graph_host/condition_nodes.rs b/crates/skate-game/src/graph_host/condition_nodes.rs new file mode 100644 index 00000000..6a1662a5 --- /dev/null +++ b/crates/skate-game/src/graph_host/condition_nodes.rs @@ -0,0 +1,151 @@ +//! Stock condition attributes bound to core evaluations. Values use the binary +//! float/bool encodings directly, including constructor precedence and defaults. +use skate_core::graph::conditions::{ActionCondition, Comparison, NumericCondition}; +use skate_data::state_graph::attributes::Attributes; + +///Original82BA3A78: first cached record, sequence filter, then numeric scalar +///comparison only for kinds0/2. Shared by the ActionGraph and MotionGraph. +pub(crate) fn animation_attribute( + attributes: &[skate_core::animation::output::attributes::AnimationAttribute], + name: skate_core::animation::output::attributes::AttributeName, + sequence_id: i32, + numeric: NumericCondition, +) -> bool { + attributes.iter().find(|a| a.name == name).is_some_and(|a| { + (sequence_id == -1 || sequence_id == a.sequence_id) + && (numeric.comparison == Comparison::None + || !matches!(a.kind, 0 | 2) + || a.payload.0[0].is_some_and(|word| numeric.matches(f32::from_bits(word)))) + }) +} +///Original82BA45D0/82BA4658 rejects an inactive containing state's time. +pub(crate) fn state_time( + frame: &skate_core::graph::controller::Frame, + target: Option, + numeric: NumericCondition, +) -> bool { + target + .and_then(|id| frame.state_times.get(id).copied().flatten()) + .is_some_and(|time| !(time < 0.0) && numeric.matches(time)) +} + +pub(super) fn parse(attributes: &Attributes<'_>) -> Result, String> { + use ActionCondition as C; + Ok(Some(match attributes.text("name").unwrap_or("") { + "HasAGIntent" => C::HasIntent { + name: attributes.text("intent").unwrap_or("").to_owned(), + numeric: numeric(attributes), + }, + "TimeSinceLastInput" => C::TimeSinceLastInput(numeric(attributes)), + "PhysicsSpeedCompare" => C::Speed { + along_skate_z: attributes.boolean_byte("alongSkateZ", 0) != 0, + numeric: numeric(attributes), + }, + "PhysicsSpeedAndSlopeCompare" => C::SpeedAndSlope(numeric(attributes)), + "PhysFilteredState" => C::FilteredState(match attributes.text("state").unwrap_or("") { + "invalid" => 0, + "ground" => 1, + "air" => 2, + "grind" => 3, + "wipeout" => 4, + "teleport" => 5, + "offboard" => 6, + "offboardair" => 7, + other => return Err(format!("PhysFilteredState has undefined state `{other}`")), + }), + "IsGrinding" => C::Grinding { + name: attributes.text("grindName").map(str::to_owned), + }, + "IsMirrored" => C::Mirrored, + "IsRidingFakie" => C::RidingFakie, + "DisablePushBrake" => C::DisablePushBrake, + "CurrentState" => C::CurrentState { + name: attributes.text("state").unwrap_or("").to_owned(), + target: None, + }, + _ => return Ok(None), + })) +} + +pub(crate) fn numeric(attributes: &Attributes<'_>) -> NumericCondition { + use Comparison as C; + // TU382C126F0. The long spelling takes precedence over all short forms; + // unrecognized long spellings retain the constructor's Equals default. + //82AE89B0 confirms case-sensitive comparison. + let (comparison, field) = if let Some(value) = attributes.text("comparison") { + ( + match value { + "NotEqual" => C::NotEqual, + "GreaterThan" => C::Greater, + "LessThan" => C::Less, + "GreaterEqual" => C::GreaterEqual, + "LessEqual" => C::LessEqual, + _ => C::Equal, + }, + "value", + ) + } else { + [ + (C::Equal, "equal"), + (C::NotEqual, "notEqual"), + (C::Greater, "greater"), + (C::GreaterAbsolute, "greaterThanAbs"), + (C::Less, "less"), + (C::GreaterEqual, "greaterEqual"), + (C::LessEqual, "lessEqual"), + ] + .into_iter() + .find(|(_, field)| attributes.get(field).is_some()) + .unwrap_or((C::None, "")) + }; + NumericCondition { + comparison, + threshold: f32::from_bits(attributes.float_bits(field, 0)), + absolute: attributes.boolean_byte("abs", 0) != 0, + } +} + +pub(super) fn bind_current_states( + graph: &crate::graph_runtime::LoadedGraph, + instances: &mut super::action_nodes::ActionInstances, +) { + let mut element_parents = vec![None; graph.source.elements.len()]; + for (parent, element) in graph.source.elements.iter().enumerate() { + for &child in &element.children { + element_parents[child] = Some(parent); + } + } + for &operation in &graph.runtime.operations.conditions { + if let super::action_nodes::ActionOperation::ExtraCondition( + super::action_conditions::ActionCondition::ParentTime { target, .. }, + ) = &mut instances.operations[operation].operation + { + let mut element = element_parents[graph.binding.operations[operation].element]; + while let Some(id) = element { + if let Some(state) = graph.binding.states.iter().position(|s| s.element == id) { + *target = Some(state); + break; + } + element = element_parents[id]; + } + continue; + } + let super::action_nodes::ActionOperation::Condition(ActionCondition::CurrentState { + name, + target, + }) = &mut instances.operations[operation].operation + else { + continue; + }; + // Node::GetParentState82C11B88 climbs through expressions/transitions + // to the containing state; it is not the graph's current leaf. + let mut element = element_parents[graph.binding.operations[operation].element]; + while let Some(id) = element { + if let Some(state) = graph.binding.states.iter().position(|s| s.element == id) { + *target = graph.binding.find_state(state, name, true); //82C13728 + break; + } + element = element_parents[id]; + } + } +} diff --git a/crates/skate-game/src/graph_host/crouching_settings.rs b/crates/skate-game/src/graph_host/crouching_settings.rs new file mode 100644 index 00000000..3cfa301f --- /dev/null +++ b/crates/skate-game/src/graph_host/crouching_settings.rs @@ -0,0 +1,17 @@ +//! anim_motion/crouching (global300) and auto_pump (global348), TU3 schema. +use skate_core::{animation::crouching::{Settings,AutoPumpSettings},point_graph::PointGraph}; +use skate_data::collections::Collections; +pub fn load(data:&Collections)->Result { + let c=|name|data.float("anim_motion","crouching",name); + let a=|name|data.float("anim_motion","auto_pump",name); + Ok(Settings { + maximum_height:negative8(data,"crouching","crouching_max_height")?, + absorption_upforce:plain8(data,"crouching","absorption_upforce")?, + pump_maxspeed:plain8(data,"crouching","pump_maxspeed")?, + minimum_height:c("crouching_min_height")?,maximum_ratio:c("crouch_max_ratio")?,maximum_delta_delta:c("crouch_max_delta_delta")?,maximum_delta:c("crouch_max_delta")?,input_blend:c("crouch_blend_input")?,pump_vertical_speed:c("pump_maxspeed_yaxis")?,maximum_crouch_from_deck:c("MaxCrouchFromDeckAngle")?,skateboard_damping:c("absorption_skateboard_damping")?,maximum_force:c("absorption_maxforce")?,maximum_ground_force:c("absorption_ground_maxforce")?,absorption_factor:c("absorption_factor")?, + auto_pump:AutoPumpSettings {maximum_crouch:negative4(data,"auto_pump","auto_pump_max_crouch")?,sufficient_crouch:a("auto_pump_sufficient_crouch")?,standing_threshold:a("auto_pump_standing_thresh")?,rise_speed:a("auto_pump_rise_speed")?,pump_speed:a("auto_pump_pump_speed")?,potential_threshold:a("auto_pump_potential_thresh")?,potential_blend:a("auto_pump_potential_blend")?,intent_magnitude_start:a("auto_pump_intent_mag_start")?,intent_angle_region:a("auto_pump_intent_angle_region")?,crouch_time:a("auto_pump_crouch_time")?,crouch_speed:a("auto_pump_crouch_speed")?}, + }) +} +fn negative8(data:&Collections,key:&str,name:&str)->Result,String> {let w=data.words::<20>("anim_motion",key,name)?;Ok(PointGraph{x:std::array::from_fn(|i|f32::from_bits(w[4+i])),y:std::array::from_fn(|i|f32::from_bits(w[12+i]))})} +fn negative4(data:&Collections,key:&str,name:&str)->Result,String> {let w=data.words::<12>("anim_motion",key,name)?;Ok(PointGraph{x:std::array::from_fn(|i|f32::from_bits(w[4+i])),y:std::array::from_fn(|i|f32::from_bits(w[8+i]))})} +fn plain8(data:&Collections,key:&str,name:&str)->Result,String> {let w=data.words::<16>("anim_motion",key,name)?;Ok(PointGraph{x:std::array::from_fn(|i|f32::from_bits(w[i])),y:std::array::from_fn(|i|f32::from_bits(w[8+i]))})} diff --git a/crates/skate-game/src/graph_host/mod.rs b/crates/skate-game/src/graph_host/mod.rs new file mode 100644 index 00000000..d785cd05 --- /dev/null +++ b/crates/skate-game/src/graph_host/mod.rs @@ -0,0 +1,38 @@ +//! Production graph host building blocks. Root owns schedule and registration. +pub mod action; +mod action_conditions; +pub(crate) mod action_nodes; +mod condition_nodes; +pub(crate) use condition_nodes::numeric as parse_numeric_condition; +mod crouching_settings; +pub mod motion; +mod motion_air_leg; +mod motion_animation; +mod motion_channels; +pub(crate) mod motion_character_gesture; +mod motion_conditions; +pub(crate) mod motion_gameplay_conditions; +pub(crate) mod motion_hand_services; +mod motion_hooks; +pub(crate) mod motion_intent_filter; +mod motion_kickturn; +pub(crate) mod motion_landing; +mod motion_landing_execute; +pub(crate) mod motion_native; +mod motion_nodes; +#[path = "motion_offboard/push_off.rs"] +pub(crate) mod motion_push_off; +#[path = "motion_offboard/reset.rs"] +pub(crate) mod motion_reset; +mod motion_riding; +pub(crate) mod motion_riding_conditions; +pub(crate) mod motion_shove; +mod motion_sliding; +pub(crate) mod motion_spin; +#[path = "motion_offboard/twist_lean.rs"] +pub(crate) mod motion_twist_lean; +pub(crate) mod motion_wipeout; +mod pumping_settings; +pub mod pushing; +pub mod pushing_settings; +mod turning_settings; diff --git a/crates/skate-game/src/graph_host/motion.rs b/crates/skate-game/src/graph_host/motion.rs new file mode 100644 index 00000000..61c877fd --- /dev/null +++ b/crates/skate-game/src/graph_host/motion.rs @@ -0,0 +1,361 @@ +#[path = "motion_air_execute.rs"] +mod air_execution; +#[path = "motion_execute.rs"] +mod execution; +#[path = "motion_filter_execute.rs"] +mod filter_execution; +#[path = "motion_sliding_execute.rs"] +mod slide_execution; +#[path = "motion_wipeout_execute.rs"] +mod wipeout_execution; +// Persistent host called by the production stock MotionGraph controller. +pub use super::motion_animation::MotionAnimation; +use super::{ + motion_nodes::{MotionFactory, MotionOperation}, + pushing::{PushContext, PushInstance, PushOperation}, + pushing_settings::PushingSettings, +}; +use crate::graph_runtime::{LoadedGraph, OperationRemap}; +use skate_core::graph::intents::IntentMap; +use skate_core::{ + animation::{ + playback::{PlayAnimationInstance, PlaybackContext}, + playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute}, + skeleton_input::name::encode, + }, + graph::{ + activation::ConditionHost, + conditions::ConditionInputs, + controller::{BehaviorId, Frame, HookId, Host}, + }, + input::set_turning, + riding::push_behaviors::{PushFootFrame, PushState}, +}; +use skate_data::collections::Collections; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug)] +pub struct MotionPhysical { + pub turning: set_turning::Physical, + pub stance: (bool, bool), + pub forward_speed: f32, + pub time_since_teleport: f32, + pub is_switch: bool, + pub foot_frame: Option, +} +enum Instance { + Stateless, + IntentFilter(skate_core::animation::intent_filter::State), + Landing(super::motion_landing::State), + Wipeout(super::motion_wipeout::State), + TwistLean(skate_core::player::offboard::twist_lean::State), + Play(PlayAnimationInstance), + Push(PushInstance), + Turning(set_turning::State), + Crouching(Option), + BodyTilt(skate_core::animation::body_tilt::State), + RidingFakie(skate_core::animation::riding_fakie::State), + Pumping(skate_core::animation::pumping_channel::State), + FakieHead(super::motion_channels::FakieHead), + LandingData(super::motion_native::LandingData), + Sliding(super::motion_sliding::State), + SlideDeceleration(f32), + KickTurn(skate_core::animation::kickturn::State), + BodySpin(super::motion_spin::State), + AirLeg(skate_core::animation::air_leg_extension::State), + CharacterGesture(super::motion_character_gesture::CharacterGesture), + Shove(super::motion_shove::ShoveState), +} +impl Instance { + fn new(operation: &MotionOperation) -> Self { + match operation { + MotionOperation::IntentFilter(_) => Self::IntentFilter(Default::default()), + MotionOperation::Landing(_) => Self::Landing(Default::default()), + MotionOperation::Wipeout(_) => Self::Wipeout(Default::default()), + MotionOperation::TwistLean(_) => Self::TwistLean(Default::default()), + MotionOperation::Play(_) => Self::Play(PlayAnimationInstance::default()), + MotionOperation::Push(operation) => Self::Push(PushInstance::new(operation.clone())), + MotionOperation::SetTurning(_) => Self::Turning(set_turning::State { + elapsed: 0.0, + smoothed: 0.0, + mode: 2, + }), + MotionOperation::Crouching(_) => Self::Crouching(None), + MotionOperation::SettingBodyTilt(_) => { + Self::BodyTilt(skate_core::animation::body_tilt::State::default()) + } + MotionOperation::UpdateRidingFakie(_) => { + Self::RidingFakie(skate_core::animation::riding_fakie::State::default()) + } + MotionOperation::Pumping => { + Self::Pumping(skate_core::animation::pumping_channel::State::default()) + } + MotionOperation::FakieHeadChannel => { + Self::FakieHead(super::motion_channels::FakieHead::default()) + } + MotionOperation::Native(super::motion_native::Operation::StoreLandingData) => { + Self::LandingData(super::motion_native::LandingData::default()) + } + MotionOperation::Slide(super::motion_sliding::Operation::Update) => { + Self::Sliding(super::motion_sliding::State::default()) + } + MotionOperation::Slide(super::motion_sliding::Operation::Deceleration) => { + Self::SlideDeceleration(0.0) + } + MotionOperation::KickTurn(super::motion_kickturn::Operation::Steering(_)) => { + Self::KickTurn(skate_core::animation::kickturn::State::new()) + } + MotionOperation::BodySpin => Self::BodySpin(super::motion_spin::State::default()), + MotionOperation::AirLeg(_) => Self::AirLeg(Default::default()), + MotionOperation::CharacterGesture => { + Self::CharacterGesture(super::motion_character_gesture::CharacterGesture::new()) + } + MotionOperation::Shove(_) => Self::Shove(super::motion_shove::ShoveState::new()), + MotionOperation::ResetAnimation(_) => Self::Stateless, + _ => Self::Stateless, + } + } +} +pub struct MotionHost { + pub animation: MotionAnimation, + pub playback_context: PlaybackContext, + pub condition_inputs: ConditionInputs, + pub gameplay_conditions: Option, + pub riding_conditions: Option, + pub condition_random: super::motion_riding_conditions::MotionRandom, + pub native_physical: Option, + pub prelanding_physical: Option, + pub air_leg_physical: Option, + pub gesture_physical: Option, + pub gesture_publication: Option, + pub shove_physical: Option, + pub hand_services: super::motion_hand_services::HandServices, + pub slide_latch: set_turning::SlideLatch, + ///SpecificCA4 bit26, getter8258F900/setter8258F910; reset clears it. + pub is_power_sliding: bool, + pub score_packet: super::motion_native::ScorePacket, + pub action_intents: IntentMap, + pub time_tags: Option>, + pub physical: Option, + pub push_state: Option, + pub applying_body_tilt: bool, + pub crouching_physical: Option, + pub body_tilt_physical: Option, + pub fakie_physical: Option, + /// PhysOutAnimation158/157, used directly by IsRidingGoofy82BA5AA8. + pub physical_stance: Option<(bool, bool)>, + pub flags: super::motion_landing::Flags, + pub landing_physical: Option, + pub wipeout_physical: Option, + pub wipeout_controls: super::motion_wipeout::Controls, + wipeout_settings: super::motion_wipeout::Settings, + ///Native PhysOutGround pumping acceleration268. + pub pumping_acceleration: Option, + pub allow_pumping: bool, + pub riding: super::motion_riding::RidingState, + pub errors: Vec, + pub(super) state_parents: Vec>, + operations: Vec, + instances: Vec, + remap: OperationRemap, + pushing: PushingSettings, + turning: set_turning::Settings, + crouching: skate_core::animation::crouching::Settings, + body_tilt: skate_core::animation::body_tilt::Settings, + pumping: skate_core::animation::pumping_channel::Settings, + sliding: super::motion_sliding::Settings, + pub(super) spin: super::motion_spin::Settings, + air_leg: skate_core::animation::air_leg_extension::Settings, + kickturn: skate_core::animation::kickturn::Settings, + next_instance: u32, +} +impl MotionHost { + pub fn from_graph( + graph: &LoadedGraph, + data: &Collections, + mut metadata: skate_data::animation_metadata::AnimationMetadata, + playback_context: PlaybackContext, + ) -> Result { + let mut operations = graph + .binding + .instantiate_operations(&graph.source, &mut MotionFactory) + .map_err(|e| e.to_string())? + .operations; + super::motion_conditions::bind_states(graph, &mut operations); + let instances = graph + .runtime + .operations + .behaviors + .iter() + .map(|&id| Instance::new(&operations[id])) + .collect(); + //8258F488 and final reset825953B0 explicitly zero the complete push + //state; reset clears manualing and body-tilt flag bits too. + let zero = skate_core::riding::push_animation::PushBlendParameters { + hstr_vel_b: 0.0, + lstr_vel_b: 0.0, + vel_e: 0.0, + }; + let push_state = Some(PushState { + out_factor: 0.0, + current_push_dv: 0.0, + current: zero, + target: zero, + continue_push: false, + }); + let pushing = PushingSettings::load(data, &mut metadata)?; + Ok(Self { + animation: MotionAnimation::from_metadata(metadata), + playback_context, + condition_inputs: ConditionInputs::default(), + gameplay_conditions: None, + riding_conditions: None, + condition_random: super::motion_riding_conditions::MotionRandom::new(), + native_physical: None, + prelanding_physical: None, + air_leg_physical: None, + gesture_physical: None, + gesture_publication: None, + shove_physical: None, + hand_services: Default::default(), + slide_latch: set_turning::SlideLatch::default(), + is_power_sliding: false, + score_packet: super::motion_native::ScorePacket::default(), + action_intents: IntentMap::new(), + time_tags: None, + physical: None, + push_state, + applying_body_tilt: false, + crouching_physical: None, + body_tilt_physical: None, + fakie_physical: None, + physical_stance: None, + flags: Default::default(), + landing_physical: None, + wipeout_physical: None, + wipeout_controls: Default::default(), + wipeout_settings: super::motion_wipeout::Settings::load(data)?, + pumping_acceleration: None, + //8258F488 seeds bit24, and reset825953B0 preserves that bit. + allow_pumping: true, + riding: super::motion_riding::RidingState::new(), + errors: Vec::new(), + state_parents: graph.binding.states.iter().map(|s| s.parent).collect(), + operations, + instances, + remap: graph.runtime.operations.clone(), + pushing, + turning: super::turning_settings::load(data)?, + crouching: super::crouching_settings::load(data)?, + body_tilt: super::motion_riding::body_tilt_settings(data)?, + pumping: super::pumping_settings::load(data)?, + sliding: super::motion_sliding::Settings::load(data)?, + spin: super::motion_spin::Settings::load(data)?, + air_leg: super::motion_air_leg::load(data)?, + kickturn: super::motion_kickturn::load_settings(data)?, + next_instance: 1, + }) + } + fn run(&mut self, id: BehaviorId, frame: &Frame, phase: u8) { + if let Err(error) = self.execute(id, frame, phase) { + self.errors + .push(format!("MotionGraph behavior {id}: {error}")); + } + } +} +impl ConditionHost for MotionHost { + fn condition_activation(&mut self, condition: usize, frame: &Frame) -> u32 { + let result = self + .remap + .conditions + .get(condition) + .and_then(|&id| self.operations.get(id)) + .ok_or_else(|| "Unbound MotionGraph condition".to_owned()) + .and_then(|op| match op { + MotionOperation::Condition(condition) => condition.evaluate(self, frame), + other => Err(format!("Unsupported MotionGraph condition {other:?}")), + }); + match result { + Ok(value) => u32::from(value), + Err(error) => { + self.errors.push(error); + 0 + } + } + } +} +impl Host for MotionHost { + fn context(&self) -> [u32; 6] { + [0; 6] + } + fn allocate(&mut self, behavior: BehaviorId, _frame: &Frame) -> u32 { + if let Some(&id) = self.remap.behaviors.get(behavior) { + self.instances[behavior] = Instance::new(&self.operations[id]); + } + let handle = self.next_instance; + self.next_instance = self.next_instance.wrapping_add(1).max(1); + handle + } + fn begin(&mut self, id: BehaviorId, _context: [u32; 6], frame: &Frame) { + self.run(id, frame, 0); + } + fn update(&mut self, id: BehaviorId, _context: [u32; 6], frame: &Frame) { + self.run(id, frame, 1); + } + fn end(&mut self, id: BehaviorId, _context: [u32; 6], frame: &Frame) { + self.run(id, frame, 2); + } + fn hook(&mut self, hook: HookId, _frame: &Frame) { + if let Some(MotionOperation::Hook(operation)) = self + .remap + .hooks + .get(hook) + .and_then(|&id| self.operations.get(id)) + .cloned() + { + match operation { + super::motion_hooks::MotionHook::GrabSlide { right } => { + self.slide_latch.grab(right) + } + super::motion_hooks::MotionHook::Override(settings) => { + self.playback_context.transition_override = Some(settings) + } + super::motion_hooks::MotionHook::MongoPushToAntic { animation } => { + //82BBBBB8 passes the authored name for both channel/tree. + let settings = skate_core::animation::channel_playback::ChannelSettings { + priority: 0, + keep_alive: false, + mirrored: false, + speed: 1.0, + blend_in: f32::from_bits(0x3dcccccd), + hold_during_blend_in: false, + blend_out: f32::from_bits(0x3d8f5c29), + hold_during_blend_out: false, + use_attributes: false, + }; + if let Err(error) = self.animation.new_channel(&animation, &animation, settings) + { + self.errors.push(error); + } + } + } + return; + } + self.errors.push(format!( + "Unsupported MotionGraph hook {:?}", + self.remap + .hooks + .get(hook) + .and_then(|&id| self.operations.get(id)) + )); + } + fn release(&mut self, _instance: u32) {} +} + +#[cfg(test)] +#[path = "tests/motion_stock.rs"] +mod tests; + +#[cfg(test)] +#[path = "tests/motion_slide.rs"] +mod slide_tests; diff --git a/crates/skate-game/src/graph_host/motion_air_execute.rs b/crates/skate-game/src/graph_host/motion_air_execute.rs new file mode 100644 index 00000000..67c8b442 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_air_execute.rs @@ -0,0 +1,100 @@ +//! Original airborne MotionGraph lifecycles on the production host. +use super::*; +impl MotionHost { + pub(super) fn execute_air( + &mut self, + behavior: BehaviorId, + operation: MotionOperation, + frame: &Frame, + phase: u8, + ) -> Result<(), String> { + let instance = self + .instances + .get_mut(behavior) + .ok_or("Unallocated airborne MotionGraph behavior")?; + match (operation, instance) { + (MotionOperation::ClearTrickAttr, _) => { + //End82BB5DB0 -> IAnimatable112 (8231E050+112) ->82531278: + //erase the construction-map key. Begin/Update are82B61BB8. + if phase == 2 { + self.animation + .construction_values + .retain(|(name, _)| *name != encode(b"Trick")); + } + } + (MotionOperation::AirLeg(operation), Instance::AirLeg(state)) => { + if phase == 1 { + let physical = self + .air_leg_physical + .ok_or("Air leg extension requires completed physical output")?; + let initial = if state.needs_initial_attribute() { + self.animation.last_attribute(operation.height)? + } else { + None + }; + let prepare = if state.needs_prelanding_query(physical) { + let query = self + .prelanding_physical + .ok_or("Air leg extension requires actual prelanding output")? + .near_landing(&self.spin); + let full = if query { + self.animation.last_attribute(encode(b"FullExtension"))? + } else { + None + }; + skate_core::animation::air_leg_extension::prepare_to_land( + query, + full, + physical, + &self.air_leg, + ) + } else { + false + }; + let [height, extension] = state.update( + operation.bone, + physical, + &self.air_leg, + frame.dt, + initial, + prepare, + ); + for (name, value) in + [(operation.height, height), (encode(b"extend"), extension)] + { + self.animation.set_attribute(SettableAttribute { + name, + value, + normalized: false, + sequence_id: -1, + }); + } + } + } + (MotionOperation::BodySpin, Instance::BodySpin(state)) => { + if phase == 1 { + state.update( + &mut self.animation, + self.condition_inputs + .physical_state + .as_ref() + .ok_or("BodySpin requires physicalcategory")? + .category, + self.gameplay_conditions + .ok_or("BodySpin requires physicalstate")? + .state, + self.flags.doing_trick, + self.hand_services.busy_hands, + self.playback_context + .is_mirrored + .ok_or("BodySpin requires animstance")?, + self.prelanding_physical, + &self.spin, + )?; + } + } + _ => return Err("Airborne MotionGraph operation/instance mismatch".into()), + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_air_leg.rs b/crates/skate-game/src/graph_host/motion_air_leg.rs new file mode 100644 index 00000000..fa864e84 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_air_leg.rs @@ -0,0 +1,46 @@ +//! Stock ControlAirLegExtension configuration82BAF450 and settings Globals336. +use skate_core::{ + animation::{ + air_leg_extension::{Bone, Settings}, + output::attributes::AttributeName, + skeleton_input::name::encode, + }, + point_graph::PointGraph, +}; +use skate_data::{collections::Collections, state_graph::attributes::Attributes}; + +#[derive(Clone, Debug, PartialEq)] +pub struct Operation { + pub height: AttributeName, + pub bone: Bone, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Self { + Self { + height: encode(a.text("driveDistToCom").unwrap_or("disttocog").as_bytes()), + bone: match a.text("bone").unwrap_or("Skateboard_Root") { + "LeftToeBase" => Bone::LeftToe, + "RightToeBase" => Bone::RightToe, + _ => Bone::Board, + }, + } + } +} +pub fn load(data: &Collections) -> Result { + let value = |name| data.float("anim_motion", "inair_disttocog", name); + //PointNegGraphData4 includes four metadata words before x/y. Update uses + //82481E10 on the four authored knots, not the negative-graph evaluator. + let w = data.words::<12>("anim_motion", "inair_disttocog", "lo_air_arm_extend")?; + Ok(Settings { + going_up_speed: value("goingup_disttocog_speed")?, + going_down_speed: value("goingdown_disttocog_speed")?, + minimum_height: value("min_inair_disttocog")?, + preland_velocity: value("preland_disttocom_vel")?, + preland_final_height: value("preland_final_disttocom")?, + offboard_multiplier: value("offboard_preland_disttocom_vel_multiplier")?, + arm_extension: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(w[8 + i])), + }, + }) +} diff --git a/crates/skate-game/src/graph_host/motion_animation.rs b/crates/skate-game/src/graph_host/motion_animation.rs new file mode 100644 index 00000000..9a9a084e --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_animation.rs @@ -0,0 +1,439 @@ +//! Stock tree ownership and persistent playback used directly by MotionHost. +mod selection_space_host; +mod tree_builder; +use skate_core::animation::posture::{PendingPosture, PosturePose}; +use skate_core::animation::{ + clip_clock::AdvanceResult, + output::attributes::{AnimationAttribute, AttributeName, MotionGraphAttribute}, + phase_blend::PhaseBlend, + playback::{PlaybackRequest, PlaybackService}, + playback_clip::{ClipAttribute, PlaybackClip}, + playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute, SettableAttributes}, + playback_transition::PlaybackTransition, + playback_tree::{Evaluation, PlaybackTree, PoseCommand}, + skeleton_input::name::encode, +}; +use skate_core::graph::intents::IntentMap; +use skate_data::animation_metadata::{AnimationMetadata, TreeMetadata}; +use tree_builder::build; + +pub struct MotionAnimation { + metadata: AnimationMetadata, + current: Option, + pub channels: super::motion_channels::MotionChannels, + pub current_name: Option, + pub motion_intents: IntentMap, + pub filtered_intents: IntentMap, + pub motion_attributes: Vec, + pub construction_values: Vec<(AttributeName, AttributeName)>, + pub posture: PendingPosture, + pub posture_bank_valid: bool, + ///Actual fullSkaterAnim15180 flags. AddBindPose consumes bits22/21 on + ///construction; the owner publishes the updated flags back to the actor. + pub skater_animation_flags: Option, + attribute_mirror: std::sync::Arc, + settable: SettableAttributes, + tree_attributes: Vec, + property: AdvanceResult, +} +impl MotionAnimation { + pub fn reset_from_stock(&mut self) { + self.current = None; + self.current_name = None; + self.channels.reset_from_stock(); + self.motion_intents.clear(); + self.filtered_intents.clear(); + self.motion_attributes.clear(); + self.tree_attributes.clear(); + self.settable.clear(); + self.skater_animation_flags = Some(0); + } + pub fn from_metadata(metadata: AnimationMetadata) -> Self { + Self { + metadata, + current: None, + channels: super::motion_channels::MotionChannels::default(), + current_name: None, + motion_intents: IntentMap::new(), + filtered_intents: IntentMap::new(), + motion_attributes: Vec::new(), + construction_values: Vec::new(), + posture: PendingPosture::default(), + posture_bank_valid: false, + skater_animation_flags: None, + attribute_mirror: std::sync::Arc::new( + skate_core::animation::playback_attributes::AttributeMirror(Vec::new()), + ), + settable: SettableAttributes::default(), + tree_attributes: Vec::new(), + property: AdvanceResult { + crossed_end: false, + overshoot: -1.0, + remaining_before_wrap: -1.0, + }, + } + } + ///Share the already loaded stock hierarchy with playback; no frame reload. + pub fn set_hierarchy( + &mut self, + names: &[String], + mirror_indices: &[i32], + ) -> Result<(), String> { + if names.len() != mirror_indices.len() { + return Err("Animation hierarchy mirror count differs from bone count".into()); + } + let mut pairs = Vec::with_capacity(names.len()); + for (index, name) in names.iter().enumerate() { + let target = match mirror_indices[index] { + -1 => name, + i if i >= 0 => names.get(i as usize).ok_or("Invalid mirrored bone index")?, + _ => return Err("Invalid negative mirrored bone index".into()), + }; + pairs.push((encode(name.as_bytes()), encode(target.as_bytes()))); + } + self.attribute_mirror = std::sync::Arc::new( + skate_core::animation::playback_attributes::AttributeMirror(pairs), + ); + Ok(()) + } + pub fn tree_attributes(&self) -> &[AnimationAttribute] { + &self.tree_attributes + } + pub fn property(&self) -> AdvanceResult { + self.property + } + pub fn current_time(&self) -> Result { + self.current + .as_ref() + .map(PlaybackTree::time) + .ok_or_else(|| "No current animation tree".into()) + } + pub fn current_length(&self) -> Result { + self.current + .as_ref() + .map(PlaybackTree::length) + .ok_or_else(|| "No current animation tree".into()) + } + pub fn in_transition(&self) -> bool { + self.current.as_ref().is_some_and(has_transition) + } + ///825310F0: retire completed transitions before advancing; reset property + /// once at the root. Graph evaluation, parametrization and pose evaluation + /// remain separate calls so the actor schedule owns their meaningful order. + pub fn advance(&mut self, dt: f32, phase: f32) { + self.channels.retire(); + self.current = self.current.take().map(prune); + self.property = AdvanceResult { + crossed_end: false, + overshoot: -1.0, + remaining_before_wrap: -1.0, + }; + if let Some(tree) = &mut self.current { + tree.advance(dt, phase, &mut self.property); + } + self.channels.advance(dt, phase); + } + pub fn apply_parameters(&mut self) -> Result<(), String> { + let attributes = self.settable.entries().to_vec(); + if let Some(mut tree) = self.current.take() { + let result = self.prepare_selection_spaces(&mut tree, &attributes); + self.current = Some(tree); + result?; + } + let mut channels = std::mem::take(&mut self.channels); + let result = + channels.prepare_trees(|tree| self.prepare_selection_spaces(tree, &attributes)); + self.channels = channels; + result?; + if let Some(tree) = &mut self.current { + tree.set_attributes(self.settable.entries())?; + } + self.channels.set_attributes(self.settable.entries())?; + self.settable.clear(); + Ok(()) + } + pub fn evaluate_pose(&mut self, parameters: Evaluation) -> Result, String> { + let mut output = Vec::new(); + let tree = self + .current + .as_mut() + .ok_or("MotionGraph has not selected an animation")?; + tree.evaluate(parameters, true, &mut output)?; + self.channels.evaluate(parameters, &mut output)?; + Ok(output) + } + pub fn refresh_tree_attributes(&mut self) -> Result<(), String> { + self.tree_attributes = self.channels.attributes( + match &self.current { + Some(tree) => tree.attributes(15)?, + None => Vec::new(), + }, + 15, + )?; + Ok(()) + } + pub fn begin_graph_update(&mut self) { + self.motion_attributes.clear(); + } + pub fn attach(&mut self, intent: &str, attribute: AttributeName, set: bool) { + if let Some(value) = self.motion_intent(intent) { + self.motion_attributes.push(MotionGraphAttribute { + name: attribute, + value, + }); + if set { + self.set_attribute(SettableAttribute { + name: attribute, + value, + normalized: false, + sequence_id: -1, + }); + } + } + } + pub fn emit_packet(&mut self, name: AttributeName, value: f32) { + self.motion_attributes + .push(MotionGraphAttribute { name, value }); + } + pub fn build_tree(&self, name: &str) -> Result { + build( + &self.metadata, + name, + &self.construction_values, + &mut Vec::new(), + ) + } + pub fn new_channel( + &mut self, + name: &str, + animation: &str, + settings: skate_core::animation::channel_playback::ChannelSettings, + ) -> Result { + if self.channels.has(name) { + return Ok(false); + } + let mut motion = self.build_tree(animation)?; + motion.set_speed(settings.speed); + let mut tree = self.add_bind_pose(motion, None)?; + if settings.mirrored { + if let PlaybackTree::BindPose { mirror_modes, .. } = &mut tree { + mirror_modes.push(2); + } + } + self.channels.insert(name.into(), tree, settings); + Ok(true) + } + pub fn transition_channel( + &mut self, + name: &str, + animation: &str, + settings: skate_core::animation::channel_playback::ChannelSettings, + transition: skate_core::animation::playback::TransitionSettings, + resurrect: bool, + create_missing: bool, + ) -> Result { + if !self.channels.has(name) { + return if create_missing { + self.new_channel(name, animation, settings) + } else { + Ok(false) + }; + } + if !self.channels.can_transition(name, resurrect) { + return Ok(false); + } + let mut motion = self.build_tree(animation)?; + motion.set_speed(settings.speed); + let mut tree = self.add_bind_pose(motion, None)?; + if settings.mirrored { + if let PlaybackTree::BindPose { mirror_modes, .. } = &mut tree { + mirror_modes.push(2); + } + } + self.channels + .transition(name, tree, settings, transition, resurrect); + Ok(true) + } + fn add_bind_pose( + &mut self, + motion: PlaybackTree, + posture: Option, + ) -> Result { + let flags = self + .skater_animation_flags + .ok_or("Animation construction requires actual SkaterAnim flags")?; + let mut mirror_modes = Vec::new(); + if flags & 0x40000000 != 0 { + mirror_modes.push(2); + } + if flags & 0x00400000 != 0 { + mirror_modes.push(u32::from(flags & 0x00200000 == 0) + 1); + } + self.skater_animation_flags = Some(flags & !0x00600000); + if !mirror_modes.is_empty() && self.attribute_mirror.0.is_empty() { + return Err("Mirrored animation requires the stock bone hierarchy".into()); + } + Ok(PlaybackTree::BindPose { + motion: Box::new(motion), + posture, + board_backwards: flags & 0x80000000 != 0, + mirror_modes, + attribute_mirror: self.attribute_mirror.clone(), + }) + } +} +fn has_transition(tree: &PlaybackTree) -> bool { + match tree { + PlaybackTree::Transition(_) => true, + PlaybackTree::PhaseBlend(tree) => tree.children.iter().any(has_transition), + PlaybackTree::SelectionSpace(tree) => tree.current().is_some_and(has_transition), + PlaybackTree::BindPose { motion, .. } => has_transition(motion), + _ => false, + } +} +fn prune(tree: PlaybackTree) -> PlaybackTree { + match tree { + PlaybackTree::Transition(mut transition) => { + if transition.complete() { + prune(*transition.to) + } else { + transition.from = Box::new(prune(*transition.from)); + transition.to = Box::new(prune(*transition.to)); + PlaybackTree::Transition(transition) + } + } + PlaybackTree::PhaseBlend(mut tree) => { + tree.children = tree.children.into_iter().map(prune).collect(); + PlaybackTree::PhaseBlend(tree) + } + PlaybackTree::SelectionSpace(mut tree) => { + tree.candidates = tree + .candidates + .into_iter() + .map(|mut candidate| { + candidate.tree = prune(candidate.tree); + candidate + }) + .collect(); + PlaybackTree::SelectionSpace(tree) + } + PlaybackTree::BindPose { + motion, + posture, + board_backwards, + mirror_modes, + attribute_mirror, + } => PlaybackTree::BindPose { + motion: Box::new(prune(*motion)), + posture, + board_backwards, + mirror_modes, + attribute_mirror, + }, + other => other, + } +} +impl ParameterInputs for MotionAnimation { + fn motion_intent(&self, name: &str) -> Option { + self.motion_intents.get(name).copied() + } + fn filtered_intent(&self, name: &str) -> Option { + self.filtered_intents.get(name).copied() + } + fn last_attribute( + &mut self, + name: AttributeName, + ) -> Result, String> { + Ok(self + .tree_attributes + .iter() + .find(|a| a.name == name) + .copied()) + } +} +impl AttributeSink for MotionAnimation { + fn set_attribute(&mut self, attribute: SettableAttribute) { + self.settable.set_attribute(attribute); + } +} +impl super::pushing::PushAnimationSink for MotionAnimation { + fn set_attribute(&mut self, a: super::pushing::SettableAttribute) { + AttributeSink::set_attribute( + self, + SettableAttribute { + name: encode(a.name.as_bytes()), + value: a.value, + normalized: a.normalized, + sequence_id: a.sequence_id, + }, + ); + } +} +impl PlaybackService for MotionAnimation { + fn set_construction_value(&mut self, name: AttributeName, value: AttributeName) { + if let Some(entry) = self + .construction_values + .iter_mut() + .find(|entry| entry.0 == name) + { + entry.1 = value; + } else { + self.construction_values.push((name, value)); + } + } + fn set_posture_enabled(&mut self, enabled: bool) { + self.posture.set_requested(enabled); + } + fn play(&mut self, request: PlaybackRequest) -> Result { + if !(1..=4).contains(&request.transition.kind) { + return Ok(false); + } + if request.transition.kind == 1 { + self.current = None; + } + let motion = self.build_tree(&request.animation)?; + //SkaterAnim::GetAnimTree82B980A0 calls AddBindPose82B98118 before + //Play/Blend set time/speed and before transition initialization. + let (motion, posture) = if self.posture_bank_valid { + self.posture + .apply::<_, String>((motion, None), |(motion, _), pose| Ok((motion, Some(pose))))? + } else { + (motion, None) + }; + let mut tree = self.add_bind_pose(motion, posture)?; + tree.set_speed(request.speed); + let prior = self.current.take(); + //82D186F8's no-current branch calls Play with start0. + if request.start_time > 0.0 && (request.transition.kind == 1 || prior.is_some()) { + tree.set_time(request.start_time); + } + self.current = Some(match prior { + Some(mut from) => { + if request.transition.under != 0 { + if let PlaybackTree::Transition(transition) = &mut from { + let to = std::mem::replace(&mut transition.to, Box::new(tree.clone())); + transition.to = Box::new(PlaybackTree::Transition( + PlaybackTransition::new(*to, tree, request.transition), + )); + from + } else { + PlaybackTree::Transition(PlaybackTransition::new( + from, + tree, + request.transition, + )) + } + } else { + PlaybackTree::Transition(PlaybackTransition::new( + from, + tree, + request.transition, + )) + } + } + None => tree, + }); + self.current_name = Some(request.animation); + Ok(true) + } +} diff --git a/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs b/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs new file mode 100644 index 00000000..377a7e96 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_animation/selection_space_host.rs @@ -0,0 +1,50 @@ +//! Deferred native GetAnimTree callback in SelectionSpace::SetAttributes. +use super::*; + +impl MotionAnimation { + pub(super) fn prepare_selection_spaces( + &mut self, + tree: &mut PlaybackTree, + attributes: &[SettableAttribute], + ) -> Result<(), String> { + match tree { + PlaybackTree::SelectionSpace(space) => { + space.select(attributes)?; + if !space.child_constructed { + let motion = space + .current() + .ok_or("SelectionSpace has no selected child")? + .clone(); + let (motion, posture) = if self.posture_bank_valid { + self.posture + .apply::<_, String>((motion, None), |(motion, _), pose| { + Ok((motion, Some(pose))) + })? + } else { + (motion, None) + }; + *space.current_mut().unwrap() = self.add_bind_pose(motion, posture)?; + space.child_constructed = true; + } + self.prepare_selection_spaces(space.current_mut().unwrap(), attributes)?; + } + PlaybackTree::PhaseBlend(space) => { + for child in &mut space.children { + self.prepare_selection_spaces(child, attributes)?; + } + } + PlaybackTree::Transition(transition) => { + // PlaybackTransition::set_attributes follows this same to/from order. + self.prepare_selection_spaces(&mut transition.to, attributes)?; + if transition.settings.kind == 4 && !transition.complete() { + self.prepare_selection_spaces(&mut transition.from, attributes)?; + } + } + PlaybackTree::BindPose { motion, .. } => { + self.prepare_selection_spaces(motion, attributes)? + } + PlaybackTree::Clip { .. } => {} + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs b/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs new file mode 100644 index 00000000..29d18d8d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_animation/tree_builder.rs @@ -0,0 +1,90 @@ +//! Stock authored tree construction; runtime selection remains in the tree. +use super::*; + +pub(super) fn build( + metadata: &AnimationMetadata, + name: &str, + construction: &[(AttributeName, AttributeName)], + parents: &mut Vec, +) -> Result { + let key = name.to_ascii_uppercase(); + if parents.contains(&key) { + return Err(format!("Cyclic authored animation tree {key}")); + } + parents.push(key); + let result = match metadata.tree(name)? { + TreeMetadata::Clip(source) => PlaybackTree::Clip { + name: source.name.clone(), + clip: PlaybackClip::new( + f32::from_bits(source.frames_bits), + f32::from_bits(source.fps_bits), + f32::from_bits(source.base_speed_bits), + source.flags_word, + source + .attributes + .iter() + .map(|a| ClipAttribute { + name: encode(a.name.as_bytes()), + kind: a.type_id, + begin: f32::from_bits(a.begin_bits), + end: f32::from_bits(a.end_bits), + payload: a.payload_words.clone(), + }) + .collect(), + ), + }, + TreeMetadata::PhaseBlend(source) => PlaybackTree::PhaseBlend(PhaseBlend::new( + encode(source.parameter.as_bytes()), + source + .children + .iter() + .map(|child| build(metadata, child, construction, parents)) + .collect::>()?, + )?), + TreeMetadata::SelectionSpace(source) => { + use skate_core::animation::playback_tree::selection_space::{ + Candidate, Parameter, SelectionSpace, + }; + let parameters = source + .parameters + .iter() + .map(|p| Parameter { + name: encode(p.name.as_bytes()), + mode: p.mode, + weight: f32::from_bits(p.weight_bits), + minimum: f32::from_bits(p.minimum_bits), + maximum: f32::from_bits(p.maximum_bits), + }) + .collect(); + let candidates = source + .candidates + .iter() + .map(|c| { + Ok(Candidate { + name: c.child.clone(), + values: c.value_bits.iter().map(|v| f32::from_bits(*v)).collect(), + tree: build(metadata, &c.child, construction, parents)?, + }) + }) + .collect::, String>>()?; + PlaybackTree::SelectionSpace(SelectionSpace::new(parameters, candidates)?) + } + //82D1B9B4..BAD8 resolves the first matching construction value at + //creation. A missing key/value selects child0; this is authored data. + TreeMetadata::Selector(source) => { + let value = construction + .iter() + .find(|(name, _)| *name == encode(source.parameter.as_bytes())) + .map(|(_, value)| *value); + let child = source + .values + .iter() + .position(|key| Some(encode(key.as_bytes())) == value) + .map(|index| source.children[index].as_str()) + .unwrap_or(&source.default); + build(metadata, child, construction, parents)? + } + }; + parents.pop(); + Ok(result) +} diff --git a/crates/skate-game/src/graph_host/motion_channels.rs b/crates/skate-game/src/graph_host/motion_channels.rs new file mode 100644 index 00000000..f4e172a2 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_channels.rs @@ -0,0 +1,277 @@ +//! Owned counterpart of ChannelAnimatable's ordered persistent overlays. +use skate_core::animation::{ + channel_playback::{ChannelPlayback, ChannelSettings}, + clip_clock::AdvanceResult, + output::attributes::{AnimationAttribute, AttributeName}, + playback_parameters::{SettableAttribute, intent_key}, + playback_tree::{Evaluation, PlaybackTree, PoseCommand}, +}; + +struct Channel { + name: String, + tree: PlaybackTree, + playback: ChannelPlayback, +} +#[derive(Default)] +pub struct MotionChannels { + channels: Vec, +} +impl MotionChannels { + pub fn reset_from_stock(&mut self) { + self.channels.clear(); + } + pub(super) fn prepare_trees( + &mut self, + mut prepare: impl FnMut(&mut PlaybackTree) -> Result<(), String>, + ) -> Result<(), String> { + for channel in &mut self.channels { + if channel.playback.weight > 0.0 { prepare(&mut channel.tree)?; } + } + Ok(()) + } + pub fn has(&self, name: &str) -> bool { + self.channels + .iter() + .any(|c| intent_key(&c.name) == intent_key(name)) + } + ///82D1D5F8: missing channel returns zero; clamp remaining child time. + pub fn remaining(&self, name: &str) -> f32 { + self.channels + .iter() + .find(|c| intent_key(&c.name) == intent_key(name)) + .map_or(0.0, |c| { + let value = c.tree.length() - c.tree.time(); + if value >= 0.0 { value } else { 0.0 } + }) + } + ///82B96FF0 reads Channel136, set/cleared by Start/CompleteTransition. + pub fn in_transition(&self, name: &str) -> bool { + self.channels + .iter() + .find(|c| intent_key(&c.name) == intent_key(name)) + .is_some_and(|c| matches!(c.tree, PlaybackTree::Transition(_))) + } + pub fn insert(&mut self, name: String, tree: PlaybackTree, settings: ChannelSettings) { + //82D1CF70..D018: greater priority lies above lesser; a new equal + //priority lies above older channels. Store in evaluation order. + let position = self + .channels + .partition_point(|c| c.playback.settings.priority <= settings.priority); + self.channels.insert( + position, + Channel { + name, + tree, + playback: ChannelPlayback::new(settings), + }, + ); + } + pub fn end(&mut self, name: &str) { + if let Some(c) = self.find_mut(name) { + c.playback.end(); + } + } + pub fn end_with(&mut self, name: &str, seconds: f32, from_last_frame: bool) { + if let Some(c) = self.find_mut(name) { + c.playback.end_with(seconds, from_last_frame); + } + } + pub fn influence(&mut self, name: &str, value: f32) -> bool { + match self.find_mut(name) { + Some(c) => { + c.playback.influence = value; + true + } + None => false, + } + } + pub fn can_transition(&mut self, name: &str, resurrect: bool) -> bool { + self.find_mut(name) + .is_some_and(|c| c.playback.can_transition(resurrect)) + } + pub fn transition( + &mut self, + name: &str, + tree: PlaybackTree, + settings: ChannelSettings, + transition: skate_core::animation::playback::TransitionSettings, + resurrect: bool, + ) { + if let Some(index) = self + .channels + .iter() + .position(|c| intent_key(&c.name) == intent_key(name)) + { + let mut c = self.channels.remove(index); + //CompleteTransition82D1DBC0 unconditionally keeps the prior target + //when another channel transition is requested during a transition. + let old = match c.tree { + PlaybackTree::Transition(t) => *t.to, + other => other, + }; + c.tree = PlaybackTree::Transition( + skate_core::animation::playback_transition::PlaybackTransition::new( + old, tree, transition, + ), + ); + c.playback.transition(settings, resurrect); + self.channels.insert(index, c); + } + } + fn find_mut(&mut self, name: &str) -> Option<&mut Channel> { + self.channels + .iter_mut() + .find(|c| intent_key(&c.name) == intent_key(name)) + } + pub fn retire(&mut self) { + //82B96F88: complete channel transitions, remove expired channels, + //then advance the composed tree. + for c in &mut self.channels { + if let PlaybackTree::Transition(t) = &c.tree { + if t.complete() { + c.tree = *t.to.clone(); + } + } + } + self.channels.retain(|c| !c.playback.expired()); + } + pub fn advance(&mut self, dt: f32, phase: f32) { + for c in &mut self.channels { + if c.playback.advance(dt, c.tree.length(), c.tree.time()) { + let mut property = AdvanceResult { + crossed_end: false, + overshoot: -1.0, + remaining_before_wrap: -1.0, + }; + c.tree.advance(dt, phase, &mut property); + c.playback.did_advance(property); + } + } + } + pub fn set_attributes(&mut self, attributes: &[SettableAttribute]) -> Result<(), String> { + for c in &mut self.channels { + if c.playback.weight > 0.0 { + c.tree.set_attributes(attributes)?; + } + } + Ok(()) + } + pub fn attributes( + &self, + mut base: Vec, + mask: u32, + ) -> Result, String> { + for c in &self.channels { + if c.playback.settings.use_attributes { + base = c.playback.merge_attributes(base, &c.tree.attributes(mask)?); + } + } + Ok(base) + } + pub fn query_attribute( + &self, + name: AttributeName, + mask: u32, + output: &mut AnimationAttribute, + ) -> Result { + //82B96AB0 queries channel first regardless of use_attributes or weight. + for c in self.channels.iter().rev() { + if c.tree.query_attribute(name, mask, output)? { + return Ok(true); + } + } + Ok(false) + } + pub fn evaluate( + &mut self, + parameters: Evaluation, + output: &mut Vec, + ) -> Result<(), String> { + for c in &mut self.channels { + let enabled = c.playback.weight > 0.0; + if c.tree.evaluate(parameters, enabled, output)? && enabled { + output.push(PoseCommand::ChannelBlend { + weight: c.playback.weight, + use_channels_from_weights: false, + }); + } + } + Ok(()) + } +} + +///FakieHeadChannel82BAC778, instance82BACA30; both instance fields seed0. +#[derive(Default)] +pub struct FakieHead { + value: f32, + was_fakie: bool, +} +impl FakieHead { + pub fn update( + &mut self, + animation: &mut super::motion_animation::MotionAnimation, + manualing: bool, + power_sliding: bool, + fakie: bool, + ) -> Result<(), String> { + use skate_core::animation::{ + playback::TransitionSettings, playback_parameters::AttributeSink, + skeleton_input::name::encode, + }; + let target = if manualing { + 1.0 + } else if power_sliding { + 0.0 + } else { + 0.5 + }; + if !self.was_fakie && fakie { + let settings = ChannelSettings { + priority: 0, + keep_alive: true, + mirrored: false, + speed: 1.0, + blend_in: f32::from_bits(0x3e99999a), + hold_during_blend_in: false, + blend_out: f32::from_bits(0x3e99999a), + hold_during_blend_out: false, + use_attributes: false, + }; + //Actual vtable8231E128+16 is TransitionTo82D1D090. + //The source permits both resurrection and creation when missing. + let transition = TransitionSettings { + kind: 2, + seconds: f32::from_bits(0x3dcccccd), + under: 0, + matching: 0, + use_channels_from_weights: false, + }; + animation.transition_channel( + "fakie", + "B_FAKIE_CHANNEL", + settings, + transition, + true, + true, + )?; + self.value = target; + } else if self.was_fakie && !fakie { + animation.channels.end("fakie"); + } + self.was_fakie = fakie; + if fakie { + let delta = target - self.value; + let limit = f32::from_bits(0x3c23d70a); + let lower = if -limit - delta >= 0.0 { -limit } else { delta }; + let delta = if limit - lower >= 0.0 { lower } else { limit }; + self.value += delta; + animation.set_attribute(SettableAttribute { + name: encode(b"torso"), + value: self.value, + normalized: false, + sequence_id: -1, + }); + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_character_gesture.rs b/crates/skate-game/src/graph_host/motion_character_gesture.rs new file mode 100644 index 00000000..25c2bcd3 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_character_gesture.rs @@ -0,0 +1,334 @@ +//! Original CharacterGesture82BAA320, instance82BAADD0 and helpers +//!82BAAFB0/82BAB080/82BAB258/82BAAED8. Allocation and channel names are owned +//! here; the original entry/cycle/exit decisions and channel timing are kept. +use super::motion_animation::MotionAnimation; +use skate_core::animation::{ + channel_playback::ChannelSettings, + playback::TransitionSettings, + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, +}; +use skate_core::graph::intents::IntentMap; + +const CHANNELS: [&str; 3] = ["GestureBoth", "GestureRight", "GestureLeft"]; +const TENTH: f32 = f32::from_bits(0x3DCC_CCCD); + +pub struct CharacterGestureInputs<'a> { + /// Actor20 resolves IMotionGraph (8258F180); virtual16 returns this map. + pub motion_intents: &'a IntentMap, + /// Specific MotionGraph+C58/C5C. Nonzero counts make a hand busy. + pub busy_hands: [u32; 2], + pub filtered_state: u32, + pub ground321: bool, + pub board_held311: bool, + pub state_offboard75: bool, + /// PhysOutAnimation+72, used only while a gesture is active. + pub distance_to_cog: f32, + /// ISkaterAnim virtual56: Up, Down, Left, Right gesture catalog indices. + pub selections: Option<[u32; 4]>, + /// ISkaterAnim virtual120, evaluated by82B988B8. + pub suppress_up: bool, + /// Actor+44 virtual24. When true ForceBrake does not suppress a start. + pub force_brake_bypass: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct GesturePublication { + ///8258FB30 writes specific MotionGraph+CE4 and sets byteCE8 to1. + pub gesture: u32, + ///8258FB38 writes byteCE9 from direction==Down. + pub down: bool, +} + +pub struct CharacterGesture { + active: bool, + stage: i32, + direction: i32, + hands: i32, +} +impl Default for CharacterGesture { + fn default() -> Self { + Self::new() + } +} +impl CharacterGesture { + ///82BAAE70/7C/88/8C: no channel is owned until a real gesture starts. + pub fn new() -> Self { + Self { + active: false, + stage: -1, + direction: -1, + hands: -1, + } + } + + pub fn update( + &mut self, + animation: &mut MotionAnimation, + input: CharacterGestureInputs<'_>, + ) -> Result, String> { + let any_channel = CHANNELS.iter().any(|name| animation.channels.has(name)); + if self.active && !any_channel { + self.stage = -1; + self.clear_active(); + } else if !self.active && any_channel { + end_all(animation); + self.clear_active(); + } + let held = [ + "GestureLeftHeld", + "GestureRightHeld", + "GestureUpHeld", + "GestureDownHeld", + ] + .map(|name| has(input.motion_intents, name)); + let mut selected = select_start(input.motion_intents); + let hands = select_hands(animation, &input); + if selected == -1 && self.direction != -1 && !held[self.direction as usize] { + selected = [3, 0, 2, 1] + .into_iter() + .find(|&direction| held[direction as usize]) + .unwrap_or(-1); + } + let braking = has(input.motion_intents, "ForceBrake") && !input.force_brake_bypass; + let start = selected != -1 + && hands != -1 + && !braking + && (!input.suppress_up || selected != 2) + && (self.direction == -1 || !held[self.direction as usize]) + && (!self.active || selected != self.direction); + if start { + let name = animation_name(selected, hands, 0, input.selections)?; + self.direction = selected; + self.stage = 0; + let transition = TransitionSettings { + kind: 2, + seconds: f32::from_bits(0x3E4C_CCCD), + under: 0, + matching: 0, + use_channels_from_weights: false, + }; + animation.transition_channel( + CHANNELS[hands as usize], + &name, + settings(false, true), + transition, + true, + true, + )?; + //82BAA954..AA4C ends the other two channels, in source order. + match hands { + 0 => { + animation.channels.end(CHANNELS[2]); + animation.channels.end(CHANNELS[1]); + } + 1 => { + animation.channels.end(CHANNELS[2]); + animation.channels.end(CHANNELS[0]); + } + 2 => { + animation.channels.end(CHANNELS[0]); + animation.channels.end(CHANNELS[1]); + } + _ => unreachable!(), + } + self.hands = hands; + self.active = true; + } else if self.active { + if self.hands != hands { + end_all(animation); + //The native context-change branch preserves stage12. + self.clear_active(); + } else { + let channel = CHANNELS[self.hands as usize]; + if !animation.channels.in_transition(channel) + && animation.channels.remaining(channel) < TENTH + { + match self.stage { + 0 => self.next_stage(animation, &input, 1)?, + 1 if !held[self.direction as usize] => { + self.next_stage(animation, &input, 2)? + } + 2 => { + self.stage = -1; + self.clear_active(); + } + _ => {} + } + } + } + } + if !self.active { + return Ok(None); + } + let gesture = selection(self.direction, input.selections)?; + animation.set_attribute(SettableAttribute { + name: encode(b"GstrDistToCog"), + value: if input.state_offboard75 { + 1.0 + } else { + input.distance_to_cog + }, + normalized: false, + sequence_id: -1, + }); + Ok(Some(GesturePublication { + gesture, + down: self.direction == 3, + })) + } + + fn next_stage( + &mut self, + animation: &mut MotionAnimation, + input: &CharacterGestureInputs<'_>, + stage: i32, + ) -> Result<(), String> { + let name = animation_name(self.direction, self.hands, stage, input.selections)?; + self.stage = stage; + //82BAAED8 -> channel virtual20=82D1D030 chooses embedded Sequence, + //vtable8231E1F8. It waits for the old clip's actual end; it is not a + //zero-duration blend. The helper enables resurrection and creation. + let transition = TransitionSettings { + kind: 4, + seconds: 0.0, + under: 0, + matching: 0, + use_channels_from_weights: false, + }; + animation.transition_channel( + CHANNELS[self.hands as usize], + &name, + settings(stage == 1, false), + transition, + true, + true, + )?; + Ok(()) + } + fn clear_active(&mut self) { + self.active = false; + self.direction = -1; + self.hands = -1; + } +} + +fn settings(keep_alive: bool, hold_in: bool) -> ChannelSettings { + ChannelSettings { + priority: 0, + keep_alive, + mirrored: false, + speed: 1.0, + blend_in: TENTH, + hold_during_blend_in: hold_in, + blend_out: TENTH, + hold_during_blend_out: true, + use_attributes: false, + } +} +fn end_all(animation: &mut MotionAnimation) { + for index in [2, 1, 0] { + animation.channels.end(CHANNELS[index]); + } +} +fn has(intents: &IntentMap, name: &str) -> bool { + intents.contains_key(name) +} +fn select_start(intents: &IntentMap) -> i32 { + [ + (3, "GestureDownStart"), + (0, "GestureLeftStart"), + (2, "GestureUpStart"), + (1, "GestureRightStart"), + ] + .into_iter() + .find(|(_, name)| has(intents, name)) + .map_or(-1, |(direction, _)| direction) +} +///82BAB258: suppress occupied special channels/mount actions, then choose +///the actual available hand. Offboard states6/7 have a separate board rule. +fn select_hands(animation: &MotionAnimation, input: &CharacterGestureInputs<'_>) -> i32 { + if ["RetrieveBoard", "Shove", "WipeoutPushOff"] + .iter() + .any(|name| animation.channels.has(name)) + || has(input.motion_intents, "OB_Mount") + || has(input.motion_intents, "OB_Dismount") + { + return -1; + } + if matches!(input.filtered_state, 6 | 7) && !input.ground321 { + return if input.board_held311 { 2 } else { 0 }; + } + match (input.busy_hands[0] != 0, input.busy_hands[1] != 0) { + (false, false) => 0, + (false, true) => 1, + (true, false) => 2, + (true, true) => -1, + } +} +fn selection(direction: i32, selections: Option<[u32; 4]>) -> Result { + let index = match direction { + 0 => 2, + 1 => 3, + 2 => 0, + 3 => 1, + _ => return Ok(37), + }; + Ok(selections.ok_or("CharacterGesture needs the actual skater gesture selections")?[index]) +} +fn animation_name( + direction: i32, + hands: i32, + stage: i32, + selections: Option<[u32; 4]>, +) -> Result { + let id = selection(direction, selections)?; + let prefix = NAMES + .get(id as usize) + .ok_or_else(|| format!("Invalid stock gesture selection {id}"))?; + Ok(format!( + "{prefix}_{}_{}", + ["BOTH", "RIGHT", "LEFT"][hands as usize], + ["INTO", "CYC", "OUT"][stage as usize] + )) +} +//82F881D0..83C4 constructs this exact37-entry table at830BF5D8. +const NAMES: [&str; 37] = [ + "B_GSTR_AIRGUITAR", + "B_GSTR_AIRPLANE", + "B_GSTR_BOXING", + "B_GSTR_BRUCE_LEE", + "B_GSTR_CHECKTIME", + "B_GSTR_DEVIL", + "B_GSTR_DBLE_GUN", + "B_GSTR_DUNNO", + "B_GSTR_FINGERWAG", + "B_GSTR_FISTS", + "B_GSTR_BICEPFLEX", + "B_GSTR_FLIPTABLE", + "B_GSTR_FONZ_EH", + "B_GSTR_FREEDOM", + "B_GSTR_FU_FIST", + "B_GSTR_GETAWAY", + "B_GSTR_GETOUTTAHERE", + "B_GSTR_HANDCUFFS", + "B_GSTR_HIGHPUMP", + "B_GSTR_LOWPUMP", + "B_GSTR_PREWIND", + "B_GSTR_PEACE", + "B_GSTR_POINT", + "B_GSTR_RAISEROOF", + "B_GSTR_SHAKA", + "B_GSTR_SHRUG", + "B_GSTR_POINT_SKY", + "B_GSTR_SNAP", + "B_GSTR_SOULARCH", + "B_GSTR_SPOCK", + "B_GSTR_SURFSUP", + "B_GSTR_SWINGHIGH", + "B_GSTR_SWINGLOW", + "B_GSTR_THROWARMS", + "B_GSTR_THMB_DWN", + "B_GSTR_WINGS", + "B_GSTR_YARDSALE", +]; diff --git a/crates/skate-game/src/graph_host/motion_conditions.rs b/crates/skate-game/src/graph_host/motion_conditions.rs new file mode 100644 index 00000000..1495382d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_conditions.rs @@ -0,0 +1,351 @@ +//! Concrete MotionGraph condition leaves and stock attributes. +use super::motion::MotionHost; +use skate_core::graph::controller::Frame; +use skate_core::{ + animation::{output::attributes::AttributeName, skeleton_input::name::encode}, + graph::conditions::{ActionCondition, Comparison, NumericCondition}, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum MotionCondition { + Gesture(crate::input::gesture_catalog::Group), + Landing(super::motion_landing::Condition), + Wipeout(super::motion_wipeout::Condition), + PushOff(super::motion_push_off::IsPushOffEnabled), + Shared(ActionCondition), + Intent { + name: String, + filtered: bool, + numeric: NumericCondition, + }, + AnimationAttribute { + name: AttributeName, + sequence_id: i32, + numeric: NumericCondition, + }, + ExpireInTime(NumericCondition), + WillExpire { + in_time: f32, + tag: Option, + wait_for_transitions: bool, + }, + InTimeWindow { + start: f32, + length: f32, + }, + InStateForTime { + name: String, + target: Option, + numeric: NumericCondition, + }, + InParentStateForTime { + target: Option, + numeric: NumericCondition, + }, + ProSkater(AttributeName), + BreakOutOfPush, + ShouldLeaveSlide { + right: bool, + }, + RidingGoofy, + RidingSwitch, + MongoPushFootTooFar, + LastState { + name: String, + target: Option, + }, + Gameplay(super::motion_gameplay_conditions::GameplayCondition), + Riding(super::motion_riding_conditions::MotionRidingCondition), + /// Native Air condition: PhysOutAnimation collision/trajectory time. + TimeToLand(NumericCondition), + /// Native off-board trajectory time (PhysOutOffBoard+32). + ObTimeToLand(NumericCondition), + /// Native off-board trajectory time used by dismount/runout branches. + ObTrajTime(NumericCondition), +} +impl MotionCondition { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + if let Some(condition) = super::motion_landing::Condition::parse(a) { + return Ok(Some(Self::Landing(condition))); + } + if let Some(condition) = super::motion_wipeout::Condition::parse(a)? { + return Ok(Some(Self::Wipeout(condition))); + } + if let Some(condition) = super::motion_push_off::IsPushOffEnabled::parse(a) { + return Ok(Some(Self::PushOff(condition))); + } + let numeric = || super::condition_nodes::numeric(a); + Ok(Some(match a.text("name").unwrap_or("") { + "HasGestureIntent" => Self::Gesture(crate::input::gesture_catalog::Group::parse( + a.text("group").unwrap_or(""), + )?), + //82BA59D8/82BA6AC0: missing intent is false even for NotEqual. + "HasIntent" | "HasFilteredMotionGraphIntent" => Self::Intent { + name: a.text("intent").unwrap_or("").into(), + filtered: a.text("name") == Some("HasFilteredMotionGraphIntent"), + numeric: numeric(), + }, + "HasAnimAttribute" => Self::AnimationAttribute { + name: encode(a.text("attribute").unwrap_or("").as_bytes()), + sequence_id: f32::from_bits(a.float_bits("sequenceid", (-1.0f32).to_bits())) as i32, + numeric: numeric(), + }, + "ExpireInTime" => Self::ExpireInTime(numeric()), //82BA65A0 + "WillExpire" => Self::WillExpire { + //82BA6620/82BA6708 + in_time: f32::from_bits(a.float_bits("InTime", 0)), + tag: a + .text("InTimeTag") + .filter(|v| !v.is_empty()) + .map(str::to_owned), + wait_for_transitions: a.boolean_byte("waitForTransitions", 1) != 0, + }, + "InTimeWindow" => Self::InTimeWindow { + //82BC4558/82BA6808 + start: f32::from_bits(a.float_bits("StartTime", 0)), + length: f32::from_bits(a.float_bits("WindowFrameLength", 0)), + }, + "InStateForTime" => Self::InStateForTime { + //82BC3788/82BA4658 + name: a.text("state").unwrap_or("").into(), + target: None, + numeric: numeric(), + }, + "InParentStateForTime" => Self::InParentStateForTime { + target: None, + numeric: numeric(), + }, //82BA45D0 + "IsProSkater" => Self::ProSkater(encode(a.text("skater").unwrap_or("").as_bytes())), //82BC56C0/82BA6A48 + "BreakOutOfPush" => Self::BreakOutOfPush, + //Factory82BC54B8 and Activation82BA7130. + "ShouldLeaveSlide" => Self::ShouldLeaveSlide { + right: a.boolean_byte("right", 1) != 0, + }, + "IsRidingGoofy" => Self::RidingGoofy, + "IsRidingSwitch" => Self::RidingSwitch, + "MongoPushFootToFar" => Self::MongoPushFootTooFar, + "LastState" => Self::LastState { + name: a.text("state").unwrap_or("").into(), + target: None, + }, + name if super::motion_gameplay_conditions::GameplayCondition::recognizes(name) => { + Self::Gameplay(super::motion_gameplay_conditions::GameplayCondition::parse( + a, + )?) + } + name if super::motion_riding_conditions::MotionRidingCondition::recognizes(name) => { + Self::Riding(super::motion_riding_conditions::MotionRidingCondition::parse(a)?) + } + "TimeToLand" => Self::TimeToLand(numeric()), + "OBTimeToLand" => Self::ObTimeToLand(numeric()), + "OBTrajTime" => Self::ObTrajTime(numeric()), + _ => return Ok(super::condition_nodes::parse(a)?.map(Self::Shared)), + })) + } + pub fn evaluate(&self, host: &MotionHost, frame: &Frame) -> Result { + use skate_core::animation::playback_parameters::ParameterInputs; + Ok(match self { + Self::ShouldLeaveSlide { right } => host.slide_latch.should_leave(*right), + Self::Gesture(group) => group.has_intent(&host.action_intents), + Self::Shared(condition) => condition + .evaluate( + &host.condition_inputs, + &host.action_intents, + frame.current, + &host.state_parents, + ) + .map_err(str::to_owned)?, + Self::Gameplay(condition) => condition.evaluate(host)?, + Self::Riding(condition) => condition.evaluate(host)?, + Self::TimeToLand(n) => n.matches( + host.gameplay_conditions + .as_ref() + .ok_or("TimeToLand requires physical condition publication")? + .time_to_land, + ), + Self::ObTimeToLand(n) | Self::ObTrajTime(n) => n.matches( + host.gameplay_conditions + .as_ref() + .ok_or("OB trajectory condition requires physical condition publication")? + .offboard_time_to_land, + ), + Self::PushOff(condition) => condition.evaluate(), + Self::Wipeout(condition) => condition.evaluate(host.wipeout_physical)?, + Self::Landing(condition) => condition.evaluate( + host.landing_physical, + &host.flags, + host.prelanding_physical + .map(|p| p.override_prelanding(&host.spin)), + )?, + Self::LastState { target, .. } => { + //82BA4798 obtains the last state, then uses the same native + //ancestor membership test as CurrentState82C13820. + let mut cursor = frame.last; + let mut matched = false; + if let Some(target) = target { + while let Some(state) = cursor { + if state == *target { + matched = true; + break; + } + cursor = host.state_parents.get(state).copied().flatten(); + } + } + matched + } + Self::Intent { + name, + filtered, + numeric, + } => { + let value = if *filtered { + host.animation.filtered_intent(name) + } else { + host.animation.motion_intent(name) + }; + value.is_some_and(|value| { + numeric.comparison == Comparison::None || numeric.matches(value) + }) + } + Self::AnimationAttribute { + name, + sequence_id, + numeric, + } => super::condition_nodes::animation_attribute( + host.animation.tree_attributes(), + *name, + *sequence_id, + *numeric, + ), + Self::ExpireInTime(numeric) => { + numeric.matches(host.animation.property().remaining_before_wrap) + } + Self::WillExpire { + in_time, + tag, + wait_for_transitions, + } => { + //8296E988 uses authored InTime if tag is empty/component absent. + let time = match (tag, &host.time_tags) { + (Some(tag), Some(tags)) => *tags + .get(tag) + .ok_or_else(|| format!("Missing MotionGraph float tag {tag}"))?, + _ => *in_time, + }; + if *wait_for_transitions && host.animation.in_transition() { + false + } else { + let p = host.animation.property(); + p.crossed_end || !(p.remaining_before_wrap > time) + } + } + Self::InTimeWindow { start, length } => { + if host.animation.in_transition() { + false + } else { + let time = host.animation.current_time()?; + !(time < *start) && !(time > *start + *length) + } + } + Self::InStateForTime { + target, numeric, .. + } => super::condition_nodes::state_time(frame, *target, *numeric), + Self::InParentStateForTime { target, numeric } => { + super::condition_nodes::state_time(frame, *target, *numeric) + } + Self::ProSkater(name) => host.playback_context.pro_skater == *name, + Self::BreakOutOfPush => { + //82BA6258: current tree time >= out factor * length, and + // the push lifecycle has not latched continue_push. + let push = host + .push_state + .as_ref() + .ok_or("BreakOutOfPush requires initialized native push state")?; + !(host.animation.current_time()? + < push.out_factor * host.animation.current_length()?) + && !push.continue_push + } + Self::RidingGoofy => { + let (first, second) = host + .physical_stance + .ok_or("IsRidingGoofy requires PhysOutAnimation stance bytes")?; + first == second + } + Self::RidingSwitch => host + .playback_context + .is_switch + .ok_or("IsRidingSwitch requires actual relative stance")?, + Self::MongoPushFootTooFar => { + //82BA6378 queries the FIRST cached tree record via82D19010. + //The event payload is a toe-bone FastString, not its weight. + if let Some(event) = host + .animation + .tree_attributes() + .iter() + .find(|a| a.name == encode(b"push_contact")) + { + let matches = |name| { + event.payload.0[..5] + .iter() + .zip(encode(name).0) + .all(|(word, key)| *word == Some(key)) + }; + let right = matches(b"RightToeBase"); + let left = matches(b"LeftToeBase"); + let mirrored = host + .playback_context + .is_mirrored + .ok_or("MongoPushFootToFar requires actual mirrored stance")?; + if (!mirrored && right) || (mirrored && left) { + host.physical + .and_then(|p| p.foot_frame) + .ok_or("MongoPushFootToFar requires actual foot frame")? + .out_distance(right)[0] + < -0.5 + } else { + false + } + } else { + false + } + } + }) + } +} + +pub fn bind_states( + graph: &crate::graph_runtime::LoadedGraph, + operations: &mut [super::motion_nodes::MotionOperation], +) { + let mut parents = vec![None; graph.source.elements.len()]; + for (parent, element) in graph.source.elements.iter().enumerate() { + for &child in &element.children { + parents[child] = Some(parent); + } + } + for &id in &graph.runtime.operations.conditions { + let super::motion_nodes::MotionOperation::Condition(condition) = &mut operations[id] else { + continue; + }; + let (name, target) = match condition { + MotionCondition::Shared(ActionCondition::CurrentState { name, target }) + | MotionCondition::InStateForTime { name, target, .. } + | MotionCondition::LastState { name, target } => (Some(name), target), + MotionCondition::InParentStateForTime { target, .. } => (None, target), + _ => continue, + }; + let mut element = parents[graph.binding.operations[id].element]; + while let Some(id) = element { + if let Some(state) = graph.binding.states.iter().position(|s| s.element == id) { + // GetParentState82C11B88 climbs expression nodes to the + // containing state; it does not select that state's parent. + *target = name.as_ref().map_or(Some(state), |name| { + graph.binding.find_state(state, name, true) + }); + break; + } + element = parents[id]; + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_execute.rs b/crates/skate-game/src/graph_host/motion_execute.rs new file mode 100644 index 00000000..a09406f1 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_execute.rs @@ -0,0 +1,503 @@ +//! Original behavior lifecycle dispatch for the persistent MotionHost. +use super::*; +use crate::graph_host::motion_kickturn::{self, Operation as KickTurnOperation}; + +impl MotionHost { + pub(super) fn execute( + &mut self, + behavior: BehaviorId, + frame: &Frame, + phase: u8, + ) -> Result<(), String> { + let id = *self + .remap + .behaviors + .get(behavior) + .ok_or("Unbound MotionGraph behavior")?; + let operation = self.operations[id].clone(); + if let MotionOperation::Slide(operation) = operation { + return self.execute_slide(behavior, operation, frame, phase); + } + if matches!( + operation, + MotionOperation::ClearTrickAttr + | MotionOperation::AirLeg(_) + | MotionOperation::BodySpin + ) { + return self.execute_air(behavior, operation, frame, phase); + } + if let MotionOperation::TwistLean(operation) = operation { + return self.execute_twist_lean(behavior, operation, phase); + } + if let MotionOperation::Wipeout(operation) = operation { + return self.execute_wipeout(behavior, operation, phase); + } + if let MotionOperation::IntentFilter(operation) = operation { + return self.execute_intent_filter(behavior, operation, frame, phase); + } + if let MotionOperation::ResetAnimation(operation) = operation { + if phase == 0 { + match operation { + crate::graph_host::motion_reset::Operation::SkaterAnimation => { + self.action_intents.clear(); + self.animation.motion_intents.clear(); + self.animation.filtered_intents.clear(); + self.animation.reset_from_stock(); + } + crate::graph_host::motion_reset::Operation::GivenStance => { + self.animation.posture.set_requested(false); + } + } + } + return Ok(()); + } + if let MotionOperation::HandService(operation) = operation { + self.hand_services + .execute(operation, phase, &mut self.animation); + return Ok(()); + } + if let MotionOperation::Landing(operation) = &operation { + let Instance::Landing(state) = self + .instances + .get_mut(behavior) + .ok_or("Unallocated landing behavior")? + else { + return Err("Landing operation/instance mismatch".into()); + }; + //StoreLandingData can update C54 earlier in this same graph traversal. + let physical = self.landing_physical.map(|mut p| { + p.last_good_landing_velocity = self.riding.last_good_landing_velocity; + p + }); + return super::super::motion_landing_execute::execute( + operation, + state, + &mut self.flags, + &mut self.animation, + &self.condition_random, + physical, + self.playback_context.is_mirrored, + frame.dt, + phase, + ); + } + let instance = self + .instances + .get_mut(behavior) + .ok_or("Unallocated MotionGraph behavior")?; + match (operation, instance) { + //Retail ctor82BA58E8 retains only diagnostic text/layout. All three + //lifecycle slots in82309664 are82B61BB8 (blr), with no state writes. + (MotionOperation::PrintText2D, _) => {} + (MotionOperation::Shove(operation), Instance::Shove(state)) => match phase { + 0 => state.begin(), + 1 => state.update( + &operation, + &mut self.animation, + self.shove_physical + .ok_or("Shove requires actual interaction output")?, + self.hand_services.busy_hands, + self.playback_context + .is_mirrored + .ok_or("Shove requires animation stance")?, + )?, + _ => state.end(&mut self.animation, self.hand_services.keep_shove_channels), + }, + (MotionOperation::CharacterGesture, Instance::CharacterGesture(state)) => { + if phase == 1 { + let physical = self.gesture_physical.ok_or( + "CharacterGesture requires original physical and hostgesture publication", + )?; + let gesture_intents = self.animation.motion_intents.clone(); + let inputs = + crate::graph_host::motion_character_gesture::CharacterGestureInputs { + motion_intents: &gesture_intents, + busy_hands: self.hand_services.busy_hands, + filtered_state: self + .condition_inputs + .physical_state + .as_ref() + .ok_or("CharacterGesture requires filteredcategory")? + .category, + ground321: physical.ground321, + board_held311: self + .playback_context + .board_available + .ok_or("CharacterGesture requires boardheldbyte311")?, + state_offboard75: physical.state_offboard75, + distance_to_cog: self + .crouching_physical + .ok_or("CharacterGesture requires actualheight")? + .animation_height_72, + selections: physical.selections, + suppress_up: physical.suppress_up, + force_brake_bypass: physical.force_brake_bypass, + }; + if let Some(output) = state.update(&mut self.animation, inputs)? { + self.gesture_publication = Some(output); + } + } + } + ( + MotionOperation::Native( + crate::graph_host::motion_native::Operation::StoreLandingData, + ), + Instance::LandingData(state), + ) => { + if phase == 1 { + state.update( + self.condition_inputs + .physical_state + .as_ref() + .ok_or("StoreLandingData requires physical category")? + .category, + self.native_physical + .ok_or("StoreLandingData requires raw physical COM velocity/up")?, + &mut self.riding, + ); + } + } + ( + MotionOperation::Native(crate::graph_host::motion_native::Operation::EndShimmy( + time, + )), + _, + ) => { + if phase == 0 { + crate::graph_host::motion_native::end_shimmy(&mut self.animation, time); + } + } + ( + MotionOperation::Native(crate::graph_host::motion_native::Operation::Score { + regular, + mirrored, + }), + _, + ) => { + if phase == 1 { + self.score_packet.set( + if self + .playback_context + .is_mirrored + .ok_or("Score augmentation requires animation stance")? + { + mirrored + } else { + regular + }, + ); + } + } + (MotionOperation::KickTurn(KickTurnOperation::ResetTimer), _) => { + //82BAE8D0 is both Begin and End; v136=8258F930 clears3224. + if phase == 0 || phase == 2 { + self.riding.time_since_kickturn = 0.0; + } + } + ( + MotionOperation::KickTurn(KickTurnOperation::Steering(parameters)), + Instance::KickTurn(state), + ) => { + motion_kickturn::execute( + state, + parameters, + &self.kickturn, + &mut self.animation, + frame.dt, + phase, + )?; + } + (MotionOperation::FakieHeadChannel, Instance::FakieHead(state)) => { + if phase == 1 { + let fakie = self + .animation + .skater_animation_flags + .ok_or("FakieHeadChannel requires actual animation flags")? + & 0x20000000 + != 0; + state.update( + &mut self.animation, + self.flags.manualing, + self.is_power_sliding, + fakie, + )?; + } + } + (MotionOperation::Pumping, Instance::Pumping(state)) => { + const NAMES: [&str; 5] = ["PUMP0", "PUMP1", "PUMP2", "PUMP3", "PUMP4"]; //82F883C8 + if phase == 2 { + for name in NAMES { + self.animation.channels.end(name); + } + } else if phase == 1 && self.allow_pumping { + let occupied = std::array::from_fn(|i| self.animation.channels.has(NAMES[i])); + let update = state.update( + self.pumping_acceleration + .ok_or("Pumping requires actual ground pumping acceleration")?, + occupied, + &self.pumping, + ); + if let Some(index) = update.start { + let settings = skate_core::animation::channel_playback::ChannelSettings { + priority: 0, + keep_alive: false, + mirrored: false, + speed: 1.0, + blend_in: self.pumping.blend_in, + hold_during_blend_in: false, + blend_out: self.pumping.blend_out, + hold_during_blend_out: true, + use_attributes: false, + }; + self.animation + .new_channel(NAMES[index], "B_PUMP", settings)?; + } + if let Some((index, value)) = update.influence { + self.animation.channels.influence(NAMES[index], value); + } + } + } + (MotionOperation::DisallowPumping, _) => { + if phase == 0 { + for name in ["PUMP0", "PUMP1", "PUMP2", "PUMP3", "PUMP4"] { + self.animation + .channels + .end_with(name, f32::from_bits(0x3dcccccd), false); + } + self.allow_pumping = false; + } else if phase == 2 { + self.allow_pumping = true; + } + } + (MotionOperation::SetSpeed { name, value }, _) => { + if phase == 1 { + //82BB0948 sets a tree parameter; it does not call SetSpeed + //on the animation clock. Source uses abs(PhysOutMotion164). + let value = match value { + Some(value) => value, + None => { + self.crouching_physical + .ok_or("SetSpeed requires actual ground-projected speed")? + .body_164 + } + }; + self.animation.set_attribute(SettableAttribute { + name, + value: value.abs(), + normalized: false, + sequence_id: -1, + }); + } + } + (MotionOperation::UpdateRidingFakie(settings), Instance::RidingFakie(state)) => { + if phase == 1 { + let mut physical = self + .fakie_physical + .ok_or("UpdateRidingFakie requires actual physical outputs")?; + physical.doing_trick = self.flags.doing_trick; + if let Some(fakie) = state.update(physical, frame.dt, settings) { + let flags = self + .animation + .skater_animation_flags + .as_mut() + .ok_or("UpdateRidingFakie requires actual animation flags")?; + *flags = (*flags & !0x20000000) | if fakie { 0x20000000 } else { 0 }; + } + } + } + (MotionOperation::SettingBodyTilt(name), Instance::BodyTilt(instance)) => { + if phase == 1 && self.applying_body_tilt { + if let Some(value) = instance.update( + true, + self.playback_context + .is_mirrored + .ok_or("SettingBodyTilt needs actual mirrored stance")?, + self.body_tilt_physical + .ok_or("SettingBodyTilt needs actual PhysOut tilt and spin")?, + &self.body_tilt, + ) { + self.animation.set_attribute(SettableAttribute { + name, + value, + normalized: false, + sequence_id: -1, + }); + } + } else if phase == 1 { + // Disabled native branch updates the enable latch without + // reading physics or emitting a parameter. + instance.disable(); + } + } + (MotionOperation::Play(operation), Instance::Play(instance)) => match phase { + 0 => instance.begin(&operation, &mut self.playback_context, &mut self.animation)?, + 1 => instance.update(&operation, &mut self.animation)?, + _ => instance.end(), + }, + ( + MotionOperation::AttachIntent { + intent, + attribute, + set, + }, + _, + ) => { + if phase == 1 { + self.animation.attach(&intent, attribute, set); + } + } + (MotionOperation::Riding(operation), _) => self.riding.execute( + operation, + phase, + frame, + &mut self.animation, + self.condition_inputs + .physical_state + .as_ref() + .map(|p| p.category), + self.crouching_physical.map(|p| p.animation_height_72), + self.flags.manualing, + )?, + (MotionOperation::ApplyingBodyTilt, _) => { + if phase == 0 { + self.applying_body_tilt = true; + } else if phase == 2 { + self.applying_body_tilt = false; + } + } + (MotionOperation::Crouching(name), Instance::Crouching(state)) => { + use skate_core::animation::crouching; + if phase == 0 { + *state = Some(crouching::State::begin( + self.crouching_physical + .ok_or("Crouching Begin requires real physical height and speed")?, + &self.crouching, + )); + } else if phase == 1 { + let intents = crouching::Intents { + auto_pump_angle: self.animation.motion_intent("AutoPumpAngle"), + auto_pump_magnitude: self.animation.motion_intent("AutoPumpMag"), + crouch: self.animation.motion_intent("Crouch"), + hard_turn_crouch: self.animation.motion_intent("HardTurnCrouch"), + manual: self.animation.motion_intent("Manual"), + motion_flag_108: self.is_power_sliding, + }; + let result = state + .as_mut() + .ok_or("Crouching updated before Begin")? + .update( + self.crouching_physical + .ok_or("Crouching requires actual physical outputs")?, + intents, + frame.dt, + &self.crouching, + ); + if result.new_auto_pump { + self.animation.emit_packet(encode(b"NewAutoPump"), 1.0); + } + if result.player_controlled_pump { + self.animation + .emit_packet(encode(b"PlayerControlledPump"), 1.0); + } + self.animation.set_attribute(SettableAttribute { + name, + value: result.height, + normalized: false, + sequence_id: -1, + }); + } + } + (MotionOperation::SetTurning(names), Instance::Turning(instance)) => { + if phase == 0 { + instance.enter(); + } else if phase == 1 { + let physical = self + .physical + .ok_or("SetTurning requires actual PhysOutAnimation and stance outputs")?; + let intents = set_turning::Intents { + fakie_turn: self.animation.motion_intent("FakieTurn"), + mode_0_slide: self.animation.motion_intent("LeftSlide"), + mode_1_slide: self.animation.motion_intent("RightSlide"), + }; + //ISkaterAnim v12=82B970D8/v28=82B97140 read live bits29/30. + //UpdateRidingFakie may have changed them earlier in this graph tick. + let flags = self + .animation + .skater_animation_flags + .ok_or("SetTurning requires actual animation stance flags")?; + set_turning::update( + instance, + &mut self.slide_latch, + physical.turning, + (flags & 0x2000_0000 != 0, flags & 0x4000_0000 != 0), + frame.dt, + &self.turning, + intents, + |attribute, value| { + let index = match attribute { + set_turning::Attribute::Angle => Some(0), + set_turning::Attribute::Direction => Some(1), + set_turning::Attribute::Quickness => Some(2), + set_turning::Attribute::Speed => Some(3), + set_turning::Attribute::Holding => Some(4), + _ => None, + }; + if let Some(index) = index { + self.animation.set_attribute(SettableAttribute { + name: names[index], + value, + normalized: false, + sequence_id: -1, + }); + } else { + self.animation.emit_packet( + encode(if attribute == set_turning::Attribute::Turn { + b"Turn" + } else { + b"Slide" + }), + value, + ); + } + }, + ); + } + } + (MotionOperation::Push(_), Instance::Push(instance)) => { + let physical = self + .physical + .ok_or("Push behavior requires actual skater physical outputs")?; + // The attribute sink borrows animation mutably during this + // callback; retain a snapshot of the same canonical intent map. + let intents = self.animation.motion_intents.clone(); + let mut context = PushContext { + settings: &self.pushing, + shared: self + .push_state + .as_mut() + .ok_or("Native push state initialization has not been published")?, + motion_intents: &intents, + forward_speed: physical.forward_speed, + delta_seconds: frame.dt, + time_since_teleport: self.riding.time_since_teleport, + is_switch: physical.is_switch, + foot_frame: physical.foot_frame, + }; + match phase { + 0 => instance.begin(&mut context, &mut self.animation), + 1 => instance.update(&mut context, &mut self.animation)?, + _ => instance.end(&mut context), + } + } + (MotionOperation::Unsupported { kind, name }, _) => { + return Err(format!("Unsupported MotionGraph {kind:?} {name}")); + } + (operation, _) => { + return Err(format!( + "Invalid MotionGraph behavior instance {operation:?}" + )); + } + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_filter_execute.rs b/crates/skate-game/src/graph_host/motion_filter_execute.rs new file mode 100644 index 00000000..0fc7813f --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_filter_execute.rs @@ -0,0 +1,39 @@ +//! Live stock intent filtering shares the existing MotionAnimation maps. +use super::*; +impl MotionHost { + pub(super) fn execute_intent_filter( + &mut self, + behavior: BehaviorId, + operation: super::super::motion_intent_filter::Operation, + frame: &Frame, + phase: u8, + ) -> Result<(), String> { + let Instance::IntentFilter(state) = self + .instances + .get_mut(behavior) + .ok_or("Unallocated intent-filter behavior")? + else { + return Err("Intent-filter operation/instance mismatch".into()); + }; + match phase { + 0 => operation.begin(state, &mut self.animation.filtered_intents), + 1 => { + //82BB17D0 reads live ISkaterAnim v12/v28; earlier graph + //operations can change these flags during the same traversal. + let flags = self + .animation + .skater_animation_flags + .ok_or("FilterMotionGraphIntent requires live animation stance flags")?; + operation.update( + state, + &self.animation.motion_intents, + &mut self.animation.filtered_intents, + frame.dt, + (flags & 0x2000_0000 != 0, flags & 0x4000_0000 != 0), + ); + } + _ => operation.end(&mut self.animation.filtered_intents), + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs new file mode 100644 index 00000000..f61b2ef9 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_gameplay_conditions.rs @@ -0,0 +1,175 @@ +//! Original TU3 physical-state condition leaves used by the stock MotionGraph. +use super::motion::MotionHost; +use skate_data::state_graph::attributes::Attributes; + +/// The preceding completed physical publication. These are native output +/// fields, separate from controller intents and presentation transforms. +#[derive(Clone, Copy, Debug)] +pub struct GameplayConditions { + pub state: u32, + pub wants_runout: bool, + pub physics_wiping: bool, + pub body_flipping: bool, + pub wants_wipeout: bool, + pub bumped: bool, + pub grabbing_object: bool, + pub retrieving_board: bool, + pub dropping_board: bool, + pub in_biped_air: bool, + pub hippy_hurdling: bool, + pub handplant_flags: u32, + pub time_to_skitch: f32, + pub skitch_transition_time: f32, + /// PhysOutAnimation+144 collision time used by TimeToLand. + pub time_to_land: f32, + /// PhysOutOffBoard+32, shared by OBTimeToLand and OBTrajTime. + pub offboard_time_to_land: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GameplayCondition { + RetrievingBoard, + DroppingBoard, + InBipedAir, + HippyHurdling, + WantsRunout, + PhysicsWiping, + BodyFlipping, + WantsWipeout, + Bumped, + CanEnterSlide { right: bool }, + GrabbingObject, + Dark, + HandPlanting { state: u8, direction: u8 }, + EnteringSkitch, + Skitching, +} +impl GameplayCondition { + pub fn recognizes(name: &str) -> bool { + matches!( + name, + "IsRetrievingSkateboard" + | "IsDroppingSkateboard" + | "IsInBipedAir" + | "IsHippyHurdling" + | "PhysicsWantsRunout" + | "IsPhysicsWiping" + | "IsBodyFlipping" + | "PhysicsWantsWipeOut" + | "IsBumped" + | "CanEnterSlide" + | "IsGrabbingObject" + | "IsDark" + | "IsHandPlanting" + | "IsEnteringSkitch" + | "IsSkitching" + ) + } + pub fn parse(a: &Attributes<'_>) -> Result { + Ok(match a.text("name").unwrap_or("") { + "IsRetrievingSkateboard" => Self::RetrievingBoard, + "IsDroppingSkateboard" => Self::DroppingBoard, + "IsInBipedAir" => Self::InBipedAir, + "IsHippyHurdling" => Self::HippyHurdling, + "PhysicsWantsRunout" => Self::WantsRunout, + "IsPhysicsWiping" => Self::PhysicsWiping, + "IsBodyFlipping" => Self::BodyFlipping, + "PhysicsWantsWipeOut" => Self::WantsWipeout, + "IsBumped" => Self::Bumped, + "CanEnterSlide" => Self::CanEnterSlide { + right: a.boolean_byte("right", 1) != 0, //82BC53F0 + }, + "IsGrabbingObject" => Self::GrabbingObject, + "IsDark" => Self::Dark, + "IsEnteringSkitch" => Self::EnteringSkitch, + "IsSkitching" => Self::Skitching, + "IsHandPlanting" => Self::HandPlanting { + //82BA54A0 compares these authored strings in this order. + state: match a.text("state").unwrap_or("") { + "air" => 0, + "ground" => 1, + value => return Err(format!("IsHandPlanting has unauthored state {value:?}")), + }, + direction: match a.text("dir").unwrap_or("") { + "FS" => 1, + "BS" => 0, + _ => 2, + }, + }, + name => return Err(format!("Unknown gameplay condition {name}")), + }) + } + /// Both stock graphs register these same native leaves. Return None for + /// conditions that also require the MotionGraph/channel owner. + pub fn evaluate_physical(&self, p: &GameplayConditions) -> Option { + Some(match self { + Self::RetrievingBoard => p.retrieving_board, //82BA5EF0:Offboard323 + Self::InBipedAir => p.in_biped_air, //82BA8030:Offboard328 + Self::HippyHurdling => p.hippy_hurdling, //82BA5DA0:Offboard317 + Self::WantsRunout => p.wants_runout, //82BA44B8:State78 + Self::PhysicsWiping => p.physics_wiping, //82BA4390:State59 + Self::BodyFlipping => p.body_flipping, //82BA71E0:Air441 + Self::WantsWipeout => p.wants_wipeout, //82BA4400:State63 || State65 + Self::Bumped => p.bumped, //82BA7310: published acceleration and anim_motion/bumps + Self::GrabbingObject => p.grabbing_object, //82BA5700:Offboard304 + Self::Skitching => p.state == 104, //82BBBC88:State16 + Self::EnteringSkitch => { + //82BBBDE0:Ground276,Globals400/layout96 + !(p.time_to_skitch < 0.0) && p.time_to_skitch <= p.skitch_transition_time + } + Self::HandPlanting { state, direction } => { + //82BA55A8:Air324,State16 + let active = if *state == 0 { + p.state == 600 + } else { + p.handplant_flags & 0x8000_0000 != 0 + }; + active + && match direction { + 2 => true, + 1 => p.handplant_flags & 0x2000_0000 != 0, + 0 => p.handplant_flags & 0x2000_0000 == 0, + _ => false, + } + } + Self::DroppingBoard | Self::Dark | Self::CanEnterSlide { .. } => return None, + }) + } + + pub fn evaluate(&self, host: &MotionHost) -> Result { + let p = host + .gameplay_conditions + .as_ref() + .ok_or("MotionGraph requires the actual physical condition publication")?; + if let Some(result) = self.evaluate_physical(p) { + return Ok(result); + } + Ok(match self { + //82BA5F60: Offboard322 or the actual RetrieveBoard channel. + Self::DroppingBoard => p.dropping_board || host.animation.channels.has("RetrieveBoard"), + //82BA79A0 calls the specific MotionGraph getter8258FB68. + Self::Dark => host.riding.dark, + Self::CanEnterSlide { right } => { + //82BA6FE0 rejects RevertGround102, reverses the side for fakie, + //then reads its start bit in the retained C84 slide packet. + if p.state == 102 { + false + } else { + //ISkaterAnim virtual12=82B970D8 reads bit29=fakie. + let fakie = host + .animation + .skater_animation_flags + .ok_or("CanEnterSlide requires actual SkaterAnim stance flags")? + & 0x2000_0000 + != 0; + if *right ^ fakie { + host.slide_latch.start(true) + } else { + host.slide_latch.start(false) + } + } + } + _ => unreachable!("Physical condition handled above"), + }) + } +} diff --git a/crates/skate-game/src/graph_host/motion_ground_slope.rs b/crates/skate-game/src/graph_host/motion_ground_slope.rs new file mode 100644 index 00000000..6652d5a2 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_ground_slope.rs @@ -0,0 +1,34 @@ +//! GroundSlopeType: TU3 ctor82BA7BD8 and condition82BA7E80. +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct GroundSlopeType(u32); + +impl GroundSlopeType { + pub fn parse(attributes: &Attributes<'_>) -> Result { + let name = attributes + .text("slopetype") + .ok_or("GroundSlopeType requires slopetype")?; + let value = match name { + "Flat" => 0, + "StairUpShallow" => 1, + "StairUpSteep" => 2, + "StairDnShallow" => 3, + "StairDnSteep" => 4, + "RampUpShallow" => 5, + "RampUpSteep" => 6, + "RampDnShallow" => 7, + "RampDnSteep" => 8, + // The native constructor has no fallback assignment. Reject malformed + // host data rather than reproducing an uninitialized member read. + _ => return Err(format!("Unknown GroundSlopeType slopetype {name}")), + }; + Ok(Self(value)) + } + + /// Exactly PhysOut bundle+72 (OffBoard), integer field+88. The physical + /// producer owns the classification; this graph leaf does not infer slope. + pub fn matches(self, physical_slope_type_88: u32) -> bool { + physical_slope_type_88 == self.0 + } +} diff --git a/crates/skate-game/src/graph_host/motion_hand_services.rs b/crates/skate-game/src/graph_host/motion_hand_services.rs new file mode 100644 index 00000000..ab980d1d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_hand_services.rs @@ -0,0 +1,90 @@ +//! TU3 HandBusy and MaintainShove lifecycle, independently recovered from IDA. +//! HandBusy: factory82BC8B08, Begin82BAB4D0, End82BAB588. +//! MaintainShove: factory82BCB1C8, Update82BBD690, End82BBD6E8. +use super::motion_animation::MotionAnimation; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Operation { + HandBusy(Hand), + MaintainShove, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Hand { + Backside, + Frontside, +} + +impl Operation { + pub fn parse(attributes: &Attributes<'_>) -> Option { + match attributes.text("name")? { + "HandBusy" => Some(Self::HandBusy(Hand::from_name( + attributes.text("hand").unwrap_or("bs"), + ))), + "MaintainShove" => Some(Self::MaintainShove), + _ => None, + } + } +} + +impl Hand { + fn from_name(name: &str) -> Self { + // Factory compares against bs with strcmp82AE89B0. Every unequal + // spelling selects index1; there is no validation or stance swap. + if name == "bs" { Self::Backside } else { Self::Frontside } + } + + fn index(self) -> usize { + match self { + Self::Backside => 0, + Self::Frontside => 1, + } + } +} + +/// One persistent owner shared by all graph instances and hand consumers. +#[derive(Clone, Debug, Default)] +pub struct HandServices { + pub busy_hands: [u32; 2], + pub keep_shove_channels: bool, +} + +impl HandServices { + pub fn execute(&mut self, operation: Operation, phase: u8, animation: &mut MotionAnimation) { + if self.apply(operation, phase) && animation.channels.has("SkitchAntic") { + // End82BBD6E8 first clears the retention flag, then requests + // premature channel end (virtual44), preserving its fade timing. + animation.channels.end("SkitchAntic"); + } + } + + fn apply(&mut self, operation: Operation, phase: u8) -> bool { + match (operation, phase) { + (Operation::HandBusy(hand), 0) => { + let count = &mut self.busy_hands[hand.index()]; + *count = count.wrapping_add(1); + } + (Operation::HandBusy(_), 1) => { + // HandBusy Update vtable8231F97C+52 is blr82B61BB8. + } + (Operation::HandBusy(hand), _) => { + let count = &mut self.busy_hands[hand.index()]; + *count = count.wrapping_sub(1); + } + (Operation::MaintainShove, 0) => { + // Begin vtable82321000+48 is blr82B61BB8. + } + (Operation::MaintainShove, 1) => self.keep_shove_channels = true, + (Operation::MaintainShove, _) => { + self.keep_shove_channels = false; + return true; + } + } + false + } +} + +#[cfg(test)] +#[path = "motion_hand_services/tests.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/motion_hand_services/tests.rs b/crates/skate-game/src/graph_host/motion_hand_services/tests.rs new file mode 100644 index 00000000..3776d336 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_hand_services/tests.rs @@ -0,0 +1,89 @@ +use super::{Hand, HandServices, Operation}; + +#[test] +fn nested_hand_lifetimes_preserve_other_owners() { + let mut state = HandServices::default(); + let bs = Operation::HandBusy(Hand::Backside); + let fs = Operation::HandBusy(Hand::Frontside); + for operation in [bs, fs, bs] { state.apply(operation, 0); } + state.apply(bs, 1); + assert_eq!(state.busy_hands, [2, 1]); + state.apply(bs, 2); + assert_eq!(state.busy_hands, [1, 1]); + state.apply(fs, 2); + assert_eq!(state.busy_hands, [1, 0]); + state.apply(bs, 2); + assert_eq!(state.busy_hands, [0, 0]); +} + +#[test] +fn retail_counter_stores_wrap_without_saturating() { + let mut state = HandServices::default(); + let bs = Operation::HandBusy(Hand::Backside); + state.apply(bs, 2); + assert_eq!(state.busy_hands, [u32::MAX, 0]); + state.apply(bs, 0); + assert_eq!(state.busy_hands, [0, 0]); +} + +#[test] +fn maintain_shove_is_an_update_latch_not_a_reference_count() { + let mut state = HandServices::default(); + let operation = Operation::MaintainShove; + assert!(!state.keep_shove_channels); + assert!(!state.apply(operation, 0)); + assert!(!state.keep_shove_channels); + for _ in 0..2 { assert!(!state.apply(operation, 1)); } + assert!(state.keep_shove_channels); + assert!(state.apply(operation, 2)); + assert!(!state.keep_shove_channels); + assert!(!state.apply(operation, 1)); + assert!(state.keep_shove_channels); +} + +#[test] +fn hand_selection_uses_exact_stock_comparison() { + assert_eq!(Hand::from_name("bs"), Hand::Backside); + for name in ["fs", "BS", "", "both"] { + assert_eq!(Hand::from_name(name), Hand::Frontside); + } +} + +#[test] +fn maintain_end_fades_only_antic_through_existing_animation_runtime() { + use crate::graph_host::motion_animation::MotionAnimation; + use skate_core::animation::{ + channel_playback::ChannelSettings, playback_clip::PlaybackClip, + playback_tree::PlaybackTree, + }; + let metadata = skate_data::animation_metadata::AnimationMetadata::parse( + r#"{"version":1,"source_bank":"test-only","source_sha256":"0000000000000000000000000000000000000000000000000000000000000000","source_bytes":48,"clips":[],"unsupported_trees":[]}"#, + ).unwrap(); + let mut animation = MotionAnimation::from_metadata(metadata); + let settings = ChannelSettings { + priority: 0, keep_alive: true, mirrored: false, speed: 1.0, + blend_in: 0.0, hold_during_blend_in: false, blend_out: 0.5, + hold_during_blend_out: false, use_attributes: false, + }; + for name in ["SkitchAntic", "Shove"] { + animation.channels.insert(name.into(), PlaybackTree::Clip { + name: name.into(), + clip: PlaybackClip::new(301.0, 30.0, 1.0, 0, Vec::new()), + }, settings); + } + let mut state = HandServices::default(); + state.execute(Operation::MaintainShove, 1, &mut animation); + state.execute(Operation::MaintainShove, 2, &mut animation); + assert!(!state.keep_shove_channels); + assert!(animation.channels.has("SkitchAntic")); + animation.channels.advance(0.25, 0.0); + animation.channels.retire(); + assert!(animation.channels.has("SkitchAntic")); + animation.channels.advance(0.25, 0.0); + animation.channels.retire(); + assert!(!animation.channels.has("SkitchAntic")); + assert!(animation.channels.has("Shove")); + // Repeating End with no antic must leave unrelated live channels intact. + state.execute(Operation::MaintainShove, 2, &mut animation); + assert!(animation.channels.has("Shove")); +} diff --git a/crates/skate-game/src/graph_host/motion_hooks.rs b/crates/skate-game/src/graph_host/motion_hooks.rs new file mode 100644 index 00000000..95942602 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_hooks.rs @@ -0,0 +1,32 @@ +//! Native MotionGraph transition hooks, dispatched by the stock controller. +use skate_core::animation::playback::TransitionSettings; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum MotionHook { + ///82BBBB68 copies the authored transition into the next-play override. + Override(TransitionSettings), + MongoPushToAntic { + animation: String, + }, + ///82BBB848, factory82BCA6A8 default right=true. + GrabSlide { + right: bool, + }, +} +impl MotionHook { + pub fn parse(a: &Attributes<'_>) -> Option { + match a.text("name")? { + "GrabSlide" => Some(Self::GrabSlide { + right: a.boolean_byte("right", 1) != 0, + }), + "OverideNextAnimTransitionHook" => { + Some(Self::Override(super::motion_nodes::transition(a))) + } + "MongoPushToAntic" => Some(Self::MongoPushToAntic { + animation: a.text("anim").unwrap_or("").into(), + }), + _ => None, + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_intent_filter.rs b/crates/skate-game/src/graph_host/motion_intent_filter.rs new file mode 100644 index 00000000..71693528 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_intent_filter.rs @@ -0,0 +1,72 @@ +//! Stock FilterMotionGraphIntent constructor and map lifecycle adapter. +use skate_core::{animation::intent_filter::{Settings, State}, graph::intents::IntentMap}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub struct Operation { + pub intent: String, + pub filtered_intent: String, + pub settings: Settings, +} + +impl Operation { + /// TU3 82BB12C8. Presence flags remain distinct from numeric defaults. + pub fn parse(a: &Attributes<'_>) -> Self { + let number = |name, default: f32| f32::from_bits(a.float_bits(name, default.to_bits())); + let optional = |name| a.get(name).map(|value| f32::from_bits(value.float_bits)); + let blend = number("blend", 1.0); + Self { + intent: a.text("intent").unwrap_or("").into(), + filtered_intent: a.text("filteredIntent").unwrap_or("").into(), + settings: Settings { + starting_value: number("startingValue", 0.0), + default_value: number("defaultValue", 0.0), + scale: number("scale", 1.0), + filters: [ + filter(a.text("filter1").or_else(|| a.text("filter"))), + filter(a.text("filter2")), + filter(a.text("fakieFilter")), + filter(a.text("mirrorFilter")), + ], + ramp_time: optional("rampTime"), + blend_rising: number("blendRising", blend), + blend_falling: number("blendFalling", blend), + blend_out: optional("blendOut"), + clamp_velocity: optional("clampVel"), + clamp_acceleration: optional("clampAcc"), + }, + } + } + + pub fn begin(&self, state: &mut State, output: &mut IntentMap) { + output.insert(&self.filtered_intent, state.begin(&self.settings)); + } + + pub fn update( + &self, + state: &mut State, + input: &IntentMap, + output: &mut IntentMap, + dt: f32, + stance: (bool, bool), + ) { + let value = state.update(&self.settings, input.get(&self.intent).copied(), dt, stance); + output.insert(&self.filtered_intent, value); + } + + /// End removes the key even when another active behavior wrote that alias. + /// Native map removal 82BC1068 has no per-behavior ownership counter. + pub fn end(&self, output: &mut IntentMap) { + output.remove(&self.filtered_intent); + } +} + +fn filter(value: Option<&str>) -> u32 { + // 82BA0A48 and original initializers 82F847C0..4850. + use skate_core::animation::playback_parameters::intent_key; + let key = intent_key(value.unwrap_or("none")); + ["negate", "abs", "oneMinus", "clamp", "angleFlip", "angleRot90", "angleRotN90"] + .iter() + .position(|name| intent_key(name) == key) + .map_or(0, |index| index as u32 + 1) +} diff --git a/crates/skate-game/src/graph_host/motion_kickturn.rs b/crates/skate-game/src/graph_host/motion_kickturn.rs new file mode 100644 index 00000000..16c503df --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_kickturn.rs @@ -0,0 +1,80 @@ +//! Original TU3 low-speed KickTurn behaviors, connected to MotionHost. +use super::motion_animation::MotionAnimation; +use skate_core::{ + animation::{kickturn, playback_parameters::ParameterInputs, skeleton_input::name::encode}, + point_graph::PointGraph, +}; +use skate_data::{collections::Collections, state_graph::attributes::Attributes}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Operation { + ResetTimer, + Steering(kickturn::Parameters), +} + +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + match a.text("name")? { + "ResetTimeSinceKickturn" => Some(Self::ResetTimer), + //82BC7218: both constructor defaults are literal8209975C=0.5. + "KickTurnSteering" => Some(Self::Steering(kickturn::Parameters { + max_height: f32::from_bits(a.float_bits("maxHeight", 0x3f000000)), + spin_scale: f32::from_bits(a.float_bits("spinScale", 0x3f000000)), + })), + _ => None, + } + } +} + +pub fn load_settings(data: &Collections) -> Result { + //Original8289FB90..FBB0 binds Globals324 to A168897919752875, + //the independently verified stock anim_motion/kickturn collection key. + let curve = |name| -> Result, String> { + let words = data.words::<20>("anim_motion", "kickturn", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[12 + i])), + }) + }; + Ok(kickturn::Settings { + spin: curve("kickturn_spin")?, //layout240, source X256/Y288 + height: curve("kickturn_balance")?, //layout320, X336/Y368 + }) +} + +pub fn execute( + state: &mut kickturn::State, + parameters: kickturn::Parameters, + settings: &kickturn::Settings, + animation: &mut MotionAnimation, + dt: f32, + phase: u8, +) -> Result<(), String> { + if phase == 0 { + state.begin(settings); + return Ok(()); + } + //Native End82B61BB8 is the no-op lifecycle method. + if phase != 1 { + return Ok(()); + } + if state.needs_animation_length() { + //82BAE51C..560: IAnimatable84 applies pending parameters through + //82D18F98, then v48 supplies the tree for GetLength(v20). + animation.apply_parameters()?; + state.capture_animation_length(animation.current_length()?); + } + //Original830BE1E0 initializer82F84BC8 identifies KickTurn. HasIntent's + //destination is initialized to0; an absent/zero intent retains the sign. + let output = state.update( + dt, + animation.motion_intent("KickTurn").unwrap_or(0.0), + parameters, + settings, + ); + //MGspecific v16=8258F6E0 appends attributes, not animation parameters. + //82F85A08/82F858D0 identify the two original names and this order. + animation.emit_packet(encode(b"Balance"), output.balance); + animation.emit_packet(encode(b"Spin"), output.spin); + Ok(()) +} diff --git a/crates/skate-game/src/graph_host/motion_landing.rs b/crates/skate-game/src/graph_host/motion_landing.rs new file mode 100644 index 00000000..4b4d4152 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_landing.rs @@ -0,0 +1,113 @@ +//! Original TU3 landing graph configuration and persistent instance state. +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub enum Operation { + IsAnticipating, + IsLanding, + IsManualing, + IsDoingTrick, + SetLandingData, + DisableTricks { length: f32 }, + ChooseRandomLanding { count: i32 }, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "IsAnticipating" => Self::IsAnticipating, + "IsLanding" => Self::IsLanding, + "IsManualing" => Self::IsManualing, + "IsDoingTrick" => Self::IsDoingTrick, + //82BC7618 fixes this operation's attribute to disttocog. + "SetLandingData" => Self::SetLandingData, + "DisableTricks" => Self::DisableTricks { + length: a + .get("length") + .map_or(0.5, |v| f32::from_bits(v.float_bits)), + }, + "ChooseRandomLanding" => Self::ChooseRandomLanding { + //82BC7788 truncates the configured float toward zero. + count: a + .get("numlandings") + .map_or(1.0, |v| f32::from_bits(v.float_bits)) as i32, + }, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Condition { + HasLandingType(u32), + HasTiltToLargeForPreland, + AllowedToTrick, +} +impl Condition { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "HasLandingType" => { + Self::HasLandingType(match a.text("landingType").unwrap_or("straight") { + "spin" => 2, + "sketchy" => 1, + _ => 0, + }) + } + "HasTiltToLargeForPreland" => Self::HasTiltToLargeForPreland, + "AllowedToTrick" => Self::AllowedToTrick, + _ => return None, + }) + } + pub fn evaluate( + self, + physical: Option, + flags: &Flags, + override_preland: Option, + ) -> Result { + match self { + Self::HasLandingType(kind) => { + Ok(physical.ok_or("HasLandingType requires Animation96")?.kind == kind) + } + Self::HasTiltToLargeForPreland => override_preland + .ok_or("HasTiltToLargeForPreland requires original prelanding output".into()), + //82BC5860 ->8231F460+48 ->82BA74D8 ->specific v116. + Self::AllowedToTrick => Ok(flags.tricks_allowed), + } + } +} + +/// Original MotionGraph CA4 flags, distinct from physical balance mode. +pub struct Flags { + pub anticipating: bool, + pub landing: bool, + pub manualing: bool, + pub doing_trick: bool, + pub tricks_allowed: bool, +} +impl Default for Flags { + fn default() -> Self { + //825953B0 clears bits30..27 and sets bit25. + Self { + anticipating: false, + landing: false, + manualing: false, + doing_trick: false, + tricks_allowed: true, + } + } +} + +/// Completed physical conditioner publication; no inferred landing classification. +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub height: f32, + pub spin: f32, + pub kind: u32, + pub last_good_landing_velocity: f32, +} + +/// Original instance scalar8 (height or countdown) and byte12 (timer complete). +#[derive(Default)] +pub struct State { + pub(super) value: f32, + pub(super) complete: bool, +} diff --git a/crates/skate-game/src/graph_host/motion_landing_execute.rs b/crates/skate-game/src/graph_host/motion_landing_execute.rs new file mode 100644 index 00000000..5071f62d --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_landing_execute.rs @@ -0,0 +1,115 @@ +//! Original landing Begin/Update/End effects, called by the production graph. +use super::{ + motion_animation::MotionAnimation, + motion_landing::{Flags, Operation, Physical, State}, + motion_riding_conditions::MotionRandom, +}; +use skate_core::animation::{ + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, +}; + +#[allow(clippy::too_many_arguments)] +pub fn execute( + operation: &Operation, + state: &mut State, + flags: &mut Flags, + animation: &mut MotionAnimation, + random: &MotionRandom, + physical: Option, + mirrored: Option, + dt: f32, + phase: u8, +) -> Result<(), String> { + match *operation { + Operation::IsAnticipating => { + //82BACB40/82BACB90: v80 ->8258F870, CA4 bit30. + if phase != 1 { + flags.anticipating = phase == 0; + } + } + Operation::IsLanding => { + //82BACBE0/82BACC30: v88 ->8258F898, CA4 bit29. + if phase != 1 { + flags.landing = phase == 0; + } + } + Operation::IsManualing => { + //82BACC80/82BACCD0: v96 ->8258F8C0, CA4 bit28. + if phase != 1 { + flags.manualing = phase == 0; + } + } + Operation::IsDoingTrick => { + //82BBDFF8/82BBE048: v104 ->8258F8E8, CA4 bit27. + if phase != 1 { + flags.doing_trick = phase == 0; + } + } + Operation::SetLandingData => match phase { + 0 => { + //82BB0180: sign follows ISkaterAnim v28, then MG v36. + let p = physical.ok_or("SetLandingData requires actual landing publication")?; + let mirror = mirrored.ok_or("SetLandingData requires animation stance")?; + set(animation, b"Spin", if mirror { p.spin } else { -p.spin }); + set(animation, b"AvgVelY", p.last_good_landing_velocity); + state.value = p.height; + } + 1 => set(animation, b"disttocog", state.value), //82BB0338. + _ => {} //Original End82B61BB8 is empty. + }, + Operation::DisableTricks { length } => match phase { + 0 => { + //82BB8DC8. + flags.tricks_allowed = false; + state.value = length; + state.complete = false; + } + 1 => { + //82BB8E58; strict comparison, no reference counting. + if !state.complete { + state.value -= dt; + if state.value < 0.0 { + flags.tricks_allowed = true; + state.complete = true; + } + } + } + _ => flags.tricks_allowed = true, //82BB8F20 unconditionally. + }, + Operation::ChooseRandomLanding { count } => match phase { + 0 => { + //82BB0398 draws from the SAME specific MotionGraph RNG v256. + //The original traps for a nonpositive divisor; reject that asset. + if count <= 0 { + return Err("ChooseRandomLanding requires numlandings > 0".into()); + } + let value = (random.next_u32()? as i32).wrapping_abs() % count; + let name = encode(b"Random"); + let value = encode(value.to_string().as_bytes()); + if let Some(item) = animation + .construction_values + .iter_mut() + .find(|(key, _)| *key == name) + { + item.1 = value; + } else { + animation.construction_values.push((name, value)); + } + } + 2 => animation + .construction_values + .retain(|(name, _)| *name != encode(b"Random")), + _ => {} //Update82B61BB8 is empty; End82BB0498 erases the key. + }, + } + Ok(()) +} +fn set(animation: &mut MotionAnimation, name: &[u8], value: f32) { + animation.set_attribute(SettableAttribute { + name: encode(name), + value, + normalized: false, + sequence_id: -1, + }); +} diff --git a/crates/skate-game/src/graph_host/motion_native.rs b/crates/skate-game/src/graph_host/motion_native.rs new file mode 100644 index 00000000..2c485f23 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_native.rs @@ -0,0 +1,138 @@ +//! Small original MotionGraph leaves used by the live riding ancestry. +use super::{motion_animation::MotionAnimation, motion_riding::RidingState}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug)] +pub struct Physical { + /// PhysOutSystemReckoning16 and96, original StoreLandingData82BAFFF0. + pub centre_of_mass_velocity: [f32; 4], + pub system_up: [f32; 4], + /// PhysOutGround32 (Reckoning752 Z), original slide helper82595930. + pub board_reckoning_z: [f32; 4], + /// Complete PhysOutGround0/16/32/48 from Reckoning752, for82BB31B0. + pub board_reckoning: [[f32; 4]; 4], +} + +#[derive(Clone, Copy, Debug)] +pub struct GesturePhysical { + pub ground321: bool, + pub state_offboard75: bool, + ///Host player gesture preferences, consumed only when starting a gesture. + pub selections: Option<[u32; 4]>, + pub suppress_up: bool, + pub force_brake_bypass: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Operation { + StoreLandingData, + EndShimmy(f32), + Score { regular: u32, mirrored: u32 }, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + Ok(Some(match a.text("name").unwrap_or("") { + "StoreLandingData" => Self::StoreLandingData, + "EndShimmy" => Self::EndShimmy(f32::from_bits(a.float_bits("blendtime", 0x3e4ccccd))), + "SetScoreAugmentation" => { + //82BBFB28's source enum; absent mirrorAugment inherits augment. + let decode = |name: &str| { + [ + "FSPowerslide", + "BSPowerslide", + "FSRevert", + "BSRevert", + "NoseManual", + "TailManual", + "Wipeout", + "Landing", + "RideIdle", + "Switching", + ] + .iter() + .position(|v| *v == name) + .map(|n| n as u32) + .ok_or_else(|| format!("Unknown authored score augmentation {name}")) + }; + let regular = decode(a.text("augment").unwrap_or(""))?; + let mirrored = match a.text("mirrorAugment") { + //Original82BBFE98 writes the regular slot for this name, + //leaving the mirror slot uninitialized. No authored stock + //riding node uses it; do not invent a deterministic value. + Some("Switching") => { + return Err( + "Native mirrorAugment=Switching has no initialized mirrored value" + .into(), + ); + } + Some(name) => decode(name)?, + None => regular, + }; + Self::Score { regular, mirrored } + } + _ => return Ok(None), + })) + } +} + +/// Meaningful graph outputs are retained even though a scoring UI is outside +/// this game.82595D08 sets a name and ORs a bit in the graph's score packet. +#[derive(Default)] +pub struct ScorePacket { + pub name: Option, + pub flags: u32, +} +impl ScorePacket { + pub fn set(&mut self, value: u32) { + let bit = match value { + 0 => 31, + 1 => 30, + 2 => 29, + 3 => 28, + 4 => 27, + 5 => 26, + 6 => 23, + 7 => 21, + 8 => 22, + 9 => 20, + _ => return, + }; + self.flags |= 1 << bit; + if value != 2 && value != 3 { + self.name = Some(value); + } + } +} + +///CreateInstance82BBAA98 seeds velocity0 and prior-air false. +#[derive(Default)] +pub struct LandingData { + previous_velocity: f32, + was_air: bool, +} +impl LandingData { + pub fn update(&mut self, category: u32, p: Physical, riding: &mut RidingState) { + let air = category == 2; + if !self.was_air && air { + riding.last_good_landing_velocity = 0.0; + } + self.was_air = air; + let velocity = skate_core::riding::ground_correction_math::dot_product( + p.centre_of_mass_velocity, + p.system_up, + ); + if velocity < 0.0 && velocity < self.previous_velocity { + riding.last_good_landing_velocity = velocity.abs(); + } + self.previous_velocity = velocity; + } +} +pub fn end_shimmy(animation: &mut MotionAnimation, seconds: f32) { + //82BBCC58; virtual40 uses EndChannelWithTime, not instantaneous removal. + for name in [ + "SKCH_2H_SHIMMY_LEFT_CHANNEL", + "SKCH_2H_SHIMMY_RIGHT_CHANNEL", + ] { + animation.channels.end_with(name, seconds, false); + } +} diff --git a/crates/skate-game/src/graph_host/motion_nodes.rs b/crates/skate-game/src/graph_host/motion_nodes.rs new file mode 100644 index 00000000..e91637fa --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_nodes.rs @@ -0,0 +1,242 @@ +//! Stock MotionGraph configuration. Native constructor defaults are kept here; +//! lifecycle execution and persistent state are owned by MotionHost. +use super::{motion_conditions::MotionCondition, pushing::PushOperation}; +use skate_core::animation::{ + output::attributes::AttributeName, + playback::{PlayAnimation, TransitionSettings}, + playback_parameters::{ParameterSource, PlaybackParameter}, + skeleton_input::name::encode, +}; +use skate_data::state_graph::{ + attributes::Attributes, + binding::{Node, OperationFactory, OperationKind}, +}; + +#[derive(Clone, Debug, PartialEq)] +pub enum MotionOperation { + Play(PlayAnimation), + ///TU3 vtable82309664: Begin/Update/End all point to the empty82B61BB8. + PrintText2D, + AttachIntent { + intent: String, + attribute: AttributeName, + set: bool, + }, + Push(PushOperation), + /// Angle, direction, quickness, speed, holding, in native emission order. + SetTurning([AttributeName; 5]), + ApplyingBodyTilt, + SettingBodyTilt(AttributeName), + Crouching(AttributeName), + Pumping, + DisallowPumping, + FakieHeadChannel, + Native(super::motion_native::Operation), + Slide(super::motion_sliding::Operation), + KickTurn(super::motion_kickturn::Operation), + BodySpin, + AirLeg(super::motion_air_leg::Operation), + ClearTrickAttr, + Landing(super::motion_landing::Operation), + Wipeout(super::motion_wipeout::Operation), + TwistLean(super::motion_twist_lean::Operation), + HandService(super::motion_hand_services::Operation), + IntentFilter(super::motion_intent_filter::Operation), + CharacterGesture, + Shove(super::motion_shove::ShoveOperation), + ResetAnimation(super::motion_reset::Operation), + UpdateRidingFakie(skate_core::animation::riding_fakie::Settings), + SetSpeed { + name: AttributeName, + value: Option, + }, + Hook(super::motion_hooks::MotionHook), + Riding(super::motion_riding::RidingOperation), + Condition(MotionCondition), + Unsupported { + kind: OperationKind, + name: String, + }, +} + +#[derive(Default)] +pub struct MotionFactory; +impl OperationFactory for MotionFactory { + type Instance = MotionOperation; + type Error = String; + + fn create( + &mut self, + kind: OperationKind, + _parent: Node, + a: &Attributes<'_>, + ) -> Result, String> { + let name = a.text("name").ok_or("MotionGraph operation has no name")?; + let operation = if kind == OperationKind::Condition { + MotionCondition::parse(a)?.map(MotionOperation::Condition) + } else if kind == OperationKind::Behavior { + if let Some(operation) = super::motion_landing::Operation::parse(a) { + Some(MotionOperation::Landing(operation)) + } else if let Some(operation) = super::motion_hand_services::Operation::parse(a) { + Some(MotionOperation::HandService(operation)) + } else if let Some(push) = PushOperation::from_attributes(a) { + Some(MotionOperation::Push(push)) + } else if let Some(operation) = super::motion_riding::RidingOperation::parse(a) { + Some(MotionOperation::Riding(operation)) + } else if let Some(operation) = super::motion_native::Operation::parse(a)? { + Some(MotionOperation::Native(operation)) + } else if let Some(operation) = super::motion_sliding::Operation::parse(a) { + Some(MotionOperation::Slide(operation)) + } else if let Some(operation) = super::motion_kickturn::Operation::parse(a) { + Some(MotionOperation::KickTurn(operation)) + } else if let Some(operation) = super::motion_shove::ShoveOperation::parse(a)? { + Some(MotionOperation::Shove(operation)) + } else { + match name { + "ResetSkaterAnimation" | "ResetToGivenStance" => Some(MotionOperation::ResetAnimation( + super::motion_reset::Operation::parse(a).ok_or("Invalid reset operation")?, + )), + "MatchTwistAndLean" => Some(MotionOperation::TwistLean( + super::motion_twist_lean::Operation::parse(a), + )), + "Wipeout" => Some(MotionOperation::Wipeout( + super::motion_wipeout::Operation::Wipeout, + )), + "EnableWipeoutGestures" => Some(MotionOperation::Wipeout( + super::motion_wipeout::Operation::EnableGestures, + )), + "FilterMotionGraphIntent" => Some(MotionOperation::IntentFilter( + super::motion_intent_filter::Operation::parse(a), + )), + "PlayAnimation" => Some(MotionOperation::Play(play(a))), + "PrintText2D" => Some(MotionOperation::PrintText2D), + "ApplyingBodyTilt" => Some(MotionOperation::ApplyingBodyTilt), + "SettingBodyTilt" => { + Some(MotionOperation::SettingBodyTilt(key(a, "tilt_x", "tilt_x"))) + } + "Crouching" => Some(MotionOperation::Crouching(key(a, "crouchName", ""))), + "Pumping" => Some(MotionOperation::Pumping), + "BodySpin" => Some(MotionOperation::BodySpin), + "ControlAirLegExtension" => Some(MotionOperation::AirLeg( + super::motion_air_leg::Operation::parse(a), + )), + "ClearTrickAttr" => Some(MotionOperation::ClearTrickAttr), + "CharacterGesture" => Some(MotionOperation::CharacterGesture), + "DisallowPumping" => Some(MotionOperation::DisallowPumping), + "FakieHeadChannel" => Some(MotionOperation::FakieHeadChannel), + "SetSpeed" => Some(MotionOperation::SetSpeed { + name: key(a, "attribute", ""), + value: a.get("setToValue").map(|v| f32::from_bits(v.float_bits)), + }), + "UpdateRidingFakie" => Some(MotionOperation::UpdateRidingFakie( + skate_core::animation::riding_fakie::Settings { + high_speed: f32::from_bits( + a.float_bits("highSpeedThreshold", 1.0f32.to_bits()), + ), + low_speed: f32::from_bits( + a.float_bits("lowSpeedThreshold", 0.5f32.to_bits()), + ), + slowly_backwards_seconds: f32::from_bits( + a.float_bits("timeSlowlyRollingBackwardsThreshold", 0x3e4ccccd), + ), + after_teleport_seconds: f32::from_bits( + a.float_bits("timeFromTeleportThreshold", 3.0f32.to_bits()), + ), + }, + )), + // Factory82BC6D40 defaults both identifiers to the string0. + "AttachIntent" => Some(MotionOperation::AttachIntent { + intent: a.text("intent").unwrap_or("0").into(), + attribute: encode(a.text("attr").unwrap_or("0").as_bytes()), + set: a.boolean_byte("set", 0) != 0, + }), + // Ctor82BB36D8; layout88 is holding and108 is speed, but + // Update emits speed before holding. + "SetTurning" => Some(MotionOperation::SetTurning([ + key(a, "angleName", "angle"), + key(a, "dirName", "dir"), + key(a, "quicknessName", "quickness"), + key(a, "speedName", "speedtuck"), + key(a, "holdingName", "holding"), + ])), + _ => None, + } + } + } else if kind == OperationKind::Hook { + super::motion_hooks::MotionHook::parse(a).map(MotionOperation::Hook) + } else { + None + }; + Ok(Some(operation.unwrap_or_else(|| { + MotionOperation::Unsupported { + kind, + name: name.into(), + } + }))) + } + fn add_parameter( + &mut self, + instance: &mut MotionOperation, + a: &Attributes<'_>, + ) -> Result<(), String> { + if let MotionOperation::Play(play) = instance { + //82BB56B8: all unrecognized from strings take filteredIntent. + let source = match a.text("from").unwrap_or("") { + "intent" => ParameterSource::MotionIntent(a.text("intent").unwrap_or("").into()), + "lastAnim" => ParameterSource::LastAnimation(key(a, "attribute", "")), + _ => ParameterSource::FilteredIntent(a.text("filteredIntent").unwrap_or("").into()), + }; + let last_animation = matches!(source, ParameterSource::LastAnimation(_)); + play.parameters.push(PlaybackParameter { + source, + rename: a + .text("rename") + .map(|v| encode(v.as_bytes())) + .filter(|v| *v != encode(b"")), + default_value: a.get("defaultValue").map(|v| f32::from_bits(v.float_bits)), + normalized: !last_animation && a.boolean_byte("normalize", 0) != 0, + }); + } + // Other implemented nodes inherit the verified no-op AddParam. + Ok(()) + } +} + +fn key(a: &Attributes<'_>, name: &str, default: &str) -> AttributeName { + encode(a.text(name).unwrap_or(default).as_bytes()) +} +fn play(a: &Attributes<'_>) -> PlayAnimation { + PlayAnimation { + animation: a.text("anim").unwrap_or("").into(), + switch_animation: a.text("switchAnim").map(str::to_owned), + mirror_animation: a.text("mirrorAnim").map(str::to_owned), + no_board_animation: a.text("noBoardAnim").map(str::to_owned), + playback_speed: f32::from_bits(a.float_bits("playBackSpeed", 1.0f32.to_bits())), + apply_posture: a.boolean_byte("applyPosture", 1) != 0, + transition: transition(a), + parameters: Vec::new(), + } +} +pub(super) fn transition(a: &Attributes<'_>) -> TransitionSettings { + TransitionSettings { + kind: match a.text("transType").unwrap_or("") { + "play" => 1, + "sequence" => 4, + "channelblend" => 3, + _ => 2, + }, + // Source literal82099280, not a fitted visual blend duration. + seconds: f32::from_bits(a.float_bits("time", 0x3e4c_cccd)), + under: u32::from(a.boolean_byte("transitionUnder", 0) != 0), + matching: if a.boolean_byte("blendWithCurrentFrame", 0) != 0 { + 1 + } else if a.boolean_byte("blendMatchPhase", 0) != 0 { + 2 + } else if a.boolean_byte("blendMatchFrame", 0) != 0 { + 3 + } else { + 0 + }, + use_channels_from_weights: a.boolean_byte("useChannelFromWeights", 0) != 0, + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/cadence.rs b/crates/skate-game/src/graph_host/motion_offboard/cadence.rs new file mode 100644 index 00000000..4a62ff70 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/cadence.rs @@ -0,0 +1,99 @@ +//! BipedCadence82BB9E78, MatchCadence82BBA938/82BBAA00 and LocoState82BA7B50. +//! TU3 default.patched.xex SHA256 +//!431b8eba23565affdc10d137df19b06fe286244cefb3e1a13f32693e9600395a. +use skate_core::animation::{ + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, +}; +use skate_data::state_graph::attributes::Attributes; +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Operation { + BipedCadence, + MatchCadence, +} +impl Operation { + /// Constructors have no operation-specific XML configuration. The common + /// graph wrapper retains mask, active and ordering behavior. + pub(crate) fn parse(a: &Attributes<'_>) -> Option { + match a.text("name") { + Some("BipedCadence") => Some(Self::BipedCadence), + Some("MatchCadence") => Some(Self::MatchCadence), + _ => None, + } + } +} + +///82BB9E78 calls SkaterAnim virtual40=82531200, a direct phase14652 store. +/// Supply the actual OffBoard80 and the existing AnimationState.phase owner. +/// Component absence skips the write. Begin/End are82B61BB8 no-ops. +pub(crate) fn biped_cadence( + physical_phase: Option, + animation_phase: Option<&mut f32>, + phase: u8, +) { + if phase == 1 { + if let (Some(value), Some(output)) = (physical_phase, animation_phase) { + *output = value; + } + } +} + +/// Per-node state from82BBAA98: value8=0 and byte12=false. This is a captured +/// Begin value, not a second physical cadence or animation phase owner. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct MatchCadence { + pub captured_phase: f32, + pub pending: bool, +} +impl MatchCadence { + ///82BBA938 requires both physical and animation components. Missing either + /// leaves the previous capture and byte untouched. + pub(crate) fn begin(&mut self, physical_phase: Option, animation_present: bool) { + if let (Some(value), true) = (physical_phase, animation_present) { + self.pending = true; + self.captured_phase = value; + } + } + ///82BBAA00 publishes on every Update, even if byte12 is already false. + /// It clears that byte only after writing through virtual80/SetAttribute. + pub(crate) fn update(&mut self, animation: Option<&mut dyn AttributeSink>) { + if let Some(animation) = animation { + animation.set_attribute(SettableAttribute { + name: encode(b"CadenceStartPercent"), + value: self.captured_phase, + normalized: false, + sequence_id: -1, + }); + self.pending = false; + } + } + ///Original vtable End82B61BB8 does not reset the capture or pending byte. + pub(crate) fn end(&mut self) {} +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct LocoState { + expected: u32, +} +impl LocoState { + ///82BA79E8 compares case-sensitive locostate values. An unknown spelling + /// leaves native field28 unwritten; reject invalid data rather than invent0. + pub(crate) fn parse(a: &Attributes<'_>) -> Result { + let expected = match a.text("locostate") { + Some("Stand") => 0, + Some("Walk") => 1, + Some("Run") => 2, + Some("Sprint") => 3, + value => return Err(format!("Invalid stock LocoState locostate {value:?}")), + }; + Ok(Self { expected }) + } + ///82BA7B50 requires the physical component and compares OffBoard84 exactly. + /// The graph wrapper applies its separately parsed mask and negation. + pub(crate) fn evaluate(self, locomotion_state_84: u32) -> bool { + locomotion_state_84 == self.expected + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/cadence/tests.rs b/crates/skate-game/src/graph_host/motion_offboard/cadence/tests.rs new file mode 100644 index 00000000..2ec2acbe --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/cadence/tests.rs @@ -0,0 +1,63 @@ +use super::*; +use skate_core::animation::playback_parameters::SettableAttributes; +#[test] +fn biped_updates_the_existing_phase_only_during_update() { + let mut phase = 0.25; + biped_cadence(Some(0.8), Some(&mut phase), 0); + assert_eq!(phase, 0.25); + biped_cadence(Some(0.8), Some(&mut phase), 1); + assert_eq!(phase, 0.8); + biped_cadence(None, Some(&mut phase), 1); + biped_cadence(Some(0.1), Some(&mut phase), 2); + assert_eq!(phase, 0.8); +} +#[test] +fn match_captures_begin_and_republishes_without_consuming_it() { + let mut state = MatchCadence::default(); + let mut sink = SettableAttributes::default(); + state.begin(Some(0.73), true); + assert!(state.pending); + state.update(Some(&mut sink)); + assert!(!state.pending); + let value = sink.entries()[0]; + assert_eq!(value.name, encode(b"CadenceStartPercent")); + assert_eq!(value.value, 0.73); + assert!(!value.normalized); + assert_eq!(value.sequence_id, -1); + sink.clear(); + state.end(); + state.update(Some(&mut sink)); + assert_eq!(sink.entries()[0].value, 0.73); +} +#[test] +fn absent_components_preserve_retained_capture_and_pending_flag() { + let mut state = MatchCadence::default(); + state.begin(Some(0.4), true); + state.begin(Some(0.9), false); + state.begin(None, true); + state.update(None); + assert_eq!(state.captured_phase, 0.4); + assert!(state.pending); + state.end(); + assert!(state.pending); +} +#[test] +fn native_initial_capture_is_zero_before_any_begin() { + let mut state = MatchCadence::default(); + let mut sink = SettableAttributes::default(); + state.update(Some(&mut sink)); + assert_eq!(sink.entries()[0].value, 0.0); +} +#[test] +fn locomotion_comparison_does_not_clamp_unknown_physical_values() { + for expected in 0..4 { + let condition = LocoState { expected }; + for actual in 0..5 { + assert_eq!(condition.evaluate(actual), expected == actual); + } + } +} +#[test] +fn missing_locomotion_config_is_not_silently_stand() { + assert!(LocoState::parse(&Attributes::new(&[])).is_err()); +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/push_off.rs b/crates/skate-game/src/graph_host/motion_offboard/push_off.rs new file mode 100644 index 00000000..29950f4b --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/push_off.rs @@ -0,0 +1,20 @@ +//! IsPushOffEnabled: original TU3 registration82F88150 -> ctor82BC62F8. +//! The ctor installs vtable8231F6D4; predicate slot+48 is8281DD70. +//! Original bytes386000014E800020 are li r3,1; blr. There is no physical +//! flag read, time gate or runtime setting in this leaf. The graph executor +//! remains responsible for the common active/mask/negation semantics. +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IsPushOffEnabled; + +impl IsPushOffEnabled { + pub fn parse(attributes: &Attributes<'_>) -> Option { + (attributes.text("name") == Some("IsPushOffEnabled")).then_some(Self) + } + + ///8281DD70: unconditional true in the verified original Skate3 TU3 image. + pub fn evaluate(self) -> bool { + true + } +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/reset.rs b/crates/skate-game/src/graph_host/motion_offboard/reset.rs new file mode 100644 index 00000000..783973a2 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/reset.rs @@ -0,0 +1,45 @@ +//! Original registered reset handlers:82F88440/82F89600. +//! Both have Begin effects; Update and End point to82B61BB8 (raw blr). +use skate_data::state_graph::attributes::Attributes; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Operation { + SkaterAnimation, + GivenStance, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + match a.text("name")? { + "ResetSkaterAnimation" => Some(Self::SkaterAnimation), + "ResetToGivenStance" => Some(Self::GivenStance), + _ => None, + } + } + ///82BB8F70 guarantees AG-intents -> MG-output/intents -> SkaterAnim order. + ///The service must borrow the actual owners; resetting AG/MG controllers or + ///replacing all animation state with Default would change native behavior. + pub fn begin(self, owners: &mut S) -> Result<(), S::Error> { + match self { + Self::SkaterAnimation => { + owners.clear_action_intents()?; + owners.reset_motion_graph_publications()?; + owners.reset_skater_animation_playback() + } + Self::GivenStance => owners.reset_to_given_stance(), + } + } +} +///Required integration boundary for the single ActionGraph, MotionGraph and +///playback owners. No default implementations or fabricated reset observations. +pub trait ResetOwners { + type Error; + ///82C0E308 ->82BC1B68: clear actual ActionGraph intent map only. + fn clear_action_intents(&mut self) -> Result<(), Self::Error>; + ///825953B0: filtered intents, current MG attrs, selective scalar writes, + ///motion intents and output2664..2680. Preserve graph execution and trick data. + fn reset_motion_graph_publications(&mut self) -> Result<(), Self::Error>; + ///82B98050 +82D1CAE8 +82D180E8: masks/stance bit, channel teardown, + ///playback tree/transition/attribute/control reset on the canonical owner. + fn reset_skater_animation_playback(&mut self) -> Result<(), Self::Error>; + ///82B97308 consumes saved15200 via82B972A8; no playback reset. + fn reset_to_given_stance(&mut self) -> Result<(), Self::Error>; +} diff --git a/crates/skate-game/src/graph_host/motion_offboard/twist_lean.rs b/crates/skate-game/src/graph_host/motion_offboard/twist_lean.rs new file mode 100644 index 00000000..923bbf9c --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_offboard/twist_lean.rs @@ -0,0 +1,49 @@ +//! MatchTwistAndLean stock configuration and existing animation queue adapter. +use skate_core::{ + animation::{ + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, + }, + player::offboard::twist_lean::{Observation, State}, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Operation { + pub always: bool, +} +impl Operation { + ///82BCC490 compares exact bytes through strcmp82AE89B0. + pub fn parse(attributes: &Attributes<'_>) -> Self { + Self { always: attributes.text("update") == Some("always") } + } + + ///Supply Some only for available original physical/animation components. + ///A missing implementation of a physical field must be an upstream error, + ///not an absent-component value. End is the original82B61BB8 no-op. + pub fn execute( + self, + state: &mut State, + observation: Option, + animation: Option<&mut impl AttributeSink>, + phase: u8, + ) { + match phase { + 0 => state.begin(observation), + 1 => { + if let Some(animation) = animation { + let values = state.update(self.always, observation); + for (name, value) in [("Twist", values.twist), ("Lean", values.lean)] { + animation.set_attribute(SettableAttribute { + name: encode(name.as_bytes()), + value, + normalized: false, + sequence_id: -1, + }); + } + } + } + _ => {} + } + } +} diff --git a/crates/skate-game/src/graph_host/motion_riding.rs b/crates/skate-game/src/graph_host/motion_riding.rs new file mode 100644 index 00000000..4de190ce --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_riding.rs @@ -0,0 +1,230 @@ +//! Native riding packet and timer leaves. The graph host owns these states; +//! physics/pose callers consume the resulting values in actor update order. +use super::motion_animation::MotionAnimation; +use skate_core::{ + animation::{ + output::attributes::AttributeName, + playback_parameters::{AttributeSink, SettableAttribute}, + skeleton_input::name::encode, + }, + graph::controller::Frame, +}; +use skate_data::state_graph::attributes::Attributes; + +pub fn body_tilt_settings( + data: &skate_data::collections::Collections, +) -> Result { + let words = data.words::<12>("anim_motion", "body_tilt", "body_tilt_bodyspin_factor")?; + let float = |name| data.float("anim_motion", "body_tilt", name); + Ok(skate_core::animation::body_tilt::Settings { + body_spin_factor: skate_core::point_graph::PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[8 + i])), + }, + ground_velocity: float("body_tilt_ground_clamp_vel")?, + ground_acceleration: float("body_tilt_ground_clamp_acc")?, + air_velocity: float("body_tilt_air_clamp_vel")?, + air_acceleration: float("body_tilt_air_clamp_acc")?, + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ForceMode { + AnimationData, + PhysicsBoard, + AnimationBoard, +} +impl ForceMode { + pub fn native_value(self) -> u32 { + match self { + Self::AnimationData => 0, + Self::PhysicsBoard => 1, + Self::AnimationBoard => 2, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RidingOperation { + CreateAttribute { + name: AttributeName, + values: [Option; 3], + set: bool, + }, + SetDistComToBoard { + name: AttributeName, + manually: bool, + adjust_for_velocity: bool, + }, + ForcePhysics(ForceMode), + TimeSinceTeleport, + TimeSinceKickturn, + ManualOutTimer, +} +impl RidingOperation { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "CreateAttribute" => { + //82BAE918: the always branch returns before reading set and + //writes Update twice, leaving End's initialized zero intact. + let (values, set) = if let Some(value) = a.get("always") { + let value = f32::from_bits(value.float_bits); + ([Some(value), Some(value), Some(0.0)], false) + } else if ["begin", "update", "end"] + .iter() + .all(|key| a.get(key).is_none()) + { + ([None, Some(0.0), None], false) + } else { + ( + ["begin", "update", "end"] + .map(|key| a.get(key).map(|v| f32::from_bits(v.float_bits))), + a.boolean_byte("set", 0) != 0, + ) + }; + Self::CreateAttribute { + name: encode(a.text("attName").unwrap_or("").as_bytes()), + values, + set, + } + } + "SetDistComToBoard" => Self::SetDistComToBoard { + name: encode(a.text("attribute").unwrap_or("").as_bytes()), + manually: a.boolean_byte("setManually", 0) != 0, + adjust_for_velocity: a.boolean_byte("adjustForVel", 0) != 0, + }, + "ForcePhysics" => { + Self::ForcePhysics(match a.text("force").unwrap_or("FOLLOW_ANIMATION_DATA") { + "FORCE_PHYSICS_SKATEBOARD" => ForceMode::PhysicsBoard, + "FORCE_ANIM_SKATEBOARD" => ForceMode::AnimationBoard, + _ => ForceMode::AnimationData, + }) + } + "UpdateTimeSinceTeleport" => Self::TimeSinceTeleport, + "UpdateTimeSinceKickturn" => Self::TimeSinceKickturn, + "UpdateManualOutTimer" => Self::ManualOutTimer, + _ => return None, + }) + } +} + +#[derive(Clone, Debug)] +pub struct RidingState { + pub time_since_teleport: f32, + pub time_since_kickturn: f32, + pub manual_out_timer: f32, + + ///Specific MotionGraph CA4 bit23, getter8258FB68/setter8258FB78. + ///Constructor8258F5B8 clears it; Reset82595480 preserves it. + pub dark: bool, + /// Specific MotionGraph+3156, GetLastGoodLandingVelocity8258F7C8. + pub last_good_landing_velocity: f32, + ///82BB2868 writes ProcessedPhysIn+16420 only on behavior Begin. + ///None means no graph request has changed the physical mode yet. + pub force_mode: Option, +} +impl RidingState { + pub fn new() -> Self { + //8258F488 initializes full5912/5916; final reset825953B0 clears + //specific3156/3224. + Self { + time_since_teleport: 0.0, + time_since_kickturn: 0.0, + manual_out_timer: 0.0, + + dark: false, + last_good_landing_velocity: 0.0, + force_mode: None, + } + } + pub fn execute( + &mut self, + operation: RidingOperation, + phase: u8, + frame: &Frame, + animation: &mut MotionAnimation, + physical_category: Option, + animation_height: Option, + manualing: bool, + ) -> Result<(), String> { + match operation { + RidingOperation::CreateAttribute { name, values, set } => { + if let Some(value) = values[usize::from(phase)] { + //82BAEC90 emits the graph record before setting tree data. + animation.emit_packet(name, value); + if set { + animation.set_attribute(SettableAttribute { + name, + value, + normalized: false, + sequence_id: -1, + }); + } + } + } + RidingOperation::SetDistComToBoard { + name, + manually, + adjust_for_velocity, + } => { + if phase == 0 { + //82BB04E8: source literal0.65 only when explicitly authored. + let height = if manually { + f32::from_bits(0x3f266666) + } else { + animation_height + .ok_or("SetDistComToBoard requires actual PhysOutAnimation distance")? + }; + let correction = if adjust_for_velocity { + let v = frame.dt * self.last_good_landing_velocity; + let cap = f32::from_bits(0x3e19999a); + if cap - v >= 0.0 { v } else { cap } + } else { + 0.0 + }; + animation.set_attribute(SettableAttribute { + name, + value: height - correction, + normalized: false, + sequence_id: -1, + }); + } + } + RidingOperation::ForcePhysics(mode) => { + if phase == 0 { + self.force_mode = Some(mode); + } + } + RidingOperation::TimeSinceTeleport => { + if phase == 1 { + //82BB9058: actual filtered category5 resets; all others add + //controller delta through8258F968, with no imposed maximum. + if physical_category + .ok_or("UpdateTimeSinceTeleport needs filtered physical state")? + == 5 + { + self.time_since_teleport = 0.0; + } else { + self.time_since_teleport += frame.dt; + } + } + } + RidingOperation::TimeSinceKickturn => { + if phase == 1 { + self.time_since_kickturn += frame.dt; + } + } //82BAE848/8258F940 + RidingOperation::ManualOutTimer => { + if phase == 2 { + self.manual_out_timer = 0.0; + } + //82BB9260/8258F980 + else if phase == 1 && !manualing { + let remaining = self.manual_out_timer - frame.dt; + self.manual_out_timer = if remaining >= 0.0 { remaining } else { 0.0 }; + } //82BB91A8/8258F998 + } + } + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/motion_riding_conditions.rs b/crates/skate-game/src/graph_host/motion_riding_conditions.rs new file mode 100644 index 00000000..ac61f8c0 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_riding_conditions.rs @@ -0,0 +1,167 @@ +//! Original TU3 random, COM velocity and slope conditions for stock riding. +use super::motion::MotionHost; +use skate_core::{ + graph::conditions::NumericCondition, + math::Vector3, + physics::board_motion_output::{dot, length}, + trigonometry, +}; +use skate_data::state_graph::attributes::Attributes; +use std::sync::Mutex; + +/// Completed physical output, before the next MotionGraph evaluation. +#[derive(Clone, Copy, Debug)] +pub struct RidingConditionInputs { + /// PhysOut bundle36+16, published from Skeleton's physical COM velocity. + pub com_velocity: [f32; 4], + /// PhysOutSkeleton432/464: effective animation-to-world X/Z columns. + /// GetEffectiveRoot82BE3650 negates both iff Processed2476 bit2 is set. + pub skeleton_x: [f32; 4], + pub skeleton_z: [f32; 4], + /// PhysOut bundle0+16.Y: the raw physical deck's up axis. + pub skate_up_y: f32, + /// PhysOutGround80.Y: retained contacting-wheel normal. + pub surface_up_y: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VelocityAxis { + X, + Y, + Z, + All, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum MotionRidingCondition { + Random, + ComVelocity { + axis: VelocityAxis, + numeric: NumericCondition, + }, + SkateSlope(NumericCondition), + SurfaceSlope(NumericCondition), +} + +impl MotionRidingCondition { + pub fn recognizes(name: &str) -> bool { + matches!( + name, + "RandomCond" | "ComVelCompare" | "SkateSlope" | "SurfaceSlope" + ) + } + + pub fn parse(a: &Attributes<'_>) -> Result { + Ok(match a.text("name").unwrap_or("") { + // Factory82BC3CE8 adds no attributes beyond the base condition. + "RandomCond" => Self::Random, + "ComVelCompare" => Self::ComVelocity { + // Ctor82BA3ED0 examines only the first byte, default "0". + axis: match a.text("axis").unwrap_or("0").as_bytes().first() { + Some(b'x' | b'X') => VelocityAxis::X, + Some(b'y' | b'Y') => VelocityAxis::Y, + Some(b'z' | b'Z') => VelocityAxis::Z, + _ => VelocityAxis::All, + }, + numeric: super::condition_nodes::numeric(a), + }, + // Factories82BC3258/82BC31B8 use NumericCondition82C126F0. + "SkateSlope" => Self::SkateSlope(super::condition_nodes::numeric(a)), + "SurfaceSlope" => Self::SurfaceSlope(super::condition_nodes::numeric(a)), + name => return Err(format!("Unknown riding condition {name}")), + }) + } + + pub fn evaluate(&self, host: &MotionHost) -> Result { + if matches!(self, Self::Random) { + //82BA4D50 -> ISkaterMotionGraph256 ->8258FB60 ->82970628. + // Advance only when the graph actually evaluates this leaf. + return Ok(host.condition_random.next_u32()? & 1 == 0); + } + let p = host + .riding_conditions + .as_ref() + .ok_or("MotionGraph requires the actual COM and slope publication")?; + Ok(match *self { + Self::ComVelocity { axis, numeric } => { + //82BA3FA8 uses relative X/Z but WORLD Y. The named Skate2 + //counterpart reads direct XYZ, so its axis behavior differs. + let velocity = xyz(p.com_velocity); + let value = match axis { + VelocityAxis::X => dot(velocity, xyz(p.skeleton_x)), + VelocityAxis::Y => velocity.y, + VelocityAxis::Z => dot(velocity, xyz(p.skeleton_z)), + VelocityAxis::All => length(velocity), + }; + numeric.matches(value) + } + Self::SkateSlope(numeric) => numeric.matches(slope(p.skate_up_y)), + Self::SurfaceSlope(numeric) => numeric.matches(slope(p.surface_up_y)), + Self::Random => unreachable!("Random condition evaluated above"), + }) + } +} + +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +fn slope(up_y: f32) -> f32 { + //82BA4DD0/82BA4F90 inline the same original inverse-sine polynomial as + //DisablePushBrake82BA5150; no clamp or absolute value precedes asin. + 90.0 - trigonometry::asin(up_y) * f32::from_bits(0x4265_2ee1) +} + +/// Per-MotionGraph generator. Original8258F488 seeds this once; its reset +///825953B0 leaves the sequence intact. Locking only gives the host shared +///condition interface interior mutability; it does not alter draw timing. +#[derive(Debug)] +pub struct MotionRandom(Mutex<[u32; 8]>); + +impl MotionRandom { + pub fn new() -> Self { + //FullMotionGraph6000/6004 start at zero, then6008..6031 receive the + //original 24-byte literal821642B0. No external runtime RNG is used. + Self(Mutex::new([ + 0, + 0, + 0xf22d_0e56, + 0x8831_26e9, + 0xc624_dd2f, + 0x0702_c49c, + 0x9e35_3f7d, + 0x6fdf_3b64, + ])) + } + + pub fn next_u32(&self) -> Result { + let mut words = self + .0 + .lock() + .map_err(|_| "MotionGraph random state lock poisoned")?; + //Complete original82970628 carry/update sequence, including the + //separate counter rollover propagation after producing word2. + let last = words[7]; + let previous = words[6]; + let mut sum = previous.wrapping_add(last); + let mut carry = u32::from(sum < last || sum < previous); + words[6] = sum; + for index in (2..=5).rev() { + let previous = words[index]; + sum = previous.wrapping_add(sum).wrapping_add(carry); + carry = u32::from(sum < previous); + words[index] = sum; + } + words[7] = last.wrapping_add(1); + if words[7] == 0 { + for index in (2..=6).rev() { + words[index] = words[index].wrapping_add(1); + if words[index] != 0 { + break; + } + } + } + words[1] = words[1].wrapping_add(1); + Ok(words[2]) + } +} diff --git a/crates/skate-game/src/graph_host/motion_shove.rs b/crates/skate-game/src/graph_host/motion_shove.rs new file mode 100644 index 00000000..32499581 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_shove.rs @@ -0,0 +1,213 @@ +//! Original TU3 Shove constructor82BBCCF0, Begin82BBCDD0, +//! Update82BBCDF0 and End82BBD540. The host owns channels and graph values. +use super::motion_animation::MotionAnimation; +use skate_core::animation::{ + channel_playback::ChannelSettings, + playback::TransitionSettings, + playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute}, + skeleton_input::name::encode, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq)] +pub struct ShoveOperation { + selection: String, + selection_board: String, + anticipation: String, + anticipation_board: String, +} +impl ShoveOperation { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + if a.text("name") != Some("Shove") { + return Ok(None); + } + //The required lookups are virtual8; virtual4 supplies empty defaults + //only for the two board-specific alternatives. + Ok(Some(Self { + selection: a + .text("Selection") + .ok_or("Shove requires Selection")? + .into(), + selection_board: a.text("SelectionBrd").unwrap_or("").into(), + anticipation: a.text("Antic").ok_or("Shove requires Antic")?.into(), + anticipation_board: a.text("AnticBrd").unwrap_or("").into(), + })) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ShovePhysical { + ///PhysOutInteraction (bundle52)+56; ProcessOutput82DB7E38 owns this. + pub interaction_trigger: bool, + ///PhysOutGround240, the interaction direction published by that producer. + pub direction: [f32; 4], + ///PhysOutState75 and PhysOutOffboard311, respectively. + pub in_biped_category: bool, + pub board_on_ground: bool, + ///PhysOutAnimation72, used only while the shove channel is active. + pub animation_height: f32, +} + +#[derive(Clone, Debug)] +pub struct ShoveState { + pub angle: f32, + ///Native instance12 records the anticipation selection. Update does not + ///use this cache to replace the newly observed board state. + pub board_anticipation: bool, +} +impl ShoveState { + pub fn new() -> Self { + Self { + angle: 0.0, + board_anticipation: false, + } + } + pub fn begin(&mut self) { + //82BBCDD0 clears both fields; this does not terminate any channel. + self.angle = 0.0; + self.board_anticipation = false; + } + pub fn update( + &mut self, + operation: &ShoveOperation, + animation: &mut MotionAnimation, + physical: ShovePhysical, + busy_hands: [u32; 2], + mirrored: bool, + ) -> Result<(), String> { + let retrieving = animation.channels.has("RetrieveBoard"); + let board_variant = physical.in_biped_category && physical.board_on_ground; + if animation.motion_intent("GrabWorld").is_some() && !retrieving && busy_hands == [0, 0] { + if !animation.channels.has("SkitchAntic") && !animation.channels.has("Shove") { + self.board_anticipation = board_variant; + start_channel( + animation, + "SkitchAntic", + if board_variant { + &operation.anticipation_board + } else { + &operation.anticipation + }, + true, + )?; + } + } else if animation.channels.has("SkitchAntic") { + animation.channels.end("SkitchAntic"); + } + + let mut shove = animation.channels.has("Shove"); + if !physical.interaction_trigger || retrieving { + if shove { + if retrieving { + animation.channels.end("Shove"); + } else { + set(animation, b"ShoveDirection", self.angle); + } + } + return Ok(()); + } + self.angle = direction_angle(physical.direction, mirrored); + if animation.channels.has("SkitchAntic") { + animation.channels.end("SkitchAntic"); + } + if !shove { + start_channel( + animation, + "Shove", + if board_variant { + &operation.selection_board + } else { + &operation.selection + }, + false, + )?; + //Original marks this true after the call, rather than querying + //the channel manager a second time during the same update. + shove = true; + } + if shove { + set(animation, b"GstrDistToCog", physical.animation_height); + set(animation, b"ShoveDirection", self.angle); + } + Ok(()) + } + pub fn end(&mut self, animation: &mut MotionAnimation, keep_channels: bool) { + //SpecificMotionGraph virtual212=8258FC30, bit18. Native reset clears it. + //Virtual44 is EndChannelPrematurely82D1D6A8, preserving fade timing. + if !keep_channels { + for name in ["SkitchAntic", "Shove"] { + if animation.channels.has(name) { + animation.channels.end(name); + } + } + } + } +} +fn set(animation: &mut MotionAnimation, name: &[u8], value: f32) { + animation.set_attribute(SettableAttribute { + name: encode(name), + value, + normalized: false, + sequence_id: -1, + }); +} +fn start_channel( + animation: &mut MotionAnimation, + channel: &str, + tree: &str, + anticipation: bool, +) -> Result<(), String> { + let seconds = f32::from_bits(if anticipation { 0x3e4ccccd } else { 0x3dcccccd }); + let settings = ChannelSettings { + priority: 0, + keep_alive: anticipation, + mirrored: false, + speed: 1.0, + blend_in: seconds, + hold_during_blend_in: false, + blend_out: seconds, + hold_during_blend_out: false, + use_attributes: false, + }; + //Vtable16 is TransitionTo82D1D090. Stack booleans at87/95/103 are1: + //allow resurrection, create missing and use channel weights. + let transition = TransitionSettings { + kind: 2, + seconds: f32::from_bits(0x3dcccccd), + under: 0, + matching: 0, + use_channels_from_weights: true, + }; + animation.transition_channel(channel, tree, settings, transition, true, true)?; + Ok(()) +} +///82BBD250..D390: original one-refinement reciprocal followed by82473B98. +///Unlike left-stick angle, zero direction has no extra zero-vector guard. +fn direction_angle(direction: [f32; 4], mirrored: bool) -> f32 { + let x = direction[0]; + let z = if mirrored { + -direction[2] + } else { + direction[2] + }; + let reciprocal = z.recip(); + let reciprocal = reciprocal.mul_add((-reciprocal).mul_add(z, 1.0), reciprocal); + let basic = skate_core::input::angle::atan(x.mul_add(reciprocal, 0.0)); + let sign = x.to_bits() & 0x80000000; + let angle = if z < 0.0 { + basic + f32::from_bits(0x40490fdb | sign) + } else { + basic + }; + let angle = if z == 0.0 { + f32::from_bits(0x3fc90fdb | sign) + } else { + angle + }; + let degrees = angle * f32::from_bits(0x42652ee1); + if degrees < 0.0 { + degrees + f32::from_bits(0x43b40000) + } else { + degrees + } +} diff --git a/crates/skate-game/src/graph_host/motion_sliding.rs b/crates/skate-game/src/graph_host/motion_sliding.rs new file mode 100644 index 00000000..e3d02df4 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_sliding.rs @@ -0,0 +1,251 @@ +//! PowerSliding Update82BB28E8, instance82BB2D70 and helper82595930. +use super::motion_animation::MotionAnimation; +use skate_core::input::set_turning::SlideLatch; +use skate_core::{animation::playback_parameters::ParameterInputs, point_graph::PointGraph}; +use skate_data::{collections::Collections, state_graph::attributes::Attributes}; + +pub struct Settings { + speed_threshold: f32, + well_into_slide: f32, + speed_factor: PointGraph<4>, + time_threshold: PointGraph<4>, + threshold: PointGraph<4>, + speed_to_lean: PointGraph<8>, + phys_to_anim_spin: PointGraph<8>, + smooth: f32, + minimum_entry: f32, + turn: f32, +} +impl Settings { + pub fn load(data: &Collections) -> Result { + let float = |name| data.float("anim_motion", "power_slide", name); + let curve = |name| -> Result, String> { + let w = data.words::<12>("anim_motion", "power_slide", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(w[8 + i])), + }) + }; + let curve8 = |name| -> Result, String> { + let w = data.words::<20>("anim_motion", "power_slide", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(w[12 + i])), + }) + }; + Ok(Self { + speed_threshold: float("slide_speed_threshold")?, + well_into_slide: float("well_into_slide_time")?, + speed_factor: curve("slide_speed_factor")?, + time_threshold: curve("not_sliding_time_thresh")?, + threshold: curve("not_sliding_threshold")?, + // Native layout0,80,1688,1692,1680 respectively. + speed_to_lean: curve8("slide_speed_to_lean")?, + phys_to_anim_spin: curve8("slide_phys_to_anim_spin")?, + smooth: float("slide_smooth")?, + minimum_entry: float("slide_min_entry")?, + turn: float("slide_turn_value")?, + }) + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Operation { + Update, + Create { right: bool }, + ManualAttribute, + Deceleration, + Spin, + Candidate, + IsPowerSliding, +} +impl Operation { + pub fn parse(a: &Attributes<'_>) -> Option { + Some(match a.text("name")? { + "PowerSliding" => Self::Update, + // Factory82BC9100 explicitly defaults authored right to true. + "CreateSlide" => Self::Create { + right: a.boolean_byte("right", 1) != 0, + }, + "PowerSlideManualAtt" => Self::ManualAttribute, + "PowerSlideDecel" => Self::Deceleration, + "PowerSlideSpin" => Self::Spin, + "InCandidateSlidingState" => Self::Candidate, + "IsPowerSliding" => Self::IsPowerSliding, + _ => return None, + }) + } +} +///Native instance8..20; the three high flags initialize clear. +#[derive(Default)] +pub struct State { + not_sliding_time: f32, + previous_right: f32, + previous_left: f32, + was_ground: bool, + was_right_start: bool, + was_left_start: bool, +} +impl State { + pub fn update( + &mut self, + animation: &MotionAnimation, + category: u32, + speed: f32, + direction: f32, + dt: f32, + settings: &Settings, + output: &mut SlideLatch, + ) { + let right = animation.motion_intent("RightSlide"); + let left = animation.motion_intent("LeftSlide"); + let right_value = right.unwrap_or(0.0); + let left_value = left.unwrap_or(0.0); + let ground = category == 1; + if ground && !self.was_ground { + self.not_sliding_time = 0.0; + self.previous_right = right_value; + self.previous_left = left_value; + self.was_right_start = false; + self.was_left_start = false; + } + self.was_ground = ground; + if !ground { + return; + } + let fast = speed > settings.speed_threshold; + let right_start = animation.motion_intent("RightSlideStart").is_some(); + output.set_start( + true, + fast && right_start && !self.was_right_start && self.previous_right < right_value, + ); + self.previous_right = right_value; + self.was_right_start = right_start; + let left_start = animation.motion_intent("LeftSlideStart").is_some(); + output.set_start( + false, + fast && left_start && !self.was_left_start && self.previous_left > left_value, + ); + self.previous_left = left_value; + self.was_left_start = left_start; + let limit = settings.time_threshold.evaluate(direction); + let sliding = settings.speed_factor.evaluate(speed) * (1.0 - direction.abs()); + self.not_sliding_time = if sliding >= settings.threshold.evaluate(direction) { + 0.0 + } else { + self.not_sliding_time + dt + }; + let stop = self.not_sliding_time > limit; + output.advance_elapsed(true, dt); + output.advance_elapsed(false, dt); + output.set_end( + true, + output.elapsed(true) > settings.well_into_slide && (right.is_none() || !fast || stop), + ); + output.set_end( + false, + output.elapsed(false) > settings.well_into_slide && (left.is_none() || !fast || stop), + ); + } +} + +///82595930 normalizes Motion80 with two root and reciprocal refinements, +/// then dots the authored Reckoning Z, reversing for Motion273. +pub fn direction(velocity: [f32; 4], mut z: [f32; 4], flipped: bool) -> f32 { + use skate_core::riding::ground_correction_math::dot_product; + let squared = dot_product(velocity, velocity); + //825959D4 ble also takes the unordered branch after fcmpu. + if !(squared > f32::from_bits(0x3a83126f)) { + return 1.0; + } + if flipped { + z = z.map(|v| -v); + } + let mut root = 1.0 / squared.sqrt(); + for _ in 0..2 { + let error = (-squared).mul_add(root * root, 1.0); + root = (root * 0.5).mul_add(error, root); + } + let length = squared * root; + let mut inverse = 1.0 / length; + for _ in 0..2 { + inverse = inverse.mul_add((-inverse).mul_add(length, 1.0), inverse); + } + dot_product(velocity.map(|v| v * inverse), z) +} + +/// CreateSlide Update82BB2F60, ordered packet values (Slide, Turn). +pub fn create( + latch: &SlideLatch, + authored_right: bool, + right_intent: Option, + left_intent: Option, + settings: &Settings, +) -> [f32; 2] { + let right = authored_right ^ latch.captured_fakie(); + let mut slide = if right { right_intent } else { left_intent }.unwrap_or(0.0); + if !(latch.elapsed(right) > settings.well_into_slide) { + slide = if right { + if settings.minimum_entry - slide >= 0.0 { + settings.minimum_entry + } else { + slide + } + } else { + let minimum = -settings.minimum_entry; + if minimum - slide >= 0.0 { + slide + } else { + minimum + } + }; + } + let mut turn = if right { settings.turn } else { -settings.turn }; + if latch.captured_fakie() { + turn = -turn; + let negative = -slide; + let absolute = negative.abs(); + let wrapped = if absolute > 0.5 { + 1.0 - absolute + } else { + absolute + }; + slide = if negative >= 0.0 { wrapped } else { -wrapped }; + } + [slide, turn] +} + +/// PowerSlideDecel82BB31B0: inverse Ground basis rotates velocity without +/// translation. Source zeros local Y before measuring the horizontal speed. +pub fn deceleration( + previous: &mut f32, + velocity: [f32; 4], + ground: [[f32; 4]; 4], + settings: &Settings, +) -> f32 { + let mut local = std::array::from_fn::<_, 4, _>(|lane| { + let x = ground[lane][0] * velocity[0]; + let y = ground[lane][1].mul_add(velocity[1], x); + ground[lane][2].mul_add(velocity[2], y) + }); + local[1] = 0.0; + let squared = skate_core::riding::ground_correction_math::dot_product(local, local); + let length = if squared == 0.0 { + 0.0 + } else { + let mut inverse = 1.0 / squared.sqrt(); + for _ in 0..2 { + let error = (-squared).mul_add(inverse * inverse, 1.0); + inverse = (inverse * 0.5).mul_add(error, inverse); + } + squared * inverse + }; + let target = settings.speed_to_lean.evaluate(length); + let retained = (1.0 - settings.smooth) * *previous; + *previous = target.mul_add(settings.smooth, retained); + *previous +} + +/// PowerSlideSpin Begin82BB33C8; the input is GetSlideDirection82595930. +pub fn spin(direction: f32, settings: &Settings) -> f32 { + -settings.phys_to_anim_spin.evaluate(direction) +} diff --git a/crates/skate-game/src/graph_host/motion_sliding_execute.rs b/crates/skate-game/src/graph_host/motion_sliding_execute.rs new file mode 100644 index 00000000..a2be4c52 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_sliding_execute.rs @@ -0,0 +1,139 @@ +//! Production lifecycle for the authored slide family; no detached state copies. +use super::*; +use crate::graph_host::motion_sliding::{self, Operation}; + +impl MotionHost { + pub(super) fn execute_slide( + &mut self, + behavior: BehaviorId, + operation: Operation, + frame: &Frame, + phase: u8, + ) -> Result<(), String> { + match operation { + Operation::Update if phase == 1 => { + let category = self + .condition_inputs + .physical_state + .as_ref() + .ok_or("PowerSliding requires actual filtered physical category")? + .category; + // Native returns before speed/direction reads outside category1. + let (speed, direction) = if category == 1 { + let speed = self + .fakie_physical + .ok_or("PowerSliding requires actual Motion164 speed")? + .ground_projected_speed; + (speed, self.slide_direction()?) + } else { + (0.0, 0.0) + }; + let Instance::Sliding(state) = &mut self.instances[behavior] else { + return Err("PowerSliding has an invalid instance".into()); + }; + state.update( + &self.animation, + category, + speed, + direction, + frame.dt, + &self.sliding, + &mut self.slide_latch, + ); + } + Operation::Create { right } => match phase { + 0 => { + //ISkaterAnim virtual12=82B970D8 reads the live bit29. + let fakie = self + .animation + .skater_animation_flags + .ok_or("CreateSlide requires actual animation stance flags")? + & 0x2000_0000 + != 0; + self.slide_latch.begin_slide(fakie); + } + 1 => { + let values = motion_sliding::create( + &self.slide_latch, + right, + self.animation.motion_intent("RightSlide"), + self.animation.motion_intent("LeftSlide"), + &self.sliding, + ); + self.animation.emit_packet(encode(b"slide"), values[0]); + self.animation.emit_packet(encode(b"turn"), values[1]); + } + _ => {} + }, + Operation::ManualAttribute if phase == 1 => { + //82BB34D8 uses Manual830BE900; only a negative present value emits. + if let Some(value) = self.animation.motion_intent("Manual") + && value < 0.0 + { + self.animation.emit_packet(encode(b"balance"), value); + } + } + Operation::Deceleration => { + let Instance::SlideDeceleration(previous) = &mut self.instances[behavior] else { + return Err("PowerSlideDecel has an invalid instance".into()); + }; + if phase == 0 { + //Begin82BB3198; same zero in native instance constructor. + *previous = 0.0; + } else if phase == 1 { + let velocity = self + .fakie_physical + .ok_or("PowerSlideDecel requires actual Motion80 velocity")? + .deck_velocity; + let ground = self + .native_physical + .ok_or("PowerSlideDecel requires actual Reckoning Ground frame")? + .board_reckoning; + let value = + motion_sliding::deceleration(previous, velocity, ground, &self.sliding); + self.animation.set_attribute(SettableAttribute { + name: encode(b"decel"), + value, + normalized: false, + sequence_id: -1, + }); + } + } + Operation::Spin if phase == 0 => { + let value = motion_sliding::spin(self.slide_direction()?, &self.sliding); + self.animation.set_attribute(SettableAttribute { + name: encode(b"slidespin"), + value, + normalized: false, + sequence_id: -1, + }); + } + Operation::Candidate if phase != 1 => { + self.slide_latch.set_candidate_enabled(phase == 0); + } + Operation::IsPowerSliding if phase != 1 => { + //Begin82BACD20/End82BACD70 -> specific setter8258F910. + self.is_power_sliding = phase == 0; + } + _ => {} + } + Ok(()) + } + + fn slide_direction(&self) -> Result { + let velocity = self + .fakie_physical + .ok_or("Slide direction requires actual Motion80 velocity")? + .deck_velocity; + let flipped = self + .physical + .and_then(|p| p.foot_frame) + .ok_or("Slide direction requires actual Motion273 orientation flag")? + .skateboard_flipped; + let z = self + .native_physical + .ok_or("Slide direction requires actual Reckoning Ground Z")? + .board_reckoning_z; + Ok(motion_sliding::direction(velocity, z, flipped)) + } +} diff --git a/crates/skate-game/src/graph_host/motion_spin.rs b/crates/skate-game/src/graph_host/motion_spin.rs new file mode 100644 index 00000000..d0bb935a --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_spin.rs @@ -0,0 +1,297 @@ +//! BodySpin82BAB640, including its native five-state channel controller. +use super::motion_animation::MotionAnimation; +use skate_core::{ + animation::{channel_playback::ChannelSettings, playback_parameters::ParameterInputs}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +const FRONT: &str = "IA_BODYSPIN_OLLIE_FS_0_N"; +const BACK: &str = "IA_BODYSPIN_OLLIE_BS_0_N"; + +pub struct Settings { + map: PointGraph<8>, + blend_out: f32, + blend_in: f32, + maximum_acceleration: f32, + maximum_delta: f32, + final_height: f32, + height_velocity: f32, + on_deck_height: f32, + override_x: f32, + override_velocity_y: f32, + landing_distance: PointGraph<8>, +} +impl Settings { + pub fn load(data: &Collections) -> Result { + //Global304 writer8289F9C0..F9EC:DCBADE97CA665643=body_spin. + let float = |name| data.float("anim_motion", "body_spin", name); + let w = data.words::<16>("anim_motion", "body_spin", "spin_map")?; + let d = data.words::<16>("animation", "default", "LandingDistanceScalarVsNormalY")?; + Ok(Self { + map: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[i])), + y: std::array::from_fn(|i| f32::from_bits(w[8 + i])), + }, + blend_out: float("spin_influence_blendout")?, + blend_in: float("spin_influence_blendin")?, + maximum_acceleration: float("spin_clamp_influence_deltadelta")?, + maximum_delta: float("spin_clamp_influence_delta")?, + //Global336=anim_motion/inair_disttocog;328=body_tilt. + final_height: data.float( + "anim_motion", + "inair_disttocog", + "preland_final_disttocom", + )?, + height_velocity: data.float( + "anim_motion", + "inair_disttocog", + "preland_disttocom_vel", + )?, + on_deck_height: data.float( + "anim_motion", + "inair_disttocog", + "landingondeck_disttocog", + )?, + override_x: data.float("anim_motion", "body_tilt", "overide_preland_x")?, + override_velocity_y: data.float("anim_motion", "body_tilt", "overide_preland_velY")?, + landing_distance: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(d[i])), + y: std::array::from_fn(|i| f32::from_bits(d[8 + i])), + }, + }) + } +} + +///Actual publication fields used only by the native airborne prelanding query. +#[derive(Clone, Copy, Debug)] +pub struct PrelandingPhysical { + pub air_444: bool, + pub air_normal_144_y: f32, + pub animation_16_x: f32, + pub com_velocity_y: f32, + pub offboard_316: bool, + pub offboard_319: bool, + pub offboard_time_32: f32, + pub air_437: bool, + pub air_normal_36: f32, + pub air_remaining_184: f32, + pub animation_height_72: f32, +} +impl PrelandingPhysical { + ///825954C0 ->82595670, original source guards and strict comparisons. + pub(crate) fn override_prelanding(self, s: &Settings) -> bool { + self.air_444 + || (self.air_normal_144_y <= 0.5 + && self.animation_16_x.abs() > s.override_x + && self.com_velocity_y > s.override_velocity_y) + } + pub(crate) fn near_landing(self, s: &Settings) -> bool { + if self.override_prelanding(s) || self.com_velocity_y >= 0.0 { + return false; + } + if self.offboard_316 { + return self.offboard_time_32 < (s.final_height - s.on_deck_height) / s.height_velocity; + } + if self.air_437 { + let mut time = (s.final_height - self.animation_height_72) / s.height_velocity; + time *= s + .landing_distance + .evaluate(clamp(self.air_normal_36, 0.0, 1.0)); + return self.air_remaining_184 < time; + } + self.offboard_319 && self.offboard_time_32 < f32::from_bits(0x3f99999a) + } +} + +///82BABE20 initializes every represented scalar and the state enum to zero. +#[derive(Default)] +pub struct State { + mode: u32, + previous_spin: f32, + back: f32, + back_delta: f32, + front: f32, + front_delta: f32, +} +impl State { + #[allow(clippy::too_many_arguments)] + pub fn update( + &mut self, + animation: &mut MotionAnimation, + category: u32, + physical_state: u32, + doing_trick: bool, + busy_hands: [u32; 2], + mirrored: bool, + prelanding: Option, + settings: &Settings, + ) -> Result<(), String> { + let biped_air = physical_state == 503; + let air = category == 2 || biped_air; + let active = air || doing_trick; + let busy = busy_hands[0] != 0 || busy_hands[1] != 0; + let mut spin = animation.motion_intent("BodySpin").unwrap_or(0.0); + if mirrored { + spin = -spin; + } + let mapped = settings.map.evaluate(spin.abs()); + let influence = if spin >= 0.0 { mapped } else { -mapped }; + let near = if air { + prelanding + .ok_or("BodySpin airborne branch requires actual prelanding physical outputs")? + .near_landing(settings) + } else { + false + }; + let mut mode = self.mode; + match mode { + 0 => { + if category == 3 { + mode = 4; + } + if active { + mode = if busy { 4 } else { 1 }; + } + } + 1 => { + if !active { + mode = 0; + } + if busy { + mode = 4; + } + if spin.abs() > f32::from_bits(0x3e99999a) { + mode = 2; + } + } + 2 => { + if !active { + mode = 0; + } + if busy { + mode = 4; + } + if air { + mode = 3; + } + } + 3 => { + if busy || (near && biped_air) { + mode = 4; + } + if !air { + mode = 0; + } + } + 4 => { + if !active && category == 1 { + mode = 0; + } + } + _ => {} + } + match mode { + 0 | 4 => { + for name in [FRONT, BACK] { + animation + .channels + .end_with(name, f32::from_bits(0x3e4ccccd), false); + } + } + 1 => { + self.front = 0.0; + self.front_delta = 0.0; + self.back = 0.0; + self.back_delta = 0.0; + } + 2 | 3 => { + if mode == 2 { + let channel = ChannelSettings { + priority: 0, + keep_alive: false, + mirrored: false, + speed: 1.0, + blend_in: f32::from_bits(0x3e4ccccd), + hold_during_blend_in: false, + blend_out: f32::from_bits(0x3e99999a), + hold_during_blend_out: false, + use_attributes: false, + }; + if spin > f32::from_bits(0x3e99999a) { + if !animation.channels.has(FRONT) && !near { + animation.new_channel(FRONT, FRONT, channel)?; + } + animation + .channels + .end_with(BACK, f32::from_bits(0x3e19999a), false); + } + if spin < f32::from_bits(0xbe99999a) { + if !animation.channels.has(BACK) && !near { + animation.new_channel(BACK, BACK, channel)?; + } + animation + .channels + .end_with(FRONT, f32::from_bits(0x3e19999a), false); + } + } + if animation.channels.has(FRONT) { + let target = clamp(influence, 0.0, 1.0); + let blend = if target < self.front { + settings.blend_out + } else { + settings.blend_in + }; + update_influence( + &mut self.front, + &mut self.front_delta, + target, + blend, + settings, + ); + animation.channels.influence(FRONT, self.front); + } + if animation.channels.has(BACK) { + let target = -clamp(influence, -1.0, 0.0); + //The original backside branch intentionally reverses this comparison. + let blend = if target > self.back { + settings.blend_out + } else { + settings.blend_in + }; + update_influence( + &mut self.back, + &mut self.back_delta, + target, + blend, + settings, + ); + animation.channels.influence(BACK, self.back); + } + } + _ => {} + } + self.mode = mode; + self.previous_spin = spin; + Ok(()) + } +} +fn update_influence(value: &mut f32, delta: &mut f32, target: f32, blend: f32, s: &Settings) { + //82BABC68..BD9C: first clamp acceleration, then change, then [0,1]. + let desired = blend.mul_add(target, (1.0 - blend) * *value) - *value; + *delta = clamp( + desired, + *delta - s.maximum_acceleration, + *delta + s.maximum_acceleration, + ); + let next = clamp( + *value + *delta, + *value - s.maximum_delta, + *value + s.maximum_delta, + ); + *value = clamp(next, 0.0, 1.0); +} +fn clamp(v: f32, low: f32, high: f32) -> f32 { + let v = if low - v >= 0.0 { low } else { v }; + if high - v >= 0.0 { v } else { high } +} diff --git a/crates/skate-game/src/graph_host/motion_wipeout.rs b/crates/skate-game/src/graph_host/motion_wipeout.rs new file mode 100644 index 00000000..9138c3d7 --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_wipeout.rs @@ -0,0 +1,124 @@ +//! Stock Wipeout graph leaves, using completed physical output. +use skate_core::graph::conditions::NumericCondition; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Operation { + Wipeout, + EnableGestures, +} + +/// Live MG CA4 bits20/19 and gesture outputs CEC/CF0. +#[derive(Default)] +pub struct Controls { + pub seed_from_air_tweak: bool, + pub gestures_enabled: bool, + pub gesture: [f32; 2], +} + +/// Wipeout instance82BC0690: counter8, angles12/16, velocity24, axes28/32, latch36. +#[derive(Default)] +pub struct State { + pub(super) ticks: u32, + pub(super) lean: f32, + pub(super) twist: f32, + pub(super) twist_velocity: f32, + pub(super) gesture: [f32; 2], + pub(super) released: bool, +} + +pub struct Settings { + pub(super) threshold: f32, + pub(super) twist_velocity: f32, + pub(super) twist_acceleration: f32, + pub(super) lean_velocity: f32, + pub(super) twist_blend: f32, + pub(super) lean_blend: f32, + pub(super) gesture_y_blend: f32, + pub(super) gesture_x_blend: f32, +} +impl Settings { + pub fn load(data: &skate_data::collections::Collections) -> Result { + //82BC0358/051C and stock anim_wipeout schema offsets0..28. + let f = |name| data.float("anim_wipeout", "default", name); + Ok(Self { + threshold: f("controlled_drives_thresh")?, + twist_velocity: f("clamp_twist_vel")?, + twist_acceleration: f("clamp_twist_acc")?, + lean_velocity: f("clamp_lean_vel")?, + twist_blend: f("blend_twist")?, + lean_blend: f("blend_lean")?, + gesture_y_blend: f("blend_gesture_y")?, + gesture_x_blend: f("blend_gesture_x")?, + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Physical { + pub over_599: bool, + pub collision_time_144: f32, + pub no_support_time_548: f32, + pub profile_148: u32, + pub below_surface_82: bool, + /// Skeleton output+80 lane Y; required only while below the surface. + pub orientation_y: Option, + pub hips_right_angle_496: f32, + pub hips_up_angle_500: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Condition { + Done, + TimeToLand(NumericCondition), + TimeSinceContact(NumericCondition), + GestureType(u32), + InWater(u32), +} +impl Condition { + pub fn parse(a: &Attributes<'_>) -> Result, String> { + Ok(Some(match a.text("name").unwrap_or("") { + "IsDoneWipingOut" => Self::Done, + "WipeoutTimeToLand" => Self::TimeToLand(super::condition_nodes::numeric(a)), + "WipeoutTimeSinceContact" => Self::TimeSinceContact(super::condition_nodes::numeric(a)), + //82BA82C0: exact authored names; invalid strings leave native storage undefined. + "GestureType" => Self::GestureType(match a.text("gesture") { + Some("Freefall") => 0, + Some("CannonBall") => 1, + Some("JudoKick") => 2, + Some("SwanDive") => 3, + Some("Torpedo") => 4, + other => return Err(format!("Invalid Wipeout gesture type {other:?}")), + }), + //82BCAAF8: unrecognized orientation has no native initialized value. + "IsInWater" => Self::InWater(match a.text("orientation") { + Some("onback") => 0, + Some("onfront") => 1, + Some("either") => 2, + other => return Err(format!("Invalid water orientation {other:?}")), + }), + _ => return Ok(None), + })) + } + + pub fn evaluate(self, physical: Option) -> Result { + let p = physical.ok_or("Wipeout condition requires completed physical output")?; + Ok(match self { + Self::Done => p.over_599, //82BC0AB8: skeleton599. + Self::TimeToLand(n) => n.matches(p.collision_time_144), //82BC0B28. + Self::TimeSinceContact(n) => n.matches(p.no_support_time_548), //82BC0BB0. + Self::GestureType(kind) => p.profile_148 == kind, //82BA8468. + Self::InWater(orientation) => { + //82BBBE90 returns before the vector read when not submerged. + if !p.below_surface_82 { + false + } else { + let y = p + .orientation_y + .ok_or("IsInWater requires skeleton output80.Y")?; + u32::from(!(y > 0.0)) == orientation || orientation == 2 + } + } + }) + } +} diff --git a/crates/skate-game/src/graph_host/motion_wipeout_execute.rs b/crates/skate-game/src/graph_host/motion_wipeout_execute.rs new file mode 100644 index 00000000..623c79ca --- /dev/null +++ b/crates/skate-game/src/graph_host/motion_wipeout_execute.rs @@ -0,0 +1,194 @@ +//! Original82BBFF88/82BC01C0; graph ownership is ours, filter order is stock. +use super::super::motion_wipeout::{Operation, Settings}; +use super::*; + +fn bounded(value: f32, limit: f32) -> f32 { + //Original fsel comparisons, preserving unordered and signed-zero behavior. + let value = if -limit - value >= 0.0 { -limit } else { value }; + if limit - value >= 0.0 { value } else { limit } +} +fn positive_turn(value: f32) -> f32 { + if value >= 0.0 { + value + } else { + value + f32::from_bits(0x40C9_0FDB) + } +} +impl MotionHost { + pub(super) fn execute_wipeout( + &mut self, + behavior: BehaviorId, + operation: Operation, + phase: u8, + ) -> Result<(), String> { + if operation == Operation::EnableGestures { + //82BC07A8/07F8, Update82B61BB8 is empty. + if phase != 1 { + self.wipeout_controls.gestures_enabled = phase == 0; + } + return Ok(()); + } + if phase > 1 { + return Ok(()); + } //Original End82B61BB8. + let Instance::Wipeout(state) = self + .instances + .get_mut(behavior) + .ok_or("Unallocated Wipeout behavior")? + else { + return Err("Wipeout operation/instance mismatch".into()); + }; + let physical = self + .wipeout_physical + .ok_or("Wipeout requires completed physical outputs")?; + if phase == 0 { + let mirrored = self + .animation + .skater_animation_flags + .ok_or("Wipeout requires animation stance")? + & 0x4000_0000 + != 0; + state.ticks = 0; + state.lean = physical.hips_right_angle_496; + let twist = if mirrored { + -physical.hips_up_angle_500 + } else { + physical.hips_up_angle_500 + }; + state.twist = positive_turn(twist); + state.twist_velocity = 0.0; + state.released = self.wipeout_controls.seed_from_air_tweak; + state.gesture = if state.released { + self.wipeout_controls.seed_from_air_tweak = false; + [ + *self.action_intents.get("OB_AirBodyTweakX").unwrap_or(&0.0), + *self.action_intents.get("OB_AirBodyTweakY").unwrap_or(&0.0), + ] + } else { + [0.0; 2] + }; + return Ok(()); + } + if self.action_intents.contains_key("WipeOutRequest") || (state.ticks as i32) > 80 { + state.released = true; + } + if self.wipeout_controls.gestures_enabled { + let mirrored = self + .animation + .skater_animation_flags + .ok_or("Wipeout requires animation stance")? + & 0x4000_0000 + != 0; + let Settings { + threshold, + twist_velocity, + twist_acceleration, + lean_velocity, + twist_blend, + lean_blend, + gesture_y_blend, + gesture_x_blend, + } = self.wipeout_settings; + let delta = ((1.0 - lean_blend) * state.lean + + physical.hips_right_angle_496 * lean_blend) + - state.lean; + state.lean += bounded(delta, lean_velocity); + set(&mut self.animation, b"Lean", state.lean); + let twist = positive_turn(if mirrored { + -physical.hips_up_angle_500 + } else { + physical.hips_up_angle_500 + }); + let mut delta = twist - state.twist; + let pi = f32::from_bits(0x4049_0FDB); + let turn = f32::from_bits(0x40C9_0FDB); + if delta > pi { + delta -= turn; + } else if delta < -pi { + delta += turn; + } + let delta = ((state.twist + delta) * twist_blend + (1.0 - twist_blend) * state.twist) + - state.twist; + let mut velocity = bounded(delta, twist_velocity); + if state.ticks >= 2 { + velocity = bounded(velocity - state.twist_velocity, twist_acceleration) + + state.twist_velocity; + } + let mut twist = state.twist + velocity; + if twist > turn { + twist -= turn; + } + state.twist_velocity = velocity; + state.twist = positive_turn(twist); + set(&mut self.animation, b"Twist", state.twist); + let x = self.action_intents.get("WipeoutGestureX").copied(); + let y = self.action_intents.get("WipeoutGestureY").copied(); + let xv = x.unwrap_or(0.0); + let yv = y.unwrap_or(0.0); + if !state.released && x.is_some() && y.is_some() && xv.abs() < 0.1 && yv.abs() < 0.1 { + state.released = true; + } + if state.released { + state.gesture[0] = + (1.0 - gesture_x_blend) * state.gesture[0] + xv * gesture_x_blend; + state.gesture[1] = + (1.0 - gesture_y_blend) * state.gesture[1] + yv * gesture_y_blend; + } + if state.gesture[1] * state.gesture[1] + state.gesture[0] * state.gesture[0] + > threshold * threshold + { + self.animation + .emit_packet(encode(b"ControlledWipeout"), 1.0); + } + self.wipeout_controls.gesture = state.gesture; //MGv2528258FB50. + } + set(&mut self.animation, b"WipeoutGestureX", state.gesture[0]); + set(&mut self.animation, b"WipeoutGestureY", state.gesture[1]); + state.ticks = state.ticks.wrapping_add(1); + Ok(()) + } +} + +fn set(animation: &mut MotionAnimation, name: &[u8], value: f32) { + animation.set_attribute(SettableAttribute { + name: encode(name), + value, + normalized: false, + sequence_id: -1, + }); +} + +impl MotionHost { + pub(super) fn execute_twist_lean( + &mut self, + behavior: BehaviorId, + operation: super::super::motion_twist_lean::Operation, + phase: u8, + ) -> Result<(), String> { + let Instance::TwistLean(state) = self + .instances + .get_mut(behavior) + .ok_or("Unallocated MatchTwistAndLean behavior")? + else { + return Err("MatchTwistAndLean operation/instance mismatch".into()); + }; + let observation = if phase == 0 || (phase == 1 && operation.always) { + let p = self + .wipeout_physical + .ok_or("MatchTwistAndLean requires canonical physical angles")?; + let flags = self + .animation + .skater_animation_flags + .ok_or("MatchTwistAndLean requires live animation stance")?; + Some(skate_core::player::offboard::twist_lean::Observation { + hips_right_angle: p.hips_right_angle_496, + hips_up_angle: p.hips_up_angle_500, + mirrored: flags & 0x4000_0000 != 0, + }) + } else { + None + }; + operation.execute(state, observation, Some(&mut self.animation), phase); + Ok(()) + } +} diff --git a/crates/skate-game/src/graph_host/pumping_settings.rs b/crates/skate-game/src/graph_host/pumping_settings.rs new file mode 100644 index 00000000..42916218 --- /dev/null +++ b/crates/skate-game/src/graph_host/pumping_settings.rs @@ -0,0 +1,8 @@ +//! Stock anim_motion/anim_pump; TU3 global308 layout offsets1152/1708..1784. +use skate_core::{animation::pumping_channel::Settings,point_graph::PointGraph}; +use skate_data::collections::Collections; +pub fn load(data:&Collections)->Result { + let f=|name|data.float("anim_motion","anim_pump",name); + let w=data.words::<16>("anim_motion","anim_pump","pump_amplify")?; + Ok(Settings {amplify:PointGraph {x:std::array::from_fn(|i|f32::from_bits(w[i])),y:std::array::from_fn(|i|f32::from_bits(w[8+i]))},input_blend:f("pump_prop_blend")?,maximum_physics_pump:f("max_phys_pump")?,new_pump_threshold:f("new_pump_thresh")?,blend_in:f("pump_blendin")?,blend_out:f("pump_blendout")?}) +} diff --git a/crates/skate-game/src/graph_host/pushing.rs b/crates/skate-game/src/graph_host/pushing.rs new file mode 100644 index 00000000..c8ee8425 --- /dev/null +++ b/crates/skate-game/src/graph_host/pushing.rs @@ -0,0 +1,234 @@ +//! MotionGraph pushing operations. The stock graph owns activation and order; +//! these handlers only mutate native push properties and animation attributes. +use super::pushing_settings::PushingSettings; +use skate_core::graph::intents::IntentMap; +use skate_core::riding::push_behaviors::{ + FirstPushStrength, PushCycle, PushFootFrame, PushIntents, PushState, +}; +use skate_data::state_graph::attributes::Attributes; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PushOperation { + InitPush, + ComputeFirstPushStrength, + ComputeTargetCoefsFromSpeedAndStrength { regular_attributes: bool }, + ComputeRepushDeadline { regular_attributes: bool }, + SetPushCoefs { on_first_update_only: bool }, + PushCycle { new_push_name: String }, + PushOut { is_right_foot: bool }, +} +impl PushOperation { + /// Defaults verified in TU3 factories82BC7ED0/7F98/80B8/8180/8248. + /// None means this is another operation, for the enclosing host to resolve. + pub fn from_attributes(attributes: &Attributes<'_>) -> Option { + Some(match attributes.text("name")? { + "InitPush" => Self::InitPush, + "ComputeFirstPushStrength" => Self::ComputeFirstPushStrength, + "ComputeTargetCoefsFromSpeedAndStrength" => { + Self::ComputeTargetCoefsFromSpeedAndStrength { + regular_attributes: attributes.boolean_byte("regularAttributes", 1) != 0, + } + } + "ComputeRepushDeadline" => Self::ComputeRepushDeadline { + regular_attributes: attributes.boolean_byte("regularAttributes", 1) != 0, + }, + "SetPushCoefs" => Self::SetPushCoefs { + on_first_update_only: attributes.boolean_byte("onFirstUpdateOnly", 0) != 0, + }, + "PushCycle" => Self::PushCycle { + new_push_name: attributes + .text("newPushName") + .unwrap_or("LeftPush") + .to_owned(), + }, + "PushOut" => Self::PushOut { + is_right_foot: attributes.boolean_byte("isRightFoot", 1) != 0, + }, + _ => return None, + }) + } +} + +/// The scalar SettableAtt values used here are untimed (begin/end=-1), +/// kind0/status6, as published by82D19100. `normalized` is the control flag +/// consumed by the animation tree, not a request to clamp the stored scalar. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SettableAttribute { + pub name: &'static str, + pub value: f32, + pub normalized: bool, + pub sequence_id: i32, +} + +pub trait PushAnimationSink { + /// Apply in call order to the active animation controller. Upsert the first + /// matching (name,sequence_id), or append, following TU382D19100. + fn set_attribute(&mut self, attribute: SettableAttribute); +} +impl PushAnimationSink for Vec { + fn set_attribute(&mut self, attribute: SettableAttribute) { + if let Some(existing) = self.iter_mut().find(|existing| { + existing.name == attribute.name && existing.sequence_id == attribute.sequence_id + }) { + *existing = attribute; + } else { + self.push(attribute); + } + } +} + +pub struct PushContext<'a> { + pub settings: &'a PushingSettings, + pub shared: &'a mut PushState, + /// The current MG list: Pushing carries accumulated AG-held seconds. + pub motion_intents: &'a IntentMap, + pub forward_speed: f32, + pub delta_seconds: f32, + pub time_since_teleport: f32, + /// ISkaterAnim::GetIsSwitch (TU382B97128), distinct from mirror/natural stance. + pub is_switch: bool, + /// None only for an actor without a physical interface, as in82595B28. + /// A physical actor must supply its actual skeleton/reckoning outputs. + pub foot_frame: Option, +} +impl PushContext<'_> { + fn intents(&self, configured_name: &str) -> PushIntents { + PushIntents { + pushing: self.motion_intents.get("Pushing").copied(), + new_push: self.motion_intents.contains_key("NewPush"), + configured_foot: self.motion_intents.contains_key(configured_name), + } + } +} + +/// Allocate one per graph activation. Its first-push/cycle data must not be +/// shared between separate active nodes, nor retained across a fresh activation. +#[derive(Clone, Debug)] +pub struct PushInstance { + pub operation: PushOperation, + first_strength: Option, + cycle: Option, + first_update: bool, +} +impl PushInstance { + pub fn new(operation: PushOperation) -> Self { + Self { + operation, + first_strength: None, + cycle: None, + first_update: false, + } + } + pub fn begin(&mut self, context: &mut PushContext<'_>, sink: &mut impl PushAnimationSink) { + match &self.operation { + PushOperation::InitPush => context.shared.initialize_push(), //82BAD5B8 + PushOperation::ComputeFirstPushStrength => { + self.first_strength = Some(FirstPushStrength::begin( + context.time_since_teleport, + context.settings.teleport_window, + )); //82BAD6E8 + } + PushOperation::SetPushCoefs { .. } => self.first_update = true, //82BACEF0 + PushOperation::PushCycle { new_push_name } => { + let intents = context.intents(new_push_name); + self.cycle = Some(PushCycle::begin( + context.shared, + &context.settings.curves, + intents, + )); + } + PushOperation::PushOut { is_right_foot } => { + //82BAE210 ->82595B28; output writes are begin-only and ordered X,Y. + let [x, y] = context + .foot_frame + .map_or([0.0; 2], |frame| frame.out_distance(*is_right_foot)); + publish(sink, "OutDistanceX", x, false); + publish(sink, "OutDistanceY", y, false); + } + _ => {} + } + } + pub fn update( + &mut self, + context: &mut PushContext<'_>, + sink: &mut impl PushAnimationSink, + ) -> Result<(), String> { + match &self.operation { + PushOperation::ComputeFirstPushStrength => { + self.first_strength + .as_mut() + .ok_or("ComputeFirstPushStrength updated before Begin")? + .update( + context.shared, + &context.settings.curves, + context.motion_intents.get("Pushing").copied(), + context.forward_speed, + context.delta_seconds, + ); + } + PushOperation::ComputeTargetCoefsFromSpeedAndStrength { regular_attributes } => { + context.shared.target = context + .settings + .attributes(*regular_attributes, context.is_switch) + .target(context.forward_speed, context.shared.current_push_dv); //82BAD258/82BAD260 + } + PushOperation::SetPushCoefs { + on_first_update_only, + } => { + if !on_first_update_only || self.first_update { + context.shared.current = context.settings.curves.update_blend( + context.shared.current, + context.shared.target, + context.forward_speed, + context.delta_seconds, + ); //82BACF50 + let values = context.shared.current; + publish(sink, "HStr_Vel_B", values.hstr_vel_b, true); + publish(sink, "LStr_Vel_B", values.lstr_vel_b, true); + publish(sink, "Vel_E", values.vel_e, true); + } + self.first_update = false; //82BACF00 + } + PushOperation::PushCycle { new_push_name } => { + let intents = context.intents(new_push_name); + self.cycle + .as_mut() + .ok_or("PushCycle updated before Begin")? + .update( + context.shared, + &context.settings.curves, + intents, + context.forward_speed, + context.settings.maximum_holding_acceleration, + ); + } + _ => {} + } + Ok(()) + } + pub fn end(&mut self, context: &mut PushContext<'_>) { + if let PushOperation::ComputeRepushDeadline { regular_attributes } = &self.operation { + context.shared.out_factor = context + .settings + .attributes(*regular_attributes, context.is_switch) + .out_factor( + context.forward_speed, + context.shared.current_push_dv, + context.settings.out_speed_weight, + context.settings.maximum_out_factor, + ); //82BADA90 + } + } +} +fn publish(sink: &mut impl PushAnimationSink, name: &'static str, value: f32, normalized: bool) { + sink.set_attribute(SettableAttribute { + name, + value, + normalized, + sequence_id: -1, + }); +} + +#[cfg(test)] +#[path = "tests/pushing.rs"] +mod tests; diff --git a/crates/skate-game/src/graph_host/pushing_settings.rs b/crates/skate-game/src/graph_host/pushing_settings.rs new file mode 100644 index 00000000..126c1df7 --- /dev/null +++ b/crates/skate-game/src/graph_host/pushing_settings.rs @@ -0,0 +1,188 @@ +//! Stock pushing settings and authored clip metadata (TU382B953E8..82B9584C). +use skate_core::{ + animation::{clip_clock::ClipClock, skeleton_input::name::encode}, + point_graph::PointGraph, + riding::{ + push_animation::PushAnimationCurves, + push_behaviors::{PushAttributes, PushClipMetrics}, + }, +}; +use skate_data::{animation_metadata::AnimationMetadata, collections::Collections}; +use std::path::Path; + +/// A tree's ordered attributes. The value is its first payload lane, including +/// non-scalar types: native82B95620 does not filter the attribute type. +#[derive(Clone, Debug)] +pub struct PushTreeAttribute { + pub name: String, + pub first_payload: f32, +} +#[derive(Clone, Debug)] +pub struct PushTreeMetadata { + pub length: f32, + pub attributes: Vec, +} +pub trait PushTreeSource { + /// Obtain the actual animation tree's length and GetAttributes(mask=15) + /// result in stored order. Missing assets must return an error. + fn metadata( + &mut self, + tree_name: &str, + attribute_mask: u32, + ) -> Result; +} + +impl PushTreeSource for AnimationMetadata { + fn metadata( + &mut self, + tree_name: &str, + attribute_mask: u32, + ) -> Result { + let clip = self.clip(tree_name)?; + let frames = f32::from_bits(clip.frames_bits); + let fps = f32::from_bits(clip.fps_bits); + let base_speed = f32::from_bits(clip.base_speed_bits); + // Init827B8AB0 sets speed1/time0, then recomputes length with this + // specific multiply order (different from later SetSpeed). + let length = (frames - 1.0) / ((1.0 * base_speed) * fps); + let clock = ClipClock { + frames, + fps, + base_speed, + speed: 1.0, + length, + time: 0.0, + previous_time: 0.0, + loops_since_evaluation: 0, + looping: clip.flags_word & 0x1000_0000 != 0, + phase_controlled: clip.flags_word & 0x4000_0000 != 0, + }; + let mut attributes = Vec::new(); + // GetAttributes82D25E30 appends in stored order. Untimed records + // bypass both mask tests; timed records use GetAttributeStatus. + for attribute in &clip.attributes { + let begin = f32::from_bits(attribute.begin_bits); + if begin != -1.0 { + let status = + u32::from(clock.attribute_status(begin, f32::from_bits(attribute.end_bits))); + if attribute_mask & 0x1c & status == 0 || attribute_mask & 3 & status == 0 { + continue; + } + } + // Attribute::Init82D164F8 copies lane0 for types0/1/3. Type2 + // samples a curve; unknown types leave lane0 untouched. Neither + // can be replaced by the first serialized word as a fallback. + if !matches!(attribute.type_id, 0 | 1 | 3) { + return Err(format!( + "{tree_name}: attribute {} needs type{} evaluation", + attribute.name, attribute.type_id, + )); + } + attributes.push(PushTreeAttribute { + name: attribute.name.clone(), + first_payload: f32::from_bits(attribute.payload_words[0]), + }); + } + Ok(PushTreeMetadata { length, attributes }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct PushingSettings { + pub curves: PushAnimationCurves, + pub teleport_window: f32, + pub maximum_holding_acceleration: f32, + pub out_speed_weight: f32, + pub maximum_out_factor: f32, + pub regular: PushAttributes, + pub mongo: PushAttributes, +} +impl PushingSettings { + /// Standalone settings check; game startup shares its already loaded banks. + pub fn load_stock(data: &Collections, asset_root: &Path) -> Result { + let mut trees = + skate_data::animation_banks::AnimationBanks::load(asset_root)?.metadata()?; + Self::load(data, &mut trees) + } + pub fn load(data: &Collections, trees: &mut impl PushTreeSource) -> Result { + let float = |name| data.float("anim_motion", "pushing", name); + Ok(Self { + curves: PushAnimationCurves { + button_time_max: curve(data, "button_time_max")?, + button_time_to_dv: curve(data, "button_time_to_dv")?, + blend_speed_over_frames: curve(data, "blend_speed_over_frames")?, + blend_acc_over_frames: curve(data, "blend_acc_over_frames")?, + }, + // Relative to the anim_motion/pushing layout pointer:1704,1788,1840,1808. + teleport_window: float("pushing_usemaxpushfromteleporttime")?, + maximum_holding_acceleration: float("max_holding_acc")?, + out_speed_weight: float("dynamic_out_factor_speed_vs_acc")?, + maximum_out_factor: float("last_push_out_time")?, + regular: attributes( + trees, + [ + "R_PUSHLSP_HSTR_N_0_CYC1", + "R_PUSHHSP_HSTR_N_0_CYC1", + "R_PUSHLSP_LSTR_N_0_CYC1", + "R_PUSHHSP_LSTR_N_0_CYC1", + ], + )?, + mongo: attributes( + trees, + [ + "R_PUSHLSP_HSTR_MONGO_0_CYC1", + "R_PUSHHSP_HSTR_MONGO_0_CYC1", + "R_PUSHLSP_LSTR_MONGO_0_CYC1", + "R_PUSHHSP_LSTR_MONGO_0_CYC1", + ], + )?, + }) + } + /// TU382BAD3B4..3DC/82BADBB8..BE0; GetIsSwitch, not natural stance. + pub fn attributes(&self, regular_attributes: bool, is_switch: bool) -> &PushAttributes { + if regular_attributes != is_switch { + &self.regular + } else { + &self.mongo + } + } +} + +fn curve(data: &Collections, name: &str) -> Result, String> { + let words = data.words::<20>("anim_motion", "pushing", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[12 + i])), + }) +} +fn attributes(trees: &mut impl PushTreeSource, names: [&str; 4]) -> Result { + let mut metrics = Vec::with_capacity(4); + for name in names { + let tree = trees.metadata(name, 15)?; + let mut begin = None; + let mut end = None; + for attribute in tree.attributes { + // Native compares five-word FastStrings, whose encoding folds + // ASCII case. Decoded bank names are uppercase; XML need not be. + let key = encode(attribute.name.as_bytes()); + if key == encode(b"HStr_Vel_B") || key == encode(b"LStr_Vel_B") { + begin = Some(attribute.first_payload); + } else if key == encode(b"Vel_E") { + end = Some(attribute.first_payload); + } + } + // Native leaves missing outputs untouched. The host loader rejects + // incomplete metadata rather than supplying arbitrary initial values. + metrics.push(PushClipMetrics { + length: tree.length, + begin_velocity: begin + .ok_or_else(|| format!("{name}: missing begin-velocity attribute"))?, + end_velocity: end.ok_or_else(|| format!("{name}: missing Vel_E attribute"))?, + }); + } + Ok(PushAttributes::from_clips(metrics.try_into().unwrap())) +} + +#[cfg(test)] +#[path = "tests/pushing_metadata.rs"] +mod metadata_tests; diff --git a/crates/skate-game/src/graph_host/tests/motion_slide.rs b/crates/skate-game/src/graph_host/tests/motion_slide.rs new file mode 100644 index 00000000..7369cdb8 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_slide.rs @@ -0,0 +1,251 @@ +//! Stock bindings plus production Host lifecycle: a split latch breaks this test. +use super::*; +use crate::graph_host::{motion_conditions::MotionCondition, motion_sliding::Operation}; +use skate_core::graph::conditions::PhysicalStateInputs; + +fn host() -> MotionHost { + use crate::graph_runtime::CompiledGraph; + use skate_data::state_graph::{StateGraph, binding::Binding}; + let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap()); + let source = + StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")) + .unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let runtime = CompiledGraph::from_binding(&binding).unwrap(); + MotionHost::from_graph( + &LoadedGraph { + source, + binding, + runtime, + }, + &Collections::load(&root).unwrap(), + skate_data::animation_banks::AnimationBanks::load(&root) + .unwrap() + .metadata() + .unwrap(), + PlaybackContext { + is_switch: Some(false), + is_mirrored: Some(false), + board_available: Some(false), + pro_skater: encode(b""), + transition_override: None, + }, + ) + .unwrap() +} +fn behavior(host: &MotionHost, predicate: impl Fn(&MotionOperation) -> bool) -> usize { + host.remap + .behaviors + .iter() + .position(|&id| predicate(&host.operations[id])) + .expect("Required authored stock behavior is missing") +} +fn slide(host: &MotionHost, operation: Operation) -> usize { + behavior( + host, + |v| matches!(v, MotionOperation::Slide(other) if *other == operation), + ) +} +fn frame() -> Frame { + Frame { + dt: 0.1, + current: None, + last: None, + state_times: Vec::new(), + } +} +fn physical(host: &mut MotionHost) { + host.animation.skater_animation_flags = Some(0); + host.condition_inputs.physical_state = Some(PhysicalStateInputs { + category: 1, + grinding: false, + grind_name: String::new(), + }); + host.physical = Some(MotionPhysical { + turning: set_turning::Physical { + field_32: 0.0, + field_36: 0.0, + field_52: 0.0, + field_56: 0.0, + field_60: 0.0, + body_168: 2.0, + }, + stance: (false, false), + forward_speed: 2.0, + time_since_teleport: 1.0, + is_switch: false, + foot_frame: Some(PushFootFrame { + left_foot: [0.0; 4], + right_foot: [0.0; 4], + deck_position: [0.0; 4], + deck_y: [0.0, 1.0, 0.0, 0.0], + deck_z: [0.0, 0.0, 1.0, 0.0], + skateboard_flipped: false, + }), + }); + host.fakie_physical = Some(skate_core::animation::riding_fakie::Physical { + category: 1, + grind_state: 0, + doing_trick: false, + board_axis: [0.0, 0.0, 1.0, 0.0], + deck_velocity: [2.0, 0.0, 0.0, 0.0], + external_velocity: [0.0; 4], + ground_projected_speed: 2.0, + }); + host.native_physical = Some(crate::graph_host::motion_native::Physical { + centre_of_mass_velocity: [0.0; 4], + system_up: [0.0, 1.0, 0.0, 0.0], + board_reckoning_z: [0.0, 0.0, 1.0, 0.0], + board_reckoning: [ + [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, 0.0, 0.0, 1.0], + ], + }); +} +fn packet(host: &MotionHost, name: &[u8]) -> f32 { + host.animation + .motion_attributes + .iter() + .rev() + .find(|a| a.name == encode(name)) + .expect("Missing physical packet attribute") + .value +} + +#[test] +#[ignore = "requires the user's original stock graph, banks and collections"] +fn stock_slide_lifecycle_shares_turning_latch_and_captured_stance() { + let mut host = host(); + physical(&mut host); + let frame = frame(); + let power = slide(&host, Operation::Update); + let create = slide(&host, Operation::Create { right: true }); + let candidate = slide(&host, Operation::Candidate); + let active = slide(&host, Operation::IsPowerSliding); + let manual = slide(&host, Operation::ManualAttribute); + let turning = behavior(&host, |v| matches!(v, MotionOperation::SetTurning(_))); + let grab = host + .remap + .hooks + .iter() + .position(|&id| { + matches!( + host.operations[id], + MotionOperation::Hook(crate::graph_host::motion_hooks::MotionHook::GrabSlide { + right: true + }) + ) + }) + .expect("Authored GrabSlide hook missing"); + for fakie in [false, true] { + host.slide_latch.reset(); + host.animation.motion_intents.clear(); + //A pre-graph snapshot must not override the current animation flag. + host.physical.as_mut().unwrap().stance.0 = !fakie; + host.animation.skater_animation_flags = Some((fakie as u32) << 29); + for id in [power, candidate, active, create, turning, manual] { + host.allocate(id, &frame); + } + host.begin(candidate, [0; 6], &frame); + host.begin(turning, [0; 6], &frame); + host.update(power, [0; 6], &frame); // native ground-entry history capture + let (intent, start, right, value) = if fakie { + ("LeftSlide", "LeftSlideStart", false, -0.7) + } else { + ("RightSlide", "RightSlideStart", true, 0.7) + }; + host.animation.motion_intents.insert(intent.into(), value); + host.animation.motion_intents.insert(start.into(), 1.0); + host.update(power, [0; 6], &frame); + assert!(host.slide_latch.start(right)); + host.update(turning, [0; 6], &frame); + assert!(matches!(host.instances[turning], Instance::Turning(s) if s.mode == right as u32)); + assert_eq!(host.slide_latch.captured_fakie(), fakie); + host.hook(grab, &frame); + host.begin(create, [0; 6], &frame); + host.begin(active, [0; 6], &frame); + assert!(host.is_power_sliding); + host.update(create, [0; 6], &frame); + let expected_slide = if fakie { 1.0 - 0.7 } else { 0.7 }; + assert!((packet(&host, b"slide") - expected_slide).abs() < 1.0e-6); + assert_eq!(packet(&host, b"turn"), host.turning.override_turn); + // Current stance changes after entry; the captured side remains authoritative. + host.animation.skater_animation_flags = Some(((!fakie) as u32) << 29); + host.update(turning, [0; 6], &frame); + assert_eq!(host.slide_latch.captured_fakie(), fakie); + host.animation.motion_intents.insert("Manual".into(), -0.25); + host.update(manual, [0; 6], &frame); + assert_eq!(packet(&host, b"balance"), -0.25); + host.animation.motion_intents.remove(intent); + host.animation.motion_intents.remove(start); + for _ in 0..4 { + host.update(power, [0; 6], &frame); + } + assert!( + MotionCondition::ShouldLeaveSlide { right: true } + .evaluate(&host, &frame) + .unwrap() + ); + host.update(turning, [0; 6], &frame); + assert!(matches!(host.instances[turning], Instance::Turning(s) if s.mode == 2)); + host.end(active, [0; 6], &frame); + host.end(candidate, [0; 6], &frame); + assert!(!host.is_power_sliding && !host.slide_latch.candidate_enabled()); + assert!(host.errors.is_empty(), "{:?}", host.errors); + } +} + +#[test] +#[ignore = "requires the user's original stock graph, banks and collections"] +fn stock_slide_deceleration_uses_ground_frame_and_resets_on_entry() { + let mut host = host(); + physical(&mut host); + let frame = frame(); + let decel = slide(&host, Operation::Deceleration); + let spin = slide(&host, Operation::Spin); + host.allocate(decel, &frame); + host.begin(decel, [0; 6], &frame); + host.fakie_physical.as_mut().unwrap().deck_velocity = [0.0, 2.0, 0.0, 0.0]; + host.update(decel, [0; 6], &frame); + let value = |host: &MotionHost| match host.instances[decel] { + Instance::SlideDeceleration(value) => value, + _ => panic!("Wrong deceleration instance"), + }; + assert_eq!(value(&host), 0.0); // motion parallel to ground up is discarded + host.native_physical.as_mut().unwrap().board_reckoning = [ + [0.0, 1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [100.0, 200.0, 300.0, 1.0], + ]; + host.update(decel, [0; 6], &frame); + let first = value(&host); + assert!(first > 0.0 && first.is_finite()); // same motion is now across the ground + host.update(decel, [0; 6], &frame); + assert!(value(&host) > first); + host.end(decel, [0; 6], &frame); + host.begin(decel, [0; 6], &frame); + assert_eq!(value(&host), 0.0); + host.update(decel, [0; 6], &frame); + assert_eq!(value(&host), first); + host.begin(spin, [0; 6], &frame); + host.update(spin, [0; 6], &frame); + host.end(spin, [0; 6], &frame); + // Both are animation parameters, so neither creates a physical packet scalar. + assert!(host.animation.motion_attributes.is_empty()); + assert!(host.errors.is_empty(), "{:?}", host.errors); +} + +#[test] +fn slide_direction_preserves_native_unordered_threshold_branch() { + assert_eq!( + crate::graph_host::motion_sliding::direction( + [f32::NAN, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + false + ), + 1.0 + ); +} diff --git a/crates/skate-game/src/graph_host/tests/motion_stock.rs b/crates/skate-game/src/graph_host/tests/motion_stock.rs new file mode 100644 index 00000000..851d42d0 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/motion_stock.rs @@ -0,0 +1,158 @@ +use super::*; + +#[test] +#[ignore = "requires the user's private stock graph, collection and animation artifacts"] +fn stock_motion_host_loads_graph_settings_and_authored_riding_tree() { + use crate::graph_runtime::CompiledGraph; + use skate_data::state_graph::{StateGraph, binding::Binding}; + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"), + ); + let source = + StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")) + .unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let runtime = CompiledGraph::from_binding(&binding).unwrap(); + let graph = LoadedGraph { + source, + binding, + runtime, + }; + let collections = Collections::load(&root).unwrap(); + let host = MotionHost::from_graph( + &graph, + &collections, + skate_data::animation_banks::AnimationBanks::load(&root) + .unwrap() + .metadata() + .unwrap(), + PlaybackContext { + is_switch: None, + is_mirrored: None, + board_available: None, + pro_skater: encode(b""), + transition_override: None, + }, + ) + .unwrap(); + let tree = host.animation.build_tree("BTREE_RIDING").unwrap(); + fn count(tree: &skate_core::animation::playback_tree::PlaybackTree) -> (usize, usize) { + use skate_core::animation::playback_tree::PlaybackTree; + match tree { + PlaybackTree::Clip { .. } => (1, 0), + PlaybackTree::PhaseBlend(tree) => tree + .children + .iter() + .map(count) + .fold((0, 1), |(ac, ap), (bc, bp)| (ac + bc, ap + bp)), + PlaybackTree::Transition(_) => { + panic!("Authored bank unexpectedly contains a runtime transition") + } + PlaybackTree::BindPose { motion, .. } => count(motion), + PlaybackTree::SelectionSpace(tree) => tree + .candidates + .iter() + .map(|candidate| count(&candidate.tree)) + .fold((0, 0), |(ac, ap), (bc, bp)| (ac + bc, ap + bp)), + } + } + let (clips, phases) = count(&tree); + assert!(clips > 1 && phases > 1); + for name in [ + "B_MONGO_PUSH_CYC1", + "B_MONGO_PUSH_CYC2", + "B_MONGO_PUSH_INTO", + "B_PUSH_CYC1", + "B_PUSH_CYC2", + "B_PUSH_INTO", + "B_PUSH_MONGO_OUT", + "B_PUSH_OUT", + "B_SWITCH", + ] { + let tree = host.animation.build_tree(name).unwrap(); + let (clips, phases) = count(&tree); + assert!( + clips > 0 && phases > 0, + "Authored push root {name} lost its blend tree" + ); + } + println!( + "Production host: {} operations, real riding closure: {clips} leaf instances, {phases} PhaseBlend instances", + host.operations.len() + ); +} + +/// Enumerate actual factory results; this is a support inventory, not a parity test. +#[test] +#[ignore = "requires the user's private stock MotionGraph"] +fn stock_motion_factory_support_inventory() { + use skate_data::state_graph::{ + StateGraph, + binding::{Binding, Node, OperationKind}, + }; + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"), + ); + let source = + StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")) + .unwrap(); + let binding = Binding::from_graph(&source).unwrap(); + let instantiated = binding + .instantiate_operations(&source, &mut MotionFactory) + .unwrap(); + let mut rows = String::from( + "kind\tname\tfactory_status\toperation_id\truntime_id\tenabled\towner\tvariant\n", + ); + let mut counts = [0usize; 3]; + for (id, (operation, implementation)) in binding + .operations + .iter() + .zip(&instantiated.operations) + .enumerate() + { + let (kind, index) = match operation.kind { + OperationKind::Behavior => ("Behavior", 0), + OperationKind::Condition => ("Condition", 1), + OperationKind::Hook => ("Hook", 2), + }; + let status = if matches!(implementation, MotionOperation::Unsupported { .. }) { + "Unsupported" + } else { + "Registered" + }; + let owner = if let Node::State(state) = operation.parent { + let mut names = Vec::new(); + let mut cursor = Some(state); + while let Some(state) = cursor { + names.push(binding.states[state].name.clone()); + cursor = binding.states[state].parent; + } + names.reverse(); + names.join(".") + } else { + format!("{:?}", operation.parent) + }; + let clean = |s: String| s.replace(['\t', '\r', '\n'], " "); + rows.push_str(&format!( + "{kind}\t{}\t{status}\t{id}\t{}\t{}\t{}\t{}\n", + clean(operation.name.clone()), + counts[index], + operation.enabled, + clean(owner), + clean(format!("{implementation:?}")) + )); + counts[index] += 1; + } + assert_eq!(counts.iter().sum::(), binding.operations.len()); + let output = std::path::PathBuf::from( + std::env::var_os("SKATE3_MOTION_INVENTORY").expect("Set SKATE3_MOTION_INVENTORY"), + ); + std::fs::write(&output, rows).unwrap(); + println!( + "MotionGraph factory inventory: {} behaviors, {} conditions, {} hooks; {}", + counts[0], + counts[1], + counts[2], + output.display() + ); +} diff --git a/crates/skate-game/src/graph_host/tests/pushing.rs b/crates/skate-game/src/graph_host/tests/pushing.rs new file mode 100644 index 00000000..0125c657 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/pushing.rs @@ -0,0 +1,387 @@ +use super::*; +use crate::graph_host::pushing_settings::{PushTreeAttribute, PushTreeMetadata, PushTreeSource}; +use skate_core::{ + point_graph::PointGraph, + riding::{ + push_animation::{PushAnimationCurves, PushBlendParameters}, + push_behaviors::{PushAttributes, PushClipMetrics}, + }, +}; +use skate_data::{collections::Collections, state_graph::GraphAttribute}; +fn settings() -> PushingSettings { + let curve = PointGraph { + x: std::array::from_fn(|i| i as f32), + y: [1.0; 8], + }; + let regular = PushAttributes::from_clips([ + PushClipMetrics { + length: 1.0, + begin_velocity: 0.0, + end_velocity: 4.0, + }, + PushClipMetrics { + length: 2.0, + begin_velocity: 4.0, + end_velocity: 8.0, + }, + PushClipMetrics { + length: 1.0, + begin_velocity: 0.0, + end_velocity: 1.0, + }, + PushClipMetrics { + length: 2.0, + begin_velocity: 4.0, + end_velocity: 5.0, + }, + ]); + let mongo = PushAttributes::from_clips(regular.clips.map(|mut c| { + c.begin_velocity += 2.0; + c.end_velocity += 5.0; + c + })); + PushingSettings { + curves: PushAnimationCurves { + button_time_max: curve, + button_time_to_dv: curve, + blend_speed_over_frames: curve, + blend_acc_over_frames: curve, + }, + teleport_window: 0.5, + maximum_holding_acceleration: 4.0, + out_speed_weight: 0.4, + maximum_out_factor: 0.659, + regular, + mongo, + } +} +fn state() -> PushState { + let unset = PushBlendParameters { + hstr_vel_b: -1.0, + lstr_vel_b: -1.0, + vel_e: -1.0, + }; + PushState { + out_factor: 0.375, + current_push_dv: 2.5, + current: unset, + target: unset, + continue_push: false, + } +} +fn context<'a>( + settings: &'a PushingSettings, + state: &'a mut PushState, + intents: &'a IntentMap, +) -> PushContext<'a> { + PushContext { + settings, + shared: state, + motion_intents: intents, + forward_speed: 2.0, + delta_seconds: 1.0, + time_since_teleport: 1.0, + is_switch: false, + foot_frame: None, + } +} +#[test] +fn authored_defaults_match_tu3_factories() { + let attributes = |name: &str| { + vec![GraphAttribute { + name: "name".into(), + text: name.into(), + boolean_byte: 0, + float_bits: 0, + }] + }; + for (name, expected) in [ + ( + "PushCycle", + PushOperation::PushCycle { + new_push_name: "LeftPush".into(), + }, + ), + ( + "PushOut", + PushOperation::PushOut { + is_right_foot: true, + }, + ), + ( + "SetPushCoefs", + PushOperation::SetPushCoefs { + on_first_update_only: false, + }, + ), + ( + "ComputeRepushDeadline", + PushOperation::ComputeRepushDeadline { + regular_attributes: true, + }, + ), + ] { + assert_eq!( + PushOperation::from_attributes(&Attributes::new(&attributes(name))), + Some(expected) + ); + } +} +#[test] +fn first_update_only_blends_once_per_activation_and_publishes_in_source_order() { + let settings = settings(); + let mut state = state(); + let intents = IntentMap::new(); + let mut context = context(&settings, &mut state, &intents); + context.shared.target = PushBlendParameters { + hstr_vel_b: 0.25, + lstr_vel_b: 0.5, + vel_e: 0.75, + }; + let mut node = PushInstance::new(PushOperation::SetPushCoefs { + on_first_update_only: true, + }); + let mut sink = Vec::new(); + node.begin(&mut context, &mut sink); + assert!(sink.is_empty()); + node.update(&mut context, &mut sink).unwrap(); + assert_eq!( + sink.iter().map(|a| a.name).collect::>(), + ["HStr_Vel_B", "LStr_Vel_B", "Vel_E"] + ); + assert!(sink.iter().all(|a| a.normalized && a.sequence_id == -1)); + let first = sink.clone(); + context.shared.target.vel_e = 0.1; + node.update(&mut context, &mut sink).unwrap(); + assert_eq!(sink, first); + node.begin(&mut context, &mut sink); + node.update(&mut context, &mut sink).unwrap(); + assert_eq!(sink[2].value, 0.1); +} +#[test] +fn target_and_out_factor_select_attributes_using_switch_state() { + let settings = settings(); + let mut state = state(); + let intents = IntentMap::new(); + let mut context = context(&settings, &mut state, &intents); + let mut sink = Vec::new(); + let mut target = PushInstance::new(PushOperation::ComputeTargetCoefsFromSpeedAndStrength { + regular_attributes: true, + }); + target.update(&mut context, &mut sink).unwrap(); + assert_eq!(context.shared.target, settings.regular.target(2.0, 2.5)); + context.is_switch = true; + target.update(&mut context, &mut sink).unwrap(); + assert_eq!(context.shared.target, settings.mongo.target(2.0, 2.5)); + assert_ne!( + settings.regular.target(2.0, 2.5), + settings.mongo.target(2.0, 2.5) + ); + let mut out = PushInstance::new(PushOperation::ComputeRepushDeadline { + regular_attributes: true, + }); + out.begin(&mut context, &mut sink); + out.update(&mut context, &mut sink).unwrap(); + assert_eq!(context.shared.out_factor, 0.375); + out.end(&mut context); + assert_eq!( + context.shared.out_factor, + settings.mongo.out_factor(2.0, 2.5, 0.4, 0.659) + ); + assert!(sink.is_empty()); +} +#[test] +fn push_out_projects_actual_selected_foot_and_publishes_only_on_begin() { + let settings = settings(); + let mut state = state(); + let intents = IntentMap::new(); + let mut context = context(&settings, &mut state, &intents); + context.foot_frame = Some(PushFootFrame { + left_foot: [2.0, 3.0, 4.0, 0.0], + right_foot: [5.0, 6.0, 7.0, 0.0], + deck_position: [1.0, 1.0, 1.0, 0.0], + deck_y: [0.0, 1.0, 0.0, 0.0], + deck_z: [0.0, 0.0, 1.0, 0.0], + skateboard_flipped: true, + }); + let mut node = PushInstance::new(PushOperation::PushOut { + is_right_foot: true, + }); + let mut sink = Vec::new(); + node.begin(&mut context, &mut sink); + assert_eq!( + sink, + [ + SettableAttribute { + name: "OutDistanceX", + value: -6.0, + normalized: false, + sequence_id: -1 + }, + SettableAttribute { + name: "OutDistanceY", + value: 5.0, + normalized: false, + sequence_id: -1 + } + ] + ); + context.foot_frame = None; + node.update(&mut context, &mut sink).unwrap(); + node.end(&mut context); + assert_eq!(sink[0].value, -6.0); +} +#[test] +fn settable_sink_updates_first_matching_name_and_sequence_without_reordering() { + let mut sink = vec![ + SettableAttribute { + name: "Vel_E", + value: 0.1, + normalized: true, + sequence_id: 7, + }, + SettableAttribute { + name: "Vel_E", + value: 0.2, + normalized: true, + sequence_id: -1, + }, + SettableAttribute { + name: "Vel_E", + value: 0.3, + normalized: true, + sequence_id: -1, + }, + ]; + sink.set_attribute(SettableAttribute { + name: "Vel_E", + value: 3.0, + normalized: false, + sequence_id: -1, + }); + assert_eq!( + sink.iter().map(|a| a.value).collect::>(), + [0.1, 3.0, 0.3] + ); + assert!(!sink[1].normalized); +} +struct Trees { + requested: Vec, + missing_end: bool, +} +impl PushTreeSource for Trees { + fn metadata(&mut self, name: &str, mask: u32) -> Result { + assert_eq!(mask, 15); + self.requested.push(name.into()); + let mut attributes = vec![ + PushTreeAttribute { + name: "HStr_Vel_B".into(), + first_payload: 1.0, + }, + PushTreeAttribute { + name: "Unrelated".into(), + first_payload: 50.0, + }, + PushTreeAttribute { + name: "LStr_Vel_B".into(), + first_payload: 2.0, + }, + ]; + if !self.missing_end { + attributes.push(PushTreeAttribute { + name: "Vel_E".into(), + first_payload: 6.0, + }); + } + Ok(PushTreeMetadata { + length: 0.5, + attributes, + }) + } +} +fn collections() -> Collections { + let mut fields = Vec::new(); + for name in [ + "pushing_usemaxpushfromteleporttime", + "max_holding_acc", + "dynamic_out_factor_speed_vs_acc", + "last_push_out_time", + ] { + fields.push(format!( + r#""{name}":{{"type":"EA::Reflection::Float","data":"3F000000"}}"# + )); + } + let data = (0..20) + .map(|i| format!("{:08X}", (i as f32).to_bits())) + .collect::(); + for name in [ + "button_time_max", + "button_time_to_dv", + "blend_speed_over_frames", + "blend_acc_over_frames", + ] { + fields.push(format!(r#""{name}":{{"type":"Graph","data":"{data}"}}"#)); + } + let json = format!( + r#"{{"version":1,"collections":[{{"class":"anim_motion","key":"pushing","parent":"","fields":{{{}}},"source":"test","sha256":"test"}}]}}"#, + fields.join(",") + ); + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "skate-pushing-test-{}-{unique}", + std::process::id() + )); + let stock = root.join("private/stock"); + std::fs::create_dir_all(&stock).unwrap(); + let file = stock.join("skater-collections.json"); + std::fs::write(&file, json).unwrap(); + let result = Collections::load(&root); + std::fs::remove_file(file).unwrap(); + std::fs::remove_dir(stock).unwrap(); + std::fs::remove_dir(root.join("private")).unwrap(); + std::fs::remove_dir(root).unwrap(); + result.unwrap() +} +#[test] +fn metadata_loader_uses_all_eight_authored_names_and_last_matching_attribute() { + let mut trees = Trees { + requested: vec![], + missing_end: false, + }; + let settings = PushingSettings::load(&collections(), &mut trees).unwrap(); + assert_eq!( + trees.requested, + [ + "R_PUSHLSP_HSTR_N_0_CYC1", + "R_PUSHHSP_HSTR_N_0_CYC1", + "R_PUSHLSP_LSTR_N_0_CYC1", + "R_PUSHHSP_LSTR_N_0_CYC1", + "R_PUSHLSP_HSTR_MONGO_0_CYC1", + "R_PUSHHSP_HSTR_MONGO_0_CYC1", + "R_PUSHLSP_LSTR_MONGO_0_CYC1", + "R_PUSHHSP_LSTR_MONGO_0_CYC1" + ] + ); + assert_eq!(settings.regular.clips[0].begin_velocity, 2.0); + assert_eq!(settings.regular.clips[0].end_velocity, 6.0); + assert_eq!( + settings.curves.button_time_max.x, + [4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0] + ); + assert_eq!( + settings.curves.button_time_max.y, + [12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0] + ); +} +#[test] +fn missing_clip_metadata_is_reported_without_guessed_velocity() { + let mut trees = Trees { + requested: vec![], + missing_end: true, + }; + let error = PushingSettings::load(&collections(), &mut trees).unwrap_err(); + assert!(error.contains("R_PUSHLSP_HSTR_N_0_CYC1: missing Vel_E")); +} diff --git a/crates/skate-game/src/graph_host/tests/pushing_metadata.rs b/crates/skate-game/src/graph_host/tests/pushing_metadata.rs new file mode 100644 index 00000000..7a926af5 --- /dev/null +++ b/crates/skate-game/src/graph_host/tests/pushing_metadata.rs @@ -0,0 +1,23 @@ +use super::*; + +#[test] +#[ignore = "requires the user's converted private stock animation bank and collections"] +fn stock_push_metrics_load_through_the_runtime_data_path() { + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"), + ); + let collections = Collections::load(&root).unwrap(); + let settings = PushingSettings::load_stock(&collections, &root).unwrap(); + for group in [&settings.regular, &settings.mongo] { + for clip in group.clips { + assert!(clip.length.is_finite() && clip.length > 0.0); + assert!(clip.end_velocity > clip.begin_velocity); + } + } + // Distinct authored timings must survive data conversion and dispatch. + assert_ne!( + settings.regular.clips[0].length, + settings.mongo.clips[0].length + ); + println!("regular={:?}\nmongo={:?}", settings.regular, settings.mongo); +} diff --git a/crates/skate-game/src/graph_host/turning_settings.rs b/crates/skate-game/src/graph_host/turning_settings.rs new file mode 100644 index 00000000..67498d69 --- /dev/null +++ b/crates/skate-game/src/graph_host/turning_settings.rs @@ -0,0 +1,35 @@ +//! SetTurning82BB3830/TurnRemap82BB4248 stock layout binding. +//! anim_carving/default's schema is752 bytes; native global slot372 selects it. +use skate_core::{input::{set_turning::Settings, turn_remap::TurnRemap}, point_graph::PointGraph}; +use skate_data::collections::Collections; + +pub fn load(data: &Collections) -> Result { + let float = |name| data.float("anim_carving", "default", name); + Ok(Settings { + // Mode0 selects layout288/432/744; mode1 selects0/144/740. + remaps: [ + TurnRemap { magnitude: curve16(data, "quickMagMap")?, angle: curve16(data, "quickAngleMap")?, angle_offset: float("quickAngleOffset")? }, + TurnRemap { magnitude: curve16(data, "slowMagMap")?, angle: curve16(data, "slowAngleMap")?, angle_offset: float("slowAngleOffset")? }, + ], + speed_tuck: curve8(data, "speed_tuck")?, + blend: curve8(data, "blend_lean_in")?, + speed_threshold: float("speed_tuck_start")?, + maximum_delta: float("clamp_curr_lean")?, + // Global332 is anim_motion/power_slide, layout1680. + override_turn: data.float("anim_motion", "power_slide", "slide_turn_value")?, + }) +} +fn curve16(data: &Collections, name: &str) -> Result, String> { + let words = data.words::<36>("anim_carving", "default", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4+i])), + y: std::array::from_fn(|i| f32::from_bits(words[20+i])), + }) +} +fn curve8(data: &Collections, name: &str) -> Result, String> { + let words = data.words::<20>("anim_carving", "default", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4+i])), + y: std::array::from_fn(|i| f32::from_bits(words[12+i])), + }) +} diff --git a/crates/skate-game/src/graph_runtime.rs b/crates/skate-game/src/graph_runtime.rs new file mode 100644 index 00000000..82f6356f --- /dev/null +++ b/crates/skate-game/src/graph_runtime.rs @@ -0,0 +1,329 @@ +//! Lossless bridge from the stock on-disk graph binding to the recovered TU3 +//! dynamic-controller runtime. +//! +//! Concrete operations are intentionally retained by source operation ID. The +//! registration layer must construct every named operation before either graph +//! can execute; this module never substitutes a default handler. + +use bevy::prelude::Resource; +use skate_core::graph::{ + activation::{ActivationProgram, Child, Condition, Expression}, + controller::{Behavior, Program}, + selection::{State, Topology, Transition}, +}; +use skate_data::{ + GameAssets, + state_graph::{ + StateGraph, + binding::{Binding, Node, OperationKind}, + }, +}; +use std::{fmt, path::Path}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum GraphCompileError { + UnresolvedTransitionTarget { + transition: usize, + }, + InvalidOperationParent { + operation: usize, + expected: &'static str, + }, + InvalidStateBehavior { + state: usize, + operation: usize, + }, + InvalidTransitionHook { + transition: usize, + operation: usize, + }, + InvalidExpressionChild { + expression: usize, + node: Node, + }, + MissingConditionMask { + operation: usize, + }, +} + +impl fmt::Display for GraphCompileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnresolvedTransitionTarget { transition } => { + write!( + f, + "stock graph transition {transition} has no resolved target" + ) + } + Self::InvalidOperationParent { + operation, + expected, + } => write!( + f, + "stock graph operation {operation} does not have its required {expected} parent" + ), + Self::InvalidStateBehavior { state, operation } => write!( + f, + "stock graph state {state} references non-behavior operation {operation}" + ), + Self::InvalidTransitionHook { + transition, + operation, + } => write!( + f, + "stock graph transition {transition} references non-hook operation {operation}" + ), + Self::InvalidExpressionChild { expression, node } => write!( + f, + "stock graph expression {expression} has invalid child {node:?}" + ), + Self::MissingConditionMask { operation } => { + write!( + f, + "stock graph condition {operation} has no parsed TU3 mask" + ) + } + } + } +} + +impl std::error::Error for GraphCompileError {} + +/// Compact runtime IDs map back to the original binding operation IDs. The +/// future concrete host uses these tables to reach its exact factory instances. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OperationRemap { + pub behaviors: Vec, + pub conditions: Vec, + pub hooks: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct CompiledGraph { + pub program: Program, + pub operations: OperationRemap, +} + +impl CompiledGraph { + pub fn from_binding(binding: &Binding) -> Result { + let operation_count = binding.operations.len(); + let mut behavior_for_operation = vec![None; operation_count]; + let mut condition_for_operation = vec![None; operation_count]; + let mut hook_for_operation = vec![None; operation_count]; + let mut behavior_operations = Vec::new(); + let mut condition_operations = Vec::new(); + let mut hook_operations = Vec::new(); + + for (operation_id, operation) in binding.operations.iter().enumerate() { + match operation.kind { + OperationKind::Behavior => { + behavior_for_operation[operation_id] = Some(behavior_operations.len()); + behavior_operations.push(operation_id); + } + OperationKind::Condition => { + condition_for_operation[operation_id] = Some(condition_operations.len()); + condition_operations.push(operation_id); + } + OperationKind::Hook => { + hook_for_operation[operation_id] = Some(hook_operations.len()); + hook_operations.push(operation_id); + } + } + } + + let topology = Topology { + states: binding + .states + .iter() + .map(|state| State { + parent: state.parent, + enabled: state.enabled != 0, + active: state.active != 0, + interruptibility: state.interruptibility, + interrupt_ancestor: state.interrupt_ancestor, + children: state.children.clone(), + transitions: state.transitions.clone(), + }) + .collect(), + transitions: binding + .transitions + .iter() + .enumerate() + .map(|(id, transition)| { + Ok(Transition { + enabled: transition.enabled != 0, + target: transition.target.ok_or( + GraphCompileError::UnresolvedTransitionTarget { transition: id }, + )?, + priority: transition.priority, + }) + }) + .collect::>()?, + }; + + let conditions = condition_operations + .iter() + .map(|&operation_id| { + let operation = &binding.operations[operation_id]; + Ok(Condition { + enabled: operation.enabled != 0, + mask: operation.condition_mask.ok_or( + GraphCompileError::MissingConditionMask { + operation: operation_id, + }, + )?, + }) + }) + .collect::>()?; + + let expressions = binding + .expressions + .iter() + .enumerate() + .map(|(expression_id, expression)| { + let children = expression + .children + .iter() + .map(|node| match *node { + Node::Expression(id) => Ok(Child::Expression(id)), + Node::Operation(operation) => condition_for_operation[operation] + .map(Child::Condition) + .ok_or(GraphCompileError::InvalidExpressionChild { + expression: expression_id, + node: *node, + }), + _ => Err(GraphCompileError::InvalidExpressionChild { + expression: expression_id, + node: *node, + }), + }) + .collect::>()?; + Ok(Expression { + operator: expression.operator, + children, + }) + }) + .collect::>()?; + + let behaviors = behavior_operations + .iter() + .map(|&operation_id| { + let operation = &binding.operations[operation_id]; + let Node::State(owner) = operation.parent else { + return Err(GraphCompileError::InvalidOperationParent { + operation: operation_id, + expected: "state", + }); + }; + Ok(Behavior { + owner, + enabled: operation.enabled != 0, + }) + }) + .collect::>()?; + + let state_behaviors = binding + .states + .iter() + .enumerate() + .map(|(state, source)| { + source + .behaviors + .iter() + .map(|&operation| { + behavior_for_operation[operation] + .ok_or(GraphCompileError::InvalidStateBehavior { state, operation }) + }) + .collect() + }) + .collect::>()?; + + let transition_hooks = binding + .transitions + .iter() + .enumerate() + .map(|(transition, source)| { + source + .hooks + .iter() + .map(|&operation| { + hook_for_operation[operation].ok_or( + GraphCompileError::InvalidTransitionHook { + transition, + operation, + }, + ) + }) + .collect() + }) + .collect::>()?; + + Ok(Self { + program: Program { + topology, + root: binding.root, + activation: ActivationProgram { + state_expressions: binding + .states + .iter() + .map(|state| state.expression) + .collect(), + transition_expressions: binding + .transitions + .iter() + .map(|transition| transition.expression) + .collect(), + expressions, + conditions, + }, + behaviors, + state_behaviors, + transition_hooks, + }, + operations: OperationRemap { + behaviors: behavior_operations, + conditions: condition_operations, + hooks: hook_operations, + }, + }) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct LoadedGraph { + pub source: StateGraph, + pub binding: Binding, + pub runtime: CompiledGraph, +} + +#[derive(Resource, Clone, Debug)] +pub(crate) struct StockGraphs { + pub action: LoadedGraph, + pub motion: LoadedGraph, +} + +impl StockGraphs { + pub fn load(root: &Path, manifest: &GameAssets) -> Result { + Ok(Self { + action: load_graph(root, &manifest.action_graph)?, + motion: load_graph(root, &manifest.motion_graph)?, + }) + } +} + +fn load_graph(root: &Path, relative: &str) -> Result { + let path = root.join(relative); + let source = StateGraph::load(&path).map_err(|error| error.to_string())?; + let binding = Binding::from_graph(&source).map_err(|error| error.to_string())?; + let runtime = CompiledGraph::from_binding(&binding) + .map_err(|error| format!("Invalid runtime graph {}: {error}", path.display()))?; + Ok(LoadedGraph { + source, + binding, + runtime, + }) +} + +#[cfg(test)] +#[path = "tests/graph_runtime.rs"] +mod tests; diff --git a/crates/skate-game/src/input.rs b/crates/skate-game/src/input.rs new file mode 100644 index 00000000..3de683b0 --- /dev/null +++ b/crates/skate-game/src/input.rs @@ -0,0 +1,42 @@ +//! Platform input adapter; no animation or physics state mutation here. +use crate::app::SimulationSet; +use bevy::prelude::*; + +mod controllers; +pub(crate) mod gesture_catalog; +mod platform; +pub(crate) use controllers::{ControllerInput, ControllerStatus}; + +pub(crate) struct InputPlugin; +impl Plugin for InputPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(PreUpdate, (poll_controllers, exit_requested)) + .add_systems(FixedUpdate, publish_actions.in_set(SimulationSet::Input)); + } +} + +fn poll_controllers(mut input: ResMut) { + let previous = input.status; + input.collect(std::array::from_fn(platform::poll)); + for (index, (&before, &after)) in previous.iter().zip(&input.status).enumerate() { + if before != after { + match after { + ControllerStatus::Ready => info!("Controller {index}: raw XInput ready"), + ControllerStatus::Unavailable(platform::DeviceError::Disconnected) => { + info!("Controller {index}: disconnected"); + } + _ => warn!("Controller {index}: {after:?}"), + } + } + } +} + +fn publish_actions(mut input: ResMut) { + input.publish_actions(); +} +fn exit_requested(keys: Res>, mut exit: MessageWriter) { + if keys.just_pressed(KeyCode::Escape) { + exit.write(AppExit::Success); + } +} diff --git a/crates/skate-game/src/input/controllers.rs b/crates/skate-game/src/input/controllers.rs new file mode 100644 index 00000000..ab7867e9 --- /dev/null +++ b/crates/skate-game/src/input/controllers.rs @@ -0,0 +1,98 @@ +//! Persistent platform-input owner. Device collection runs on host frames; +//! this does not choose the skater simulation clock or advance Derived input. +use super::platform::{DeviceError, DevicePacket}; +use bevy::prelude::Resource; +use skate_core::input::{ + controller::ActionMap, + gameplay_map::GameplayActions, + history::{DEVICE_SLOTS, HistoryRecord, PadHistory}, + pad::Pad, + xbox, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ControllerStatus { + Unpolled, + Ready, + Unavailable(DeviceError), +} + +#[derive(Resource)] +pub(crate) struct ControllerInput { + cache: [[HistoryRecord; DEVICE_SLOTS]; 2], + active: usize, + history: PadHistory, + pads: [Pad; DEVICE_SLOTS], + pub status: [ControllerStatus; DEVICE_SLOTS], + pub packet_numbers: [Option; DEVICE_SLOTS], + pub mapped_actions: [[f32; 18]; DEVICE_SLOTS], + pub publications: u64, + pub consumed_batches: u64, +} + +impl Default for ControllerInput { + fn default() -> Self { + Self { + cache: [[HistoryRecord::new(&[]); DEVICE_SLOTS]; 2], + active: 0, + history: PadHistory::new(), + pads: std::array::from_fn(|_| Pad::new()), + status: [ControllerStatus::Unpolled; DEVICE_SLOTS], + packet_numbers: [None; DEVICE_SLOTS], + mapped_actions: [[0.0; 18]; DEVICE_SLOTS], + publications: 0, + consumed_batches: 0, + } + } +} + +impl ControllerInput { + pub(crate) fn player_actions(&self) -> GameplayActions { + let device = self.status.iter().position(|status| *status == ControllerStatus::Ready).unwrap_or(0); + GameplayActions::from_pad(&self.pads[device]) + } + /// TU3 8296D288/8296D0D0: write the inactive cache, publish one four-device + /// batch. Even an unchanged platform packet is sampled; packet-number + /// deduplication would alter native Pad edge/repeat behavior. + pub(super) fn collect(&mut self, samples: [Result; DEVICE_SLOTS]) { + let next = self.active ^ 1; + for (device, sample) in samples.into_iter().enumerate() { + match sample { + Ok(packet) => { + // 8296D480 sets byte13 only when capability SubType == 7. + let values = xbox::convert(&packet.state, u8::from(packet.subtype == 7)); + self.cache[next][device] = HistoryRecord::new(&values); + self.packet_numbers[device] = Some(packet.number); + self.status[device] = ControllerStatus::Ready; + } + Err(error) => { + self.cache[next][device].clear_count(); + self.packet_numbers[device] = None; + self.status[device] = ControllerStatus::Unavailable(error); + } + } + } + self.active = next; + self.history.publish(&self.cache[self.active]); + self.publications += 1; + } + + /// TU3 82699230 drains first, then updates each Pad once. The game owns + /// this snapshot for subsequent consumers; Derived timers require the + /// actual actor timestep and state flags and are not driven by render dt. + pub(super) fn publish_actions(&mut self) -> bool { + if !self.history.drain_to_latest(&mut self.pads) { + return false; + } + for (device, pad) in self.pads.iter().enumerate() { + let mut actions = GameplayActions::from_pad(pad); + self.mapped_actions[device] = std::array::from_fn(|i| actions.value(64 + i as u32)); + } + self.consumed_batches += 1; + true + } +} + +#[cfg(test)] +#[path = "tests/controllers.rs"] +mod tests; diff --git a/crates/skate-game/src/input/gesture_catalog.rs b/crates/skate-game/src/input/gesture_catalog.rs new file mode 100644 index 00000000..b37b2c91 --- /dev/null +++ b/crates/skate-game/src/input/gesture_catalog.rs @@ -0,0 +1,153 @@ +//! Original TU3 gesture membership,82BA10C0 ->82BA07F0. +//! The catalog is constructed by82B98DB8 ->82B98F70, independently checked +//! against all270 raw map-insertion calls. PAT paths do not define these groups. +use skate_core::graph::intents::IntentMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Group { + Square, + Nose, + Tail, + Nose90, + Tail90, + NoseN90, + TailN90, +} + +impl Group { + /// Original82BA0F78 does case-sensitive constructor comparisons. + pub(crate) fn parse(name: &str) -> Result { + Ok(match name { + "Square" => Self::Square, + "Nose" => Self::Nose, + "Tail" => Self::Tail, + "90Nose" => Self::Nose90, + "90Tail" => Self::Tail90, + "N90Nose" => Self::NoseN90, + "N90Tail" => Self::TailN90, + _ => return Err(format!("HasGestureIntent has undefined group `{name}`")), + }) + } + + ///82BA07F0 tests map-key presence, never the intent value. All seven maps + ///contain the same30 unrotated keys; each rotated map adds its15 own keys. + pub(crate) fn has_intent(self, action_intents: &IntentMap) -> bool { + let additional: &[&str] = match self { + Self::Square | Self::Nose | Self::Tail => &[], + Self::Nose90 => &NOSE_90, + Self::Tail90 => &TAIL_90, + Self::NoseN90 => &NOSE_N90, + Self::TailN90 => &TAIL_N90, + }; + COMMON + .iter() + .chain(additional) + .any(|name| action_intents.contains_key(name)) + } +} + +// Square82B99060..99DAC; Tail82B99E20..9AAD0; Nose82B9AB40..9B7F0. +const COMMON: [&str; 30] = [ + "Ollie", + "PopShuvit", + "FSPopShuvit", + "VarialKickflip", + "VarialHeelflip", + "Hardflip", + "InwardHeelflip", + "360PopShuvit", + "FS360PopShuvit", + "360Flip", + "Laserflip", + "360Hardflip", + "360InwardHeelflip", + "Kickflip", + "Heelflip", + "Nollie", + "N_PopShuvit", + "N_FSPopShuvit", + "N_VarialKickflip", + "N_VarialHeelflip", + "N_Hardflip", + "N_InwardHeelflip", + "N_360PopShuvit", + "N_FS360PopShuvit", + "N_360Flip", + "N_Laserflip", + "N_360Hardflip", + "N_360InwardHeelflip", + "N_Kickflip", + "N_Heelflip", +]; +// Tail90 map5676,82B9B864..9BE84; commonkeys82B9BEF4..9CBA4. +const TAIL_90: [&str; 15] = [ + "90_Ollie", + "90_PopShuvit", + "90_FSPopShuvit", + "90_VarialKickflip", + "90_VarialHeelflip", + "90_Hardflip", + "90_InwardHeelflip", + "90_360PopShuvit", + "90_FS360PopShuvit", + "90_360Flip", + "90_Laserflip", + "90_360Hardflip", + "90_360InwardHeelflip", + "90_Kickflip", + "90_Heelflip", +]; +// TailN90 map9460,82B9CC18..9D238; commonkeys82B9D2A8..9DF58. +const TAIL_N90: [&str; 15] = [ + "N90_Ollie", + "N90_PopShuvit", + "N90_FSPopShuvit", + "N90_VarialKickflip", + "N90_VarialHeelflip", + "N90_Hardflip", + "N90_InwardHeelflip", + "N90_360PopShuvit", + "N90_FS360PopShuvit", + "N90_360Flip", + "N90_Laserflip", + "N90_360Hardflip", + "N90_360InwardHeelflip", + "N90_Kickflip", + "N90_Heelflip", +]; +// Nose90 map3784,82B9DFCC..9E5EC; commonkeys82B9E65C..9F30C. +const NOSE_90: [&str; 15] = [ + "90_Nollie", + "90_N_PopShuvit", + "90_N_FSPopShuvit", + "90_N_VarialKickflip", + "90_N_VarialHeelflip", + "90_N_Hardflip", + "90_N_InwardHeelflip", + "90_N_360PopShuvit", + "90_N_FS360PopShuvit", + "90_N_360Flip", + "90_N_Laserflip", + "90_N_360Hardflip", + "90_N_360InwardHeelflip", + "90_N_Kickflip", + "90_N_Heelflip", +]; +// NoseN90 map7568,82B9F380..9F9A0; commonkeys82B9FA10..BA06C0. +const NOSE_N90: [&str; 15] = [ + "N90_Nollie", + "N90_N_PopShuvit", + "N90_N_FSPopShuvit", + "N90_N_VarialKickflip", + "N90_N_VarialHeelflip", + "N90_N_Hardflip", + "N90_N_InwardHeelflip", + "N90_N_360PopShuvit", + "N90_N_FS360PopShuvit", + "N90_N_360Flip", + "N90_N_Laserflip", + "N90_N_360Hardflip", + "N90_N_360InwardHeelflip", + "N90_N_Kickflip", + "N90_N_Heelflip", +]; diff --git a/crates/skate-game/src/input/platform.rs b/crates/skate-game/src/input/platform.rs new file mode 100644 index 00000000..1b675331 --- /dev/null +++ b/crates/skate-game/src/input/platform.rs @@ -0,0 +1,104 @@ +//! Windows device transport. Raw signed axes/trigger bytes reach the TU3 +//! converter without Bevy/gilrs deadzones or normalized-axis reconstruction. +use skate_core::input::xbox::XboxState; + +pub(super) struct DevicePacket { + pub number: u32, + pub state: XboxState, + pub subtype: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DeviceError { + Disconnected, + State(u32), + Capabilities(u32), + #[cfg(not(windows))] + UnsupportedPlatform, +} + +#[cfg(windows)] +mod windows { + use super::*; + use std::mem::MaybeUninit; + + // ABI from the installed Windows SDK Xinput.h. No OS-owned pointers are + // retained and only successful calls permit reading output storage. + #[repr(C)] + struct Gamepad { + buttons: u16, + left_trigger: u8, + right_trigger: u8, + left_x: i16, + left_y: i16, + right_x: i16, + right_y: i16, + } + #[repr(C)] + struct State { + number: u32, + gamepad: Gamepad, + } + #[repr(C)] + struct Vibration { + left: u16, + right: u16, + } + #[repr(C)] + struct Capabilities { + device_type: u8, + subtype: u8, + flags: u16, + gamepad: Gamepad, + vibration: Vibration, + } + const _: () = assert!(size_of::() == 12); + const _: () = assert!(size_of::() == 16); + const _: () = assert!(size_of::() == 20); + + #[link(name = "xinput")] + unsafe extern "system" { + fn XInputGetState(index: u32, state: *mut State) -> u32; + fn XInputGetCapabilities(index: u32, flags: u32, capabilities: *mut Capabilities) -> u32; + } + + pub(super) fn poll(index: u32) -> Result { + let mut state = MaybeUninit::::uninit(); + // SAFETY: properly aligned writable storage with the SDK's exact C ABI. + let result = unsafe { XInputGetState(index, state.as_mut_ptr()) }; + if result == 1167 { + return Err(DeviceError::Disconnected); + } + if result != 0 { + return Err(DeviceError::State(result)); + } + let mut capabilities = MaybeUninit::::uninit(); + // TU3 8296D480 obtains subtype with capability flags=1. A capability + // failure stays explicit; it must not invent the converter's byte 13. + // SAFETY: same output-storage contract as above. + let result = unsafe { XInputGetCapabilities(index, 1, capabilities.as_mut_ptr()) }; + if result != 0 { + return Err(DeviceError::Capabilities(result)); + } + // SAFETY: both successful SDK calls initialized their complete structs. + let (state, capabilities) = unsafe { (state.assume_init(), capabilities.assume_init()) }; + Ok(DevicePacket { + number: state.number, + state: XboxState { + buttons: state.gamepad.buttons, + triggers: [state.gamepad.left_trigger, state.gamepad.right_trigger], + left: [state.gamepad.left_x, state.gamepad.left_y], + right: [state.gamepad.right_x, state.gamepad.right_y], + }, + subtype: capabilities.subtype, + }) + } +} + +pub(super) fn poll(index: usize) -> Result { + assert!(index < 4); + #[cfg(windows)] + return windows::poll(index as u32); + #[cfg(not(windows))] + Err(DeviceError::UnsupportedPlatform) +} diff --git a/crates/skate-game/src/input/tests/controllers.rs b/crates/skate-game/src/input/tests/controllers.rs new file mode 100644 index 00000000..5b191989 --- /dev/null +++ b/crates/skate-game/src/input/tests/controllers.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::input::platform::{DeviceError, DevicePacket}; +use skate_core::input::xbox::XboxState; + +impl ControllerInput { + /// Feed test packets through the production conversion, cache, history and Pad. + /// Only the operating-system read is supplied by the test. + pub(crate) fn sample_raw_for_test(&mut self, state: XboxState) { + self.collect([ + Ok(DevicePacket { + number: self.publications as u32 + 1, + state, + subtype: 1, + }), + Err(DeviceError::Disconnected), + Err(DeviceError::Disconnected), + Err(DeviceError::Disconnected), + ]); + assert!(self.publish_actions()); + } +} + +fn packet(number: u32, buttons: u16, left: [i16; 2]) -> Result { + Ok(DevicePacket { + number, + state: XboxState { + buttons, + triggers: [0; 2], + left, + right: [0; 2], + }, + subtype: 1, + }) +} + +#[test] +fn raw_packets_reach_stock_actions_without_losing_device_identity() { + let mut input = ControllerInput::default(); + input.collect([ + packet(1, 0x1000, [32767, 0]), + packet(2, 0x2000, [-32768, 0]), + packet(3, 0x4000, [0, 0]), + packet(4, 0x8000, [0, 0]), + ]); + assert!(input.publish_actions()); + assert!((input.mapped_actions[0][0] - 1.0).abs() < 0.000001); + assert!((input.mapped_actions[1][0] + 1.0).abs() < 0.000001); + assert_eq!(input.mapped_actions[0][16], 1.0); // GP_AFace + assert_eq!(input.mapped_actions[1][17], 1.0); // GP_BFace + assert_eq!(input.mapped_actions[2][14], 1.0); // GP_XFace + assert_eq!(input.mapped_actions[3][15], 1.0); // GP_YFace + assert_eq!(input.packet_numbers, [Some(1), Some(2), Some(3), Some(4)]); +} + +#[test] +fn unchanged_packet_numbers_do_not_suppress_publication_and_empty_drain_does_not_advance() { + let mut input = ControllerInput::default(); + for _ in 0..4 { + input.collect(std::array::from_fn(|_| packet(7, 0x1000, [0; 2]))); + assert!(input.publish_actions()); + } + assert_eq!(input.publications, 4); + assert_eq!(input.consumed_batches, 4); + assert_ne!(input.pads[0].records()[12][1] & 0x01000000, 0); + let prior = input.pads.clone(); + assert!(!input.publish_actions()); + assert_eq!(input.pads, prior); +} + +#[test] +fn disconnect_publishes_zero_count_and_reconnect_reinitializes_pad_records() { + let mut input = ControllerInput::default(); + input.collect(std::array::from_fn(|_| packet(1, 0x1000, [32767, 0]))); + input.publish_actions(); + input.collect(std::array::from_fn(|_| Err(DeviceError::Disconnected))); + input.publish_actions(); + assert_eq!(input.mapped_actions, [[0.0; 18]; 4]); + assert!(input.pads.iter().all(|pad| pad.count() == 0)); + input.collect(std::array::from_fn(|_| packet(1, 0, [0; 2]))); + input.publish_actions(); + assert_eq!(input.mapped_actions, [[0.0; 18]; 4]); + assert_eq!(input.pads[0].records()[12], [0, 0, 0, 1]); +} + +#[test] +fn alternate_guitar_subtype_uses_verified_conversion_mask_and_errors_stay_visible() { + let mut input = ControllerInput::default(); + let special = DevicePacket { + number: 1, + state: XboxState { + buttons: 0, + triggers: [255; 2], + left: [0; 2], + right: [-32768, 32767], + }, + subtype: 7, + }; + input.collect([ + Ok(special), + Err(DeviceError::Capabilities(5)), + Err(DeviceError::Disconnected), + Err(DeviceError::Disconnected), + ]); + input.publish_actions(); + assert_eq!(input.mapped_actions[0][6..8], [0.0; 2]); + assert_eq!(input.mapped_actions[0][3..5], [0.0; 2]); + assert_eq!( + input.status[1], + ControllerStatus::Unavailable(DeviceError::Capabilities(5)) + ); + assert_eq!(input.mapped_actions[1], [0.0; 18]); +} diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs new file mode 100644 index 00000000..ab2b46f1 --- /dev/null +++ b/crates/skate-game/src/main.rs @@ -0,0 +1,60 @@ +mod animation; +mod animation_pose; +mod app; +mod assets; +mod camera; +mod config; +mod graph_host; +mod graph_runtime; +mod input; +mod physics; +mod skater_animation; +mod verification; +mod world; + +fn main() -> bevy::app::AppExit { + let config = match config::Config::from_env() { + Ok(config) => config, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + if let Err(error) = skate_data::input_config::StockGameplayConfig::load(&config.asset_root) { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + let manifest = match skate_data::GameAssets::load(&config.asset_root) { + Ok(manifest) => manifest, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + let graphs = match graph_runtime::StockGraphs::load(&config.asset_root, &manifest) { + Ok(graphs) => graphs, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + let physics = match physics::GamePhysics::load(&config.asset_root) { + Ok(physics) => physics, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + let skater = match physics::SkaterRuntime::load(&config.asset_root, &graphs, &physics, "easy") { + Ok(skater) => skater, + Err(error) => { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + }; + app::build(config, manifest, graphs, physics, skater).run() +} + +#[cfg(test)] +#[path = "tests/action_host.rs"] +mod action_host_tests; diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs new file mode 100644 index 00000000..764558f5 --- /dev/null +++ b/crates/skate-game/src/physics.rs @@ -0,0 +1,312 @@ +//! Host game physics ownership and schedule. Physical calculations stay in core. +mod animated_skeleton; +mod air_reckoning; +mod air_phase; +mod air_trajectory; +pub(crate) mod camera_output; +mod clock; +mod colliders; +mod controls; +mod foot_ik; +mod footplant; +mod foot_ik_queries; +mod foot_physical_output; +pub(crate) mod ground; +mod render_pose; +mod riding_outputs; +mod skateboard_controller; +mod skater; +mod skeleton_body; +mod skeleton_air; +mod skeleton_controller; +mod skeleton_colliders; +mod skeleton_feedback; +mod skeleton_input_runtime; +mod skeleton_output; +mod solve; +pub(crate) use skater::SkaterRuntime; +mod animation_feedback; +mod animation_feedback_settings; +mod animation_input; +mod animation_phase; +mod landing_quality; +mod frame; +mod ground_phase; +mod ground_animation; +mod slide_state; +mod ground_exit; +mod ground_runtime; +mod input_phase; +mod player_input; +mod player_state; +mod settings; +mod wipeout; +mod wipeout_states; +mod teleport_state; +mod offboard; +mod known_air; +use crate::{ + app::{FrameSet, SimulationSet}, + world::PlayerRoot, +}; +use bevy::prelude::*; +pub(crate) use controls::PlayerControls; +use riding_outputs::RidingOutputs; +use settings::PhysicsSettings; +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, + board_runtime::{BoardMotion, BoardRuntime}, + board_world::{BoardWorld, ContactRetentionSettings}, + collision::WorldContactSettings, + drive_frames::RetailAffineTransform, + }, +}; +use skate_data::collections::Collections; + +#[derive(Resource)] +pub(crate) struct GamePhysics { + clock: clock::SimulationClock, + pub board: BoardRuntime, + pub riding: RidingOutputs, + world: BoardWorld, + settings: PhysicsSettings, + animation_profile: animation_phase::AnimationProfile, + query: WorldContactSettings, + retention: ContactRetentionSettings, + pub ticks: u64, + pub contact_count: usize, + pub failed: bool, + /// ProcessedPhysIn reset82BF9EF0 sets0x2000; initial stancebit20 is clear. + /// Animation packet publication owns subsequent stance-bit updates. + pub processed_flags_2468: u32, + /// Toolkit ctor82C0680C clears8384bit7; wipeout entry/exit owns changes. + pub board_wiping_out: bool, +} + +impl GamePhysics { + pub(crate) fn world(&self) -> &BoardWorld { + &self.world + } + + pub(crate) fn world_triangles(&self) -> &[skate_core::physics::board_world::WorldTriangle] { + self.world.triangles() + } + + pub fn load(asset_root: &std::path::Path) -> Result { + let data = Collections::load(asset_root)?; + let settings = PhysicsSettings::load(&data)?; + let animation_profile = animation_phase::AnimationProfile::load(&data, "easy")?; + let spawn = RetailAffineTransform { + translation: Vector3::new( + 0.0, + ground::HEIGHT + settings.wheel_radius - settings.authored[0].translation.y, + 0.0, + ), + ..RetailAffineTransform::IDENTITY + }; + let board = BoardRuntime::new( + settings.masses, + settings.authored, + spawn, + settings.step.simulation, + BoardMotion::Active, + ); + let world = ground::world(settings.floor_material); + let processed_flags_2468 = 0x2000; + let riding = RidingOutputs::load(&data, &board, processed_flags_2468)?; + let (query, retention) = ground::query_settings(); + Ok(Self { + clock: clock::SimulationClock::default(), + board, + riding, + world, + settings, + animation_profile, + query, + retention, + ticks: 0, + contact_count: 0, + failed: false, + processed_flags_2468, + board_wiping_out: false, + }) + } + + #[cfg(test)] + fn advance_board(&mut self) -> Result<(), String> { + self.board.clear_forces(); + self.riding.start_wheel_queries(&self.board, &self.world)?; + self.riding.finish_wheel_queries()?; + let volumes = colliders::world_volumes(&self.board, &self.settings); + let contacts = self + .world + .query_primitives(&volumes, self.query, self.retention); + self.contact_count = contacts.len(); + // The graph/ground-state consumer must supply recovered steering and + // forces before this is playable. This tick verifies physical assembly. + self.board.advance(contacts, [0.0; 2], self.settings.step); + self.riding.finish_post_physics( + &mut self.board, + self.board_wiping_out, + self.processed_flags_2468, + self.settings.step.simulation.time_step, + )?; + self.ticks += 1; + if self.board.bodies().iter().any(|body| { + let p = body.rates.position; + !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite() + }) { + self.failed = true; + return Err(format!( + "Board solver produced a non-finite pose on tick {}", + self.ticks + )); + } + Ok(()) + } +} + +pub(crate) struct PhysicsPlugin; +impl Plugin for PhysicsPlugin { + fn build(&self, app: &mut App) { + let period = app.world().resource::().clock.period(); + app.insert_resource(Time::::from_duration(period)) + .init_resource::() + .add_systems( + FixedUpdate, + controls::sample.in_set(SimulationSet::Controls), + ) + .add_systems(FixedUpdate, advance.in_set(SimulationSet::Physics)) + .add_systems(Update, present.in_set(FrameSet::Physics)); + } +} + +fn advance( + mut physics: ResMut, + mut skater: ResMut, + mut controls: ResMut, + graphs: Res, + input: Res, + mut camera: ResMut, + mut cadence: ResMut>, + mut exit: MessageWriter, +) { + if physics.failed { + return; + } + let mut actions = input.player_actions(); + let input_available = input + .status + .iter() + .any(|s| *s == crate::input::ControllerStatus::Ready); + if let Err(message) = frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + input_available, + &mut camera, + ) { + physics.failed = true; + error!( + "{message}; tick={}; mapped_input={:?}; force_mode={}; board_axis_y={}; flags={:08x}/{:08x}/{:08x}/{:08x}/{:08x}", + physics.ticks, + input.mapped_actions, + skater.skeleton_input.force_mode, + skater.skeleton_input.drive_frames[0][2][1], + skater.player_input.processed.flags_2468, + skater.player_input.processed.flags_2472, + skater.player_input.processed.flags_2476, + skater.player_input.processed.flags_2480, + skater.player_input.processed.flags_2484, + ); + exit.write(AppExit::error()); + } + cadence.set_timestep(physics.clock.period()); +} + +impl GamePhysics { + fn finish_skater(&mut self, skater: &mut SkaterRuntime) -> Result<(), String> { + //Skateboard::UpdatePostPhysics82C02158 prepares the wall probe for + //the next StartBoard, using this input phase's cached deck toolkit. + self.riding.probes.prepare_wall( + &skater.player_input.processed, + skater + .player_input + .toolkit + .as_ref() + .ok_or("Postphysics wall probe requires the current board toolkit")?, + ); + self.riding.finish_post_physics( + &mut self.board, + self.board_wiping_out, + self.processed_flags_2468, + self.settings.step.simulation.time_step, + )?; + let partial = skateboard_controller::partial_request( + &skater.skateboard_controller, + &self.riding.ground, + ); + skeleton_feedback::publish(self, skater, partial); + if skater.player_state.current() == skate_core::player::state::PhysicalStateId::WipeoutGround { + wipeout_states::post_physics(skater); + } + if skater.player_state.current() == skate_core::player::state::PhysicalStateId::PhysicsAir { + air_phase::update_apex(self, &mut skater.air_state); + } + if skater.player_state.current() == skate_core::player::state::PhysicalStateId::KnownAir { + known_air::post_physics(self, skater)?; + } + wipeout::check_after_physics(self, skater)?; + let compression = skater.skeleton_output.average_compressions(&self.board); + render_pose::publish(self, skater, compression)?; + self.ticks += 1; + let invalid = self + .board + .bodies() + .iter() + .chain(skater.skeleton.bodies()) + .any(|body| { + let position = body.rates.position; + !position.x.is_finite() || !position.y.is_finite() || !position.z.is_finite() + }); + if invalid { + self.failed = true; + return Err(format!( + "Shared skater solver produced a non-finite pose on tick{}", + self.ticks + )); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "tests/physics_startup.rs"] +mod tests; + +#[cfg(test)] +#[path = "tests/air_playback.rs"] +mod air_tests; + +#[cfg(test)] +#[path = "tests/wipeout_playback.rs"] +mod wipeout_tests; + +fn present( + physics: Res, + skater: Res, + mut roots: Query<&mut Transform, With>, +) { + if physics.failed { + return; + } + for mut root in &mut roots { + *root = Transform::from_matrix(crate::animation::native_matrix( + skater.animated_skeleton.roots.animation_to_world, + )); + } +} diff --git a/crates/skate-game/src/physics/air_phase.rs b/crates/skate-game/src/physics/air_phase.rs new file mode 100644 index 00000000..d423ead5 --- /dev/null +++ b/crates/skate-game/src/physics/air_phase.rs @@ -0,0 +1,149 @@ +//! PhysicsAir200 production lifecycle82D34388/82D346C0/82D346A0. +//! Uses the live player, board, skeleton and selector; no alternate air solver. +mod input; +mod board; +mod skeleton; +pub(crate) use input::AirSettings; +pub(crate) use input::selector_input; +pub(crate) use input::launch_info; +use super::{GamePhysics, SkaterRuntime}; +use skate_core::air::state::{ + AirMath, AirTrajectory, PhysicsAirState, PhysicsAirMath, + angle_between_vectors, calculate_velocity_from_jump, integrate_trajectory_fixed_step, + wrap_signed_angle, +}; + +///Original initializer82F826B0/822F8B40. This COM trajectory acceleration is +///distinct from the world's separately supplied rigid-body gravity setting. +const COM_ACCELERATION: [f32; 4] = [0.0, f32::from_bits(0xc11c_cccd), 0.0, 0.0]; + +pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let frame = input::frame(skater); + let life = &mut skater.ground_lifecycle; + life.skeleton_controller.flag_18 = true; + life.skeleton_controller.request(7, &mut skater.skeleton_collision)?; + skater.foot_ik.enable_feet(true); + life.skeleton_controller.request(6, &mut skater.skeleton_collision)?; + physics.board.hook_mut().drive.enable_angular_only(&mut life.board_animated_290); + skater.footplant.enabled = false; + skater.footplant.reset(); + let state = &mut skater.air_state; + 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 = physics.board.part_transforms()[6].translation.y; + state.start_y = frame.ground_position_y_500; + let (use_com, heading) = if frame.previous_physics_category_2516 == 400 + || frame.previous_physics_state_2504 == 701 { + (frame.flags_2468 & 0x4000 == 0, false) + } else { + let selected = !matches!(frame.previous_physics_state_2504, 100 | 103 | 201); + (selected, selected) + }; + state.use_centre_of_mass_velocity = use_com; + if use_com { + let mut velocity = frame.trajectory_velocity_608; + if frame.previous_physics_state_2504 == 500 { + velocity[1] = AirMath.minimum_vminfp(velocity[1] * f32::from_bits(0x3f26_6666), 3.0); + } + if frame.frames_since_jump_correction_2576 < 12 { + velocity = calculate_velocity_from_jump(&frame, frame.frames_since_jump_correction_2576, &mut AirMath); + } + state.centre_of_mass_trajectory = AirTrajectory { + position: frame.trajectory_position_592, velocity, + acceleration: COM_ACCELERATION, scalar_48: -1.0, + }; + } + if heading { skater.animated_skeleton.roots.initialize_heading = true; } + Ok(()) +} + +pub(super) fn exit(skater: &mut SkaterRuntime) { + skater.air_state.use_centre_of_mass_velocity = false; + skater.air_reckoning.state.reset_spin(); +} + +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let frame = input::frame(skater); + skater.ground_lifecycle.skeleton_controller.update_air( + skater.player_input.processed.flags_2472, + frame.current_velocity_400[1], &mut skater.skeleton_collision, + )?; + let state = &mut skater.air_state; + state.trajectory_query_countdown = state.trajectory_query_countdown.wrapping_sub(1); + if state.trajectory_query_countdown <= 0 && frame.flags_2468 & 0x8000 == 0 + && frame.previous_physics_state_2504 != 0 && !skater.trajectory.selector.pending() { + let mut info = input::launch_info(physics, skater)?; + if frame.frames_since_jump_correction_2576 < 12 { + info.start_velocity = calculate_velocity_from_jump(&frame, frame.frames_since_jump_correction_2576, &mut AirMath); + } else if skater.air_state.use_centre_of_mass_velocity { + //COM helper82D34570 repeats Fill, then offsets start/board and + //narrows the cone. Caller clears player_jumped again before Launch. + let velocity = skater.air_state.centre_of_mass_trajectory.velocity; + info.start_velocity = std::array::from_fn(|i| COM_ACCELERATION[i].mul_add(f32::from_bits(0x3c88_8889), velocity[i])); + info.start_position_override = info.animation_com_position; + info.start_position_override[1] += f32::from_bits(0xbf26_6666); + info.board_position_override = info.animation_com_position; + info.board_position_override[1] += f32::from_bits(0xbf4c_cccd); + info.use_position_override = true; + info.trajectory_count = 5; + info.cone_angle_x *= f32::from_bits(0x3ecc_cccd); + info.cone_angle_z *= f32::from_bits(0x3ecc_cccd); + } + info.player_jumped = false; + let input = input::selector_input(physics, skater)?; + skater.trajectory.launch(info, input, &physics.world)?; + skater.air_state.trajectory_query_countdown = 7; + } + let input = input::selector_input(physics, skater)?; + skater.trajectory.update(input, &physics.world)?; + let current = skater.air_reckoning.fields(&physics.riding).current_landing_normal_1152; + let normal = skater.trajectory.selector.suggested_normal().unwrap_or(current); + let state = &mut skater.air_state; + state.landing_normal = normal; + let settings = &skater.air_settings.state; + let mut blend = if wrap_signed_angle(angle_between_vectors(normal, current)) <= 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 = skater.trajectory.selector.all_predictions_missed(); + } + 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: COM_ACCELERATION, scalar_48: -1.0, + }; + } + } + if frame.state_timer_2664 <= 0.0 { blend = 0.0; } + else if frame.flags_2468 & 0x4000 != 0 { state.use_centre_of_mass_velocity = false; } + let spin = (frame.body_spin_input_2640 * settings.body_spin_scale_428) * f32::from_bits(0x3c8e_fa35); + let target_spin = settings.body_spin_over_time_320.evaluate(state.time_in_state * 2.0) * spin; + skater.air_reckoning.update( + &mut physics.riding, &skater.player_input.processed, + skater.animation_input.extra.physical_body_spin, normal, blend, target_spin, 0.0, + )?; + if skater.air_state.use_centre_of_mass_velocity { + integrate_trajectory_fixed_step(&mut skater.air_state.centre_of_mass_trajectory); + skeleton::advance(physics, skater, Some(skater.air_state.centre_of_mass_trajectory.position))?; + } else { skeleton::advance(physics, skater, None)?; } + board::advance(physics, skater, frame)?; + skater.ground_lifecycle.skeleton_ground_16388 = true; + let height = physics.board.part_transforms()[6].translation.y; + let state = &mut skater.air_state; + if !(state.max_y > height) { state.max_y = height; } + state.time_in_state += frame.delta_time_2604; + Ok(()) +} + +///82D34DD8. The common post-physics coordinator immediately follows this +///apex latch with Wipeout::CheckForAirWipeout(false), after collision feedback. +pub(super) fn update_apex(physics: &GamePhysics, state: &mut PhysicsAirState) { + if !state.reached_apex && physics.board.bodies()[6].rates.linear_velocity.y < 0.0 { + state.reached_apex = true; + } +} diff --git a/crates/skate-game/src/physics/air_phase/board.rs b/crates/skate-game/src/physics/air_phase/board.rs new file mode 100644 index 00000000..c5d9dc36 --- /dev/null +++ b/crates/skate-game/src/physics/air_phase/board.rs @@ -0,0 +1,23 @@ +//!82D34AB0 consumes the completed Air reckoning's collision normal1216. +use super::*; +use skate_core::{air::state::PhysicsAirFrame, math::Vector3, + physics::force_queue::QueuedPointForce, riding::collision_response::CollisionResponsePhysical}; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime, frame: PhysicsAirFrame) -> Result<(), String> { + let p = &skater.player_input.processed; + skater.ground.steering.update(0.0, skater.air_settings.steering_blend, p.flags_2468,p.flags_2472); + let t = skater.player_input.toolkit.as_ref().ok_or("Air collision requires the actual BoardToolkit")?; + let normal = physics.riding.reckoning.ground_normal; + if let Some(force) = skater.ground_runtime.calculate_collision_force(CollisionResponsePhysical { + flags_2472: p.flags_2472, collision_displacement: p.collision_pose_error_736.map(f32::from_bits), + velocity: p.vectors_400_416[1].map(f32::from_bits), forward: t.travel_direction, + up: p.vectors_544_560_592_608[0].map(f32::from_bits), ground_normal: [normal.x,normal.y,normal.z,0.0], + time_step: p.timestep_2604, mass: t.total_mass, + }) { + let xyz = |v:[f32;4]| Vector3::new(v[0],v[1],v[2]); + physics.board.forces_mut().append(QueuedPointForce {tag:15,force_world:xyz(force.force_2528),point_body:xyz(force.point_2544)}); + } else if !skater.air_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,&mut AirMath); + skater.ground_runtime.set_animated_velocity(&mut physics.board,velocity); + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/air_phase/input.rs b/crates/skate-game/src/physics/air_phase/input.rs new file mode 100644 index 00000000..1e352802 --- /dev/null +++ b/crates/skate-game/src/physics/air_phase/input.rs @@ -0,0 +1,78 @@ +//! Actual Processed/animation/stock owners read by PhysicsAir and its launch. +use super::*; +use skate_core::{air::{state::{PhysicsAirFrame, PhysicsAirSettings}, trajectory::{LaunchInfo,SelectorInput}},point_graph::PointGraph}; +use skate_data::collections::Collections; + +pub(crate) struct AirSettings { + pub state: PhysicsAirSettings, + pub steering_blend: f32, + pub grind_lock_distance: [f32; 5], +} +impl AirSettings { + pub fn load(data: &Collections) -> Result { + let values = data.words::<16>("physics_airstates", "default", "BodySpinInputFilter")?.map(f32::from_bits); + let f = |name| data.float("physics_airstates", "default", name); + let mut distances = [0.0; 5]; + for (i, mode) in ["easy", "normal", "hardcore", "motorized", "test"].into_iter().enumerate() { + distances[i] = data.float("physics_mode", mode, "GrindLockDist")?; + } + Ok(Self { + state: PhysicsAirSettings { + body_spin_over_time_320: PointGraph {x: values[..8].try_into().unwrap(), y: values[8..].try_into().unwrap()}, + landing_normal_blend_388: f("SpeedToAlignToGround_PhysAir")?, + body_spin_scale_428: f("MaxSpinSpeed")?, + landing_normal_angle_limit_444: f("DontAlignAnglePhysicsAir")?, + }, + steering_blend: data.float("physics_steering", "default", "SteeringTiltBlending")?, + grind_lock_distance: distances, + }) + } +} +pub(super) fn frame(skater: &SkaterRuntime) -> PhysicsAirFrame { + let p = &skater.player_input.processed; + PhysicsAirFrame { + current_velocity_400: p.vectors_400_416[0].map(f32::from_bits), + ground_position_y_500: f32::from_bits(p.vectors_464_480_496_512_528[2][1]), + trajectory_position_592: p.vectors_544_560_592_608[2].map(f32::from_bits), + trajectory_velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + jump_velocity_848: skater.player_state.post.jump_reference.map(f32::from_bits), + flags_2468: p.flags_2468, previous_physics_state_2504: p.state_2504 as i32, + previous_physics_category_2516: p.category_2516 as i32, + frames_since_jump_correction_2576: skater.player_state.post.jump_fix_frames as i32, + delta_time_2604: p.timestep_2604, body_spin_input_2640: skater.animation_input.fields.body_spin, + gravity_y_2648: p.gravity_2648, state_timer_2664: p.state_timer_2664, + } +} +pub(crate) fn selector_input(physics: &GamePhysics, skater: &SkaterRuntime) -> Result { + let p = &skater.player_input.processed; + let g = physics.settings.step.simulation.gravity_acceleration; + Ok(SelectorInput { + gravity: [g.x,g.y,g.z,0.0], + ground_normal: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + contact_position: p.vectors_464_480_496_512_528[2].map(f32::from_bits), + heading_direction: p.vectors_464_480_496_512_528[3].map(f32::from_bits), + reference_up: p.vectors_544_560_592_608[0].map(f32::from_bits), + board_vertical_velocity: f32::from_bits(p.vectors_400_416[0][1]), + directional_input: p.transition_2636, previous_physics_state: p.state_2504, + flags_2472: p.flags_2472, flags_2476: p.flags_2476, + offboard_flags_1776: p.external_physics_1616.flags, + grind_lock_distance: *skater.air_settings.grind_lock_distance.get(p.state_variant_index_2528 as usize) + .ok_or_else(|| format!("Invalid trajectory physics mode {}",p.state_variant_index_2528))?, + }) +} +pub(crate) fn launch_info(physics: &GamePhysics, skater: &SkaterRuntime) -> Result { + let p = &skater.player_input.processed; + let toolkit = skater.player_input.toolkit.as_ref().ok_or("Air launch requires current BoardToolkit")?; + Ok(LaunchInfo { + reckoning_transform: physics.riding.reckoning_frames.system, + reckoning_inverse: physics.riding.reckoning_frames.inverse_system, + start_velocity: p.vectors_400_416[0].map(f32::from_bits), + com_velocity: p.prepared_jump_704.map(f32::from_bits), + skeleton_vector_160: skater.animated_skeleton.board_frames.local_centre_of_mass, + skeleton_vector_176: skater.animated_skeleton.board_frames.local_board_position, + board_position: toolkit.deck[3], animation_com_position: p.vectors_544_560_592_608[2].map(f32::from_bits), + cone_angle_x: skater.trajectory.settings.cone_x, cone_angle_z: skater.trajectory.settings.cone_z, + timestep: p.timestep_2604, trajectory_count: if p.flags_2468 & 0x2000 != 0 {7} else {1}, + ..LaunchInfo::default() + }) +} diff --git a/crates/skate-game/src/physics/air_phase/skeleton.rs b/crates/skate-game/src/physics/air_phase/skeleton.rs new file mode 100644 index 00000000..a36a5670 --- /dev/null +++ b/crates/skate-game/src/physics/air_phase/skeleton.rs @@ -0,0 +1,25 @@ +//! Same live Skeleton owners as Ground; Air chooses its original update method. +use super::*; +use crate::physics::{input_phase, skeleton_input_runtime::SkeletonOwners}; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime, com: Option<[f32;4]>) -> Result<(), String> { + let collision = input_phase::collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + let packet = &skater.animation.packet; + if let Some(com) = com { + skater.skeleton_input.update_known_air( + &mut skater.skeleton_air,&mut physics.board,&physics.riding.reckoning_frames.system,com,packet, + &mut skater.player_input.processed,&mut owners,&packet.hierarchy,&collision,physics.settings.step.simulation, + )?; + } else { + skater.skeleton_input.update_animated( + &mut skater.skeleton_air,&mut physics.board,&physics.riding.reckoning_frames.system, + &mut skater.player_input.processed,&mut owners,&packet.hierarchy,&collision,physics.settings.step.simulation,false, + )?; + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/air_reckoning.rs b/crates/skate-game/src/physics/air_reckoning.rs new file mode 100644 index 00000000..5ed417b2 --- /dev/null +++ b/crates/skate-game/src/physics/air_reckoning.rs @@ -0,0 +1,84 @@ +//! Production stock settings and live-input adapter for Reckoning82D8DBD8. +mod settings; +use super::riding_outputs::RidingOutputs; +use skate_core::{ + air::{ + reckoning::{self, AirState, Input, Settings}, + state::PhysicsAirReckoningFields, + }, + player::input_phase::ProcessedPhysicsInput, +}; +use skate_data::collections::Collections; + +pub(crate) struct AirReckoning { + pub state: AirState, + settings: Settings, + modes: [settings::Mode; 5], +} +impl AirReckoning { + pub fn load(data: &Collections) -> Result { + let (settings, modes) = settings::load(data)?; + Ok(Self { + state: AirState::new(), + settings, + modes, + }) + } + ///The Ground/PhysicsAir lifecycles borrow this canonical pair for their + ///native spin resets. Filters and matrix1008 remain owned by RidingOutputs. + pub fn fields(&self, riding: &RidingOutputs) -> PhysicsAirReckoningFields { + let up = riding.reckoning.up; + let normal = riding.reckoning.ground_normal; + PhysicsAirReckoningFields { + current_landing_normal_1152: [up.x, up.y, up.z, 0.0], + collision_reference_normal_1216: [normal.x, normal.y, normal.z, 0.0], + body_spin_angle_1568: self.state.spin_angle, + body_spin_speed_1572: self.state.spin_speed, + } + } + ///Called by the actual PhysicsAir/KnownAir update before its Board phase. + ///Return the newly published normal1216, not the caller's earlier snapshot. + pub fn update( + &mut self, + riding: &mut RidingOutputs, + processed: &ProcessedPhysicsInput, + physical_body_spin: f32, + landing_normal: [f32; 4], + normal_blend: f32, + target_spin: f32, + flip_request: f32, + ) -> Result { + let mode = self + .modes + .get(processed.state_variant_index_2528 as usize) + .ok_or_else(|| { + format!( + "Undefined physics mode {}", + processed.state_variant_index_2528 + ) + })?; + reckoning::update( + &mut riding.reckoning, + &mut riding.reckoning_frames, + &mut riding.body_spin, + &mut self.state, + &self.settings, + &Input { + landing_normal, + normal_blend, + target_spin, + flip_request, + com_to_deck: processed.animation_com_to_deck_752.map(f32::from_bits), + timestep: processed.timestep_2604, + physical_body_spin, + grind_adjusted_body_spin: processed.grind_adjusted_body_spin_2644, + additive_spin: processed.flags_2484 & (1 << 15) != 0, + direct_spin: processed.flags_2472 & (1 << 28) != 0, + reverse_stance: processed.flags_2468 & (1 << 20) != 0, + easy_body_spins: mode.easy_body_spins, + perfect_body_flips: mode.perfect_body_flips, + }, + ); + Ok(self.fields(riding)) + } +} diff --git a/crates/skate-game/src/physics/air_reckoning/settings.rs b/crates/skate-game/src/physics/air_reckoning/settings.rs new file mode 100644 index 00000000..1ee17a06 --- /dev/null +++ b/crates/skate-game/src/physics/air_reckoning/settings.rs @@ -0,0 +1,85 @@ +//! Stock classes are verified against original8289D180 and828A0054..00A0: +//! physics_reckoning7CE1B6B581709C6F, physics_bodyspin05EC9CB03A5B2EEE. +use skate_core::{ + air::{body_flip::BodyFlipSettings, body_spin::BodySpinSettings, reckoning::Settings}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +#[derive(Clone, Copy)] +pub(super) struct Mode { + pub easy_body_spins: bool, + pub perfect_body_flips: bool, +} +pub(super) fn load(data: &Collections) -> Result<(Settings, [Mode; 5]), String> { + let float = |class, field| data.float(class, "default", field); + let control = data + .words::<4>("physics_reckoning", "default", "GroundNormalSmoothing")? + .map(f32::from_bits); + let mut modes = Vec::with_capacity(5); + //Player ctor82DB1B08..1B78 installs these exact physics_mode keys. + for name in ["easy", "normal", "hardcore", "motorized", "test"] { + modes.push(Mode { + easy_body_spins: data.boolean("physics_mode", name, "EasyBodySpins")?, + perfect_body_flips: data.boolean("physics_mode", name, "PerfectBodyFlips")?, + }); + } + Ok(( + Settings { + ground_normal_smoothing: control, + max_up_angle_delta: negative_graph( + data, + "physics_reckoning", + "Air_MaxUpVectAngleDelta", + )?, + tilt_vs_rotation: graph(data, "TiltVsRotAir")?, + tilt_vs_slope: graph(data, "TiltVsSlopeAir")?, + body_spin: BodySpinSettings { + derivative_floor: float("physics_bodyspin", "MinDerivativeScalar")?, + acceleration_limit: float("physics_bodyspin", "MaxDeltaOppositeDirection")?, + //Original82D8BE38 copies layouts160,240,320,480,400,80,0. + curves: [ + negative_graph(data, "physics_bodyspin", "Hash_BEA30B5DC6AFF26A")?, + negative_graph(data, "physics_bodyspin", "MaxDeltaVsTime")?, + negative_graph(data, "physics_bodyspin", "MaxDeltaForAutoVsTime")?, + negative_graph(data, "physics_bodyspin", "Hash_3974F0228331EB22")?, + negative_graph(data, "physics_bodyspin", "DerivativeBodySpinVsTime")?, + negative_graph(data, "physics_bodyspin", "Hash_D7C6855B7814D048")?, + negative_graph(data, "physics_bodyspin", "PropBodySpinVsTime")?, + ], + //82F82690 splats original821647E0 into830BD300. + input_fade_threshold: [f32::from_bits(0x3780_0000); 4], + }, + body_flip: BodyFlipSettings { + smoothing: Some(float("physics_reckoning", "FlipSpeedSmoothingFactor")?), + maximum_speed: Some(float("physics_reckoning", "FlipMaxSpeed")?), + spin_scale: Some(float("physics_reckoning", "FlipBodySpinScalar")?), + //All three attributes are mandatory above; no missing-attribute + //branch is reachable through this production settings loader. + missing_attribute_value: 0.0, + }, + }, + modes + .try_into() + .map_err(|_| "Expected five physics modes".to_owned())?, + )) +} +fn negative_graph(data: &Collections, class: &str, name: &str) -> Result, String> { + let values = data + .words::<20>(class, "default", name)? + .map(f32::from_bits); + //Source directly calls PointGraphEval on +16/+48, ignoring sign metadata. + Ok(PointGraph { + x: values[4..12].try_into().unwrap(), + y: values[12..20].try_into().unwrap(), + }) +} +fn graph(data: &Collections, name: &str) -> Result, String> { + let values = data + .words::<16>("physics_reckoning", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: values[..8].try_into().unwrap(), + y: values[8..].try_into().unwrap(), + }) +} diff --git a/crates/skate-game/src/physics/air_trajectory/mod.rs b/crates/skate-game/src/physics/air_trajectory/mod.rs new file mode 100644 index 00000000..e7cb2dcb --- /dev/null +++ b/crates/skate-game/src/physics/air_trajectory/mod.rs @@ -0,0 +1,76 @@ +//! Real stock-settings and BoardWorld adapter for the player trajectory selector. +mod settings; +mod world; +use skate_core::{ + air::trajectory::{ + LaunchInfo, QueryRequest, QueryResult, SelectorInput, SelectorSettings, TrajectorySelector, + query_trajectory, + }, + physics::board_world::BoardWorld, +}; +use skate_data::collections::Collections; + +pub struct AirTrajectoryRuntime { + pub selector: TrajectorySelector, + pub settings: SelectorSettings, + pending_results: Option>, +} +impl AirTrajectoryRuntime { + ///Full82E099A0 query shared by trajectory and Footplant callers. + pub fn query(world: &BoardWorld, request: QueryRequest) -> Result { + query_trajectory( + request, + |start, end, radius| world::line(world, start, end, radius), + |position, radius| world::nearby(world, position, radius), + ) + } + pub fn load(collections: &Collections) -> Result { + Ok(Self { + selector: TrajectorySelector::new(), + settings: settings::load(collections)?, + pending_results: None, + }) + } + pub fn launch( + &mut self, + info: LaunchInfo, + input: SelectorInput, + world: &BoardWorld, + ) -> Result { + let launched = self.selector.launch(info, input, &self.settings)?; + if launched { + self.submit(world)?; + } + Ok(launched) + } + pub fn update(&mut self, input: SelectorInput, world: &BoardWorld) -> Result { + let Some(results) = self.pending_results.take() else { + return Ok(self.selector.update_without_completion()); + }; + let valid = self.selector.complete_batch( + &results, + input, + &self.settings, + world::topology(world), + |start, end, radius| world::line(world, start, end, radius), + )?; + if self.selector.pending() { + self.submit(world)?; + } + Ok(valid) + } + pub fn cancel_pending(&mut self) { + self.pending_results = None; + self.selector.cancel_pending(); + } + fn submit(&mut self, world: &BoardWorld) -> Result<(), String> { + let results = self + .selector + .requests() + .iter() + .map(|&request| Self::query(world, request)) + .collect::, _>>()?; + self.pending_results = Some(results); + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/air_trajectory/settings.rs b/crates/skate-game/src/physics/air_trajectory/settings.rs new file mode 100644 index 00000000..f34f6a79 --- /dev/null +++ b/crates/skate-game/src/physics/air_trajectory/settings.rs @@ -0,0 +1,122 @@ +//! Stock class hashes429A8F4385DD539D(trajectory),7CE1B6B581709C6F +//! (reckoning),5338958C408B6009(feet), defaultD7EDBD362D7D2152. +//! Layout names/offsets were checked against original TU3 loads and stock XML. +use skate_core::{air::trajectory::SelectorSettings, point_graph::PointGraph}; +use skate_data::collections::Collections; + +pub(super) fn load(data: &Collections) -> Result { + let t = |name| data.float("physics_trajectory", "default", name); + let r = |name| data.float("physics_reckoning", "default", name); + Ok(SelectorSettings { + cone_x: t("ConeAngleX")?, + cone_z: t("ConeAngleZ")?, + trajectory_max_time: t("TrajectoryMaxTime")?, + trajectory_max_drop: t("TrajectoryMaxDrop")?, //63673FB6DA092B7E + trajectory_error_start: t("TrajectoryMaxErrorStart")?, + trajectory_error_end: t("TrajectoryMaxErrorEnd")?, + speed_factor_min: t("SpeedFactorMin")?, + speed_factor_max: t("SpeedFactorMax")?, + cone_angle_z_vs_speed: graph8(data, "physics_trajectory", "ConeAngleZVsSpeed", false)?, + cone_x_second_pass: t("ConeAngleXSecondPass")?, + cone_z_second_pass: t("ConeAngleZSecondPass")?, + landing_time_bonus: graph8(data, "physics_trajectory", "LandingTimeBonus", true)?, + landing_com_scalar_vs_slope: graph8( + data, + "physics_trajectory", + "LandingComScalarVsSlope", + true, + )?, + landing_force_scalar: graph8( + data, + "physics_trajectory", + "LandingForceScalarVsDPVelNorm", + false, + )?, + grind_penalty_vs_distance: graph8( + data, + "physics_trajectory", + "GrindPenaltyVsDistToGrind", + true, + )?, + score_middle_bonus: t("ScoreMiddleBonus")?, + score_landing_force: t("ScoreLandingForce")?, + score_landing_direction: t("ScoreLandingDirection")?, + score_transition: t("ScoreTransition")?, + surface_unrideable_score: t("ScoreSurface_Unrideable")?, + surface_dont_align_score: t("ScoreSurface_DontAlign")?, + natural_air_off_verts_scalar: t("NaturalAirOffVertsScalar")?, + minimum_valid_time: t("MinTimeForTrajectoryToBeValid")?, + minimum_time_after_apex: t("MinTimeAfterApexForTransition")?, + minimum_normal_delta_second_pass: t("MinNormalDeltaForSecondPass")?, + maximum_trajectory_adjust: t("MaxTrajectoryAdjust")?, + wall_ride_test_distance: t("WallRideTestDist")?, + wall_ride_minimum_height: t("WallRideMinHeightFromGround")?, + wall_ride_angle_allow_landing: t("WallRideAngleToAllowLanding")?, + wall_ride_height_score: t("ScoreWallrideHeight")?, + wall_ride_boost: negative_graph4(data, "physics_trajectory", "WallRideBoost")?, + //Globals8289D5C8:ED14 class lookup;ED48 stores188, layout248. + wall_ride_normal_dot_limit: data.float( + "physics_feet", + "default", + "WallRideMaxDotFloorWall", + )?, + displacement_vs_speed: graph8(data, "physics_reckoning", "TrajectoryDispVsSpeed", false)?, + displacement_vs_ground_normal: graph8( + data, + "physics_reckoning", + "TrajectoryDispVsGroundNorm", + false, + )?, + trajectory_radius: r("TrajectoryRadius")?, //C6710942E297D587 + trajectory_displacement: r("TrajectoryDisplacement")?, //F85A91F6A233DDAE + minimum_trajectory_frames: data.integer( + "physics_reckoning", + "default", + "MinTrajectorySize", + )? as i32, + vert_jump_align_factor: r("VertJumpAlignFactor")?, //0B42DFBB7F7756A0 + vert_jump_align_max_ground_normal_y: r("VertJumpAlignMaxGroundNormalY")?, //94923D136EA2DA37 + vert_jump_align_min_direction_y: r("VertJumpAlignMinJumpDirY")?, //58BFC8D41216C7EE + vert_jump_align_max_angle: r("VertJumpAlignMaxAngle")?, //723FF102068622CF + }) +} +fn graph8( + data: &Collections, + class: &str, + name: &str, + negative: bool, +) -> Result, String> { + let expected = if negative { + "Sk8::PointNegGraphData8" + } else { + "Sk8::PointGraphData8" + }; + if data.field(class, "default", name)?.type_name != expected { + return Err(format!("Wrong stock graph type {class}/{name}")); + } + let (x, y) = if negative { + let words = data.words::<20>(class, "default", name)?; + ( + std::array::from_fn(|i| f32::from_bits(words[4 + i])), + std::array::from_fn(|i| f32::from_bits(words[12 + i])), + ) + } else { + let words = data.words::<16>(class, "default", name)?; + ( + std::array::from_fn(|i| f32::from_bits(words[i])), + std::array::from_fn(|i| f32::from_bits(words[8 + i])), + ) + }; + //82481E10 receives the x/y spans after any PointNeg header. + Ok(PointGraph { x, y }) +} +fn negative_graph4(data: &Collections, class: &str, name: &str) -> Result, String> { + if data.field(class, "default", name)?.type_name != "Sk8::PointNegGraphData4" { + return Err(format!("Wrong stock graph type {class}/{name}")); + } + let words = data.words::<12>(class, "default", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[8 + i])), + }) +} diff --git a/crates/skate-game/src/physics/air_trajectory/world.rs b/crates/skate-game/src/physics/air_trajectory/world.rs new file mode 100644 index 00000000..be1ce091 --- /dev/null +++ b/crates/skate-game/src/physics/air_trajectory/world.rs @@ -0,0 +1,142 @@ +//! Original trajectory collision leaf82771D08 and nearby triangle collector +//!82772028, across the host's world-format boundary. BoardWorld stores static +//!world-space triangles, so their source geometry transform is identity. +use skate_core::{ + air::trajectory::{SurfaceHit, WorldWithoutGrindEdges}, + math::Vector3, + physics::{ + board_world::BoardWorld, + triangle_query::{TriangleLineHit, triangle_segment}, + }, +}; +type Vector = [f32; 4]; +const IDENTITY: [[f32; 4]; 4] = [ + [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], +]; + +///BoardWorld's authored format contains triangles only; no grind primitive +///collection is present. Update this adapter together with any world format +///extension that introduces actual grind edges. +pub(super) fn topology(_world: &BoardWorld) -> WorldWithoutGrindEdges { + WorldWithoutGrindEdges +} + +pub(super) fn line( + world: &BoardWorld, + start: Vector, + end: Vector, + radius: f32, +) -> Result, String> { + if !radius.is_finite() || radius < 0.0 || start.into_iter().chain(end).any(|v| !v.is_finite()) { + return Err("Non-finite trajectory collision request or invalid radius".into()); + } + let start = vec3(start); + let delta = Vector3::new(end[0] - start.x, end[1] - start.y, end[2] - start.z); + let mut nearest = f32::MAX; + let mut result = None; + for (index, entry) in world.triangles().iter().enumerate() { + let mut hit = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.0; 3], + }; + if triangle_segment(&mut hit, start, delta, entry.triangle.vertices, radius, 0.0) { + let lower = if -hit.fraction >= 0.0 { + 0.0 + } else { + hit.fraction + }; + let fraction = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + if fraction < nearest { + nearest = fraction; + result = Some(SurfaceHit { + position: lanes(hit.position), + normal: lanes(entry.triangle.feature.normal), + transform: IDENTITY, + surface: entry.tag, + geometry: u32::try_from(index).map_err(|_| "World geometry index overflow")?, + }); + } + } + } + Ok(result) +} +pub(super) fn nearby( + world: &BoardWorld, + center: Vector, + radius: f32, +) -> Result, String> { + if !radius.is_finite() || radius < 0.0 { + return Err("Invalid nearby-trajectory triangle radius".into()); + } + let minimum = std::array::from_fn(|i| center[i] - radius); + let maximum = std::array::from_fn(|i| center[i] + radius); + let mut triangles = Vec::with_capacity(64); + for entry in world.triangles() { + let vertices = entry.triangle.vertices.map(lanes); + if triangle_box(vertices, minimum, maximum) { + triangles.push(vertices); + if triangles.len() == 64 { + break; + } + } + } + Ok(triangles) +} +///82761698 full separating-axis triangle/AABB predicate: nine edge-axis +///crosses, three box axes and the face plane. No broadphase-only substitution. +fn triangle_box(vertices: [Vector; 3], minimum: Vector, maximum: Vector) -> bool { + let center: Vector = std::array::from_fn(|i| (minimum[i] + maximum[i]) * 0.5); + let half: Vector = std::array::from_fn(|i| (maximum[i] - minimum[i]) * 0.5); + let v = vertices.map(|p| sub(p, center)); + let edges = [sub(v[1], v[0]), sub(v[2], v[1]), sub(v[0], v[2])]; + let axes = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + ]; + for edge in edges { + for axis in axes { + if separated(v, cross(edge, axis), half) { + return false; + } + } + } + for axis in axes { + if separated(v, axis, half) { + return false; + } + } + !separated(v, cross(edges[0], edges[1]), half) +} +fn separated(vertices: [Vector; 3], axis: Vector, half: Vector) -> bool { + let projections = vertices.map(|v| dot(v, axis)); + let min = projections[0].min(projections[1]).min(projections[2]); + let max = projections[0].max(projections[1]).max(projections[2]); + let radius = (half[0] * axis[0].abs() + half[1] * axis[1].abs()) + half[2] * axis[2].abs(); + min > radius || max < -radius +} +fn dot(a: Vector, b: Vector) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +fn sub(a: Vector, b: Vector) -> Vector { + std::array::from_fn(|i| a[i] - b[i]) +} +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, + ] +} +fn vec3(v: Vector) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn lanes(v: Vector3) -> Vector { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/animated_skeleton.rs b/crates/skate-game/src/physics/animated_skeleton.rs new file mode 100644 index 00000000..b330ed95 --- /dev/null +++ b/crates/skate-game/src/physics/animated_skeleton.rs @@ -0,0 +1,244 @@ +//! Stock skeleton-to-physics ownership. Animation globals and real physical +//! observations enter here; native calculations and history stay in core. +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + skeleton_animation_record::{ + AnimationPartTransform, IDENTITY, SkeletonAnimationMasses, SkeletonAnimationRecord, + compose_affine, map_animation_parts, physics_bone_frame, + }, + skeleton_board_frames::SkeletonBoardFrames, + skeleton_board_offset::SkateboardOffset, + skeleton_landing::{LandingAdjustment, LandingInput, LandingSettings}, + skeleton_motion::SkeletonMotion, + skeleton_root::SkeletonRootFrames, + }, + point_graph::PointGraph, +}; +use skate_data::{ + animation_frames::AnimationFrames, collections::Collections, physics_skeleton::PhysicsSkeleton, +}; +use std::path::Path; + +pub(crate) struct AnimatedSkeleton { + pub record: SkeletonAnimationRecord, + pub roots: SkeletonRootFrames, + pub board_frames: SkeletonBoardFrames, + pub board_offset: SkateboardOffset, + pub landing: LandingAdjustment, + pub targets: [AnimationPartTransform; 4], + pub masses: SkeletonAnimationMasses, + pub bone_indices: [usize; 24], + pub physics_frames: [AnimationPartTransform; 24], + pub animation_board: AnimationPartTransform, + pub animation_hips: AnimationPartTransform, + pub motion: SkeletonMotion, + pub board_at_y_delta: f32, + target_bones: [usize; 4], + landing_settings: LandingSettings, +} +impl AnimatedSkeleton { + ///Existing stock82BED688 target mappings; left and right reparented hands. + pub(crate) fn reparented_hand_indices(&self) -> [usize; 2] { + [self.target_bones[2], self.target_bones[3]] + } + + pub fn load( + asset_root: &Path, + data: &Collections, + animation: &AnimationFrames, + head_has_hat: bool, + ) -> Result { + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + &animation.source_sha256, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Stock skater physics skeleton must contain24 parts".into()); + } + let lookup = |name: &str| { + animation + .bone_names + .iter() + .position(|n| n.eq_ignore_ascii_case(name)) + .ok_or_else(|| { + format!("Stock physics bone {name} is absent from the animation hierarchy") + }) + }; + let mut bone_indices = [0; 24]; + let mut physics_frames = [IDENTITY; 24]; + let mut sizes = [Vector3::ZERO; 24]; + let mut shapes = [0; 24]; + for (part, bone) in skeleton.bones.iter().enumerate() { + bone_indices[part] = lookup(&bone.name)?; + let translation = std::array::from_fn(|i| f32::from_bits(bone.words[16 + i])); + physics_frames[part] = physics_bone_frame(bone.rotation(), translation); + let [x, y, z] = bone.size(); + sizes[part] = Vector3::new(x, y, z); + shapes[part] = data.words::<6>( + "physics_skeleton_bones", + "default", + &format!("PART_{}", bone.name), + )?[3]; + } + let mut target_bones = [0; 4]; + // Native82BED688 exact order and names. These are board-parented + // targets; do not replace them with the physical foot/hand indices. + for (i, name) in [ + "LeftToeBase_Reparented", + "RightToeBase_Reparented", + "LeftHand_Reparented", + "RightHand_Reparented", + ] + .iter() + .enumerate() + { + target_bones[i] = lookup(name)?; + } + Ok(Self { + record: SkeletonAnimationRecord::default(), + roots: SkeletonRootFrames::default(), + board_frames: SkeletonBoardFrames::default(), + board_offset: SkateboardOffset::default(), + landing: LandingAdjustment::default(), + targets: [IDENTITY; 4], + masses: SkeletonAnimationMasses::from_bone_data(sizes, shapes, head_has_hat), + bone_indices, + physics_frames, + animation_board: IDENTITY, + animation_hips: IDENTITY, + motion: SkeletonMotion::default(), + board_at_y_delta: 0.0, + target_bones, + landing_settings: load_landing_settings(data)?, + }) + } + + /// ProcessData's mapped pose, target frames, landing adjustment, board offset, + /// COM and final board cache. The caller supplies actual processed physical + /// observations; this owner never substitutes the board body's COM for them. + /// Grind/landing-on-board/offboard offset producers refresh board_offset + /// before this call's landing stage when their native conditions require it. + pub fn process_pose( + &mut self, + globals: &[AnimationPartTransform], + mut landing: LandingInput, + dt: f32, + flags_2468: &mut u32, + flags_2472: &mut u32, + ) -> Result<(), String> { + let trajectory = globals.first().ok_or("Animation has no trajectory bone")?; + self.motion + .process_trajectory(trajectory, &self.roots.animation_to_world, dt); + let mut parts = map_animation_parts(globals, &self.bone_indices, &self.physics_frames)?; + SkeletonMotion::publish_unadjusted_board(&parts[0], flags_2472); + for (target, part) in [15, 19, 3, 7].into_iter().enumerate() { + let bone = globals + .get(self.target_bones[target]) + .ok_or("Missing animation IK target bone")?; + self.targets[target] = compose_affine(bone, &self.physics_frames[part]); + } + landing.animation_com_height = self.record.centre_of_mass[1] - parts[0][3][1]; + self.landing + .update(landing, &self.landing_settings, &mut self.board_offset); + self.board_offset.update(&mut parts[0], &mut self.targets); + self.record + .update(&parts, &self.roots.animation_to_board, &self.masses); + // ProcessData82BD8E04..8E10 writes local board11728 AFTER COM. + // World animation target15952 belongs to the later board-update phase. + self.animation_board = parts[0]; + self.animation_hips = parts[23]; + self.board_at_y_delta = self.motion.publish_adjusted_board(&parts[0], flags_2468); + Ok(()) + } + + /// UpdateRootTransforms is called by the native ground/animated board update + /// after ProcessData. Its result becomes the next ProcessData COM frame. + pub fn update_roots( + &mut self, + board: &BoardRuntime, + reckoning: &AnimationPartTransform, + dt: f32, + ) { + let deck = board.part_transforms()[BodyId::Deck.index()]; + let velocity = board.bodies()[BodyId::Deck.index()].rates.linear_velocity; + let mut transform = IDENTITY; + for (axis, column) in deck.basis.columns.iter().enumerate() { + transform[axis][..3].copy_from_slice(column); + } + transform[3] = [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ]; + self.roots.update( + transform, + [velocity.x, velocity.y, velocity.z, 0.0], + dt, + &self.animation_board, + reckoning, + ); + } + + ///Ground board phase before GeneralUpdate's IK and physical drives. + pub fn prepare_ground( + &mut self, + board: &BoardRuntime, + reckoning: &AnimationPartTransform, + dt: f32, + flags_2468: &mut u32, + ) -> AnimationPartTransform { + self.roots.initialize_heading = true; //82BDF550, on every Ground update. + self.update_roots(board, reckoning, dt); + self.board_frames.prepare_ground( + &self.roots, + &self.record.pose[0], + self.roots.board, + flags_2468, + ) + } + + ///82BDF778..F7AC runs AFTER the current GeneralUpdate consumes trajectory. + pub fn finish_ground(&mut self) { + self.motion.next_trajectory = IDENTITY; + } +} + +fn load_landing_settings(data: &Collections) -> Result { + let value = |name| data.float("physics_animation", "default", name); + let graph = |name| -> Result, String> { + // PointNegGraphData4 stores four bounds, followed by native X/Y arrays. + let words = data + .words::<12>("physics_animation", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[4..8].try_into().unwrap(), + y: words[8..12].try_into().unwrap(), + }) + }; + Ok(LandingSettings { + manual_blend: graph("LandingAdjustManualBlend")?, + grind_blend: graph("LandingAdjustGrindBlend")?, + coffin_height: graph("LandingAdjustCoffinBounceHeight")?, + minimum_height: value("LandingAdjustMinYPos")?, + maximum_velocity: value("LandingAdjustMaxVel")?, + manual_damping: value("LandingAdjustManualK2")?, + manual_spring: value("LandingAdjustManualK1")?, + ground_minimum_compression_time: value("LandingAdjustGroundMinCompressionTime")?, + ground_damping: value("LandingAdjustGroundK2")?, + ground_spring: value("LandingAdjustGroundK1")?, + grind_animation_target_time: value("LandingAdjustGrindStartUsingAnimTargetAfterTime")?, + grind_damping: value("LandingAdjustGrindK2")?, + grind_spring: value("LandingAdjustGrindK1")?, + grind_target_delta: value("LandingAdjustGrindBlendTowardsAnimTargetSpeed")?, + desired_com_height: value("LandingAdjustDesiredCom")?, + coffin_time: value("LandingAdjustCoffinBounceTime")?, + coffin_maximum_velocity: value("LandingAdjustCoffinBounceMaxVel")?, + coffin_blend_frames: value("LandingAdjustCoffinBounceFramesToBlendAway")?, + coffin_base_height: value("LandingAdjustCoffinBounceBaseHeight")?, + }) +} diff --git a/crates/skate-game/src/physics/animation_feedback.rs b/crates/skate-game/src/physics/animation_feedback.rs new file mode 100644 index 00000000..47109087 --- /dev/null +++ b/crates/skate-game/src/physics/animation_feedback.rs @@ -0,0 +1,75 @@ +//! Persistent physical-to-animation conditioning with stock collection values. +use skate_core::{ + animation::{ + ground_acceleration, + physical_feedback::{self, ControlFeedback, PhysicalFeedback, ReckoningFeedback}, + }, + input::turn_conditioner, + physics::board_motion_output::BoardMotionOutput, + riding::{pumping::state::PumpingState, speed_wobble::SpeedWobbleState}, +}; +use skate_data::collections::Collections; + +pub(crate) struct AnimationFeedback { + settings: turn_conditioner::Settings, + bump_settings: ground_acceleration::Settings, + state: turn_conditioner::State, + previous_lateral_tilt: [f32; 4], + pub published_previous_lateral_tilt: [f32; 4], +} + +impl AnimationFeedback { + pub fn load(data: &Collections) -> Result { + Ok(Self { + //82DEFAA0..FAC8 zeros the retained vector96. + previous_lateral_tilt: [0.0; 4], + published_previous_lateral_tilt: [0.0; 4], + settings: super::animation_feedback_settings::load(data)?, + // Global344 at8289FCA0 binds hash7823ADA8E7C896AA = bumps. + bump_settings: ground_acceleration::Settings { + scale_x_acc: data.float("anim_motion", "bumps", "scale_x_acc")?, + min_bump_mag: data.float("anim_motion", "bumps", "min_bump_mag")?, + }, + //82DEFAA0 resets histories to zero. Every82DEFF38 update refreshes + //the four coefficient words before using each filter. + state: turn_conditioner::State { + history: [0.0; 8], + filters: [[0.0; 9]; 3], + }, + }) + } + + pub fn reset(&mut self) { + self.state.reset_history(); + self.previous_lateral_tilt = [0.0; 4]; + self.published_previous_lateral_tilt = [0.0; 4]; + } + + /// Call once after the native physical outputs are published. Absence of a + /// completed Reckoning frame belongs to the caller, not a fallback here. + pub fn update( + &mut self, + motion: &BoardMotionOutput, + pumping: &PumpingState, + wobble: &SpeedWobbleState, + reckoning: ReckoningFeedback, + controls: ControlFeedback, + acceleration: ground_acceleration::Input, + lateral_tilt: [f32; 4], + ) -> PhysicalFeedback { + //82DEFB6C..FB7C publishes the preceding Animation0 at Animation16, + //then retains current Reckoning1248 for the next conditioner update. + self.published_previous_lateral_tilt = self.previous_lateral_tilt; + self.previous_lateral_tilt = lateral_tilt; + physical_feedback::publish( + &mut self.state, + &self.settings, + motion, + pumping, + wobble, + reckoning, + controls, + ground_acceleration::publish(acceleration, &self.bump_settings), + ) + } +} diff --git a/crates/skate-game/src/physics/animation_feedback_settings.rs b/crates/skate-game/src/physics/animation_feedback_settings.rs new file mode 100644 index 00000000..226ab886 --- /dev/null +++ b/crates/skate-game/src/physics/animation_feedback_settings.rs @@ -0,0 +1,60 @@ +//! PhysOutConditioner_Animation turn settings, TU3 global204/physics_animation. +use skate_core::{input::turn_conditioner::Settings, point_graph::PointGraph}; +use skate_data::collections::Collections; +pub(crate) fn load(data: &Collections) -> Result { + let coefficients = |name| { + data.words::<4>("physics_animation", "default", name) + .map(|w| w.map(f32::from_bits)) + }; + let plain = |name| -> Result, String> { + let w = data + .words::<16>("physics_animation", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: w[..8].try_into().unwrap(), + y: w[8..].try_into().unwrap(), + }) + }; + let speed = data + .words::<20>("physics_animation", "default", "ScaleQuicknessAtSpeed")? + .map(f32::from_bits); + let smoothing = data + .words::<12>("physics_animation", "default", "SpeedWobbleTurnBlend")? + .map(f32::from_bits); + let mut parameters = [0.0; 13]; + for (target, name) in parameters.iter_mut().zip([ + "QuicknessMaxDelta", + "InputQuicknessMax", + "InputQuicknessBlendVal", + "HoldingMinVal", + "HoldingMaxAcceleration", + "HoldingIntegralBlend", + "HoldingBlendUp", + "HoldingBlendDown", + "AnimationTurnWobbleScalar", + "AnimationTurnPhysicsWeight", + "AnimationTurnMaxLean", + "AnimationTurnGraphX", + "AnimationTurnDirMax", + ]) { + *target = data.float("physics_animation", "default", name)?; + } + Ok(Settings { + filter_coefficients: [ + coefficients("AnimationTurnLeanFilterSlow")?, + coefficients("AnimationTurnLeanFilter")?, + coefficients("AnimationTurnDirFilter")?, + ], + input_curve: plain("AnimationTurnSpeedGraph")?, + quickness_curve: plain("QuicknessVsQuickness")?, + speed_curve: PointGraph { + x: speed[4..12].try_into().unwrap(), + y: speed[12..20].try_into().unwrap(), + }, + smoothing_curve: PointGraph { + x: smoothing[4..8].try_into().unwrap(), + y: smoothing[8..12].try_into().unwrap(), + }, + parameters, + }) +} diff --git a/crates/skate-game/src/physics/animation_input.rs b/crates/skate-game/src/physics/animation_input.rs new file mode 100644 index 00000000..e618f6d4 --- /dev/null +++ b/crates/skate-game/src/physics/animation_input.rs @@ -0,0 +1,139 @@ +//! Animation attributes become owned physical inputs through the actual +//! Skeleton dispatcher. This adapter loads stock settings and retains caches; +//! it does not choose animations or manufacture a push from controller input. +use skate_core::{ + animation::{ + output::{ + NativeMatrix, + attributes::{AnimationAttribute, AttributeName}, + }, + skeleton_input::{ + attribute_finalization::{FinalizationInput, JumpAttributeState}, + contact_events::{ContactEventPose, ContactEventState}, + extended_attributes::ExtendedAttributes, + name::encode, + process_attributes, + scalar_attributes::{AnimationControlOutput, ScalarAttributeInputs}, + }, + }, + input::controller::ActionMap, +}; +use skate_data::{animation_frames::AnimationFrames, collections::Collections}; + +pub(crate) struct AnimationInput { + pub fields: ScalarAttributeInputs, + pub extra: ExtendedAttributes, + pub contacts: ContactEventState, + pub output: AnimationControlOutput, + cached_jump: JumpAttributeState, + bone_names: Vec, + right_toe: usize, + settings: FinalizationInput, +} + +impl AnimationInput { + pub fn load(data: &Collections, frames: &AnimationFrames, mode: &str) -> Result { + let right_toe = frames + .bone_names + .iter() + .position(|n| n.eq_ignore_ascii_case("RightToeBase")) + .ok_or("Stock skeleton is missing RightToeBase")?; + Ok(Self { + fields: ScalarAttributeInputs::reset(0, 0), + extra: ExtendedAttributes::reset(0.0), + contacts: ContactEventState { + bone: 0, + push_speed: 0.0, + }, + cached_jump: JumpAttributeState::new(), + // AnimOut construction82DB1E88..1EFC initializes the null name, + // float24=0 and clears all represented high flag bits. + output: AnimationControlOutput { + grind_name: encode(b""), + flags: 0, + }, + bone_names: frames + .bone_names + .iter() + .map(|n| encode(n.as_bytes())) + .collect(), + right_toe, + settings: FinalizationInput { + //TU3 global constructor8289F8B4 binds292 to anim_motion + //collectionAC260B44FA3CA0A4 (jumping), not a default profile. + select_jump_extremes: data.boolean("anim_motion", "jumping", "clamp_jump")?, + low_jump_threshold: data.float("anim_motion", "jumping", "clamp_low_inclusive")?, + high_jump_threshold: data.float( + "anim_motion", + "jumping", + "clamp_high_inclusive", + )?, + allow_height_override: data.boolean( + "physics_mode", + mode, + "JumpHeightOverrideEnabled", + )?, + use_prepared_controls: data.boolean( + "physics_jump", + "default", + "AdjustOnPrepare", + )?, + external_impulse_active: false, + animation_flags: 0, + }, + }) + } + + /// ProcessedPhysIn Reset precedes physical/packet flag publication. The + /// caller then publishes those flags before dispatching the current pose. + pub fn reset_processed(&mut self) { + self.fields = ScalarAttributeInputs::reset(self.fields.flags2468, self.fields.flags2488); + self.extra = ExtendedAttributes::reset(self.extra.footstep_strength); + self.contacts = ContactEventState { + bone: 0, + push_speed: 0.0, + }; + } + + ///82DB7DCC..7E0C runs AFTER publishing AnimOut into the physical output. + /// It is distinct from ProcessedPhysIn reset at the start of input. + pub fn finish_output_publication(&mut self) { + self.output.grind_name = encode(b""); + self.output.flags &= 0x003fffff; + self.extra.footstep_strength = 0.0; + } + + pub fn process( + &mut self, + attributes: &[AnimationAttribute], + hierarchy: &[NativeMatrix], + timestep: f32, + animation_flags: u32, + external_impulse_active: bool, + map: &mut dyn ActionMap, + ) -> Result<(), String> { + let pose = ContactEventPose { + bone_names: &self.bone_names, + hierarchy, + trajectory_bone: 0, + right_toe_bone: self.right_toe as i32, + timestep, + }; + let settings = FinalizationInput { + animation_flags, + external_impulse_active, + ..self.settings + }; + process_attributes::process( + attributes, + &pose, + &mut self.fields, + &mut self.extra, + &mut self.contacts, + &mut self.cached_jump, + &mut self.output, + settings, + Some(map), + ) + } +} diff --git a/crates/skate-game/src/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs new file mode 100644 index 00000000..ca1180dd --- /dev/null +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -0,0 +1,340 @@ +//! The production physical-output -> stock graph -> next physical-input phase. +//! Native actor phases82592FD8/82593128/82593230/82593640 preserve this order. +#[path = "animation_phase_packet.rs"] +mod packet; +use super::{GamePhysics, PlayerControls, skater::SkaterRuntime}; +use crate::{graph_runtime::StockGraphs, skater_animation::AnimationPhysical}; +pub(crate) use packet::{AnimationPhaseOutput, AnimationProfile}; +use skate_core::{ + animation::{ + body_tilt, ground_acceleration, + physical_feedback::{ControlFeedback, PhysicalFeedback, ReckoningFeedback}, + riding_fakie, + }, + graph::conditions::{ConditionInputs, PhysicalStateInputs, PushBrakeInputs}, + math::Vector3, + physics::board::BodyId, + riding::push_behaviors::PushFootFrame, +}; + +/// Observations are from the preceding completed physics tick. The current +/// controller map then advances both stock graphs; returned storage remains +/// alive while PlayerInput consumes packet(), hierarchy and ordered attributes. +pub(crate) fn advance( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + controls: &PlayerControls, + graphs: &StockGraphs, + profile: &AnimationProfile, +) -> Result { + let p = &skater.player_input.processed; + let physical = &skater.player_input.physical; + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let (fakie, mirrored) = skater.animation.stance(); + let feedback = skater.physical_feedback; + skater.animation.motion.wipeout_physical = Some(crate::graph_host::motion_wipeout::Physical { + over_599: physical.skeleton.over_599 != 0, + collision_time_144: physical.animation.collision_time_144, + no_support_time_548: physical.skeleton.no_support_time_548, + profile_148: physical.animation.profile_148, + below_surface_82: skater.player_state.state_flags[82 - 52], + // Skeleton Fill82BE1E94/98 copies hips physical up into output80. + orientation_y: Some(skater.skeleton.record.pose[23][1][1]), + hips_right_angle_496: physical.skeleton.hips_right_angle_496, + hips_up_angle_500: physical.skeleton.hips_up_angle_500, + }); + skater.animation.motion.landing_physical = Some(crate::graph_host::motion_landing::Physical { + height: feedback.crouching.animation_height_72, + spin: skater.landing_quality.spin_92, + kind: skater.landing_quality.landing_type_96, + last_good_landing_velocity: skater.animation.motion.riding.last_good_landing_velocity, + }); + skater.animation.motion.prelanding_physical = + Some(crate::graph_host::motion_spin::PrelandingPhysical { + air_444: physical.air.flag_444 != 0, + air_normal_144_y: f32::from_bits(physical.air.landing_normal_144[1]), + animation_16_x: skater.animation_feedback.published_previous_lateral_tilt[0], + com_velocity_y: f32::from_bits(physical.reckoning.vector_16[1]), + offboard_316: physical.off_board.flag_316 != 0, + offboard_319: physical.off_board.flag_319 != 0, + offboard_time_32: physical.off_board.scalar_32, + air_437: physical.air.known_air_valid_437 != 0, + air_normal_36: f32::from_bits(physical.air.landing_normal_32[1]), + air_remaining_184: physical.air.scalar_184, + animation_height_72: feedback.crouching.animation_height_72, + }); + let [right_toe, left_toe] = skater + .foot_ik + .physical_toe_positions(&skater.skeleton.record); + skater.animation.motion.air_leg_physical = + Some(skate_core::animation::air_leg_extension::Physical { + com_velocity: physical.reckoning.vector_16.map(f32::from_bits), + com_position: physical.reckoning.vector_64.map(f32::from_bits), + system_up: physical.reckoning.vector_96.map(f32::from_bits), + right_toe, + left_toe, + animation_height: feedback.crouching.animation_height_72, + offboard_316: physical.off_board.flag_316 != 0, + remaining_air_time: physical.air.scalar_184, + }); + skater.animation.action.physical_conditions = + Some(crate::graph_host::action::PhysicalConditions { + requests_dismount: skater.player_state.state_flags[77 - 52], + state: physical.state.state_16, + }); + skater.animation.motion.gameplay_conditions = Some( + crate::graph_host::motion_gameplay_conditions::GameplayConditions { + state: physical.state.state_16, + wants_runout: skater.player_state.state_flags[78 - 52], + physics_wiping: skater.player_state.state_flags[59 - 52], + body_flipping: physical.air.flag_441 != 0, + bumped: feedback.bumped, + wants_wipeout: skater.player_state.state_flags[63 - 52] + || skater.player_state.state_flags[65 - 52], + grabbing_object: physical.off_board.flag_304 != 0, + retrieving_board: physical.off_board.retrieving_board_323 != 0, + dropping_board: physical.off_board.dropping_board_322 != 0, + in_biped_air: physical.off_board.flag_328 != 0, + hippy_hurdling: physical.off_board.hippy_hurdling_317 != 0, + handplant_flags: physical.air.handplant_flags_324, + time_to_skitch: physical.ground.scalar_276, + skitch_transition_time: profile.skitch_transition_time, + time_to_land: physical.animation.collision_time_144, + offboard_time_to_land: physical.off_board.scalar_32, + }, + ); + skater.animation.action.gameplay_conditions = skater.animation.motion.gameplay_conditions; + skater.animation.motion.native_physical = Some(crate::graph_host::motion_native::Physical { + centre_of_mass_velocity: skater.animated_skeleton.board_frames.com_velocity, + system_up: vec4(physics.riding.reckoning.up), + //82DB70A0..70FC publishes Reckoning752, not the physical deck frame. + board_reckoning_z: physics.riding.reckoning_frames.ground[2], + board_reckoning: physics.riding.reckoning_frames.ground, + }); + skater.animation.motion.gesture_physical = + Some(crate::graph_host::motion_native::GesturePhysical { + //Original82DB74D8, not the similarly numbered Air output byte. + ground321: p.flags_2480 & 0x1000 != 0, + state_offboard75: physical.state.category_12 == 500, + selections: profile.gesture_selections, + suppress_up: profile.suppress_up_gesture, + force_brake_bypass: profile.gesture_force_brake_bypass, + }); + //Original ProcessOutput82DB7E38. Probe+72 is copied to Processed1848; + //Ground240 resets to zero and receives this point only on an interaction. + let interaction_trigger = matches!(p.category_2512, 100 | 500) + && p.probe_1792.bytes_72_73[0] != 0 + && p.flags_2476 & 0x400000 != 0; + let direction = if interaction_trigger { + let source = if p.flags_2484 & 0x40000 != 0 { 0 } else { 1 }; + let point = p.probe_1792.vectors_16_32_48[source].map(f32::from_bits); + let inverse = skater.animated_skeleton.roots.world_to_animation; + std::array::from_fn(|i| { + inverse[2][i].mul_add( + point[2], + inverse[1][i].mul_add(point[1], inverse[0][i].mul_add(point[0], inverse[3][i])), + ) + }) + } else { + [0.0; 4] + }; + skater.animation.motion.shove_physical = Some(crate::graph_host::motion_shove::ShovePhysical { + interaction_trigger, + direction, + in_biped_category: physical.state.category_12 == 500, + board_on_ground: physical.off_board.flag_311 != 0, + animation_height: feedback.crouching.animation_height_72, + }); + // FilteredState82DE5BA0 clears grind metadata on every nongrind output. + // A future grind phase must supply its actual cached name, not an AG intent. + if physical.filtered_state_0 == 3 { + return Err("Animation phase requires the selected grind's filtered output name".into()); + } + let conditions = ConditionInputs { + speeds: Some(physics.riding.graph_speeds()), + physical_state: Some(PhysicalStateInputs { + category: physical.filtered_state_0, + grinding: false, + grind_name: String::new(), + }), + time_since_last_input: Some(p.time_since_last_input_2748), + mirrored: Some(mirrored), + riding_fakie: Some(fakie), + push_brake: Some(PushBrakeInputs { + // Board FillPhysOut82C03304/3318: CollisionInfo+16 ->Ground80. + ground_axis_y: physics.riding.ground.wheel_normal.y, + skeleton_disables_push_brake: skater.foot_ik.state.contacts.support_failed_this_update, + maximum_ground_angle_degrees: profile.maximum_ground_angle_degrees, + }), + }; + // Skeleton GetEffectiveRoot82BE3650 negates X/Z iff Processed2476 bit2. + let mut effective_z = skater.animated_skeleton.roots.animation_to_world[2]; + if p.flags_2476 & 4 != 0 { + effective_z = effective_z.map(|v| -v); + } + let mut effective_x = skater.animated_skeleton.roots.animation_to_world[0]; + if p.flags_2476 & 4 != 0 { + effective_x = effective_x.map(|v| -v); + } + skater.animation.motion.riding_conditions = Some( + crate::graph_host::motion_riding_conditions::RidingConditionInputs { + com_velocity: physical.reckoning.vector_16.map(f32::from_bits), + skeleton_x: effective_x, + skeleton_z: effective_z, + skate_up_y: deck.basis.columns[1][1], + surface_up_y: physics.riding.ground.wheel_normal.y, + }, + ); + let observations = AnimationPhysical { + conditions, + feedback, + body_tilt: body_tilt::Physical { + lateral_tilt: physics.riding.reckoning_frames.lateral_tilt[0], + body_spin_speed: skater.air_reckoning.state.spin_speed, + filtered_category: physical.filtered_state_0, + }, + fakie: riding_fakie::Physical { + category: physical.filtered_state_0, + grind_state: physical.state.state_16, + doing_trick: skater.animation.motion.flags.doing_trick, + board_axis: effective_z, + deck_velocity: vec4(physics.riding.motion.linear_velocity), + external_velocity: skater.centre_of_mass_output.velocity, + ground_projected_speed: physics.riding.motion.ground_speed, + }, + // Original82DB7A58..7AA4 copies AnimOut10374/10371 respectively. + physical_stance: ( + skater.animation.packet.regular_stance, + skater.animation.packet.riding_switch, + ), + foot_frame: PushFootFrame { + left_foot: skater.skeleton.record.pose[15][3], + right_foot: skater.skeleton.record.pose[19][3], + //82C02D0C: physical deck body+16, not geometric part translation. + deck_position: vec4(physics.board.bodies()[BodyId::Deck.index()].rates.position), + deck_y: axis(deck.basis.columns[1]), + deck_z: axis(deck.basis.columns[2]), + //82DB7598 publishes Motion273 from Processed2468 bit20. + skateboard_flipped: p.flags_2468 & (1 << 20) != 0, + }, + board_present: physical.off_board.flag_311 != 0, + physical_28_byte75: physical.state.category_12 == 500, + time_since_teleport: skater.animation.motion.riding.time_since_teleport, + + }; + let mut output = AnimationPhaseOutput::new(feedback); + skater.animation.advance( + graphs, + physics.settings.step.simulation.time_step, + &controls.action_intents, + observations, + &mut output.reset, + )?; + output.publish(&skater.animation.packet, profile, controls.actor_flags); + if let Some(reply) = skater.teleport_state.take_reply() { + output.publish_external_reset(reply); + } + Ok(output) +} + +/// Run once after completed physical output, before camera consumes that output. +/// Animation reads this same stored result on the following actor phase. +pub(crate) fn publish_feedback( + physics: &GamePhysics, + skater: &mut SkaterRuntime, +) -> PhysicalFeedback { + // World8275F430 -> AirCollector82DA7888 publishes the complete mask. + // This host runs active gameplay with HoM and challenges outside its scope. + skater.player_input.physical.scoring.capabilities_204 = + skate_core::player::conditioner_capabilities::ConditionerCapabilityContext { + in_front_end: false, + hall_of_meat_enabled: false, + challenge_query_active: false, + challenge_configuration_enabled: false, + }.capabilities(); + let p = &skater.player_input.processed; + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + // ProcessOutput82DE53F0 resets the animation packet; CalcLandingQuality + //82DE61D0 follows FilteredState82DE5BA0 and uses the completed physical output. + skater.landing_quality = Default::default(); + if let Some(filtered) = skater.player_state.filtered_output.as_ref() { + skater.landing_quality.update( + skate_core::animation::landing_quality::Input { + previous_filtered_state: filtered.previous_category as u32, + filtered_state: filtered.category as u32, + ground_normal: vec4(physics.riding.ground.overall_normal), + deck_velocity: vec4(physics.riding.motion.linear_velocity), + flipped: p.flags_2468 & (1 << 20) != 0, + // Board Fill82C02AD8..2B1C publishes physical part6 Z to + // bundle0+32; this is separate from Reckoning752's ground frame. + reckoning_forward: axis(deck.basis.columns[2]), + // Ground Enter clears this owner before publication, as in82D375C8. + air_spin: skater.air_reckoning.state.spin_speed, + }, + &skater.landing_quality_settings, + ); + } + let feedback = skater.animation_feedback.update( + &physics.riding.motion, + &skater.ground.pumping, + &skater.ground.wobble, + ReckoningFeedback { + // Skeleton16144 -> PhysOutSystemReckoning64,82BE1D84/8C. + system_position: vec3(skater.animated_skeleton.board_frames.centre_of_mass), + system_up: physics.riding.reckoning.up, + board_position: deck.translation, + target_lean_angle: physics.riding.reckoning_frames.target_lean_angle, + }, + ControlFeedback { + processed_flags: p.flags_2468, + turn: skater.animation_input.fields.turn, + animation_mirrored: skater.animation.packet.mirrored, + }, + ground_acceleration::Input { + deck: super::solve::deck_frame(&physics.board), + ground: physics.riding.reckoning_frames.ground, + world_acceleration: vec4(physics.riding.ground.accelerations[BodyId::Deck.index()]), + }, + physics.riding.reckoning_frames.lateral_tilt, + ); + skater.physical_feedback = feedback; + feedback +} + +/// PhysOutAnimation reset82DEFAA0 and the initial represented body outputs. +pub(crate) fn initial_feedback() -> PhysicalFeedback { + PhysicalFeedback { + turning: skate_core::input::set_turning::Physical { + field_32: 0.0, + field_36: 0.0, + field_52: 0.0, + field_56: 0.0, + field_60: 0.0, + body_168: 0.0, + }, + crouching: skate_core::animation::crouching::Physical { + body_84: 0.0, + body_164: 0.0, + body_188: 0.0, + force_516: 0.0, + ground_force_520: 0.0, + minimum_crouch_528: 0.0, + deck_angle_532: 0.0, + animation_height_72: 0.0, + }, + pumping_acceleration: 0.0, + ground_acceleration: [0.0; 4], + // Animation reset vector112 is zero; stock bumps threshold is positive100. + bumped: false, + conditioned_turn: [0.0; 8], + } +} +fn vec3(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn vec4(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn axis(v: [f32; 3]) -> [f32; 4] { + [v[0], v[1], v[2], 0.0] +} diff --git a/crates/skate-game/src/physics/animation_phase_packet.rs b/crates/skate-game/src/physics/animation_phase_packet.rs new file mode 100644 index 00000000..bec89da1 --- /dev/null +++ b/crates/skate-game/src/physics/animation_phase_packet.rs @@ -0,0 +1,199 @@ +//! Owned PlayerInput packet storage; original actor publication825937EC. +use skate_core::{ + animation::{ + output::{ + actor_packet::ExternalPhysicsInput, + packet_reset::{AdditionalResetFields, RESET_POSE}, + physics_packet::PhysicsPosePacket, + }, + physical_feedback::PhysicalFeedback, + }, + input::animation_packet::AnimationPacketFields, + player::input_phase::AnimationInputPacket, +}; +use skate_data::collections::Collections; + +/// Host-owned player preferences, distinct from the stock response curves. +pub(crate) struct AnimationProfile { + pub maximum_ground_angle_degrees: f32, + pub skitch_transition_time: f32, + pub truck_tightness: f32, + pub wheel_hardness: f32, + pub physics_mode: u32, + pub prevent_manual_respawn: bool, + pub ignore_respawn_reset_button: u8, + pub force_braking: bool, + pub suppress_transition: bool, + /// Optional gesture customization and external context are host policy. + pub gesture_selections: Option<[u32; 4]>, + pub suppress_up_gesture: bool, + pub gesture_force_brake_bypass: bool, +} +impl AnimationProfile { + pub fn load(data: &Collections, mode: &str) -> Result { + //Original82DB1B08..1B78 initializes these variant slots in this order. + let physics_mode = ["easy", "normal", "hardcore", "motorized", "test"] + .iter() + .position(|name| *name == mode) + .ok_or_else(|| format!("Undefined player physics profile {mode}"))? + as u32; + Ok(Self { + //82BBBDE0: Globals400/layout96, bound by828A0154. + skitch_transition_time: data.float( + "anim_skitching", + "default", + "LongSkitchIntoReachTime", + )?, + //82BA5150 global316/layout1844;8289FB24 binds pushing. + maximum_ground_angle_degrees: data.float( + "anim_motion", + "pushing", + "disable_push_brake_at_slope", + )?, + //Initial equipment preferences use the fixed settings value from + //82593988/94. The host allows users to change these independently. + truck_tightness: f32::from_bits(0x3f333333), + wheel_hardness: f32::from_bits(0x3f333333), + physics_mode, + prevent_manual_respawn: false, + ignore_respawn_reset_button: 0, + force_braking: false, + suppress_transition: false, + gesture_selections: None, + //82B988B8 returns false when its optional context is absent. + suppress_up_gesture: false, + gesture_force_brake_bypass: false, + }) + } +} + +pub(crate) struct AnimationPhaseOutput { + /// Camera reuses this result; never run the conditioner a second time. + pub feedback: PhysicalFeedback, + pub(super) reset: AdditionalResetFields, + publication: AnimationPacketFields, + external: ExternalPhysicsInput, + mirrored: u8, + weight_forwards: u8, + flags: u32, + // Actor825938FC/393C consumes these request bits after publication. +} +impl AnimationPhaseOutput { + pub(super) fn new(feedback: PhysicalFeedback) -> Self { + Self { + feedback, + //Reserved storage: native reset writes every field before use. + reset: AdditionalResetFields { + compression: 0.0, + foot_ik_influence: [0.0; 2], + next_step_position_valid: false, + actor_flag_1904_bit23: false, + actor_flag_1908_bit2: false, + external_impulse_active: false, + external_physics_input_active: false, + externally_controlled: false, + prevent_manual_respawn: false, + ignore_respawn_reset_button: 0, + force_braking: false, + truck_tightness: 0.0, + wheel_hardness: 0.0, + auxiliary_vectors: [[0.0; 4]; 6], + requested_physics_mode: 1, + }, + publication: AnimationPacketFields { + stance_byte: 0, + timestep: 0.0, + scalar_10388: 0.0, + flags_10375_10496_10784: [0; 3], + vector_10480: [0; 4], + matrix_10704: RESET_POSE.map(|c| c.map(f32::to_bits)), + byte_10768: 0, + truck_tightness: 0.0, + scalar_10792: 0.0, + flag_10371: 0, + }, + //No external impulse/controller/reset provider in this game. The + //inactive payloads are owned storage and are not consumed. + external: ExternalPhysicsInput { + vectors: [[0; 4]; 10], + flags: 0, + }, + mirrored: 0, + weight_forwards: 0, + flags: 0, + } + } + pub(super) fn publish( + &mut self, + pose: &PhysicsPosePacket, + profile: &AnimationProfile, + actor_flags: u32, + ) { + let r = &mut self.reset; + //No external impulse provider: Actor1904 bit23 is inactive. + r.actor_flag_1904_bit23 = false; + r.actor_flag_1908_bit2 = profile.suppress_transition || actor_flags & (1 << 2) != 0; + r.truck_tightness = profile.truck_tightness; + r.wheel_hardness = profile.wheel_hardness; + r.requested_physics_mode = profile.physics_mode; + r.prevent_manual_respawn = profile.prevent_manual_respawn; + r.ignore_respawn_reset_button = profile.ignore_respawn_reset_button; + r.force_braking = profile.force_braking; + //Free skating has no challenge scene19/20 requesting bit22. + //82593924 reads Actor1904 bit25, not the input-filter word1908. + self.flags = pose.flags & !((1 << 24) | (1 << 22)); + self.mirrored = u8::from(pose.mirrored); + self.weight_forwards = u8::from(pose.weight_forwards); + self.publication = AnimationPacketFields { + stance_byte: u8::from(pose.board_flipped), + timestep: pose.timestep, + scalar_10388: r.compression, + flags_10375_10496_10784: [ + u8::from(r.actor_flag_1904_bit23), + 0, + 0, //Actor1904 bit29 is published only by the canonical reset reply. + ], + vector_10480: [0; 4], + matrix_10704: RESET_POSE.map(|c| c.map(f32::to_bits)), + byte_10768: 0, + truck_tightness: r.truck_tightness, + scalar_10792: r.wheel_hardness, + flag_10371: u8::from(pose.riding_switch), + }; + } + ///Actor825926F8 reply ->10704/10768/10784, then82DB5BE0 maps physical input. + pub(super) fn publish_external_reset( + &mut self, reply: skate_core::animation::output::actor_packet::ExternalReset, + ) { + self.publication.matrix_10704 = reply.transform; + self.publication.byte_10768 = reply.byte64; + self.publication.flags_10375_10496_10784[2] = 1; + } + pub fn packet(&self) -> AnimationInputPacket<'_> { + let r = &self.reset; + let vectors = r.auxiliary_vectors.map(|v| v.map(f32::to_bits)); + AnimationInputPacket { + publication: &self.publication, + external_physics_10512: &self.external, + flag_10369: u8::from(r.next_step_position_valid), + flag_10370: self.mirrored, + flag_10373: self.weight_forwards, + suppress_transition_10376: u8::from(r.actor_flag_1908_bit2), + use_external_physics_10688: 0, + external_physics_flag_10689: 0, + flag_10786: u8::from(r.prevent_manual_respawn), + flag_10787: r.ignore_respawn_reset_button, + force_braking_10796: u8::from(r.force_braking), + vector_10816: vectors[0], + vector_10832: vectors[1], + vector_10848: vectors[2], + vector_10864: vectors[3], + vector_10880: vectors[4], + vector_10896: vectors[5], + scalar_10912: r.foot_ik_influence[0], + scalar_10916: r.foot_ik_influence[1], + state_variant_10928: r.requested_physics_mode, + flags_10932: self.flags, + } + } +} diff --git a/crates/skate-game/src/physics/camera_output.rs b/crates/skate-game/src/physics/camera_output.rs new file mode 100644 index 00000000..823b2cad --- /dev/null +++ b/crates/skate-game/src/physics/camera_output.rs @@ -0,0 +1,175 @@ +//! Physical-output publications consumed by gameplay camera82DF69C0. +//! Reset82DE53F0 runs before the actual producers82DB6EC0/82D3A388. +use super::{GamePhysics, SkaterRuntime}; +use crate::camera::{ + CameraAirOutput, CameraAnimationOutput, CameraEventsOutput, CameraGrindOutput, + CameraOffboardOutput, CameraPreferences, CameraPublicationInputs, CameraStateOutput, +}; +use skate_core::{ + animation::physical_feedback::PhysicalFeedback, + camera::{MovingObstacleProvider, PathObstacle}, + physics::centre_of_mass_filter::CentreOfMassOutput, +}; + +/// The actual game camera call, after completed physical output conditioning. +/// The user's selected normal High camera is graph type1. This custom world +/// has no road/ledge/camera-volume annotations or other moving actors. +pub(crate) fn advance( + physics: &GamePhysics, + skater: &SkaterRuntime, + feedback: &PhysicalFeedback, + camera: &mut crate::camera::CameraRuntime, +) -> Result<(), String> { + let inputs = publish( + physics, + skater, + feedback, + skater.centre_of_mass_output, + CameraPreferences { + // User preferences are owned here: ordinary uninverted controls. + invert_look: [false; 2], + // Native preferences constructor82DF6294/629C initializes both + // fields to zero.82DF8E80 only updates its air-side bytes37..39. + shake_variant: 0, + value_32: 0.0, + }, + // SkaterAnim vtable8231E170+28=82B97140: full15180 bit30. + u8::from(skater.animation.stance().1), + 1, // Stable host player identity replaces the original actor pointer. + )?; + let snapshot = crate::camera::publish_camera_subject(physics, skater, &inputs)?; + let environment = crate::camera::CameraGraphEnvironment { + camera_type: 1, + on_road: false, + ledge_left: false, + ledge_right: false, + volumes: Vec::new(), + }; + let gravity = physics.settings.step.simulation.gravity_acceleration; + camera + .advance( + physics.settings.step.simulation.time_step, + snapshot, + physics.world(), + [gravity.x, gravity.y, gravity.z, 0.0], + &environment, + &mut StaticWorld, + ) + .map(|_| ()) +} + +struct StaticWorld; +impl MovingObstacleProvider for StaticWorld { + fn collect(&mut self, _: [f32; 4], _: [f32; 4], _: f32, _: &mut [PathObstacle; 50]) -> usize { + // The subject actor is excluded by the native provider. Our current + // world contains this subject and static triangles, with no other actors. + 0 + } +} + +/// Call after current state, board/Skeleton and animation conditioning have +/// published, before clearing AnimationControlOutput's per-frame intent bits. +///Selected state owners publish their output before this common consumer. +pub(crate) fn publish( + physics: &GamePhysics, + skater: &SkaterRuntime, + feedback: &PhysicalFeedback, + com: CentreOfMassOutput, + preferences: CameraPreferences, + skater_animation_stance: u8, + context: u32, +) -> Result { + let p = &skater.player_input.processed; + let physical = &skater.player_input.physical; + let fields = &skater.animation_input.fields; + let intents = skater.animation_input.output.flags; + let packet = &skater.animation.packet; + let normal = physics.riding.ground.wheel_normal; + Ok(CameraPublicationInputs { + state: CameraStateOutput { + height_32: physical.state.surface_height_32, + physically_pushing_55: bit(p.flags_2468, 25), + wiping_out_59: bit(p.flags_2468, 18), + manual_60: u8::from(fields.balance != 0.0), + reset_62: bit(p.flags_2472, 10), + use_skeleton_root_75: u8::from(physical.state.category_12 == 500), + flag_79: u8::from(p.state_variant_index_2528 == 3), + flag_81: u8::from(skater.player_state.state_flags[81 - 52]), + }, + animation: CameraAnimationOutput { + conditioned_turn: feedback.conditioned_turn, + input_turn_64: fields.turn, + input_kickturn_68: p.spin_input_2672, + time_since_input_128: p.time_since_last_input_2748, + wipeout_tweak_148: physical.animation.profile_148, + stance_155: u8::from(packet.riding_fakie), + running_out_160: bit(p.flags_2480, 2), + skater_animation_stance, + }, + //Consume current output, including KnownAir82D36880's predicted + //landing/apex. Other states retain their actual template fields. + air: CameraAirOutput { + apex_0: physical.air.trajectory_apex_0.map(f32::from_bits), + landing_position_16: physical.air.collision_position_16.map(f32::from_bits), + landing_normal_32: physical.air.landing_normal_32.map(f32::from_bits), + launch_position_48: physical.air.selector_vector_48.map(f32::from_bits), + heading_80: physical.air.landing_heading_80.map(f32::from_bits), + time_176: physical.air.time_in_state_176, + duration_180: physical.air.collision_time_180, + apex_time_196: physical.air.time_to_apex_196, + flag_440: bit(p.flags_2468, 22), + }, + //Offboard82DE4010 has a distinct board-present327=1. None of that + //record's alternate trajectory fields becomes valid while riding. + offboard: CameraOffboardOutput { + duration_92: 0.0, + time_152: 0.0, + apex_time_156: 0.0, + launch_normal_160: [0.0; 4], + launch_position_176: [0.0; 4], + landing_normal_192: [0.0; 4], + landing_position_208: [0.0; 4], + heading_224: [0.0; 4], + apex_240: [0.0; 4], + //82DB76E0 overwrites the Ground Fill's earlier304 publication. + object_held_304: bit(p.flags_2480, 19), + hurdle_317: physical.off_board.hippy_hurdling_317, + use_trajectory_331: 0, + dropping_in_334: 0, + }, + grinds: CameraGrindOutput { + //Reset82DE3518 loads original world-X literal82139A10. + direction_0: [1.0, 0.0, 0.0, 0.0], + camera_target_96: [0.0; 4], + grinding_316: u8::from(matches!(physical.grinds.words_136_140[1], 1 | 2)), + }, + events: CameraEventsOutput { + intent_51: bit(intents, 28), + preparing_52: bit(intents, 27), + dropping_in_63: bit(intents, 22), + trick_125: bit(p.flags_2480, 11), + //Ground322 resets in82DE3728. Air/trick states own active writes. + hippy_jump_322: 0, + //Scoring2 reset82DE4468; HoM duration stays inactive in this host. + broken_bone_duration_200: 0.0, + //AirCollector82DA78D0 publishes the world conditioner capability mask. + capabilities_204: physical.scoring.capabilities_204, + }, + damped_com_80: com.position, + //82C03304..3340 copies actual BoardBody80 into Ground80 and96. + ground_up_80: [normal.x, normal.y, normal.z, 0.0], + //Ground288 resets82DE3728; selected ordinary Ground Fill leaves it. + ground_scalar_288: 0.0, + look_552_556: [ + skater.animation_input.extra.look_x, + skater.animation_input.extra.look_y, + ], + collision_look_target_64: physical.collision.predicted_position_64.map(f32::from_bits), + preferences, + context, + }) +} + +fn bit(value: u32, shift: u32) -> u8 { + ((value >> shift) & 1) as u8 +} diff --git a/crates/skate-game/src/physics/clock.rs b/crates/skate-game/src/physics/clock.rs new file mode 100644 index 00000000..ec0c9317 --- /dev/null +++ b/crates/skate-game/src/physics/clock.rs @@ -0,0 +1,70 @@ +//! Offline tick cadence. Original TU3 Simulation::OnRequestSetSimRate82857EC8 +//! sets a wall-clock timer; it does not change the physical integration step. +use skate_core::camera::SimulationRateRequest; +use std::time::Duration; + +const NORMAL_STEP: f32 = f32::from_bits(0x3c888889); + +#[derive(Clone, Copy, Debug)] +pub(super) struct SimulationClock { + ticks_until_reset: u32, + timer_period: Duration, +} + +impl Default for SimulationClock { + fn default() -> Self { + Self { + ticks_until_reset: 0, + timer_period: timer_period(60), + } + } +} + +impl SimulationClock { + pub fn apply(&mut self, request: SimulationRateRequest) -> Result<(), String> { + //82857F44..7C rounds the reciprocal to an integer timer frequency. + let frequency = (1.0_f32 / request.timestep + 0.5).trunc(); + if !frequency.is_finite() || frequency < 1.0 || frequency > i32::MAX as f32 { + return Err(format!( + "Invalid simulation-rate request: {}", + request.timestep + )); + } + let frequency = frequency as i32; + if 10_000_000 / frequency == 0 { + return Err("Simulation-rate request has a zero timer period".into()); + } + self.timer_period = timer_period(frequency); + self.ticks_until_reset = if request.timestep == NORMAL_STEP { + 0 + } else if (request.ticks as i32) > 0 { + request.ticks.wrapping_add(1) + } else { + //TU3 hardcodes180 at82857F34; S2 used a debug setting here. + 180 + }; + Ok(()) + } + + pub fn finish_tick(&mut self) { + //SimUpdate2 8285A694..A6C8 runs after the camera publication. Pending + //camera messages are dispatched at the start of the following tick. + self.ticks_until_reset = self.ticks_until_reset.wrapping_sub(1); + if self.ticks_until_reset == 0 { + self.timer_period = timer_period(60); + } + } + + pub fn period(&self) -> Duration { + self.timer_period + } +} + +fn timer_period(frequency: i32) -> Duration { + //Original timer setter82966910..2C: integer division in100ns units. + Duration::from_nanos((10_000_000 / frequency) as u64 * 100) +} + +#[cfg(test)] +#[path = "tests/clock.rs"] +mod tests; diff --git a/crates/skate-game/src/physics/colliders.rs b/crates/skate-game/src/physics/colliders.rs new file mode 100644 index 00000000..254ec719 --- /dev/null +++ b/crates/skate-game/src/physics/colliders.rs @@ -0,0 +1,148 @@ +//! Bind stock child geometry to the live solver part transforms. +use super::settings::PhysicsSettings; +use skate_core::{ + math::{Basis3, Vector3}, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + board_step::CollisionBody, + board_world::BoardWorldVolume, + collision::Sphere, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + mass::{DeckShape, MassShape}, + world_contact::{ContactPrimitive, transform_triangle_volume}, + }, +}; + +pub(super) fn world_volumes( + board: &BoardRuntime, + settings: &PhysicsSettings, +) -> Vec { + let poses = board.part_transforms(); + let mut volumes = Vec::new(); + // SerializeVolumes preserves part order, then compound child order. + for id in BodyId::ORDER { + let body = &board.bodies()[id.index()]; + if body.state_flags == 1 { + continue; + } + let pose = poses[id.index()]; + let mut add = |primitive, material: RetailContactMaterial| { + volumes.push(BoardWorldVolume { + body: CollisionBody::Board(id), + primitive, + material, + linear_velocity: body.rates.linear_velocity, + }); + }; + match id { + BodyId::Deck => { + for child in &settings.deck_geometry.children { + if !child.collision_enabled { + continue; + } + let world = compose(pose, child.transform); + let primitive = match child.shape { + DeckShape::Sphere { radius } => ContactPrimitive::Sphere(Sphere { + center: world.translation, + radius, + }), + DeckShape::Capsule { + radius, + half_length, + } => capsule(world, radius, half_length), + DeckShape::RoundedBox { + half_extents, + radius, + } => ContactPrimitive::RoundedBox { + center: world.translation, + basis: world.basis, + half_extents, + radius, + }, + DeckShape::Triangle { + vertices, + fatness, + edge_cosines, + volume_flags, + } => ContactPrimitive::Triangle(transform_triangle_volume( + vertices, + fatness, + edge_cosines, + volume_flags, + world.basis, + world.translation, + )), + }; + add(primitive, settings.deck_material); + } + } + BodyId::FrontTruck | BodyId::BackTruck => { + if settings.truck_collisions { + let MassShape::Capsule { + radius, + half_length, + } = settings.truck_shape + else { + unreachable!("TU382C0A6F8 constructs a capsule for each truck"); + }; + add(capsule(pose, radius, half_length), settings.truck_material); + } + } + _ => add( + ContactPrimitive::Sphere(Sphere { + center: pose.translation, + radius: settings.wheel_radius, + }), + settings.wheel_material, + ), + } + } + volumes +} + +fn capsule(pose: RetailAffineTransform, radius: f32, half_length: f32) -> ContactPrimitive { + let z = pose.basis.columns[2]; + ContactPrimitive::Capsule { + center: pose.translation, + axis: Vector3::new(z[0], z[1], z[2]), + half_length, + radius, + } +} + +// Same multiply/add order as the Volume-to-world vertex path82ADE468. +fn transform(basis: Basis3, point: Vector3, origin: Vector3) -> Vector3 { + let translation = [origin.x, origin.y, origin.z]; + let component = |row| { + point.z.mul_add( + basis.columns[2][row], + point.y.mul_add( + basis.columns[1][row], + point.x.mul_add(basis.columns[0][row], translation[row]), + ), + ) + }; + Vector3::new(component(0), component(1), component(2)) +} +fn compose(parent: RetailAffineTransform, child: RetailAffineTransform) -> RetailAffineTransform { + RetailAffineTransform { + basis: Basis3 { + columns: child.basis.columns.map(|c| { + // CreateGP82AD8758 starts basis composition with a product; + // only position composition includes an initial translation. + core::array::from_fn(|row| { + c[2].mul_add( + parent.basis.columns[2][row], + c[1].mul_add( + parent.basis.columns[1][row], + c[0] * parent.basis.columns[0][row], + ), + ) + }) + }), + }, + translation: transform(parent.basis, child.translation, parent.translation), + } +} diff --git a/crates/skate-game/src/physics/controls.rs b/crates/skate-game/src/physics/controls.rs new file mode 100644 index 00000000..6c4701ae --- /dev/null +++ b/crates/skate-game/src/physics/controls.rs @@ -0,0 +1,81 @@ +//! Controller-derived state is evaluated on the same fixed gameplay tick. +use super::{GamePhysics, skater::SkaterRuntime}; +use crate::input::ControllerInput; +use bevy::prelude::*; +use skate_core::graph::intents::IntentMap; +use skate_core::input::{ + controller::{ActionMap, DerivedControllerInput, MagnitudeHeldSettings}, + riding_intentions::{self, PushPreferences, RidingIntent}, + wipeout_intentions, +}; + +#[derive(Resource)] +pub(crate) struct PlayerControls { + pub controller: DerivedControllerInput, + pub intents: Vec, + pub action_intents: IntentMap, + pub ticks: u64, + pub actor_flags: u32, + pub bumper_state_502: bool, + pub bumper_state_104: bool, + pub preferences: PushPreferences, +} +impl Default for PlayerControls { + fn default() -> Self { + let mut controller = DerivedControllerInput::from_words([0; 26]); + controller.initialize(); + Self { + controller, + intents: Vec::new(), + action_intents: IntentMap::new(), + ticks: 0, + actor_flags: 0, + bumper_state_502: false, + bumper_state_104: false, + preferences: PushPreferences::default(), + } + } +} + +pub(super) fn sample( + input: Res, + physics: Res, + skater: Res, + mut player: ResMut, +) { + let mut map = input.player_actions(); + player.update( + &mut map, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); +} + +impl PlayerControls { + pub fn update(&mut self, map: &mut impl ActionMap, dt: f32, magnitude_threshold: f32, physical_capabilities: u32) { + self.controller.update( + map, + dt, + self.bumper_state_502, + self.bumper_state_104, + &MagnitudeHeldSettings { + attribute: Some(magnitude_threshold), + // The validated field is present, so no engine missing-field path runs. + missing_attribute_value: 0.0, + }, + ); + self.intents = + riding_intentions::produce(&self.controller, self.actor_flags, self.preferences); + self.intents.extend(wipeout_intentions::produce( + &self.controller, self.actor_flags, physical_capabilities, + )); + //GenerateActionGraphIntents82594310 clears the AG map through82BC1B68 + //before Listener::Fill. MG lifecycle intents use a different persistent map. + self.action_intents.clear(); + for intent in &self.intents { + self.action_intents.insert(intent.name, intent.value); + } + self.ticks += 1; + } +} diff --git a/crates/skate-game/src/physics/foot_ik.rs b/crates/skate-game/src/physics/foot_ik.rs new file mode 100644 index 00000000..c8da136b --- /dev/null +++ b/crates/skate-game/src/physics/foot_ik.rs @@ -0,0 +1,229 @@ +//! Physical skeleton IK data binding. This is the production skeleton's IK owner, +//! using authored physics volumes and the real reparented animation targets. +use super::animated_skeleton::AnimatedSkeleton; +use skate_core::{ + animation::foot_ik::{ + drive::Geometry, + post_contact::SettingsPost, + post_physics, + settings::Settings, + state::{FootIkState, LIMBS, UpdateInput}, + status::BlendSettings, + transforms::inverse_rigid, + two_bone::AngleLimits, + }, + physics::skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, +}; +use skate_data::{animation_frames::AnimationFrames, collections::Collections}; + +pub(crate) struct FootIk { + pub state: FootIkState, + geometry: Geometry, + settings: Settings, + post_settings: SettingsPost, + bone_indices: [usize; 24], +} + +/// Physical observations from the real processed state and body owner. All +///matrices remain in stock simulation coordinates, before renderer conversion. +pub(crate) struct PhysicalInput<'a> { + pub state_id: u32, + pub flags_2468: u32, + pub flags_2472: u32, + pub flags_2480: u32, + pub contact_bone: usize, + /// Skeleton12496, produced by the current board-update branch. + pub physical_board: &'a Transform, + /// Body skeleton's actual hips position, DataIn416. + pub hips_world_position: [f32; 4], + /// Actual processed foot queries960/1008, not fabricated deck hits. + pub current_contacts: [Option<[f32; 4]>; 2], +} + +pub(crate) struct DriveOutput { + pub frames: [Transform; 24], + pub updated_limbs: [bool; 4], + pub support_failed: bool, + pub support_failed_this_update: bool, +} + +impl FootIk { + pub fn load( + data: &Collections, + animation: &AnimationFrames, + skeleton: &AnimatedSkeleton, + ) -> Result { + //82BD3720 walks parents until it finds a represented physical part; + //intermediate hierarchy bones do not become phantom physical joints. + let mut parents = [None; 24]; + for (part, &bone) in skeleton.bone_indices.iter().enumerate() { + let mut ancestor = *animation + .parents + .get(bone) + .ok_or("IK bone exceeds stock hierarchy")?; + let mut visited = vec![false; animation.parents.len()]; + while ancestor >= 0 { + let index = ancestor as usize; + if index >= visited.len() || visited[index] { + return Err(format!( + "Invalid stock IK hierarchy ancestor for part {part}" + )); + } + visited[index] = true; + if let Some(parent) = skeleton.bone_indices.iter().position(|&bone| bone == index) { + parents[part] = Some(parent); + break; + } + ancestor = animation.parents[index]; + } + } + let geometry = Geometry::new(parents, &skeleton.physics_frames)?; + geometry.validate_limbs(&LIMBS)?; + Ok(Self { + state: FootIkState::default(), + geometry, + settings: load_settings(data)?, + post_settings: SettingsPost { + minimum_board_up: data.float( + "physics_animation", + "default", + "PostIKMinYAxisVal", + )?, + wipeout_height: data.float("animation", "default", "FeetRelativeHeightWipeout")?, + riding_height: data.float("animation", "default", "FeetRelativeHeightOnDeck")?, + }, + bone_indices: skeleton.bone_indices, + }) + } + + pub fn enable_feet(&mut self, enabled: bool) { + self.state.enable_feet(enabled); + } + + ///Skeleton Fill82BE1D98..1E00 publishes each physical toe frame applied + ///to the adjacent authored inverse-part translation, not its volume COM. + pub fn physical_toe_positions( + &self, + record: &skate_core::physics::skeleton_body::SkeletonPhysicalRecord, + ) -> [[f32; 4]; 2] { + use skate_core::physics::skeleton_animation_record::transform_point; + [ + transform_point(&record.pose[19], self.geometry.inverse_part_frames[20][3]), + transform_point(&record.pose[15], self.geometry.inverse_part_frames[16][3]), + ] + } + + ///Call after native post-physics records/COM/error publication and board + ///wobble, before SkeletonOutput::publish replaces the final pose buffers. + pub fn post_physics( + &mut self, + body: &mut skate_core::physics::skeleton_body::SkeletonBody, + input: post_physics::Input<'_>, + ) -> [bool; 4] { + post_physics::update( + &mut self.state, + body, + &self.geometry, + &self.settings, + &self.post_settings, + input, + ) + } + + pub fn update( + &mut self, + skeleton: &AnimatedSkeleton, + globals: &[Transform], + input: PhysicalInput<'_>, + ) -> Result { + let mut drives = skeleton.record.pose; + //GeneralUpdate skips all IK stages for702 but still submits mapped + //animation drives. Preserve histories across that skipped update. + let mut updated_limbs = [false; 4]; + if input.state_id != 702 { + let mut originals = skeleton.record.pose; + for (part, &bone) in self.bone_indices.iter().enumerate() { + originals[part] = *globals + .get(bone) + .ok_or("IK original joint is absent from the current pose")?; + } + //UpdateIKData's503 branch uses the animated board in world space + //for both board inputs; every other state consumes distinct caches. + let animated_world = + compose_affine(&skeleton.roots.animation_to_world, &skeleton.record.pose[0]); + let special_inverse = inverse_rigid(&animated_world); + let (physical_board, contact_board, inverse_contact_board) = if input.state_id == 503 { + (&animated_world, &animated_world, &special_inverse) + } else { + ( + input.physical_board, + &skeleton.roots.board, + &skeleton.roots.inverse_board, + ) + }; + updated_limbs = self.state.update( + UpdateInput { + flags_2468: input.flags_2468, + flags_2472: input.flags_2472, + flags_2480: input.flags_2480, + animation_to_world: &skeleton.roots.animation_to_world, + world_to_animation: &skeleton.roots.world_to_animation, + physical_board, + contact_board, + inverse_contact_board, + animation: &skeleton.record.pose, + original_animation: &originals, + targets: &skeleton.targets, + hips_world_position: input.hips_world_position, + contact_bone: input.contact_bone, + foot_bones: [self.bone_indices[15], self.bone_indices[19]], + current_contacts: input.current_contacts, + }, + &self.geometry, + &self.settings, + &mut drives, + ); + } + Ok(DriveOutput { + frames: drives, + updated_limbs, + support_failed: self.state.contacts.support_failed, + support_failed_this_update: self.state.contacts.support_failed_this_update, + }) + } +} + +///82BED418 classF2BD493E resolves to physics_skeletonik/default. XML schema +/// gives the cached offsets; each vector retains its authored fourth word. +pub(crate) fn load_settings(data: &Collections) -> Result { + let vector = |name| { + data.words::<4>("physics_skeletonik", "default", name) + .map(|words| words.map(f32::from_bits)) + }; + let scalar = |name| data.float("physics_skeletonik", "default", name); + let half_width = data.float("physicsdeck", "default", "DeckWidth")? * 0.5; + let half_length = data.float("physicsdeck", "default", "DeckMidLength")? * 0.5; + Ok(Settings { + angle_limits: AngleLimits { + minimum_degrees: data.float("physics_animation", "default", "IKMinAngle")?, + maximum_degrees: data.float("physics_animation", "default", "IKMaxAngle")?, + }, + blend: BlendSettings { + hand_inner_padding: vector("IKOnPadding")?, + hand_outer_padding: vector("IKOffPadding")?, + external_blend_step: scalar("FeetIKMaxBlendDeltaOut")?, + board_blend_step: scalar("FeetIKMaxBlendDelta")?, + }, + post_ik_padding: vector("PostIKPadding")?, + contact_bounds: vector("IKBoneOnDeckBBox")?, + foot_on_deck_padding: vector("FootOnDeckPadding")?, + wipeout_feet_offset: scalar("WipeoutFeetOffset")?, + deck_half_width: half_width, + deck_half_length: half_length, + // Source uses FrontEndSize here, while the deck collider's fan loop + // uses BackEndSize. They are distinct authored parameters. + deck_total_half_length: data.float("physicsdeck", "default", "DeckFrontEndSize")? + + half_length, + deck_front_angle_degrees: data.float("physicsdeck", "default", "DeckFrontEndAngle")?, + }) +} diff --git a/crates/skate-game/src/physics/foot_ik_queries.rs b/crates/skate-game/src/physics/foot_ik_queries.rs new file mode 100644 index 00000000..7edbf147 --- /dev/null +++ b/crates/skate-game/src/physics/foot_ik_queries.rs @@ -0,0 +1,151 @@ +//! PhysicalPlayerHiLOD StartSkeletonLineTests82DB63C0 / result publication +//!82DB6580. These are radius-bearing trajectory tests from actual physical parts. +use skate_core::{ + camera::TrajectoryQuery, + math::Vector3, + physics::{ + board_world::BoardWorld, + skeleton_body::SkeletonBody, + triangle_query::{TriangleLineHit, triangle_segment}, + }, + player::input_phase::{LineTestFields, PlayerInputState}, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct SkeletonLineHit { + /// Result0 and32, copied to the physical query record by82DB65E0/F0. + pub position: [f32; 4], + pub normal: [f32; 4], + /// Result132 and collisionTime48>=0, not an arbitrary geometry-present flag. + pub surface: u32, + pub hit: bool, + pub collision_time: f32, +} +impl SkeletonLineHit { + fn pending_result() -> Self { + // Valid test result initialized by8276C9D8: position0, normalY, + //time-1 and surface132=0. A no-hit result still has this real record. + Self { + position: [0.0; 4], + normal: [0.0, 1.0, 0.0, 0.0], + surface: 0, + hit: false, + collision_time: -1.0, + } + } +} + +pub(crate) struct SkeletonLineTests { + pub hips: SkeletonLineHit, + pub feet: [SkeletonLineHit; 2], +} +impl SkeletonLineTests { + /// EndSkeletonLineTests82DB6580 publishes the completed tests in the + /// order hips, left toe, right toe. ProcessInput copies these retained + /// records into Processed1056/960/1008 during its input phase. + pub fn publish(&self, player: &mut PlayerInputState) { + let fields = |hit: SkeletonLineHit| LineTestFields { + position: hit.position.map(f32::to_bits), + normal: hit.normal.map(f32::to_bits), + surface: hit.surface, + valid: u8::from(hit.collision_time >= 0.0), + }; + player.hips_line_test_1488 = fields(self.hips); + player.left_line_test_1536 = fields(self.feet[0]); + player.right_line_test_1584 = fields(self.feet[1]); + } + + /// These observations become Processed960/1008. SkeletonIK retains its + ///own previous query and consumes it before importing this pair. + pub fn foot_contacts(&self) -> [Option<[f32; 4]>; 2] { + self.feet.map(|foot| foot.hit.then_some(foot.position)) + } +} + +pub(crate) fn query(world: &BoardWorld, body: &SkeletonBody) -> Result { + let parts = body.part_transforms(); + //82DB63EC..647C selects hips23,lefttoe15,righttoe19 from the + //physical pose. Body COM and the reparented animated targets are different. + let hips = query_trajectory(world, parts[23][3], [0.0, -100.0, 0.0, 0.0])?; + let raised = [0.0, f32::from_bits(0x3EA8_F5C3), 0.0, 0.0]; + let lowered = [0.0, -1.5, 0.0, 0.0]; + //Keep the subtraction and subsequent FMA evaluation separate: do not + //replace this with an independently rounded literal -1.83 or finalY-1.5. + let velocity = std::array::from_fn(|lane| lowered[lane] - raised[lane]); + let mut feet = [SkeletonLineHit::pending_result(); 2]; + for (foot, part) in [15, 19].into_iter().enumerate() { + let start = std::array::from_fn(|lane| parts[part][3][lane] + raised[lane]); + feet[foot] = query_trajectory(world, start, velocity)?; + } + Ok(SkeletonLineTests { hips, feet }) +} + +fn query_trajectory( + world: &BoardWorld, + start: [f32; 4], + velocity: [f32; 4], +) -> Result { + //82E0B4E0 -> TestBatchManager backend virtual28. Its ctor82E0B460 + //uses Island virtual84=82764CF8, then82775E48/8276E010 creates + //ClusteredMeshTrajectoryBatch vtable8231093C, AddTest8276E280. + //S2 named827BDCC0 corroborates radius/time/error fields; TU3 preserves + //f1..f4 through AggregateTrajectoryBatch8276EDD0 when multiple batches exist. + let request = TrajectoryQuery { + position: start, + velocity, + gravity: [0.0; 4], + duration: 1.0, + radius: f32::from_bits(0x3CF5_C28F), + start_error: 0.0, + end_error: 0.0, + }; + let mut result = SkeletonLineHit::pending_result(); + result.collision_time = request.collision_time(|start, end, radius| { + let origin = vector(start); + let direction = Vector3::new(end[0] - start[0], end[1] - start[1], end[2] - start[2]); + let mut nearest = f32::MAX; + let mut position = None; + for triangle in world.triangles() { + let mut geometry = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0.0, + volume_parameter: [0.0; 3], + }; + //Same source82771D08 leaf as camera: zero triangle fatness, + //authored face normal, fraction clamp and first equal hit retained. + if triangle_segment( + &mut geometry, + origin, + direction, + triangle.triangle.vertices, + radius, + 0.0, + ) { + let lower = if -geometry.fraction >= 0.0 { + 0.0 + } else { + geometry.fraction + }; + let fraction = if 1.0 - lower >= 0.0 { lower } else { 1.0 }; + if fraction < nearest { + nearest = fraction; + let point = lanes(geometry.position); + result.position = point; + result.normal = lanes(triangle.triangle.feature.normal); + result.surface = triangle.tag; + position = Some(point); + } + } + } + Ok::<_, String>(position) + })?; + result.hit = result.collision_time >= 0.0; + Ok(result) +} +fn vector(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/foot_physical_output.rs b/crates/skate-game/src/physics/foot_physical_output.rs new file mode 100644 index 00000000..9b07c843 --- /dev/null +++ b/crates/skate-game/src/physics/foot_physical_output.rs @@ -0,0 +1,36 @@ +//! Persistent original physical-foot output owner. Call after final physical +//! record correction, at Skeleton::FillPhysOut82BE2138/2140 publication time. +use skate_core::physics::{ + foot_physical_output::{FootPhysicalOutput, FootPhysicalSettings, FootPhysicalState}, + skeleton_body::SkeletonPhysicalRecord, +}; +use skate_data::collections::Collections; +pub(crate) struct FootPhysicalOutputs { + state: FootPhysicalState, + settings: FootPhysicalSettings, + pub output: FootPhysicalOutput, +} +impl FootPhysicalOutputs { + pub fn load(data: &Collections) -> Result { + Ok(Self { + state: FootPhysicalState::default(), + output: FootPhysicalOutput::default(), + settings: FootPhysicalSettings { + deck_half_width: data.float("physicsdeck", "default", "DeckWidth")? * 0.5, + deck_total_half_length: data.float("physicsdeck", "default", "DeckFrontEndSize")? + + data.float("physicsdeck", "default", "DeckMidLength")? * 0.5, + padding: data + .words::<4>("physics_skeletonik", "default", "FootOnDeckPadding")? + .map(f32::from_bits), + }, + }) + } + pub fn reset(&mut self) { + self.state.reset(); + self.output = FootPhysicalOutput::default(); + } + pub fn publish(&mut self, record: &SkeletonPhysicalRecord, dt: f32) -> FootPhysicalOutput { + self.output = self.state.update(record, dt, self.settings); + self.output + } +} diff --git a/crates/skate-game/src/physics/footplant.rs b/crates/skate-game/src/physics/footplant.rs new file mode 100644 index 00000000..9645c505 --- /dev/null +++ b/crates/skate-game/src/physics/footplant.rs @@ -0,0 +1,126 @@ +//! FootPlantManager state, original TU3 inline ctor82DB217C and Reset82D6F440. +//! Query results are retained separately from pending query ownership. +use skate_core::air::trajectory::{QueryResult, Trajectory}; +use skate_data::collections::Collections; +mod math; +mod pose; +mod settings; +mod update; +use settings::Settings; +pub(crate) use update::FootplantFrame; +pub(crate) type V = [f32; 4]; +pub(crate) struct Footplant { + pub enabled: bool, //240; FullReset and Air Enter clear this separately. + pub(super) result: QueryResult, + pub(super) settings: Settings, + pub selected_toe: Option, //616 signed -1 + pub candidate: bool, //624 + pub hit: bool, //625 + pub perform: bool, //626 + pub flag_627: bool, + pub launch_valid: bool, //628 + pub lock_valid: bool, //629 + pub contact_active: bool, //630 + pub requested: bool, //631 + pub contact_time: f32, //592 + pub scalar_596: f32, + pub scalar_600: f32, + pub target_blend: f32, //604 + pub active_elapsed: f32, //608 + pub scalar_612: f32, + pub surface: u32, //620 survives Reset + pub contact: V, //288 + pub adjusted_contact: V, //304 + pub(super) current_up: V, //320: Reset preserves it; ctor does not define it + pub(super) selected_world: V, //256 + pub(super) selected_record: V, //272 + pub(super) leg_direction: V, //336 + pub(super) vectors_352_368: [V; 2], + pub(super) physical_com: V, //384 + pub(super) animation_com: V, //400 + pub(super) launch_direction: V, //416 + pub(super) locked_target: V, //432 + pub(super) request: Trajectory, //528..591 +} +impl Footplant { + pub fn load(data: &Collections) -> Result { + //Deterministic host storage for fields the native ctor leaves unwritten. + //The first-ever320 value is not claimed as native memory parity. + let mut state = Self { + enabled: false, + result: QueryResult::miss(), + settings: Settings::load(data)?, + selected_toe: None, + candidate: false, + hit: false, + perform: false, + flag_627: false, + launch_valid: false, + lock_valid: false, + contact_active: false, + requested: false, + contact_time: -1.0, + scalar_596: -1.0, + scalar_600: 0.0, + target_blend: 0.0, + active_elapsed: 0.0, + scalar_612: 0.0, + surface: 0, + contact: [0.0; 4], + adjusted_contact: [0.0; 4], + current_up: [0.0; 4], + selected_world: [0.0; 4], + selected_record: [0.0; 4], + leg_direction: [0.0; 4], + vectors_352_368: [[0.0; 4]; 2], + physical_com: [0.0; 4], + animation_com: [0.0; 4], + launch_direction: [0.0; 4], + locked_target: [0.0; 4], + request: Trajectory { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + duration: -1.0, + }, + }; + state.reset(); + Ok(state) + } + ///82D6F440. Does not cancel the query or erase its last delivered result, + ///current up, selected positions, or surface. Enter clears240 separately. + pub fn reset(&mut self) { + self.contact_time = -1.0; + self.scalar_596 = -1.0; + self.selected_toe = None; + self.scalar_600 = 0.0; + self.target_blend = 0.0; + self.active_elapsed = 0.0; + self.scalar_612 = 0.0; + self.candidate = false; + self.hit = false; + self.perform = false; + self.flag_627 = false; + self.launch_valid = false; + self.lock_valid = false; + self.contact_active = false; + self.requested = false; + self.request = Trajectory { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + duration: -1.0, + }; + self.contact = [0.0; 4]; + self.adjusted_contact = [0.0; 4]; + self.vectors_352_368 = [[0.0; 4]; 2]; + self.animation_com = [0.0; 4]; + self.locked_target = [0.0; 4]; + self.launch_direction = [0.0; 4]; + self.physical_com = [0.0; 4]; + } + pub fn full_reset(&mut self) { + self.enabled = false; + self.reset(); + } +} diff --git a/crates/skate-game/src/physics/footplant/math.rs b/crates/skate-game/src/physics/footplant/math.rs new file mode 100644 index 00000000..6873c422 --- /dev/null +++ b/crates/skate-game/src/physics/footplant/math.rs @@ -0,0 +1,74 @@ +//! Original vector operation order from82D6FD60/82D6F5F0/82D70070. +//! Estimate seeds use independent PC math; Xenon bit equality is unverified. +use super::V; +pub(super) const STEP: f32 = f32::from_bits(0x3c888889); +pub(super) fn dot(a: V, b: V) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +pub(super) fn add(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] + b[i]) +} +pub(super) fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn scale(a: V, s: f32) -> V { + a.map(|v| v * s) +} +pub(super) fn madd(a: V, s: f32, b: V) -> V { + std::array::from_fn(|i| a[i].mul_add(s, b[i])) +} +pub(super) fn reciprocal(s: f32) -> f32 { + let mut r = s.recip(); + for _ in 0..2 { + r = (-s).mul_add(r, 1.0).mul_add(r, r); + } + r +} +fn rsqrt(s: f32) -> f32 { + let mut r = s.sqrt().recip(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-s).mul_add(r * r, 1.0), r); + } + r +} +pub(super) fn length(a: V) -> f32 { + let s = dot(a, a); + let l = s * rsqrt(s); + if s == 0.0 { 0.0 } else { l } +} +pub(super) fn normalize(a: V) -> V { + let s = dot(a, a); + let r = rsqrt(s); + let l = if s == 0.0 { 0.0 } else { s * r }; + if l > f32::from_bits(0x358637bd) { + scale(a, r) + } else { + [0.0; 4] + } +} +pub(super) fn point(m: &[V; 4], p: V) -> V { + std::array::from_fn(|i| { + let x = m[0][i].mul_add(p[0], m[3][i]); + let y = m[1][i].mul_add(p[1], x); + m[2][i].mul_add(p[2], y) + }) +} +pub(super) fn rotate(m: &[V; 4], v: V) -> V { + std::array::from_fn(|i| { + let x = m[0][i] * v[0]; + let y = m[1][i].mul_add(v[1], x); + m[2][i].mul_add(v[2], y) + }) +} +pub(super) fn clamp_length(v: V, maximum: f32) -> V { + let l = length(v); + if !(l >= f32::from_bits(0x37800000)) { + return v; + } + let capped = if maximum - l >= 0.0 { l } else { maximum }; + scale(scale(v, capped), reciprocal(l)) +} +pub(super) fn clamp01(v: f32) -> f32 { + let a = if -v >= 0.0 { 0.0 } else { v }; + if 1.0 - a >= 0.0 { a } else { 1.0 } +} diff --git a/crates/skate-game/src/physics/footplant/pose.rs b/crates/skate-game/src/physics/footplant/pose.rs new file mode 100644 index 00000000..6fbd8dcb --- /dev/null +++ b/crates/skate-game/src/physics/footplant/pose.rs @@ -0,0 +1,68 @@ +//! Footplant82D70070 publishes real FootIK targets and temporary collision disables. +use super::{Footplant, math::*}; +use crate::physics::{animated_skeleton::AnimatedSkeleton, foot_ik::FootIk}; +use skate_core::{ + air::known::KnownAirFootplantInput, physics::skeleton_body::SkeletonCollisionMode, +}; +impl Footplant { + pub(super) fn update_pose( + &mut self, + input: &KnownAirFootplantInput, + skeleton: &AnimatedSkeleton, + ik: &mut FootIk, + collision: &mut SkeletonCollisionMode, + ) { + //82453298, shared source-derived polynomial and independent PC estimate seed. + let angle = skate_core::trigonometry::acos(dot(self.launch_direction, self.leg_direction)); + let turns = angle * f32::from_bits(0x3e22f983); + let fraction = turns - turns.floor(); + let wrapped = + (fraction - if fraction > 0.5 { 1.0 } else { 0.0 }) * f32::from_bits(0x40c90fdb); + if self.requested && self.candidate && self.contact_time < 0.3 { + let contact_length = length(sub(self.adjusted_contact, self.animation_com)); + let leg_length = length(sub(self.selected_world, self.animation_com)); + let distance = if leg_length < contact_length { + leg_length + } else { + contact_length + }; + let inverse = &skeleton.roots.world_to_animation; + let com = point(inverse, self.animation_com); + let velocity = rotate(inverse, scale(input.trajectory.velocity, -STEP)); + let direction = rotate(inverse, self.launch_direction); + let mut target = madd(direction, distance, add(com, velocity)); + if self.lock_valid { + target = add( + self.locked_target, + clamp_length(sub(target, self.locked_target), 0.15), + ); + } + self.locked_target = target; + self.lock_valid = true; + let side = if self.selected_toe == Some(15) { 0 } else { 1 }; + ik.state.external_targets[side].animation_position = target; + ik.state.limbs[side].local_target_set = true; + ik.state.limbs[side].target_blend = self.target_blend; + self.target_blend = clamp01(self.target_blend + 0.12); + if self.contact_time < 0.2 { + for part in if side == 0 { + [15, 16, 17] + } else { + [19, 20, 21] + } { + collision.pending_reenable = true; + collision.disable_count[part] = 2; + collision.parts[part].enabled = false; + } + } + } + if self.requested { + self.perform = self.hit + && !(self.contact_time > 0.017) + && self.settings.max_leg_angle_error * f32::from_bits(0x3c8efa35) > wrapped; + } else { + self.target_blend = 0.0; + self.perform = false; + } + } +} diff --git a/crates/skate-game/src/physics/footplant/settings.rs b/crates/skate-game/src/physics/footplant/settings.rs new file mode 100644 index 00000000..4d831416 --- /dev/null +++ b/crates/skate-game/src/physics/footplant/settings.rs @@ -0,0 +1,23 @@ +//! Cached physics_footplantmanager layout used by82D6FD60/82D6F5F0/82D70070. +use skate_data::collections::Collections; +pub(super) struct Settings { + pub deck_bounds: [f32; 4], //0 + pub max_leg_angle_error: f32, //280 degrees + pub leg_length_on_landing: f32, //288 + pub foot_volume_y_offset: f32, //296 + pub deck_bounds_y_offset: f32, //300 +} +impl Settings { + pub fn load(data: &Collections) -> Result { + let float = |name| data.float("physics_footplantmanager", "default", name); + Ok(Self { + deck_bounds: data + .words::<4>("physics_footplantmanager", "default", "DeckBB")? + .map(f32::from_bits), + max_leg_angle_error: float("MaxLegAngleError")?, + leg_length_on_landing: float("LegLengthOnLanding")?, + foot_volume_y_offset: float("FootVolumeYOffset")?, + deck_bounds_y_offset: float("DeckBBYOffset")?, + }) + } +} diff --git a/crates/skate-game/src/physics/footplant/update.rs b/crates/skate-game/src/physics/footplant/update.rs new file mode 100644 index 00000000..93d0a605 --- /dev/null +++ b/crates/skate-game/src/physics/footplant/update.rs @@ -0,0 +1,161 @@ +//! Original82D6FD60 ->82D6F5F0 ->82D6F910, invoked by82D36728. +use super::{Footplant, V, math::*}; +use crate::physics::{ + air_trajectory::AirTrajectoryRuntime, animated_skeleton::AnimatedSkeleton, foot_ik::FootIk, +}; +use skate_core::{ + air::{ + known::KnownAirFootplantInput, + trajectory::{QueryRequest, Trajectory}, + }, + physics::{ + board_toolkit::BoardToolkit, + board_world::BoardWorld, + skeleton_body::{SkeletonBody, SkeletonCollisionMode}, + }, + player::input_phase::ProcessedPhysicsInput, +}; +pub(crate) struct FootplantFrame<'a> { + pub processed: &'a ProcessedPhysicsInput, + pub toolkit: &'a BoardToolkit, + pub animated: &'a AnimatedSkeleton, + pub body: &'a SkeletonBody, + pub gravity: V, + pub world: &'a BoardWorld, +} +impl Footplant { + pub fn update_candidate(&mut self, input: &KnownAirFootplantInput, frame: &FootplantFrame<'_>) { + self.candidate = false; + let offset = [0.0, self.settings.deck_bounds_y_offset, 0.0, 0.0]; + let upper = add(self.settings.deck_bounds, offset); + let lower = sub(offset, self.settings.deck_bounds); + let physical = frame.body.record.pose; + let toes = [physical[15][3], physical[19][3]]; + let board = toes.map(|p| point(&frame.toolkit.inverse_effective, p)); + let outside = |p: V| (0..3).any(|i| lower[i] > p[i] || p[i] > upper[i]); + self.physical_com = madd( + input.trajectory.velocity, + STEP, + frame.animated.board_frames.centre_of_mass, + ); + self.animation_com = madd( + input.trajectory.velocity, + STEP, + point( + &frame.animated.roots.animation_to_world, + frame.animated.record.centre_of_mass, + ), + ); + let side = if outside(board[0]) { + 0 + } else if outside(board[1]) { + 1 + } else { + return; + }; + let toe = [15, 19][side]; + self.selected_toe = Some(toe); + self.candidate = true; + self.selected_world = point( + &frame.animated.roots.animation_to_world, + frame.animated.record.pose[toe][3], + ); + self.selected_record = toes[side]; + self.leg_direction = normalize(sub(self.selected_record, self.physical_com)); + } + pub fn update_launch(&mut self, input: &KnownAirFootplantInput) { + use skate_core::air::reckoning::clamp_vector_within_max_angle as limit; + if !self.hit { + self.contact_time = input.remaining_collision_time; + } + let future = madd( + input.trajectory.acceleration, + self.contact_time, + input.trajectory.velocity, + ); + let candidate = sub(normalize(future), input.landing_normal); + let from = scale(self.current_up, -1.0); + let angle = f32::from_bits(0x420c0000) * f32::from_bits(0x3c8efa35); + let direction = normalize(limit(candidate, from, angle)); + //82D6F7A8's second pure limiter result is discarded; v125 retains direction. + let _ = limit(sub(self.adjusted_contact, self.physical_com), from, angle); + self.launch_direction = normalize(direction); + self.launch_valid = true; + self.request = Trajectory { + position: madd( + self.launch_direction, + self.settings.leg_length_on_landing, + self.physical_com, + ), + velocity: input.trajectory.velocity, + //830BD330 initializer82F826B0 uses822F8B40, independently read original bytes. + acceleration: [0.0, f32::from_bits(0xc11ccccd), 0.0, 0.0], + duration: -1.0, + }; + } + pub fn consume_and_submit( + &mut self, + input: &KnownAirFootplantInput, + frame: &FootplantFrame<'_>, + ik: &mut FootIk, + collision: &mut SkeletonCollisionMode, + ) -> Result<(), String> { + self.hit = false; + if self.enabled && self.result.contact_time >= 0.0 { + self.contact_time = clamp01(self.result.contact_time); + self.contact = self.result.contact_position; + self.surface = self.result.surface; + self.hit = true; + //82D6FBB0's nearby-edge scan is empty in our explicitly edge-free BoardWorld. + self.adjusted_contact = madd( + input.landing_normal, + self.settings.foot_volume_y_offset, + self.contact, + ); + if !self.contact_active { + if self.candidate + && self.contact_time > f32::from_bits(0x3de147ae) + && self.contact_time < 0.3 + { + self.contact_active = true; + self.active_elapsed = 0.0; + } else { + self.clear_contact(); + } + } else if self.active_elapsed + self.contact_time < 0.0 { + self.contact_active = false; + self.clear_contact(); + self.active_elapsed = 0.0; + } + if self.contact_active { + self.requested |= frame.processed.flags_2480 & (1 << 23) != 0; + self.active_elapsed += STEP; + self.update_pose(input, frame.animated, ik, collision); + } + } + self.current_up = frame.processed.vectors_544_560_592_608[0].map(f32::from_bits); + let next = AirTrajectoryRuntime::query( + frame.world, + QueryRequest { + trajectory: Trajectory { + position: self.request.position, + velocity: self.request.velocity, + acceleration: frame.gravity, + duration: 1.0, + }, + radius: f32::from_bits(0x3d23d70a), + start_error: 1.0, + end_error: 1.0, + }, + )?; + self.result = next; + self.enabled = true; + Ok(()) + } + fn clear_contact(&mut self) { + self.surface = 0; + self.hit = false; + self.contact_time = -1.0; + self.contact = [0.0; 4]; + } +} diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs new file mode 100644 index 00000000..14d648cb --- /dev/null +++ b/crates/skate-game/src/physics/frame.rs @@ -0,0 +1,147 @@ +//! One production tick: controller graphs, physical input/state, shared solve, +//! physical animation output and the normal gameplay camera. +use super::{ + GamePhysics, PlayerControls, SkaterRuntime, animation_phase, camera_output, ground_phase, + input_phase, player_state, solve, +}; +use crate::{camera::CameraRuntime, graph_runtime::StockGraphs}; +use skate_core::{input::controller::ActionMap, math::Vector3}; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + controls: &mut PlayerControls, + graphs: &StockGraphs, + actions: &mut dyn ActionMap, + input_available: bool, + camera: &mut CameraRuntime, +) -> Result<(), String> { + //SimController8285C968 dispatches the preceding tick's camera messages + //before simulation. Keep End/Begin ordering when both occur in one update. + for request in camera.simulation_rate_requests.drain(..) { + physics.clock.apply(request)?; + } + if physics.ticks == 0 { + player_state::initialize(physics, skater)?; + //Ctor82DB3008 enters Ground without ProcessOutput. Keep the constructed + //State/FilteredState zeros (82DE3BE8/82DE5588) until the completed tick. + //Publishing Ground here runs its ForcePhysics Begin before the initial + //input reset82DB8998, which would immediately clear that mode again. + } + physics.board.clear_forces(); + //World8275EA20 starts both batches before actor SetUpPhysics. Foot + //results stay pending while PlayerInput consumes the preceding records. + physics + .riding + .start_wheel_queries(&physics.board, &physics.world)?; + let skeleton_queries = super::foot_ik_queries::query(&physics.world, &skater.skeleton)?; + let animation = animation_phase::advance( + physics, + skater, + controls, + graphs, + &physics.animation_profile, + ) + .map_err(|e| format!("Animation tick{}: {e}", physics.ticks))?; + //ForcePhysics Begin82BB2868 writes the live Skeleton16420 mode once. + //Consume the graph request so a later physical reset can retain its own0. + if let Some(mode) = skater.animation.motion.riding.force_mode.take() { + skater.skeleton_input.force_mode = mode.native_value(); + } + let teleported = input_phase::advance( + physics, + skater, + &animation.packet(), + actions, + input_available, + )?; + if teleported { + skater.player_state.reset_for_teleport(); + skater.centre_of_mass_filter.reset(); + skater.animation_feedback.reset(); + player_state::enter_after_teleport(physics, skater)?; + //82DB8E40 calls SetUpNormal after pose reset, state100 and board reset. + skater.wipeout_state.ragdoll.restore_normal( + &mut skater.skeleton, &mut skater.skeleton_joints, + &mut skater.skeleton_collision, &mut skater.collision_feedback, + ); + } + //World8275EC0C ends board queries before PostInput/state selection. + physics.riding.finish_wheel_queries()?; + player_state::post_input_and_select(physics, skater)?; + player_state::pre_state(physics, skater)?; + match skater.player_state.current() { + skate_core::player::state::PhysicalStateId::PhysicsGround => { + let p = &skater.player_input.processed; + let com = p.animation_com_to_deck_752.map(f32::from_bits); + physics.riding.update_ground_reckoning( + &physics.board, + super::riding_outputs::RidingPoseInputs { + com_to_deck: Vector3::new(com[0], com[1], com[2]), + body_spin: skater.animation_input.extra.physical_body_spin, + }, + p.flags_2468, + skater.animation_input.fields.balance, + p.flags_2476 & 0x4000_0000 != 0, + ); + //PhysicsGround::PreUpdate82D37C50 clears the previous push suppression + //after Reckoning; this frame's propulsion may then set it again. + skater.ground.state.push_suppressed_2730 = false; + ground_phase::advance(physics, skater)?; + input_phase::update_ground(physics, skater)?; + } + skate_core::player::state::PhysicalStateId::PhysicsAir => { + super::air_phase::advance(physics, skater)? + } + skate_core::player::state::PhysicalStateId::KnownAir => { + super::known_air::update(physics, skater)? + } + skate_core::player::state::PhysicalStateId::GroundAnimation => { + super::ground_animation::advance(physics, skater)? + } + skate_core::player::state::PhysicalStateId::SlideGround => { + super::slide_state::update(physics, skater)? + } + skate_core::player::state::PhysicalStateId::WipeoutGround => { + super::wipeout_states::advance(physics, skater)? + } + skate_core::player::state::PhysicalStateId::Teleporting => { + //VT823272FC slot8=82D431F0; slots12/40 are original empty leaves. + skater.teleport_state.update(&skater.player_input.processed); + } + state => return Err(format!("Selected {state:?} requires its physical update")), + } + // Source State82DB6120 submits the queue once, then advances state time. + // BoardRuntime applies that same queue during its shared solve. + skater.player_input.player.state_timer_1344 += skater.player_input.processed.timestep_2604; + physics.processed_flags_2468 = skater.player_input.processed.flags_2468; + //World8275ECA4 ends skeleton tests after state/forces and before solving. + //Teleport resets previous observations, but preserves this pending batch. + skeleton_queries.publish(&mut skater.player_input.player); + solve::advance(physics, skater, skater.ground.steering.targets)?; + //ProcessOutput82DB6EE8 resets the packet before its component publishers. + //All consumers of the preceding output have completed this frame's input. + super::player_input::reset_outputs(&mut skater.player_input.physical); + physics.finish_skater(skater)?; + let simulation = physics.settings.step.simulation; + skater.player_input.update_dynamic_normal( + &physics.board, + &physics.riding, + simulation.gravity_acceleration, + simulation.time_step, + ); + skater.player_input.publish_board(&physics.riding)?; + player_state::publish(physics, skater)?; + let physical = &skater.player_input.physical; + skater.centre_of_mass_output = skater.centre_of_mass_filter.update( + physical.reckoning.vector_64.map(f32::from_bits), + physical.reckoning.vector_16.map(f32::from_bits), + ); + let feedback = animation_phase::publish_feedback(physics, skater); + camera_output::advance(physics, skater, &feedback, camera)?; + skater.animation_input.finish_output_publication(); + skater.player_input.player.update_count_1316 = + skater.player_input.player.update_count_1316.wrapping_add(1); + physics.clock.finish_tick(); + Ok(()) +} diff --git a/crates/skate-game/src/physics/ground.rs b/crates/skate-game/src/physics/ground.rs new file mode 100644 index 00000000..55b7ffb1 --- /dev/null +++ b/crates/skate-game/src/physics/ground.rs @@ -0,0 +1,196 @@ +//! Our level geometry, queried with the recovered physics/world calculations. +use skate_core::{ + math::Vector3, + physics::{ + board_world::{ + BoardWorld, ContactRetentionSettings, WorldTriangle, + query_metadata::{Bounds, EdgeSegment, QueryMesh, QueryMetadata, QueryPool}, + }, + collision::{TriangleFeature, WorldContactSettings}, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + world_contact::triangle_from_volume, + }, +}; + +pub(crate) const HEIGHT: f32 = -0.035; + +/// Authored level dimensions in metres; HEIGHT remains the starting surface. +pub(crate) const FLOOR_HEIGHT: f32 = HEIGHT - 1.5; + +/// Shared quads for rendering and collision: landing floor, starting box, half pipe. +/// Quads use the same winding as the original floor (0,2,1 and 0,3,2). +pub(crate) fn surfaces() -> [Vec<[Vector3; 4]>; 3] { + let v = Vector3::new; + let floor = vec![[ + v(-50.0, FLOOR_HEIGHT, -50.0), + v(50.0, FLOOR_HEIGHT, -50.0), + v(50.0, FLOOR_HEIGHT, 50.0), + v(-50.0, FLOOR_HEIGHT, 50.0), + ]]; + let top = [ + v(-3.0, HEIGHT, -4.0), + v(3.0, HEIGHT, -4.0), + v(3.0, HEIGHT, 4.0), + v(-3.0, HEIGHT, 4.0), + ]; + let mut platform = vec![top]; + for i in [0, 1, 3] { + // Forward face opens directly onto the ramp. + let a = top[i]; + let b = top[(i + 1) % 4]; + platform.push([b, a, v(a.x, FLOOR_HEIGHT, a.z), v(b.x, FLOOR_HEIGHT, b.z)]); + } + + // Full-width downhill route straight ahead from spawn. Ease both ends + // into the horizontal surfaces so the crest is not a sharp launch edge. + let ramp_segments = 48; + let ramp_length = 12.0; + let ramp_point = |i: usize| { + let t = i as f32 / ramp_segments as f32; + let blend = t * t * (3.0 - 2.0 * t); + ( + 4.0 + ramp_length * t, + HEIGHT + (FLOOR_HEIGHT - HEIGHT) * blend, + ) + }; + for i in 0..ramp_segments { + let (z0, y0) = ramp_point(i); + let (z1, y1) = ramp_point(i + 1); + platform.push([ + v(-3.0, y0, z0), + v(3.0, y0, z0), + v(3.0, y1, z1), + v(-3.0, y1, z1), + ]); + } + // Open ends at z +/-6 allow entry from the landing floor. Circular + // transitions meet the four-metre flat bottom tangentially. + let radius = 2.5; + let segments = 32; + let mut profile = Vec::new(); + for i in (0..=segments).rev() { + let angle = std::f32::consts::FRAC_PI_2 * i as f32 / segments as f32; + profile.push(( + 12.0 - 2.0 - radius * angle.sin(), + FLOOR_HEIGHT + radius * (1.0 - angle.cos()), + )); + } + for i in 0..=segments { + let angle = std::f32::consts::FRAC_PI_2 * i as f32 / segments as f32; + profile.push(( + 12.0 + 2.0 + radius * angle.sin(), + FLOOR_HEIGHT + radius * (1.0 - angle.cos()), + )); + } + let mut half_pipe = Vec::new(); + for pair in profile.windows(2) { + let (x0, y0) = pair[0]; + let (x1, y1) = pair[1]; + // The flat bottom already belongs to the landing floor. + if y0 == FLOOR_HEIGHT && y1 == FLOOR_HEIGHT { + continue; + } + half_pipe.push([ + v(x0, y0, -6.0), + v(x1, y1, -6.0), + v(x1, y1, 6.0), + v(x0, y0, 6.0), + ]); + } + // One-metre decks at both lips. + for (x0, x1) in [(6.5, 7.5), (16.5, 17.5)] { + let y = FLOOR_HEIGHT + radius; + half_pipe.push([v(x0, y, -6.0), v(x1, y, -6.0), v(x1, y, 6.0), v(x0, y, 6.0)]); + } + [floor, platform, half_pipe] +} + +pub(super) fn world(material: RetailContactMaterial) -> BoardWorld { + let mut triangles = Vec::new(); + for (tag, quads) in surfaces().into_iter().enumerate() { + for vertices in quads { + for indices in [[0, 2, 1], [0, 3, 2]] { + triangles.push(WorldTriangle { + triangle: triangle_from_volume( + indices.map(|i| vertices[i]), + 0.0, + [1.0; 3], + TriangleFeature::ONE_SIDED, + ), + material, + tag: tag as u32, + }); + } + } + } + //This test level explicitly authors material0/category0/upper flags0. + //This is host metadata, not a claim that the stock material is concrete, + //and not derived from the neutral contact coefficients or rendering tag. + let metadata = QueryMetadata { + packed_surfaces: vec![0u16; triangles.len()], + meshes: vec![QueryMesh { + triangle_range: 0..triangles.len(), + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: Bounds::from_points( + triangles.iter().flat_map(|entry| entry.triangle.vertices), + ) + .expect("Authored level contains collision triangles"), + matching_group: -1, + pool: QueryPool::Ground, + }], + static_edges: query_edges(), + //No island or conditional meshes exist in the current static level. + island_flags: 0, + }; + BoardWorld::with_query_metadata(triangles, metadata) + .expect("Authored level query metadata matches its collision geometry") +} +///Deliberately authored ledge records, in level order. Neither the triangle +///diagonals nor the smooth ramp/half-pipe tessellation seams are ledges. +fn query_edges() -> Vec { + let v = Vector3::new; + let top = [ + v(-3.0, HEIGHT, -4.0), + v(3.0, HEIGHT, -4.0), + v(3.0, HEIGHT, 4.0), + v(-3.0, HEIGHT, 4.0), + ]; + let mut edges: Vec<_> = [0, 1, 3] + .into_iter() + .map(|i| (top[i], top[(i + 1) % 4])) + .collect(); + let lip_height = FLOOR_HEIGHT + 2.5; + for x in [7.5, 16.5] { + edges.push((v(x, lip_height, -6.0), v(x, lip_height, 6.0))); + } + edges + .into_iter() + .map(|(start, end)| EdgeSegment { + start, + end, + local_bounds: Bounds::from_points([start, end]).expect("Two authored edge endpoints"), + }) + .collect() +} + +pub(super) fn query_settings() -> (WorldContactSettings, ContactRetentionSettings) { + ( + WorldContactSettings { + // Serialized primitive82DC3AE4/82DC3B84, source literal82165A00. + volume_padding: 0.05, + // Ground job8277C864/8277C86C; board selection82768728. + maximum_separating_distance: 0.5, + edge_cos_bend_normal_threshold: 0.999, + convexity_epsilon: 0.01, + is_object: false, + }, + ContactRetentionSettings { + capacity: u32::MAX, // Host storage capacity, no artificial row limit. + // Step_Collision1 8276430C ->827771E8 ->82777384 ->8277C664. + duplicate_distance_squared: -1.0, + deferred_reduction: true, + }, + ) +} diff --git a/crates/skate-game/src/physics/ground_animation.rs b/crates/skate-game/src/physics/ground_animation.rs new file mode 100644 index 00000000..a81f07a7 --- /dev/null +++ b/crates/skate-game/src/physics/ground_animation.rs @@ -0,0 +1,84 @@ +//! GroundAnimation103 lifecycle82D33BF8/33DB8/33C70/34150, original Skate3TU3. +mod board; +mod settings; +mod skeleton; +use super::{GamePhysics, SkaterRuntime}; +pub(crate) use settings::GroundAnimationSettings; +use skate_core::{ + air::ground_jump::GroundJump, + math::Vector3, + player::input_phase::{AirOutputFields, ProcessedPhysicsInput}, +}; + +#[derive(Default)] +pub(crate) struct GroundAnimationRuntime { + pub jump: GroundJump, //48..68 + pub launched: bool, //80, reset at each update + pub launch_velocity: [f32; 4], //96, selector launch-info2048 +} +impl GroundAnimationRuntime { + ///82D34150 writes only its active fields after the common PhysOut reset. + pub fn fill(&self, p: &ProcessedPhysicsInput, out: &mut AirOutputFields) { + if p.flags_2468 & 0x0040_0000 != 0 { + out.jump_velocity_delta_112 = std::array::from_fn(|i| { + (self.jump.velocity[i] - f32::from_bits(p.vectors_400_416[0][i])).to_bits() + }); + } + if self.launched { + out.launched_442 = 1; + out.launch_velocity_128 = self.launch_velocity.map(f32::to_bits); + } + } +} +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.ground_animation.launched = false; + skater.ground_animation.launch_velocity = [0.0; 4]; + let life = &mut skater.ground_lifecycle; + life.skeleton_elapsed_16505 = true; + physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut life.board_animated_290); + if skater.wipeout.state.mode != 1 { + skater.wipeout.state.mode = 1; + skater.wipeout.state.balance = 0.0; + } + Ok(()) +} +pub(crate) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.ground_animation.launched = false; + let p = &skater.player_input.processed; + let com = p.animation_com_to_deck_752.map(f32::from_bits); + physics.riding.update_ground_reckoning( + &physics.board, + super::riding_outputs::RidingPoseInputs { + com_to_deck: Vector3::new(com[0], com[1], com[2]), + body_spin: skater.animation_input.extra.physical_body_spin, + }, + p.flags_2468, + skater.animation_input.fields.balance, + p.flags_2476 & 0x4000_0000 != 0, + ); + skeleton::advance(physics, skater)?; + skater.ground_lifecycle.skeleton_ground_16388 = true; + board::advance(physics, skater) +} +pub(crate) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + board::set_drag(physics, 0.0); + physics.settings.wheel_material = physics.settings.standard_wheel_material; + if skater.ground_animation.jump.active { + let p = &skater.player_input.processed; + let mut reference = skater.ground_animation.jump.velocity; + reference[1] = p.gravity_2648.mul_add(p.timestep_2604, reference[1]); + let velocity = skate_core::air::state::clamp_jump_velocity( + reference, + p.vectors_400_416[0].map(f32::from_bits), + ); + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, velocity); + } + skater.ground_animation.jump = GroundJump::default(); + Ok(()) +} diff --git a/crates/skate-game/src/physics/ground_animation/board.rs b/crates/skate-game/src/physics/ground_animation/board.rs new file mode 100644 index 00000000..5a95db48 --- /dev/null +++ b/crates/skate-game/src/physics/ground_animation/board.rs @@ -0,0 +1,192 @@ +//!82D33E30: jump launch or ordered foot/brake/friction/collision forces. +use super::*; +use skate_core::{ + air::ground_jump::{self, GroundJumpInput}, + physics::force_queue::QueuedPointForce, + riding::{ + braking::{self, BrakeInput, LinearDragInput}, + collision_response::CollisionResponsePhysical, + ground_force::{self, GroundForceInput}, + grounded::drag::DRAG_FREQUENCY, + slide_friction::{self, SlideFrictionInput}, + }, +}; + +pub(super) fn set_drag(physics: &mut GamePhysics, drag: f32) { + //BodySetLinearDrag82D9CD60 multiplies by native59.999996, for all seven parts. + for body in physics.board.bodies_mut() { + body.inertia.linear_drag = drag * DRAG_FREQUENCY; + } +} +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("GroundAnimation requires current BoardToolkit")?; + let s = &skater.ground_settings; + skater.ground.steering.update( + 0.0, + s.board().steering.tilt_blending, + p.flags_2468, + p.flags_2472, + ); + physics.settings.wheel_material = s.wheel_material; + let mode = *skater + .ground_animation_settings + .modes + .get(p.state_variant_index_2528 as usize) + .ok_or_else(|| { + format!( + "Invalid GroundAnimation physics mode {}", + p.state_variant_index_2528 + ) + })?; + skater.ground_animation.jump = ground_jump::calculate( + GroundJumpInput { + flags_2468: p.flags_2468, + flags_2484: p.flags_2484, + flags_2488: p.flags_2488, + effective_forward: t.effective[2], + forward: t.forward, + current_velocity: p.vectors_400_416[0].map(f32::from_bits), + ground_reference_position: p.vectors_464_480_496_512_528[1].map(f32::from_bits), + reference_up: p.vectors_544_560_592_608[0].map(f32::from_bits), + filtered_ground_normal: t.filtered_normal, + animation_com_position: p.vectors_544_560_592_608[2].map(f32::from_bits), + prepared_velocity: p.prepared_jump_704.map(f32::from_bits), + jump_strength: skater.animation_input.extra.jump_strength, + jump_controls: skater.animation_input.extra.jump_controls, + gravity_y: p.gravity_2648, + surface_speed: p.scalar_2656, + }, + mode, + &skater.ground_animation_settings.jump, + ); + if skater.ground_animation.jump.active { + let velocity = skater.ground_animation.jump.velocity; + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, velocity); + skater.ground_animation.launched = true; + set_drag(physics, 0.0); + let mut info = super::super::air_phase::launch_info(physics, skater)?; + info.start_velocity = velocity; + info.player_jumped = true; + //The native caller does not replace Fill's COM velocity144. + let input = super::super::air_phase::selector_input(physics, skater)?; + skater.trajectory.launch(info, input, &physics.world)?; + skater.trajectory.update(input, &physics.world)?; + //Launch82D678DC stores packet at1920;128 is selector2048. Update's + //second pass may replace that retained launch velocity before this read. + skater.ground_animation.launch_velocity = skater + .trajectory + .selector + .launch_info() + .ok_or("GroundAnimation launch has no retained trajectory packet")? + .start_velocity; + return Ok(()); + } + let settings = s.board(); + let balance = skater.animation_input.fields.balance; + let front_scale = if balance <= 0.0 { + if balance >= -0.0 { 1.0 } else { 2.0 } + } else { + 0.0 + }; + let rear_scale = if balance >= -0.0 { + if balance <= 0.0 { 1.0 } else { 2.0 } + } else { + 0.0 + }; + let foot = |offset, absorption, amount| { + ground_force::calculate( + settings.ground_force, + &GroundForceInput { + argument_1: 1.0, + application_z: offset, + argument_3: absorption, + argument_4: amount, + balance, + surface_speed: p.scalar_2656, + axis_384: t.transverse_up, + velocity_400: p.vectors_400_416[0].map(f32::from_bits), + axis_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + }, + ) + }; + let front = foot(s.foot_force_offset, 0.0, front_scale); + //Toolkit cache1240 is FootForceOffset,1236 is AbsorptionFootForceRearScalar. + let rear = foot(-s.foot_force_offset, s.absorption_rear * 0.0, rear_scale); + let brake = braking::calculate_braking( + BrakeInput { + flags_2468: p.flags_2468, + input_2728: skater.animation_input.fields.brake, + signed_speed: p.scalar_2612, + absolute_body_speed: t.absolute_speed, + surface_factor: s.surface_braking_factor, + direction: xyz(t.horizontal_forward), + }, + settings.propulsion.braking, + ); + let friction = slide_friction::calculate( + settings.slide_friction, + &SlideFrictionInput { + heading_time: p.state_timer_2664, + normal: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + velocity: p.vectors_400_416[0].map(f32::from_bits), + side_axis: t.deck[0], + surface_speed: p.scalar_2656, + scalar_2764: p.scalar_2764, + }, + ); + let normal = physics.riding.reckoning.ground_normal; + let drag = braking::calculate_linear_drag( + LinearDragInput { + flags_2468: p.flags_2468, + absolute_body_speed: t.absolute_speed, + balance_2720: balance, + scalar_2724: skater.ground_lifecycle.manual_drag_2724, + comparison_scalar: normal.y, + }, + settings.linear_drag, + ); + let collision = skater + .ground_runtime + .calculate_collision_force(CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: p.collision_pose_error_736.map(f32::from_bits), + velocity: p.vectors_400_416[1].map(f32::from_bits), + forward: t.travel_direction, + up: p.vectors_544_560_592_608[0].map(f32::from_bits), + ground_normal: [normal.x, normal.y, normal.z, 0.0], + time_step: p.timestep_2604, + mass: t.total_mass, + }); + if let Some(collision) = collision { + physics.board.forces_mut().append(QueuedPointForce { + tag: 15, + force_world: xyz(collision.force_2528), + point_body: xyz(collision.point_2544), + }); + //Collision branch preserves the preceding body's drag coefficient. + } else { + physics.board.forces_mut().append(point_force(4, front)); + physics.board.forces_mut().append(point_force(5, rear)); + physics.board.forces_mut().append(point_force(1, friction)); + physics.board.forces_mut().append(brake); + set_drag(physics, drag); + } + Ok(()) +} +fn point_force(tag: u32, v: [f32; 8]) -> QueuedPointForce { + QueuedPointForce { + tag, + force_world: Vector3::new(v[0], v[1], v[2]), + point_body: Vector3::new(v[4], v[5], v[6]), + } +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/ground_animation/settings.rs b/crates/skate-game/src/physics/ground_animation/settings.rs new file mode 100644 index 00000000..891949bc --- /dev/null +++ b/crates/skate-game/src/physics/ground_animation/settings.rs @@ -0,0 +1,66 @@ +//!82D94C10 copies physics_jump layout0..456 into the board toolkit cache. +use skate_core::{ + air::ground_jump::{GroundJumpMode, GroundJumpSettings}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +pub(crate) struct GroundAnimationSettings { + pub jump: GroundJumpSettings, + pub modes: [GroundJumpMode; 5], +} +impl GroundAnimationSettings { + pub fn load(d: &Collections) -> Result { + let f = |name| d.float("physics_jump", "default", name); + let words = d.words::<32>("physics_jump", "default", "VerticalResponse")?; + let mut modes = [GroundJumpMode { + minimum_height_64: 0.0, + minimum_height_68: 0.0, + maximum_height: 0.0, + }; 5]; + for (slot, key) in modes + .iter_mut() + .zip(["easy", "normal", "hardcore", "motorized", "test"]) + { + *slot = GroundJumpMode { + minimum_height_64: d.float("physics_mode", key, "Hash_BE3F74F978D777E5")?, + minimum_height_68: d.float("physics_mode", key, "JumpMinHeight")?, + maximum_height: d.float("physics_mode", key, "JumpMaxHeight")?, + }; + } + Ok(Self { + modes, + jump: GroundJumpSettings { + vertical_response: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[i])), + y: std::array::from_fn(|i| f32::from_bits(words[16 + i])), + }, + y_scalar_vs_normal_y: graph(d, "JumpYScalarVsGroundNormalY", true)?, + speed_scalar_vs_angle: graph(d, "JumpSpeedScalarVsAngle", true)?, + minimum_height_vs_speed: graph(d, "MinHeightVsSpeed", false)?, + maximum_height_vs_speed: graph(d, "MaxHeightVsSpeed", false)?, + speed_response_max_speed: f("SpeedResponseMaxSpeed")?, + minimum_scalar: f("MinScalar")?, + maximum_y_bonus: f("JumpYBonusMax")?, + adjust_z_factor: f("JumpAdjustZFactor")?, + adjust_x_factor: f("JumpAdjustXFactor")?, + absolute_minimum_height: f("AbsoluteMinHeight")?, + }, + }) + } +} +fn graph(d: &Collections, name: &str, negative: bool) -> Result, String> { + let (x, y) = if negative { + let w = d.words::<20>("physics_jump", "default", name)?; + ( + std::array::from_fn(|i| f32::from_bits(w[4 + i])), + std::array::from_fn(|i| f32::from_bits(w[12 + i])), + ) + } else { + let w = d.words::<16>("physics_jump", "default", name)?; + ( + std::array::from_fn(|i| f32::from_bits(w[i])), + std::array::from_fn(|i| f32::from_bits(w[8 + i])), + ) + }; + Ok(PointGraph { x, y }) +} diff --git a/crates/skate-game/src/physics/ground_animation/skeleton.rs b/crates/skate-game/src/physics/ground_animation/skeleton.rs new file mode 100644 index 00000000..67aaa387 --- /dev/null +++ b/crates/skate-game/src/physics/ground_animation/skeleton.rs @@ -0,0 +1,27 @@ +//!82D33E00 calls SkeletonUpdateAnimated82BDDA10 with fast blending enabled. +use super::*; +use crate::physics::{input_phase, skeleton_input_runtime::SkeletonOwners}; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let collision = input_phase::collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + skater.skeleton_input.update_animated( + &mut skater.skeleton_air, + &mut physics.board, + &physics.riding.reckoning_frames.system, + &mut skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + true, + )?; + Ok(()) +} diff --git a/crates/skate-game/src/physics/ground_exit.rs b/crates/skate-game/src/physics/ground_exit.rs new file mode 100644 index 00000000..e18fd31c --- /dev/null +++ b/crates/skate-game/src/physics/ground_exit.rs @@ -0,0 +1,18 @@ +//!Complete PhysicsGround Exit82D37B20, on the real board and controller owners. +use super::{GamePhysics, SkaterRuntime}; +use skate_core::air::state::clamp_jump_velocity; +pub(super) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { + let state = &mut skater.ground.state; + state.steering_damped_turn_2644 = 0.0; + state.steering_push_scalar_2640 = 1.0; + skater.ground.pumping.reset(); + for body in physics.board.bodies_mut() { body.inertia.linear_drag = 0.0; } + physics.settings.wheel_material = physics.settings.standard_wheel_material; + if state.flag_2708 { + let p = &skater.player_input.processed; + let gravity = [0.0,p.gravity_2648,0.0,0.0]; + let reference = std::array::from_fn(|i| gravity[i].mul_add(p.timestep_2604,state.vector_2688[i])); + let velocity = clamp_jump_velocity(reference,p.vectors_400_416[0].map(f32::from_bits)); + skater.ground_runtime.set_animated_velocity(&mut physics.board,velocity); + } +} diff --git a/crates/skate-game/src/physics/ground_phase.rs b/crates/skate-game/src/physics/ground_phase.rs new file mode 100644 index 00000000..d7535a91 --- /dev/null +++ b/crates/skate-game/src/physics/ground_phase.rs @@ -0,0 +1,289 @@ +//! Production Ground entry82D37538 and update82D37C88/82D38800. +//! State selection belongs to the player coordinator. This phase consumes its +//! current processed packet and changes the same board that the solver advances. +use super::{ + GamePhysics, + ground_runtime::{ + GroundEntryTargets, GroundInputObservations, GroundLaunchInfo, GroundLaunchPhysical, + GroundPhysicalFrame, GroundTrajectoryState, GroundUpdateFrame, GroundUpdateTargets, + }, + skater::SkaterRuntime, + skeleton_controller::SkeletonControllerState, +}; +use skate_core::{ + math::Vector3, + physics::{ground_hang_geometry::HangGeometryInput, skeleton_body::SkeletonCollisionMode}, + riding::{ + collision_response::CollisionResponsePhysical, ground_contact_response::WallRidePhysical, + grounded::state::board::GroundBoardOutcome, + }, +}; + +/// Edge observations are owned geometry results, not inferred from the deck. +/// The current authored world explicitly has no grind edges. +#[derive(Clone, Copy)] +pub(crate) struct GroundEdge { + pub flags: u32, + pub point: [f32; 4], + pub start: Vector3, + pub end: Vector3, +} + +pub(crate) struct GroundLifecycle { + pub skeleton_controller: SkeletonControllerState, + /// These are the actual retained Skeleton lifecycle flags, also shared + /// with teleport and GeneralUpdate by the coordinator. + pub skeleton_elapsed_16505: bool, + pub skeleton_ground_16388: bool, + pub board_animated_290: u8, + /// Processed2724: Reset82BFA35C clears this. The original image contains + /// no other direct scalar writer at that offset. + pub manual_drag_2724: f32, + pub edge: Option, + pub trajectory: GroundTrajectoryState, + /// A selected wall jump is retained for its actual selector continuation. + /// It cannot be discarded as if Ground's ordinary tail had completed. + pub pending_wall_jump: Option, +} +impl GroundLifecycle { + pub fn new() -> Self { + Self { + skeleton_controller: SkeletonControllerState::new(), + skeleton_elapsed_16505: false, + skeleton_ground_16388: false, + board_animated_290: 0, + manual_drag_2724: 0.0, + edge: None, + trajectory: GroundTrajectoryState { + pending_request: None, + primary_valid_288: false, + secondary_valid_592: false, + result_valid_9840: false, + flags_12836: 0, + }, + pending_wall_jump: None, + } + } +} + +/// Typed portion of Skateboard Reset82C05F50, alongside the coordinator's +/// actual body/pose/force reset. Native timer fields8364/8368 are not written +/// by either this reset or ResetBoardBody82C0D680, so preserve activation_time. +pub(crate) fn reset_board_state( + ground: &mut super::ground_runtime::GroundState, + runtime: &mut super::ground_runtime::GroundRuntime, + life: &mut GroundLifecycle, + board_wiping_out: &mut bool, +) { + ground.steering.deck_tilt = 0.0; //82C06048, wrapper256. + ground.steering.targets = [0.0; 2]; //82C06060/64, body7680/7684. + runtime.reset_board_toolkit(); + life.board_animated_290 = 0; //82C06070. + *board_wiping_out = false; //82C0D79C..7A0 clears body8384 high2bits. +} + +/// Called only on the coordinator's real transition into Ground. Entry needs +/// the toolkit already produced by this tick's PlayerInput phase. +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Ground entry requires PlayerInput's current board toolkit")?; + enter_components( + &mut physics.board, + &skater.player_input.processed, + &skater.animation_input, + toolkit, + &mut skater.ground, + &mut skater.ground_lifecycle, + &mut skater.air_reckoning.state, + &mut skater.wipeout.state, + &mut skater.skeleton_collision, + &mut skater.foot_ik, + &mut skater.skeleton_output, + &mut physics.board_wiping_out, + ) +} + +/// The same entry for the reset callback while PlayerInput owns its mutable +/// processed packet. Splitting host borrows does not alter the source order. +#[allow(clippy::too_many_arguments)] +pub(crate) fn enter_components( + board: &mut skate_core::physics::board_runtime::BoardRuntime, + processed: &skate_core::player::input_phase::ProcessedPhysicsInput, + animation: &super::animation_input::AnimationInput, + toolkit: &skate_core::physics::board_toolkit::BoardToolkit, + ground: &mut super::ground_runtime::GroundState, + life: &mut GroundLifecycle, + air_reckoning: &mut skate_core::air::reckoning::AirState, + wipeout: &mut skate_core::player::wipeout::Requests, + collision_mode: &mut SkeletonCollisionMode, + ik: &mut super::foot_ik::FootIk, + output: &mut super::skeleton_output::SkeletonOutput, + board_wiping_out: &mut bool, +) -> Result<(), String> { + // GamePhysics owns the meaningful high bit of Skateboard8384. + let mut board_flags = u8::from(*board_wiping_out) << 7; + let mut collision = |state| { + if state != 6 { + return Err("Ground requested a non-ground skeleton collision state".into()); + } + life.skeleton_controller.request_ground(collision_mode) + }; + let mut landing = |reverse| { + output.trigger_wobble(true, reverse); + Ok(()) + }; + let result = ground.enter( + board, + processed, + animation, + toolkit, + GroundEntryTargets { + foot_ik: ik, + skeleton_elapsed_16505: &mut life.skeleton_elapsed_16505, + reckoning_spin_angle: &mut air_reckoning.spin_angle, + reckoning_spin_speed: &mut air_reckoning.spin_speed, + board_flags_8384: &mut board_flags, + board_animated_290: &mut life.board_animated_290, + wipeout_mode: &mut wipeout.mode, + wipeout_timer: &mut wipeout.balance, + set_skeleton_collision_state: &mut collision, + enter_air_landing_modifier: &mut landing, + }, + ); + *board_wiping_out = board_flags & 0x80 != 0; + result +} + +/// Run after the Ground Reckoning preupdate and before the shared body solve. +/// The caller passes ground.steering.targets to that solve and must not clear +/// the board's force queue between this call and the solve. +pub(crate) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result { + let p = &skater.player_input.processed; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("Ground update requires PlayerInput's current board toolkit")?; + let life = &mut skater.ground_lifecycle; + if life.pending_wall_jump.is_some() { + return Err("Ground wall jump is pending its trajectory selector continuation".into()); + } + // Native grind reset clears the complete observation block. An authored + // world with no edges therefore has no active edge data to publish. + let edge = life.edge.unwrap_or(GroundEdge { + flags: 0, + point: [0.0; 4], + start: Vector3::ZERO, + end: Vector3::ZERO, + }); + let normal = raw(p.vectors_464_480_496_512_528[0]); + let up = raw(p.vectors_544_560_592_608[0]); + let initial_velocity = raw(p.vectors_400_416[0]); + let mut velocity = initial_velocity; + let mut predicted = skater.animated_skeleton.roots.predicted_board_position; + let targets = &mut skater.skeleton_drives.targets; + let mut move_future = |delta: Vector3| { + targets.apply_future_deck_displacement(delta); + predicted[0] += delta.x; + predicted[1] += delta.y; + predicted[2] += delta.z; + Ok(()) + }; + let mut launch = |info: &GroundLaunchInfo| { + life.pending_wall_jump = Some(info.clone()); + Err("Ground selected wall jump; its retained launch requires TrajectorySelector::Launch/Update".into()) + }; + let physical = GroundPhysicalFrame { + skeleton_record: &skater.animated_skeleton.record, + wall_ride: WallRidePhysical { + board_normal: normal, + up, + velocity, + board_mass: toolkit.total_mass, + gravity: p.gravity_2648, + speed: p.scalar_2652, + contact_count: p.wheel_count_2556 as i32, + }, + collision: CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: raw(p.collision_pose_error_736), + velocity: raw(p.vectors_400_416[1]), + forward: toolkit.travel_direction, + up, + ground_normal: lanes(physics.riding.reckoning.ground_normal), + time_step: p.timestep_2604, + mass: toolkit.total_mass, + }, + hang_geometry: HangGeometryInput { + edge_start: edge.start, + edge_end: edge.end, + reference_point: xyz(edge.point), + }, + processed_velocity: &mut velocity, + wheel_material: &mut physics.settings.wheel_material, + wipeout: &mut skater.wipeout.state, + time_step: p.timestep_2604, + launch_physical: Some(GroundLaunchPhysical { + reckoning: &physics.riding.reckoning_frames, + skeleton_vector_16208: skater.animated_skeleton.board_frames.local_centre_of_mass, + skeleton_vector_16240: skater.animated_skeleton.board_frames.local_board_position, + board_position: toolkit.deck[3], + physical_center_of_mass: raw(p.vectors_544_560_592_608[2]), + velocity: initial_velocity, + angular_velocity: raw(p.prepared_jump_704), + flags_2468: p.flags_2468, + time_step: p.timestep_2604, + }), + launch_and_update: &mut launch, + }; + let result = skater.ground.update( + &mut skater.ground_runtime, + &mut physics.board, + &physics.world, + &skater.ground_settings, + GroundUpdateFrame { + processed: p, + animation: &skater.animation_input, + riding: &physics.riding, + skeleton: &skater.animated_skeleton, + toolkit, + base_trucks: physics.settings.step.base_truck_transforms, + extra: GroundInputObservations { + manual_drag_2724: life.manual_drag_2724, + trajectory_state_bits: p.external_physics_1616.flags, + edge_flags: edge.flags, + edge_point: edge.point, + }, + }, + physical, + GroundUpdateTargets { + foot_ik: &mut skater.foot_ik, + skeleton_elapsed_16505: &mut life.skeleton_elapsed_16505, + skeleton_ground_16388: &mut life.skeleton_ground_16388, + // Original830BD4A0 initializer82F825F0 splats8216DEE0=-1. + world_grab_reset_direction: [-1.0; 4], + move_future_deck: &mut move_future, + trajectory: &mut life.trajectory, + }, + ); + // These source writes occur during update. Publish even on a later branch + // error so retrying cannot silently erase a completed physical side effect. + skater.player_input.processed.vectors_400_416[0] = velocity.map(f32::to_bits); + skater.animated_skeleton.roots.predicted_board_position = predicted; + result +} +fn raw(v: [u32; 4]) -> [f32; 4] { + v.map(f32::from_bits) +} +fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/ground_runtime/corrections.rs b/crates/skate-game/src/physics/ground_runtime/corrections.rs new file mode 100644 index 00000000..eaf46ed8 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/corrections.rs @@ -0,0 +1,109 @@ +//! Native numerical callbacks for the ordinary ground-state branch. +use super::GroundRuntime; +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, board_runtime::BoardRuntime, manual::controller::ManualAngleMeasurement, + }, + riding::{ + collision_response::signed_angle, ground_correction_math, + grounded::state::corrections::AntiFlipNudgeMath, + }, +}; +use std::convert::Infallible; + +impl ManualAngleMeasurement for GroundRuntime { + type Error = Infallible; + fn angle_between( + &mut self, + deck_z: [f32; 4], + reference_z: [f32; 4], + reference_x: [f32; 4], + ) -> Result { + Ok(signed_angle( + xyz(deck_z), + xyz(reference_z), + xyz(reference_x), + )) + } +} +impl AntiFlipNudgeMath for GroundRuntime { + type Error = Infallible; + fn dot3(&mut self, a: [f32; 4], b: [f32; 4]) -> Result { + Ok(ground_correction_math::dot_product(a, b)) + } + fn scale_to_magnitude( + &mut self, + vector: [f32; 4], + squared_length: f32, + magnitude: f32, + ) -> Result<[f32; 4], Self::Error> { + Ok(ground_correction_math::scale_to_magnitude( + vector, + squared_length, + magnitude, + )) + } +} +impl GroundRuntime { + pub fn detect_hung_up_geometry( + &self, + world: &skate_core::physics::board_world::BoardWorld, + input: skate_core::physics::ground_hang_geometry::HangGeometryInput, + ) -> Result { + skate_core::physics::ground_hang_geometry::detect_hung_geometry( + world, + input, + self.deck_center_to_truck, + ) + .map_err(str::to_owned) + } + pub fn collision_force_projection(&self, force: [f32; 4], velocity: [f32; 4]) -> f32 { + ground_correction_math::collision_force_projection(force, velocity) + } + pub fn build_hang_force( + &self, + board: &BoardRuntime, + edge_start: [f32; 4], + edge_end: [f32; 4], + ) -> [f32; 4] { + let p = board.part_transforms()[BodyId::Deck.index()].translation; + ground_correction_math::hang_force(edge_start, edge_end, [p.x, p.y, p.z, 0.]) + } + pub fn apply_hang_force(&mut self, board: &mut BoardRuntime, force: [f32; 4]) { + let position = board.part_transforms()[BodyId::Deck.index()].translation; + ground_correction_math::apply_world_force( + &mut board.bodies_mut()[BodyId::Deck.index()], + position, + xyz(force), + position, + ); + } + pub fn apply_wheel_catch(&mut self, board: &mut BoardRuntime) { + let frame = board.part_transforms()[BodyId::Deck.index()].basis.columns; + let value = ground_correction_math::wheel_catch_displacement( + [frame[1][0], frame[1][1], frame[1][2], 0.], + [frame[2][0], frame[2][1], frame[2][2], 0.], + ); + self.apply_angular_displacement(board, value); + } + pub fn pin_to_position( + &mut self, + board: &mut BoardRuntime, + captured_x: f32, + captured_z: f32, + dt: f32, + ) { + let p = board.part_transforms()[BodyId::Deck.index()].translation; + let velocity = ground_correction_math::pinning_velocity( + [p.x, p.y, p.z, 0.], + captured_x, + captured_z, + dt, + ); + self.set_animated_velocity(board, velocity); + } +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/ground_runtime/entry.rs b/crates/skate-game/src/physics/ground_runtime/entry.rs new file mode 100644 index 00000000..f8ae3375 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/entry.rs @@ -0,0 +1,153 @@ +//! Actual Ground entry82D37538. Numerical operations touch live board bodies; +//! the physical skeleton/air modifier remain calls to their separate owners. +use super::{ + super::{animation_input::AnimationInput, foot_ik::FootIk}, + GroundState, +}; +use skate_core::{ + math::Vector3, + physics::{board_runtime::BoardRuntime, board_toolkit::BoardToolkit}, + player::input_phase::ProcessedPhysicsInput, + riding::{ + ground_correction_math::dot_product, + grounded::{ + manual_entry::{self, ManualGroundBodies, ManualGroundInput, ManualGroundProjection}, + state::motion, + }, + }, +}; +use skate_data::collections::Collections; +use std::convert::Infallible; + +pub(super) struct EntrySettings { + deck_angular_drag: f32, + powerslide_exit: f32, + landing_strength: f32, + landing_offset: f32, +} +impl EntrySettings { + pub fn load(data: &Collections) -> Result { + Ok(Self { + //82C091F8 restores the standard angular drag with literal822F860C. + deck_angular_drag: data.float("physicsdeck", "default", "DeckAngularDrag")? + * f32::from_bits(0x426f_ffff), + powerslide_exit: data.float("physics_manual", "default", "PowerslideExitScalar")?, + landing_strength: data.float("physics_feet", "default", "LandingOnDeckEffectScalar")?, + landing_offset: data.float("physics_feet", "default", "LandingOnDeckOffset")?, + }) + } +} +///References to the actual lifecycle owners. These fields are not derived from +///controller input or cached separately in Ground. Collision and air-landing +///calls must apply the recovered physical owner behavior before returning. +pub(crate) struct GroundEntryTargets<'a> { + pub foot_ik: &'a mut FootIk, + pub skeleton_elapsed_16505: &'a mut bool, + pub reckoning_spin_angle: &'a mut f32, + pub reckoning_spin_speed: &'a mut f32, + pub board_flags_8384: &'a mut u8, + pub board_animated_290: &'a mut u8, + pub wipeout_mode: &'a mut u32, + pub wipeout_timer: &'a mut f32, + pub set_skeleton_collision_state: &'a mut dyn FnMut(u32) -> Result<(), String>, + pub enter_air_landing_modifier: &'a mut dyn FnMut(bool) -> Result<(), String>, +} +impl GroundState { + pub fn enter( + &mut self, + board: &mut BoardRuntime, + processed: &ProcessedPhysicsInput, + animation: &AnimationInput, + toolkit: &BoardToolkit, + targets: GroundEntryTargets<'_>, + ) -> Result<(), String> { + //SetStandard82C03AF8: standard dynamic part mode, deck damping and + //source-owned wrapper flag. Authored board volumes stay enabled. + *targets.board_flags_8384 &= 0x7f; + board.bodies_mut()[6].inertia.angular_drag = self.entry_settings.deck_angular_drag; + for body in board.bodies_mut() { + body.state_flags = 4; + } + targets.foot_ik.enable_feet(true); + *targets.skeleton_elapsed_16505 = false; + (targets.set_skeleton_collision_state)(6)?; + self.state.begin_entry(); + *targets.reckoning_spin_angle = 0.0; + *targets.reckoning_spin_speed = 0.0; + board + .hook_mut() + .drive + .disable_animation(targets.board_animated_290); + let normal = processed.vectors_464_480_496_512_528[0].map(f32::from_bits); + let angular = processed.vectors_720_784_800_816_832_864[0].map(f32::from_bits); + board.bodies_mut()[6].rates.angular_velocity = + motion::entry_angular_velocity(normal, angular); + self.speed.target_speed = motion::entry_target_speed( + board.bodies()[6].rates.linear_velocity, + normal, + toolkit.forward, + ); + self.wobble.reset(); + manual_entry::enter_ground( + &mut self.manual, + processed.category_2516, + self.entry_settings.powerslide_exit, + ManualGroundInput { + balance: animation.fields.balance, + ground_normal: normal, + flags_2468: processed.flags_2468, + flags_2472: processed.flags_2472, + }, + &mut ManualBodies(board), + &mut Projection, + ) + .unwrap_or_else(|never| match never {}); + if processed.category_2516 == 200 { + let stance = ((processed.flags_2468 >> 20) ^ (processed.flags_2476 >> 2)) & 1 != 0; + (targets.enter_air_landing_modifier)(stance)?; + } + if processed.state_2504 == 503 { + let force = motion::landing_on_deck_force( + processed.vectors_400_416[0].map(f32::from_bits), + processed.vectors_544_560_592_608[3].map(f32::from_bits), + processed.vectors_544_560_592_608[0].map(f32::from_bits), + toolkit.total_mass, + self.entry_settings.landing_strength, + self.entry_settings.landing_offset, + ); + board.forces_mut().append(force); + } + //The scalar assignment precedes pumping reset in source; no called + //operation between those writes reads it. + self.state.straighten_scale_2672 = if processed.state_2504 == 101 { + f32::from_bits(0x3e23_d70a) + } else { + 1.0 + }; + self.pumping.reset(); + if *targets.wipeout_mode != 1 { + *targets.wipeout_timer = 0.0; + *targets.wipeout_mode = 1; + } + self.state.finish_entry(processed.state_2504); + self.entered = true; + Ok(()) + } +} +struct ManualBodies<'a>(&'a mut BoardRuntime); +impl ManualGroundBodies for ManualBodies<'_> { + fn linear_velocity(&mut self, part: usize) -> [f32; 4] { + let v = self.0.bodies()[part].rates.linear_velocity; + [v.x, v.y, v.z, 0.0] + } + fn set_linear_velocity(&mut self, part: usize, v: [f32; 4]) { + self.0.bodies_mut()[part].rates.linear_velocity = Vector3::new(v[0], v[1], v[2]); + } +} +struct Projection; +impl ManualGroundProjection for Projection { + type Error = Infallible; + fn normal_speed(&mut self, n: [f32; 4], v: [f32; 4]) -> Result { + Ok(dot_product(n, v)) + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/input.rs b/crates/skate-game/src/physics/ground_runtime/input.rs new file mode 100644 index 00000000..4cdff0c9 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/input.rs @@ -0,0 +1,274 @@ +//! UpdateSkateboard82D38800 inputs from the actual processed packet, animation +//! attributes, physical pose and source board toolkit. No raw button-to-force +//! or guessed contact flags are introduced at this boundary. +use super::super::{ + animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, + riding_outputs::RidingOutputs, +}; +use super::{GroundRuntime, GroundSettings}; +use skate_core::{ + math::Vector3, + physics::{ + board_runtime::BoardRuntime, board_toolkit::BoardToolkit, + drive_frames::RetailAffineTransform, manual::controller::ManualInput, + }, + player::input_phase::ProcessedPhysicsInput, + riding::{ + anti_flip::AntiFlipInput, + ground_correction_math::dot_product as dot3, + grounded::{ + drag::GroundDragInput, + propulsion::GroundPropulsionInput, + state::{ + board_types::{GroundBoardInput, GroundForceFrame}, + corrections::{ + AntiFlipNudgeInput, HalfpipeWheelCatchInput, HangUpInput, PinningInput, + }, + }, + }, + heading::HeadingInput, + pumping::{PumpForceInput, state::PumpingState}, + slide_friction::SlideFrictionInput, + speed_model::SpeedModelInput, + speed_wobble::SpeedWobbleInput, + steering::SteeringInput, + straighten::StraightenInput, + }, +}; + +/// Only values owned by separate state producers, not derivable from a rigid +/// body. The coordinator supplies the actual output of those producers. +pub(crate) struct GroundInputObservations { + /// Reset82BF9EF0 writes0; preserve subsequent producer updates. + pub manual_drag_2724: f32, + /// Actual trajectory output, Processed1776. + pub trajectory_state_bits: u32, + /// Processed1516, copied from the grind/edge observation producer. + pub edge_flags: u32, + /// Processed1232, the native detected edge point. + pub edge_point: [f32; 4], +} +impl GroundRuntime { + /// Input preparation before Ground. Retained normal has its own native + /// lifetime and is updated here, not in post-solver contact publication. + pub fn prepare_toolkit( + &mut self, + board: &BoardRuntime, + p: &ProcessedPhysicsInput, + ) -> BoardToolkit { + let result = BoardToolkit::from_board( + board, + p.flags_2468, + p.scalar_2612, + raw(p.vectors_464_480_496_512_528[0]), + self.retained_board_normal, + ); + self.retained_board_normal = result.filtered_normal; + result + } +} +impl GroundSettings { + pub fn input( + &self, + t: &BoardToolkit, + p: &ProcessedPhysicsInput, + a: &AnimationInput, + pumping: &PumpingState, + unintentional_pump_scalar: f32, + riding: &RidingOutputs, + skeleton: &AnimatedSkeleton, + base_trucks: [RetailAffineTransform; 2], + extra: GroundInputObservations, + ) -> GroundBoardInput { + let f = &a.fields; + let normal = raw(p.vectors_464_480_496_512_528[0]); + let velocity = raw(p.vectors_400_416[0]); + let angular = raw(p.vectors_400_416[1]); + let axis544 = raw(p.vectors_544_560_592_608[0]); + let ground_normal = v(riding.reckoning.ground_normal); + let ground = riding.reckoning_frames.ground; + let edge_delta = std::array::from_fn(|i| t.deck[3][i] - extra.edge_point[i]); + let hang_axis = raw(p.vectors_720_784_800_816_832_864[4]); + GroundBoardInput { + steering: SteeringInput { + turn: f.turn, + hard_turn: f.hard_turn, + absolute_body_speed: t.absolute_speed, + flipped_controls_scalar: t.control_sign, + balance: f.balance, + truck_tightness: p.truck_tightness_2760, + pushing: p.flags_2468 & 0x0800_0000 != 0, + }, + //Ground's core controller replaces tilt and height before consuming + //them; these are deliberately not used as substitute observations. + speed_wobble: SpeedWobbleInput { + tilt: 0.0, + speed: p.scalar_2612, + center_of_mass_height: 0.0, + truck_tightness: p.truck_tightness_2760, + activation_threshold: self.wobble_activation, + amplitude_multiplier: self.wobble_amplitude, + }, + truck_flags_2468: p.flags_2468, + truck_flags_2472: p.flags_2472, + contact: riding + .probes + .wall_contact_frame(p.time_on_ground_2752, p.signed_ground_time_2756), + ground_vector_1216: ground_normal, + propulsion: GroundPropulsionInput { + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + target_speed: a.contacts.push_speed, + signed_speed: p.scalar_2612, + absolute_body_speed: t.absolute_speed, + scalar_2660: t.total_mass, + timestep: p.timestep_2604, + brake_input: f.brake, + push_direction: xyz(t.forward), + brake_direction: xyz(t.horizontal_forward), + surface_braking_factor: self.surface_braking_factor, + }, + ground_force: GroundForceFrame { + argument_1_2752: p.time_on_ground_2752, + processed_2776: p.crouch_2776, + processed_2780: p.crouch_delta_2780, + previous_state_scalar_56: pumping.absorption, + ground_scalar_1216: self.foot_force_offset, + ground_scalar_1232: self.absorption_front, + ground_scalar_1236: self.absorption_rear, + ground_scalar_1240: self.foot_force_offset, + balance_2720: f.balance, + surface_speed_2656: p.scalar_2656, + axis_384: t.transverse_up, + velocity_400: velocity, + axis_544: axis544, + }, + speed_model: SpeedModelInput { + flags_2468: p.flags_2468, + flags_2476: p.flags_2476, + wheel_contact_count: p.wheel_count_2556 as i32, + frames_2580: p.spin_same_direction_frames_2580 as i32, + timestep: p.timestep_2604, + signed_speed: p.scalar_2612, + angle_2652: p.scalar_2652, + surface_speed: p.scalar_2656, + turn_2672: p.spin_input_2672, + balance: f.balance, + elapsed_without_input: p.time_since_last_input_2748, + manual_state_276: 0.0, + vector_160: t.effective[2], + vector_352: t.forward, + velocity_416: angular, + normal_464: normal, + effective_forward: t.effective[2], + mass: t.total_mass, + }, + slide_friction: SlideFrictionInput { + heading_time: 0.0, + normal, + velocity, + side_axis: t.deck[0], + surface_speed: p.scalar_2656, + scalar_2764: p.scalar_2764, + }, + pump_force: PumpForceInput { + flags_2476: p.flags_2476, + mode_multiplier: unintentional_pump_scalar, + pumping_scalar: pumping.pump_acceleration, + input_scalar_2660: t.total_mass, + timestep: p.timestep_2604, + direction_432: t.forward_velocity, + normal_threshold: [f32::from_bits(0x3586_37bd); 4], + }, + straighten: StraightenInput { + heading_time: 0.0, + scalar_2764: p.scalar_2764, + turn: f.turn, + forward: t.forward, + velocity, + normal, + }, + heading: HeadingInput { + balance: f.balance, + manual_turn: p.spin_input_2672, + flags_2472: p.flags_2472, + timestep: p.timestep_2604, + signed_speed: p.scalar_2612, + manual_curve_input: p.scalar_2652, + turn_2712: f.raw_turn, + scalar_2740: riding.heading_adjust_factor(), + velocity: t.forward_velocity, + normal, + angular_velocity: raw(p.vectors_720_784_800_816_832_864[0]), + transform: ground, + }, + anti_flip: AntiFlipInput { + flags_2468: p.flags_2468, + balance: f.balance, + axis_96: t.deck[2], + axis_64: t.deck[0], + projection_axis_544: axis544, + }, + manual: ManualInput { + balance: f.balance, + flipped_controls: t.control_sign, + procedural_noise_time: p.state_timer_2664, + absolute_speed: t.absolute_speed, + animation_noise: skeleton.board_at_y_delta, + timestep: p.timestep_2604, + powersliding: false, + braking: p.flags_2468 & 0x2000_0000 != 0, + positive_balance_contact: p.flags_2472 & 0x0800_0000 != 0, + negative_balance_contact: p.flags_2472 & 0x0400_0000 != 0, + reversed_point_selection: p.flags_2468 & 0x0010_0000 != 0, + reference_x: ground[0], + reference_z: ground[2], + deck_z: t.deck[2], + velocity_frame_z: raw(p.effective_anim_transform_192[2]), + angular_velocity_world: angular, + correction_point_7888: v(base_trucks[0].translation), + correction_point_7952: v(base_trucks[1].translation), + }, + ground_drag: GroundDragInput { + flags_2468: p.flags_2468, + absolute_body_speed_2616: t.absolute_speed, + balance_2720: f.balance, + scalar_2724: extra.manual_drag_2724, + ground_normal_y: ground_normal[1], + }, + contact_time_2756: p.signed_ground_time_2756, + trajectory_state_1776_bits: extra.trajectory_state_bits, + anti_flip_nudge: AntiFlipNudgeInput { + deck_speed_2652: p.scalar_2652, + deck_axis_96: t.deck[2], + }, + hang_up: HangUpInput { + flags_1516: extra.edge_flags, + deck_speed_2652: p.scalar_2652, + scalar_84: t.deck[1][1], + geometry_axis_dot_positive: dot3(hang_axis, hang_axis) > 0.0, + }, + halfpipe_wheel_catch: HalfpipeWheelCatchInput { + flags_1516: extra.edge_flags, + deck_speed_2652: p.scalar_2652, + deck_x_axis_y: t.deck[0][1], + deck_y_axis_y: t.deck[1][1], + signed_deck_distance: dot3(t.deck[1], edge_delta), + }, + pinning: PinningInput { + flags_2488: p.flags_2488, + frames_since_teleport_2584: p.frames_since_teleport_2584 as i32, + flags_2472: p.flags_2472, + }, + } + } +} +fn raw(v: [u32; 4]) -> [f32; 4] { + v.map(f32::from_bits) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn v(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/ground_runtime/launch.rs b/crates/skate-game/src/physics/ground_runtime/launch.rs new file mode 100644 index 00000000..5a620f60 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/launch.rs @@ -0,0 +1,83 @@ +//! Wall-jump launch packet;82D33448 constructs it,82BE33D0 fills it. +//! The native selector consumes this packet after body velocity publication. +use skate_core::{ + physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + riding::reckoning_frames::ReckoningFrames, +}; + +#[derive(Clone, Debug)] +pub(crate) struct GroundLaunchInfo { + pub system: AnimationPartTransform, + pub inverse_system: AnimationPartTransform, + pub velocity: [f32; 4], + pub angular_velocity: [f32; 4], + pub skeleton_vector_16208: [f32; 4], + pub skeleton_vector_16240: [f32; 4], + pub board_position: [f32; 4], + pub physical_center_of_mass: [f32; 4], + pub vector_224: [f32; 4], + pub vector_240: [f32; 4], + pub cone_angle_x: f32, + pub cone_angle_z: f32, + pub time_step: f32, + pub flag_269: bool, + pub wall_jump: bool, + pub flags_270: u16, +} +impl Default for GroundLaunchInfo { + fn default() -> Self { + Self { + system: IDENTITY, + inverse_system: IDENTITY, + velocity: [0.; 4], + angular_velocity: [0.; 4], + skeleton_vector_16208: [0.; 4], + skeleton_vector_16240: [0.; 4], + board_position: [0.; 4], + physical_center_of_mass: [0.; 4], + vector_224: [0.; 4], + vector_240: [0.; 4], + cone_angle_x: 0., + cone_angle_z: 0., + time_step: 0., + flag_269: false, + wall_jump: false, + flags_270: 0, + } + } +} +pub(crate) struct GroundLaunchPhysical<'a> { + pub reckoning: &'a ReckoningFrames, + ///Actual Skeleton16208/16240; do not substitute animation COM differences. + pub skeleton_vector_16208: [f32; 4], + pub skeleton_vector_16240: [f32; 4], + ///Processed112,592,400,704,2468 and2604. + pub board_position: [f32; 4], + pub physical_center_of_mass: [f32; 4], + pub velocity: [f32; 4], + pub angular_velocity: [f32; 4], + pub flags_2468: u32, + pub time_step: f32, +} +impl GroundLaunchInfo { + pub fn fill(&mut self, p: &GroundLaunchPhysical<'_>, cone_angle_x: f32, cone_angle_z: f32) { + self.board_position = p.board_position; + self.physical_center_of_mass = p.physical_center_of_mass; + self.skeleton_vector_16208 = p.skeleton_vector_16208; + self.skeleton_vector_16240 = p.skeleton_vector_16240; + self.system = p.reckoning.system; + self.inverse_system = p.reckoning.inverse_system; + self.velocity = p.velocity; + self.angular_velocity = p.angular_velocity; + self.time_step = p.time_step; + self.flags_270 = if p.flags_2468 & 0x2000 != 0 { 7 } else { 1 }; + self.cone_angle_x = cone_angle_x; + self.cone_angle_z = cone_angle_z; + } + ///Ground82D38CF4..D10 overrides both velocity records and byte268. + pub fn wall_jump(&mut self, velocity: [f32; 4]) { + self.velocity = velocity; + self.angular_velocity = velocity; + self.wall_jump = true; + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/mod.rs b/crates/skate-game/src/physics/ground_runtime/mod.rs new file mode 100644 index 00000000..1834c69e --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/mod.rs @@ -0,0 +1,170 @@ +//! Owned native ground contact/force services for the live BoardRuntime. +//! The coordinator supplies the processed physical packet in native phase order. +mod corrections; +mod entry; +mod state; +mod update; +mod world_grab; +pub(crate) use update::{GroundUpdateFrame, GroundUpdateTargets}; +mod trajectory; +pub(crate) use entry::GroundEntryTargets; +pub(crate) use trajectory::GroundTrajectoryState; +mod surface; +pub(crate) use state::GroundState; +pub(crate) use surface::{active_surface, surface_key}; +mod input; +mod pumping; +pub(crate) use input::GroundInputObservations; +pub(crate) use pumping::GroundPumping; +mod launch; +mod services; +mod settings; +pub(crate) use launch::{GroundLaunchInfo, GroundLaunchPhysical}; +pub(crate) use services::{GroundControllers, GroundPhysicalFrame}; +pub(crate) use settings::GroundSettings; +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, board_runtime::BoardRuntime, deck_angular_correction, + force_queue::QueuedPointForce, + }, + point_graph::PointGraph, + riding::{ + collision_response::{ + CollisionResponsePhysical, CollisionResponseSettings, collision_response, + }, + ground_contact_response::{WallRidePhysical, WallRideSettings, wall_ride_response}, + grounded::state::board_types::{ + CollisionForceResponse, GroundContactFrame, GroundContactResponse, + }, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct GroundRuntime { + retained_board_normal: [f32; 4], + wall_ride: WallRideSettings, + collision: CollisionResponseSettings, + deck_center_to_truck: f32, + launch_cone_x: f32, + launch_cone_z: f32, + pub contact: GroundContactResponse, + pub collision_force: Option, +} +impl GroundRuntime { + /// Skateboard Reset82C060B0 publishes [0,1,0,0] to the retained + /// toolkit normal192. Do not reset Ground contact-response state here. + pub fn reset_board_toolkit(&mut self) { + self.retained_board_normal = [0.0, 1.0, 0.0, 0.0]; + } + pub fn load(data: &Collections) -> Result { + let feet = |field| data.float("physics_feet", "default", field); + let collision = |field| data.float("physics_collision", "default", field); + Ok(Self { + //SkateboardReset82C05F50 explicitly clears192..208, then sets196=1. + retained_board_normal: [0.0, 1.0, 0.0, 0.0], + launch_cone_x: data.float("physics_trajectory", "default", "ConeAngleX")?, + launch_cone_z: data.float("physics_trajectory", "default", "ConeAngleZ")?, + deck_center_to_truck: data.float("physics_grinds", "default", "DeckCenterToTruck")?, + wall_ride: WallRideSettings { + anti_gravity_vs_time: curve(data, "physics_feet", "WallRideAntiGravityVsTime")?, + max_dot_floor_wall: feet("WallRideMaxDotFloorWall")?, + foot_force_time: feet("WallRideFootForceTime")?, + auto_jump_height: feet("WallRideAutoJumpHeight")?, + max_time: feet("WallRideMaxTime")?, + velocity_time_to_consider: feet("WallRideVelTimeToConsider")?, + auto_jump_y_down_scalar: feet("WallRideAutoJumpYDownScalar")?, + auto_jump_force: feet("WallRideAutoJumpForce")?, + }, + collision: CollisionResponseSettings { + maximum_velocity_delta: collision("MaxVelDelta")?, + force_y_offset: collision("ForceYOffset")?, + force_scalar: collision("CollisionForceScalar")?, + target_displacement_velocity: collision("TargetDisplacementVel")?, + torque_vs_angle: curve(data, "physics_collision", "CollisionTorqueVsAngle")?, + }, + //PhysicsGround ctor/reset initializes this output vector tozero. + contact: GroundContactResponse { + active_2731: false, + tag_16_force: QueuedPointForce { + tag: 16, + ..Default::default() + }, + vector_2688: [0.; 4], + scalar_2704: 0., + animated_board_2708: false, + }, + collision_force: None, + }) + } + pub fn contact_response( + &mut self, + frame: GroundContactFrame, + physical: WallRidePhysical, + ) -> GroundContactResponse { + self.contact = + wall_ride_response(&self.wall_ride, physical, frame, self.contact.vector_2688); + self.contact + } + /// Slide82D3AA74..AA9C passes a fresh zero previous vector each update. + /// This call does not overwrite Ground's separately retained contact state. + pub fn contact_response_with_previous( + &self, frame: GroundContactFrame, physical: WallRidePhysical, previous: [f32;4], + ) -> GroundContactResponse { + wall_ride_response(&self.wall_ride, physical, frame, previous) + } + /// Called immediately after contact response and before animated/physical + /// branch selection at82D389DC. This changes the solver's actual deck. + pub fn update_body_accumulator(&mut self, board: &mut BoardRuntime) { + deck_angular_correction::apply_ground_body_torque( + &mut board.bodies_mut()[BodyId::Deck.index()].rates, + ); + } + /// Native82D944E8 output publication. Do not discard an existing ground + /// collision vector on a false return; caller's collision-fade state owns it. + pub fn calculate_collision_force( + &mut self, + physical: CollisionResponsePhysical, + ) -> Option { + let result = collision_response(&self.collision, physical)?; + if !result.applied { + return None; + } + let output = CollisionForceResponse { + force_2528: result.force, + point_2544: result.point, + vector_2592: result.angular_displacement, + }; + self.collision_force = Some(output); + Some(output) + } + pub fn apply_angular_target(&mut self, board: &mut BoardRuntime, displacement: [f32; 4]) { + deck_angular_correction::apply_limited_displacement( + &mut board.bodies_mut()[BodyId::Deck.index()].rates, + xyz(displacement), + ); + } + pub fn apply_angular_displacement(&mut self, board: &mut BoardRuntime, displacement: [f32; 4]) { + deck_angular_correction::apply_angular_displacement( + &mut board.bodies_mut()[BodyId::Deck.index()].rates, + xyz(displacement), + ); + } + ///82C04168 publishes the requested linear velocity to all seven real body + /// parts, preserving angular velocity and accumulated forces. + pub fn set_animated_velocity(&mut self, board: &mut BoardRuntime, velocity: [f32; 4]) { + for body in board.bodies_mut() { + body.rates.linear_velocity = xyz(velocity); + } + } +} +fn curve(data: &Collections, class: &str, field: &str) -> Result, String> { + let words = data.words::<20>(class, "default", field)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(words[12 + i])), + }) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/physics/ground_runtime/pumping.rs b/crates/skate-game/src/physics/ground_runtime/pumping.rs new file mode 100644 index 00000000..8e9b3993 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/pumping.rs @@ -0,0 +1,125 @@ +//! Actual Ground82D37D34..7EAC pumping preparation and stock bindings. +use super::super::{animated_skeleton::AnimatedSkeleton, riding_outputs::RidingOutputs}; +use skate_core::{ + math::Vector3, + physics::board_toolkit::BoardToolkit, + riding::{ + collision_response::signed_angle, + ground_correction_math::dot_product as dot3, + pumping::{ + controller::{self, PumpingSample}, + geometry::NativePumpingGeometry, + settings::{PumpingMode, PumpingSettings}, + state::PumpingState, + }, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct GroundPumping { + settings: PumpingSettings, + modes: [GroundPumpingMode; 5], +} +#[derive(Clone, Copy)] +pub(crate) struct GroundPumpingMode { + controller: PumpingMode, + /// Selected physics_mode layout+8, consumed by CalcPumpForce82D933F0. + pub unintentional_scalar: f32, +} +impl GroundPumping { + pub fn load(data: &Collections) -> Result { + let f = |field| data.float("physics_pumping", "default", field); + let c = |field| super::settings::curve8(data, "physics_pumping", "default", field); + let mode = |key| -> Result { + let m = |field| data.float("physics_mode", key, field); + Ok(GroundPumpingMode { + controller: PumpingMode { + maximum_absorption_per_second: m("Hash_9D1AEE3D7D8A4A7")?, + maximum_acceleration_per_second: m("Hash_D77AFD320B6241C5")?, + absorption_factor: m("PumpEffectFactorAbsorption")?, + acceleration_factor: m("PumpEffectFactor")?, + }, + unintentional_scalar: m("UnintentionalPumpScalar")?, + }) + }; + Ok(Self { + settings: PumpingSettings { + pump_vs_speed: c("PumpVsVel")?, + pump_vs_time: c("PumpVsTime")?, + min_crouch_vs_ground_angle: c("MinCrouchVsGroundAngle")?, + compression_vs_ground_angle: c("CompressionVsGroundAngle")?, + compression_vs_deck_angle: c("CompressionVsDeckAngle")?, + height_change_damping: f("PumpEffectDamping")?, + minimum_height_change: f("MinChangeInCOMBeforePumping")?, + maximum_height_change: f("MaxDeltaHeightAllowedPerFrame")?, + ground_compression_scale: f("CompressionGroundScalar")?, + deck_compression_scale: f("CompressionDeckScalar")?, + angular_speed_damping: f("AngularSpeedDamping")?, + }, + modes: [ + mode("easy")?, + mode("normal")?, + mode("hardcore")?, + mode("motorized")?, + mode("test")?, + ], + }) + } + /// Ground82D37E74 reads the current Processed2548 mode, not the startup mode. + pub fn mode(&self, index: u32) -> Result { + self.modes + .get(index as usize) + .copied() + .ok_or_else(|| format!("Invalid pumping physics mode {index}")) + } + ///Runs after contact-state update and before Ground::UpdateSkateboard. + pub fn update( + &self, + state: &mut PumpingState, + toolkit: &BoardToolkit, + riding: &RidingOutputs, + skeleton: &AnimatedSkeleton, + flags_2476: u32, + mode: GroundPumpingMode, + ) { + let up = v(riding.reckoning.up); + let animated_at = skeleton.board_frames.animation_target[2]; + let deck_angle = if dot3(toolkit.deck[2], up).abs() > dot3(animated_at, up).abs() { + let axis = xyz(riding.reckoning_frames.ground[0]); + let deck = wrap(signed_angle(xyz(toolkit.deck[2]), xyz(up), axis)); + let animated = wrap(signed_angle(xyz(animated_at), xyz(up), axis)); + (deck - animated).abs() + } else { + 0.0 + }; + let sample = PumpingSample { + position: toolkit.deck[3], + normal: v(riding.reckoning.ground_normal), + com_to_deck_world: skeleton.record.com_to_deck_world, + deck_angle, + intentional_pumping: ((flags_2476 >> 1) & 1) as u8, + }; + match controller::update_ground( + state, + &self.settings, + mode.controller, + sample, + &mut NativePumpingGeometry, + ) { + Ok(()) => {} + Err(never) => match never {}, + } + } +} +fn wrap(angle: f32) -> f32 { + let turns = angle * f32::from_bits(0x3e22_f983); + let fraction = turns - turns.floor(); + let signed = fraction - if fraction > 0.5 { 1.0 } else { 0.0 }; + signed * f32::from_bits(0x40c9_0fdb) +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn v(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/ground_runtime/services.rs b/crates/skate-game/src/physics/ground_runtime/services.rs new file mode 100644 index 00000000..8d4c8b28 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/services.rs @@ -0,0 +1,293 @@ +//! Ground's physical operations execute against the live board, in core's +//! recovered order. Detached queue/inertia ownership is published before the +//! solver runs; no force, drag or steering output is left in a test-only object. +use super::{ + GroundRuntime, GroundSettings, + launch::{GroundLaunchInfo, GroundLaunchPhysical}, +}; +use skate_core::{ + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + board_world::BoardWorld, + contact::RetailContactMaterial, + ground_hang_geometry::HangGeometryInput, + manual::{controller::ManualAngleMeasurement, state::ManualState}, + skeleton_animation_record::SkeletonAnimationRecord, + }, + riding::{ + collision_response::{CollisionResponsePhysical, signed_angle}, + ground_contact_response::{WallRidePhysical, wall_ride_response}, + ground_correction_math, + grounded::{ + drag::BodyInertias, + state::{ + board::{self, GroundBoardOutcome}, + board_types::{ + CollisionForceResponse, GroundBoardComponents, GroundBoardInput, + GroundBoardServices, GroundContactFrame, GroundContactResponse, + }, + corrections::{ + AntiFlipNudgeMath, HalfpipeWheelCatchServices, HangUpServices, PinningServices, + }, + data::PhysicsGroundState, + }, + }, + speed_model::SpeedModelState, + speed_wobble::SpeedWobbleState, + steering::TruckSteeringState, + }, +}; + +pub(crate) struct GroundControllers<'a> { + pub speed_wobble: &'a mut SpeedWobbleState, + pub truck_steering: &'a mut TruckSteeringState, + pub speed_model: &'a mut SpeedModelState, + pub manual: &'a mut ManualState, + pub heading_previous: &'a mut f32, +} +pub(crate) struct GroundPhysicalFrame<'a> { + pub skeleton_record: &'a SkeletonAnimationRecord, + pub wall_ride: WallRidePhysical, + pub collision: CollisionResponsePhysical, + pub hang_geometry: HangGeometryInput, + pub processed_velocity: &'a mut [f32; 4], + ///The same material record used by colliders::world_volumes for all4wheels. + pub wheel_material: &'a mut RetailContactMaterial, + pub wipeout: &'a mut skate_core::player::wipeout::Requests, + pub time_step: f32, + ///Needed only when the native wall-jump branch is selected. Missing data + ///is an error on that branch, never an invented physical skeleton pose. + pub launch_physical: Option>, + ///Own host call boundary for the full native TrajectorySelector Launch + ///and Update. Callers must finish prediction before advancing this board. + pub launch_and_update: &'a mut dyn FnMut(&GroundLaunchInfo) -> Result<(), String>, +} +struct BoardServices<'a, 'p> { + runtime: &'a mut GroundRuntime, + board: &'a mut BoardRuntime, + world: &'a BoardWorld, + settings: &'a GroundSettings, + physical: GroundPhysicalFrame<'p>, + launch: Option, +} +impl GroundRuntime { + pub fn update_board( + &mut self, + board: &mut BoardRuntime, + world: &BoardWorld, + settings: &GroundSettings, + state: &mut PhysicsGroundState, + controllers: GroundControllers<'_>, + mut input: GroundBoardInput, + physical: GroundPhysicalFrame<'_>, + ) -> Result { + input.speed_wobble.activation_threshold = settings.wobble_activation; + input.speed_wobble.amplitude_multiplier = settings.wobble_amplitude; + let mut queue = std::mem::take(board.forces_mut()); + let mut inertias = std::array::from_fn::<_, 7, _>(|i| board.bodies()[i].inertia); + let mut bindings = BodyInertias { + part_inertia_indices: &[0, 1, 2, 3, 4, 5, 6], + inertias: &mut inertias, + }; + let mut services = BoardServices { + runtime: self, + board, + world, + settings, + physical, + launch: None, + }; + let result = board::update( + state, + GroundBoardComponents { + speed_wobble: controllers.speed_wobble, + truck_steering: controllers.truck_steering, + speed_model: controllers.speed_model, + manual: controllers.manual, + heading_previous: controllers.heading_previous, + force_queue: &mut queue, + inertias: &mut bindings, + }, + settings.board(), + input, + &mut services, + ); + //Only SetLinearDrag changes the detached inertia records. Publish that + //field alone: direct body torque/velocity operations above are retained. + for (body, inertia) in services.board.bodies_mut().iter_mut().zip(inertias) { + body.inertia.linear_drag = inertia.linear_drag; + } + *services.board.forces_mut() = queue; + result.map_err(|e| format!("Native Ground board update: {e:?}")) + } +} +impl ManualAngleMeasurement for BoardServices<'_, '_> { + type Error = String; + fn angle_between(&mut self, a: [f32; 4], b: [f32; 4], axis: [f32; 4]) -> Result { + Ok(signed_angle(super::xyz(a), super::xyz(b), super::xyz(axis))) + } +} +impl AntiFlipNudgeMath for BoardServices<'_, '_> { + type Error = String; + fn dot3(&mut self, a: [f32; 4], b: [f32; 4]) -> Result { + Ok(ground_correction_math::dot_product(a, b)) + } + fn scale_to_magnitude( + &mut self, + v: [f32; 4], + square: f32, + size: f32, + ) -> Result<[f32; 4], String> { + Ok(ground_correction_math::scale_to_magnitude(v, square, size)) + } +} +impl HangUpServices for BoardServices<'_, '_> { + type Error = String; + fn build_hang_force(&mut self) -> Result<[f32; 4], String> { + let g = self.physical.hang_geometry; + Ok(self + .runtime + .build_hang_force(self.board, vector(g.edge_start), vector(g.edge_end))) + } + fn apply_hang_force(&mut self, f: [f32; 4]) -> Result<(), String> { + self.runtime.apply_hang_force(self.board, f); + Ok(()) + } + fn detect_hung_up_geometry(&mut self) -> Result { + self.runtime + .detect_hung_up_geometry(self.world, self.physical.hang_geometry) + } + fn request_wipeout(&mut self) -> Result<(), String> { + //82D3983C..54 writes byte32 (reason12), value104 and increments200. + self.physical.wipeout.request(12, 0.0); + Ok(()) + } +} +impl HalfpipeWheelCatchServices for BoardServices<'_, '_> { + type Error = String; + fn angular_displacement(&mut self) -> Result<[f32; 4], String> { + let f = self.board.part_transforms()[BodyId::Deck.index()] + .basis + .columns; + Ok(ground_correction_math::wheel_catch_displacement( + [f[1][0], f[1][1], f[1][2], 0.], + [f[2][0], f[2][1], f[2][2], 0.], + )) + } + fn apply_angular_displacement(&mut self, v: [f32; 4]) -> Result<(), String> { + self.runtime.apply_angular_displacement(self.board, v); + Ok(()) + } +} +impl PinningServices for BoardServices<'_, '_> { + type Error = String; + fn pin_to_captured_position(&mut self, x: f32, z: f32) -> Result<(), String> { + self.runtime + .pin_to_position(self.board, x, z, self.physical.time_step); + Ok(()) + } +} +impl GroundBoardServices for BoardServices<'_, '_> { + type BoardError = String; + type AnimatedPose = GroundLaunchInfo; + fn center_of_mass_height_82d38838(&mut self) -> Result { + Ok(ground_correction_math::center_of_mass_height( + self.physical.skeleton_record.com_to_deck_world, + )) + } + fn set_contact_wheel_materials(&mut self) -> Result<(), String> { + *self.physical.wheel_material = self.settings.wheel_material; + Ok(()) + } + fn contact_response_82d93df0( + &mut self, + frame: GroundContactFrame, + previous: [f32; 4], + ) -> Result { + self.runtime.contact = wall_ride_response( + &self.runtime.wall_ride, + self.physical.wall_ride, + frame, + previous, + ); + Ok(self.runtime.contact) + } + fn update_body_accumulator_82d389dc(&mut self) -> Result<(), String> { + self.runtime.update_body_accumulator(self.board); + Ok(()) + } + fn set_animated_velocity_82c04168(&mut self, v: [f32; 4]) -> Result<(), String> { + self.runtime.set_animated_velocity(self.board, v); + Ok(()) + } + fn write_processed_velocity_400(&mut self, v: [f32; 4]) -> Result<(), String> { + *self.physical.processed_velocity = v; + Ok(()) + } + fn build_animated_pose_82d33448(&mut self) -> Result { + Ok(GroundLaunchInfo::default()) + } + fn publish_animated_pose_82be33d0(&mut self, info: &GroundLaunchInfo) -> Result<(), String> { + let physical = self.physical.launch_physical.as_ref().ok_or( + "Ground wall jump requires actual Skeleton/Reckoning launch observations82BE33D0", + )?; + let mut info = info.clone(); + info.fill( + physical, + self.runtime.launch_cone_x, + self.runtime.launch_cone_z, + ); + self.launch = Some(info); + Ok(()) + } + fn update_external_player_82d67848( + &mut self, + _info: &GroundLaunchInfo, + velocity: [f32; 4], + ) -> Result<(), String> { + self.launch + .as_mut() + .ok_or("Ground launch packet was not filled")? + .wall_jump(velocity); + Ok(()) + } + fn commit_external_player_82d68800(&mut self) -> Result<(), String> { + let info = self + .launch + .as_ref() + .ok_or("Ground launch packet was not prepared")?; + (self.physical.launch_and_update)(info) + } + fn finalize_animated_board_sk83_na_f_01a4(&mut self) -> Result<(), String> { + //TU382B61BB8 is one blr instruction. This confirmed native no-op + //does not replace the preceding required trajectory calculation. + Ok(()) + } + fn collision_force_82d944e8( + &mut self, + normal: [f32; 4], + ) -> Result, String> { + let mut physical = self.physical.collision; + physical.ground_normal = normal; + Ok(self.runtime.calculate_collision_force(physical)) + } + fn collision_force_dot_velocity_82d38e18( + &mut self, + f: [f32; 4], + v: [f32; 4], + ) -> Result { + Ok(self.runtime.collision_force_projection(f, v)) + } + fn apply_vector_82c07000(&mut self, v: [f32; 4]) -> Result<(), String> { + self.runtime.apply_angular_target(self.board, v); + Ok(()) + } + fn apply_angular_displacement_82c075b8(&mut self, v: [f32; 4]) -> Result<(), String> { + self.runtime.apply_angular_displacement(self.board, v); + Ok(()) + } +} +fn vector(v: skate_core::math::Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.] +} diff --git a/crates/skate-game/src/physics/ground_runtime/settings.rs b/crates/skate-game/src/physics/ground_runtime/settings.rs new file mode 100644 index 00000000..539dff85 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/settings.rs @@ -0,0 +1,277 @@ +//! Stock bindings used by the complete Ground::UpdateSkateboard call. +//! Toolkit copies follow82D94C10; selected surface/mode fields stay explicit. +use skate_core::{ + physics::{ + contact::RetailContactMaterial, + manual::settings::{ManualGains, ManualMode, ManualSettings}, + }, + point_graph::PointGraph, + riding::{ + anti_flip::AntiFlipSettings, + braking::{BrakeSettings, LinearDragSettings}, + ground_force::GroundForceSettings, + grounded::{propulsion::GroundPropulsionSettings, state::board_types::GroundBoardSettings}, + heading::HeadingSettings, + slide_friction::SlideFrictionSettings, + speed_model::SpeedModelSettings, + speed_wobble::SpeedWobbleSettings, + steering::SteeringSettings, + straighten::StraightenSettings, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct GroundSettings { + steering: SteeringSettings, + wobble: SpeedWobbleSettings, + propulsion: GroundPropulsionSettings, + force: GroundForceSettings, + speed: SpeedModelSettings, + slide: SlideFrictionSettings, + straighten: StraightenSettings, + heading: HeadingSettings, + anti_flip: AntiFlipSettings, + manual: ManualSettings, + manual_mode: ManualMode, + drag: LinearDragSettings, + collision_duration: f32, + collision_scale: f32, + contact_force_time: f32, + pub wheel_material: RetailContactMaterial, + pub foot_force_offset: f32, + pub absorption_front: f32, + pub absorption_rear: f32, + pub surface_braking_factor: f32, + pub wobble_activation: f32, + pub wobble_amplitude: f32, +} +impl GroundSettings { + pub fn load(data: &Collections, mode: &str, surface: &str) -> Result { + let f = |class, field| data.float(class, "default", field); + let m = |field| data.float("physics_mode", mode, field); + let s = |field| data.float("physics_surfaces", surface, field); + let c8 = |class, field| curve8(data, class, "default", field); + let c16 = |class, field| curve16(data, class, "default", field); + //82F826F8 broadcasts82181A88 into830BD350.82F82610 similarly + //broadcasts82165A10 (zero) into830BD380 for speed override direction. + let threshold = [f32::from_bits(0x3586_37bd); 4]; + Ok(Self { + foot_force_offset: f("physics_feet", "FootForceOffset")?, + absorption_front: f("physics_feet", "AbsorptionFootForceScalar")?, + absorption_rear: f("physics_feet", "AbsorptionFootForceRearScalar")?, + surface_braking_factor: s("BrakingScalar")?, + steering: SteeringSettings { + hard_turn_increase: f("physics_steering", "HardTurnIncrease")?, + damping: f("physics_steering", "Damping")?, + speed_graph_max_speed: f("physics_steering", "SteeringVsSpeedMaxSpeed")?, + push_scalar_increment: f("physics_steering", "PushScalarIncriment")?, + push_scalar_decrement: f("physics_steering", "PushScalarDecriment")?, + push_scalar_min: f("physics_steering", "PushScalarMin")?, + manual_scalar: f("physics_manual", "Tilt_TiltScalar")?, + general_scalar: f("physics_steering", "GeneralScalar")?, + tight_trucks_scalar: f("physics_steering", "SteeringScalar_TightTrucks")?, + speed_graph: c8("physics_steering", "SteeringVsSpeed")?, + input_graph: c8("physics_steering", "SteeringVsInput")?, + tilt_blending: f("physics_steering", "SteeringTiltBlending")?, + }, + wobble: SpeedWobbleSettings { + frequency_time: c8("physics_speed_wobble", "FrequencyVsTime")?, + frequency_speed: c8("physics_speed_wobble", "FrequencyVsSpeed")?, + amplitude_time: c8("physics_speed_wobble", "AmplitudeVsTime")?, + amplitude_speed: c8("physics_speed_wobble", "AmplitudeVsSpeed")?, + tightness_threshold: f("physics_speed_wobble", "TruckTightnessSpeedMaxAdjust")?, + time_range: f("physics_speed_wobble", "TimeMax")?, + speed_range: f("physics_speed_wobble", "SpeedRange")?, + frequency_scale: f("physics_speed_wobble", "MaxFrequency")?, + amplitude_scale: f("physics_speed_wobble", "MaxAmplitude")?, + crouch_threshold: f("physics_speed_wobble", "CrouchStartSpeedMaxAdjust")?, + height_min: f("physics_speed_wobble", "CrouchMin")?, + height_max: f("physics_speed_wobble", "CrouchMax")?, + }, + propulsion: GroundPropulsionSettings { + braking: BrakeSettings { + input_force: f("physics_brakes", "FootBrakeForce")?, + override_force: f("physics_brakes", "TailBrakeForce")?, + minimum_speed: f("physics_brakes", "SlowSpeed")?, + }, + maximum_pushable_speed: f("physics_push", "MaxPushableSpeed")?, + mode_speed_changes: [m("MaxPushDVStart")?, m("MaxPushDVEnd")?], + }, + force: GroundForceSettings { + range_1220: f("physics_feet", "LandingForceTime")?, + speed_scale_1224: f("physics_feet", "MinForce")?, + scale_1228: f("physics_feet", "MaxLandingForce")?, + normal_threshold: threshold, + }, + speed: SpeedModelSettings { + negative_gain: f("physics_speed_conservation", "NegativeGeneralAmount")?, + maximum_gravity_acceleration: f( + "physics_speed_conservation", + "MaxGravityAcceleration", + )?, + gravity: f("physics_speed_conservation", "Gravity")?, + positive_gain: f("physics_speed_conservation", "GeneralAmount")?, + coffin_acceleration: f("physics_speed_conservation", "CoffinAcceleration")?, + speed_error_bound: s("Friction_MaxSpeedChange")?, + surface_friction: curve8(data, "physics_surfaces", surface, "FrictionVsSpeedNew")?, + manual_acceleration: f("physics_manual", "SpinCorrectiveForce")?, + negative_manual_angle_limit: f("physics_manual", "MinSpeedForCorrection")?, + manual_angle_limit: f("physics_manual", "MaxSpeedForCorrection")?, + no_input_delay: f("physics_friction", "NoInputTime")?, + no_input_friction: c8("physics_friction", "FrictionVsSpeed_NoInput")?, + manual_friction: c8("physics_friction", "FrictionVsSpeed_Manual")?, + override_enabled: data.boolean("physics_mode", mode, "MotorEnabled")?, + override_speed: m("MotorTopSpeed")?, + normal_threshold: threshold, + override_direction_threshold: [0.; 4], + }, + slide: SlideFrictionSettings { + angle_response: c16("physics_friction", "SlideVsNormal")?, + speed_response: c8("physics_friction", "SlideVsSpeed")?, + time_response: c16("physics_heading", "SideFrictionFactor")?, + scalar_516: f("physicswheels", "SoftestWheelSideFrictionScalar")?, + heading_time_limit: s("StraightenOut_ForceTime")?, + friction: s("SideFriction_Scalar")?, + }, + straighten: StraightenSettings { + time_response: c16("physics_heading", "StraightenOutForce")?, + opposite_turn_limit: f("physics_heading", "StraightenMaxTurnInput")?, + //Lookup8 DF474D0A659A7AE3, direct82D94F80..FC0 binding. + time_scalar: f("physicswheels", "SoftestWheelStraightenOutTimeScalar")?, + heading_time_limit: s("StraightenOut_ForceTime")?, + strength: s("StraightenOut_Scalar")?, + }, + heading: HeadingSettings { + manual_wrong_wheel_scalar: f("physics_manual", "SpinNoLiftScalar")?, + manual_damping: f("physics_manual", "ManualSpinBlendSpeed")?, + speed_max: f("physics_heading", "HeadingAdjustMaxSpeed")?, + heading_strength: f("physics_heading", "HeadingAdjustFactor")?, + turn_strength: f("physics_heading", "TurnTorqueScalar")?, + angular_response: c8("physics_heading", "TurnTorqueVsNegAngVel")?, + manual_response: c8("physics_manual", "SpinVsSpeed")?, + inclination_response: c8("physics_heading", "HeadingAdjustVsSlope")?, + speed_response: c8("physics_heading", "HeadingAdjustVsSpeed")?, + normal_threshold: threshold, + }, + anti_flip: AntiFlipSettings { + axis_96_response: c8("physics_feet", "AntiFlipTorqueX")?, + axis_64_response: c8("physics_feet", "AntiFlipTorqueZ")?, + normal_threshold: threshold, + }, + manual: manual_settings(data)?, + manual_mode: ManualMode { + correction_angular_speed_threshold: m("Hash_84CB2F459F3FC811")?, + corrective_force_enabled: data.boolean( + "physics_mode", + mode, + "Hash_F8CBC0F5FEF2240E", + )?, + }, + drag: LinearDragSettings { + brake_speed: f("physics_brakes", "SlowSpeed")?, + balance_speed: f("physics_brakes", "ManualSpinDragSpeed")?, + comparison_threshold: f("physics_brakes", "ManualSpinDragNormal")?, + balance_drag: f("physics_brakes", "ManualSpinDrag")?, + }, + collision_duration: f("physics_collision", "CollisionTorqueMaxTime")?, + collision_scale: f("physics_collision", "CollisionTorqueFadeoff")?, + contact_force_time: f("physics_feet", "MaxTimeWallRidingForFootForce")?, + wheel_material: RetailContactMaterial { + static_friction: s("WheelStaticFriction")?, + dynamic_friction: s("WheelDynamicFriction")?, + restitution: f("physicswheels", "WheelRestitution")?, + }, + wobble_activation: m("Hash_77AFCE78FE1206CA")?, + wobble_amplitude: m("Hash_5B57F2CCCCEEF430")?, + }) + } + pub fn board(&self) -> GroundBoardSettings<'_> { + GroundBoardSettings { + steering: &self.steering, + speed_wobble: &self.wobble, + propulsion: self.propulsion, + ground_force: &self.force, + speed_model: &self.speed, + slide_friction: &self.slide, + straighten: &self.straighten, + heading: &self.heading, + anti_flip: &self.anti_flip, + manual: &self.manual, + manual_mode: self.manual_mode, + linear_drag: self.drag, + collision_response_duration: self.collision_duration, + collision_response_scale: self.collision_scale, + ground_force_contact_time_limit: self.contact_force_time, + speed_model_reset_force_squared: f32::from_bits(0x3780_0000), + } + } +} +fn manual_settings(d: &Collections) -> Result { + let f = |field| d.float("physics_manual", "default", field); + let gains = |p, i, v| -> Result { + Ok(ManualGains { + proportional: f(p)?, + integral: f(i)?, + derivative: f(v)?, + }) + }; + Ok(ManualSettings { + noise_vs_speed: curve8(d, "physics_manual", "default", "NoiseVsSpeed")?, + torque_scale_without_contact: f("TorqueScalarWithNoContact")?, + torque_bleed_without_contact: f("TorqueBleedOffWithNoContact")?, + start_torque_scale: f("StartTorqueScalar")?, + procedural_noise_scale: f("ProceduralNoiseScalar")?, + procedural_noise_frequency: f("ProceduralNoiseFreq")?, + powerslide: gains("PowerScalar_P", "PowerScalar_I", "PowerScalar_D")?, + manual: gains("ManualScalar_P", "ManualScalar_I", "ManualScalar_D")?, + maximum_tilt_degrees: f("MaxTiltAngle")?, + maximum_angle_error: f("MaxAngleDiff")?, + derivative_limit: f("D_Scalar_Limit")?, + brake_tilt_degrees: f("BrakeTiltAngle")?, + animation_noise_scale: f("AnimationNoiseScalar")?, + }) +} +pub(super) fn curve8(d: &Collections, c: &str, k: &str, f: &str) -> Result, String> { + let data = &d.field(c, k, f)?.data; + match data.len() { + 128 => { + let w = d.words::<16>(c, k, f)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[i])), + y: std::array::from_fn(|i| f32::from_bits(w[i + 8])), + }) + } + 160 => { + let w = d.words::<20>(c, k, f)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[i + 4])), + y: std::array::from_fn(|i| f32::from_bits(w[i + 12])), + }) + } + _ => Err(format!("Invalid native eight-point graph {c}/{k}/{f}")), + } +} +fn curve16(d: &Collections, c: &str, k: &str, f: &str) -> Result, String> { + let w = d.words::<32>(c, k, f)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[i])), + y: std::array::from_fn(|i| f32::from_bits(w[i + 16])), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[ignore = "requires extracted private stock collections"] + fn stock_ground_settings_closure_loads_without_defaults() { + let root = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT"), + ); + let data = Collections::load(&root).unwrap(); + GroundSettings::load(&data, "default", "default").unwrap(); + super::super::GroundRuntime::load(&data).unwrap(); + super::super::GroundPumping::load(&data).unwrap(); + super::super::GroundState::load(&data, "default", true).unwrap(); + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/state.rs b/crates/skate-game/src/physics/ground_runtime/state.rs new file mode 100644 index 00000000..e9ccfb30 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/state.rs @@ -0,0 +1,103 @@ +//! Native Ground controller ownership. Source entry/update and output retain +//! the same instances; constructing this does not mark Ground as entered. +use super::super::animation_input::AnimationInput; +use super::{GroundControllers, GroundPumping}; +use skate_core::physics::board_toolkit::BoardToolkit; +use skate_core::{ + physics::manual::state::ManualState, + player::input_phase::ProcessedPhysicsInput, + riding::{ + grounded::state::{ + PhysicsGroundState, + output::{self, GroundOutputFrame, GroundOutputSettings, PhysicsGroundOutput}, + }, + pumping::state::PumpingState, + speed_model::SpeedModelState, + speed_wobble::SpeedWobbleState, + steering::TruckSteeringState, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct GroundState { + pub world_grab: super::world_grab::GroundWorldGrabState, + pub state: PhysicsGroundState, + pub pumping: PumpingState, + pub wobble: SpeedWobbleState, + pub manual: ManualState, + pub steering: TruckSteeringState, + pub speed: SpeedModelState, + pub heading_previous: f32, + pub pumping_settings: GroundPumping, + output_settings: GroundOutputSettings, + auto_push_enabled: bool, + pub entered: bool, + pub(super) entry_settings: super::entry::EntrySettings, +} +impl GroundState { + pub fn load(data: &Collections, mode: &str, human_player: bool) -> Result { + let push = |field| data.float("physics_push", "default", field); + Ok(Self { + world_grab: super::world_grab::GroundWorldGrabState::default(), + state: PhysicsGroundState::before_first_enter(human_player), + pumping: PumpingState::reset_state(), + wobble: SpeedWobbleState([0; 8]), + manual: ManualState { + filtered_angle_error: 0.0, + target_angle: 0.0, + measured_angle: 0.0, + angular_correction: 0.0, + elapsed: 0.0, + }, + //Initial truck state; later board resets preserve activation timers. + steering: TruckSteeringState::default(), + speed: SpeedModelState { + target_speed: 0.0, + flags_1360: 0, + }, + heading_previous: 0.0, + pumping_settings: GroundPumping::load(data)?, + output_settings: GroundOutputSettings { + pushable_speed_terms_4_8: [ + push("MaxPushableSpeed_CameraDelta")?, + push("MaxPushableSpeed")?, + ], + mode_speed_threshold_0: push("Hash_501D5581043D7D3C")?, + }, + entry_settings: super::entry::EntrySettings::load(data)?, + auto_push_enabled: data.boolean("physics_mode", mode, "AutoPushEnabled")?, + entered: false, + }) + } + pub fn output( + &self, + p: &ProcessedPhysicsInput, + animation: &AnimationInput, + t: &BoardToolkit, + ) -> PhysicsGroundOutput { + output::fill_physics_output( + &self.state, + GroundOutputFrame { + axis_464: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + velocity_608: p.vectors_544_560_592_608[3].map(f32::from_bits), + absolute_body_speed_2616: t.absolute_speed, + deck_speed_2652: p.scalar_2652, + state_timer_2664: p.state_timer_2664, + scalar_2720: animation.fields.balance, + flags_2476: p.flags_2476, + flags_2484: p.flags_2484, + selected_mode_flag_109: self.auto_push_enabled, + }, + self.output_settings, + ) + } + pub fn controllers(&mut self) -> GroundControllers<'_> { + GroundControllers { + speed_wobble: &mut self.wobble, + truck_steering: &mut self.steering, + speed_model: &mut self.speed, + manual: &mut self.manual, + heading_previous: &mut self.heading_previous, + } + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/surface.rs b/crates/skate-game/src/physics/ground_runtime/surface.rs new file mode 100644 index 00000000..cb5b4351 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/surface.rs @@ -0,0 +1,30 @@ +//! Native wheel surface vote82C08818 on live wheel lines and contact reports. +use super::super::riding_outputs::RidingOutputs; +use skate_core::physics::{board_runtime::BoardRuntime, contact_feedback::choose_surface}; +/// Wheel surface IDs come from line queries. The report loop82C08198 updates +/// only truck/deck material slots, so wheel contacts contribute vote weights +/// without replacing the query-owned material ID. +pub(crate) fn active_surface(riding: &RidingOutputs, board: &BoardRuntime) -> u32 { + let contacts = std::array::from_fn(|i| riding.ground.parts[i].in_contact); + let forced = board + .contact_reports() + .iter() + .any(|r| r.other_surface & 0x0f80 == 0x0600); + choose_surface(riding.wheel_lines.physics_surfaces, contacts, forced) +} + +/// SurfacePhysics constructor82D85D08 binds these exact five collection keys +/// at24/40/56/72/88. Lookup8 hashes independently match the native immediates. +/// The selector's mode normalization lives in the processed-input producer. +pub(crate) fn surface_key(mode: u32) -> Result<&'static str, String> { + match mode { + 1 => Ok("smooth"), + 2 => Ok("rough"), + 3 => Ok("slow"), + 4 => Ok("slippery"), + 5 => Ok("veryslow"), + _ => Err(format!( + "Processed surface mode {mode} was not normalized by SurfacePhysics" + )), + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/trajectory.rs b/crates/skate-game/src/physics/ground_runtime/trajectory.rs new file mode 100644 index 00000000..97b66476 --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/trajectory.rs @@ -0,0 +1,22 @@ +//! Ground ordinary tail82D749D0 cancels pending trajectory work and clears +//!published validity. Rust owns the request lifetime; no guest pool is copied. +///The caller's actual pending request type can cancel work on Drop. Prediction +///data may remain cached, as native Reset clears validity rather than erasing +///all output vectors. Lower six flags retain their existing values. +pub(crate) struct GroundTrajectoryState { + pub pending_request: Option, + pub primary_valid_288: bool, + pub secondary_valid_592: bool, + pub result_valid_9840: bool, + pub flags_12836: u8, +} +impl GroundTrajectoryState { + ///Publish the native cancellation after releasing the current request. + pub fn cancel(&mut self) { + self.pending_request.take(); + self.result_valid_9840 = false; + self.primary_valid_288 = false; + self.secondary_valid_592 = false; + self.flags_12836 &= 0x3f; + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/update.rs b/crates/skate-game/src/physics/ground_runtime/update.rs new file mode 100644 index 00000000..b017e42c --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/update.rs @@ -0,0 +1,121 @@ +//! Ordinary Ground82D37C88 executes persistent controller updates and actual +//!body forces before prediction and IK publication. Skitch's active query and +//!force path is a distinct behavior; absent world data cannot simulate it. +use super::{ + super::{ + animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, foot_ik::FootIk, + riding_outputs::RidingOutputs, + }, + GroundControllers, GroundInputObservations, GroundPhysicalFrame, GroundRuntime, GroundSettings, + GroundState, GroundTrajectoryState, +}; +use skate_core::{ + math::Vector3, + physics::{ + board_runtime::BoardRuntime, board_toolkit::BoardToolkit, board_world::BoardWorld, + drive_frames::RetailAffineTransform, + }, + player::input_phase::ProcessedPhysicsInput, + riding::grounded::state::{board::GroundBoardOutcome, motion}, +}; + +pub(crate) struct GroundUpdateFrame<'a> { + pub processed: &'a ProcessedPhysicsInput, + pub animation: &'a AnimationInput, + pub riding: &'a RidingOutputs, + pub skeleton: &'a AnimatedSkeleton, + pub toolkit: &'a BoardToolkit, + pub base_trucks: [RetailAffineTransform; 2], + pub extra: GroundInputObservations, +} +pub(crate) struct GroundUpdateTargets<'a, T> { + pub foot_ik: &'a mut FootIk, + pub skeleton_elapsed_16505: &'a mut bool, + pub skeleton_ground_16388: &'a mut bool, + pub world_grab_reset_direction: [f32; 4], + ///Applies to SkeletonDrives hook432 body and Skeleton16112 together. + pub move_future_deck: &'a mut dyn FnMut(Vector3) -> Result<(), String>, + pub trajectory: &'a mut GroundTrajectoryState, +} +impl GroundState { + pub fn update( + &mut self, + runtime: &mut GroundRuntime, + board: &mut BoardRuntime, + world: &BoardWorld, + settings: &GroundSettings, + frame: GroundUpdateFrame<'_>, + physical: GroundPhysicalFrame<'_>, + targets: GroundUpdateTargets<'_, T>, + ) -> Result { + if !self.entered { + return Err("Ground::Enter must finish before Ground::Update".into()); + } + let p = frame.processed; + let signals = self + .state + .begin_update_at(p.wheel_count_2556 as i32, frame.toolkit.deck[3]); + if signals.set_skeleton_flag_16505 { + *targets.skeleton_elapsed_16505 = true; + } + if p.flags_2476 & 0x0040_0000 != 0 { + return Err("Active world-grab requires recovered Skitch query/force behavior; ordinary Ground cannot invent an object".into()); + } + self.world_grab + .clear_inactive(targets.world_grab_reset_direction); + let pumping_mode = self.pumping_settings.mode(p.state_variant_index_2528)?; + self.pumping_settings.update( + &mut self.pumping, + frame.toolkit, + frame.riding, + frame.skeleton, + p.flags_2476, + pumping_mode, + ); + let input = settings.input( + frame.toolkit, + p, + frame.animation, + &self.pumping, + pumping_mode.unintentional_scalar, + frame.riding, + frame.skeleton, + frame.base_trucks, + frame.extra, + ); + let controllers = GroundControllers { + speed_wobble: &mut self.wobble, + truck_steering: &mut self.steering, + speed_model: &mut self.speed, + manual: &mut self.manual, + heading_previous: &mut self.heading_previous, + }; + let outcome = runtime.update_board( + board, + world, + settings, + &mut self.state, + controllers, + input, + physical, + )?; + let normal = p.vectors_464_480_496_512_528[0].map(f32::from_bits); + if let Some(delta) = motion::future_deck_displacement( + board.forces(), + frame.toolkit.total_mass, + p.timestep_2604, + normal, + self.state.flag_2720, + self.state.manual_correction_2732, + ) { + (targets.move_future_deck)(delta)?; + } + if self.state.flag_2722 { + targets.foot_ik.state.contacts.support_failed_this_update = true; + } + *targets.skeleton_ground_16388 = true; + self.state.finish_update(p.timestep_2604); + targets.trajectory.cancel(); + Ok(outcome) + } +} diff --git a/crates/skate-game/src/physics/ground_runtime/world_grab.rs b/crates/skate-game/src/physics/ground_runtime/world_grab.rs new file mode 100644 index 00000000..0011f46a --- /dev/null +++ b/crates/skate-game/src/physics/ground_runtime/world_grab.rs @@ -0,0 +1,59 @@ +//! Typed Skitch/world-grab observations. Ground inactive82D38430 executes +//!Stop82D62F20 and predictor reset82D65FB8 before pumping. A target is present +//!only when the world query owner supplied an actual object observation. +#[derive(Clone, Debug)] +pub(crate) struct WorldGrabObservation { + pub object: u64, + pub position: [f32; 4], + pub relative_velocity: [f32; 4], +} +#[derive(Clone, Debug, Default)] +pub(crate) struct GroundWorldGrabState { + pub observation: Option, + pub contact_flags: u32, + pub contact_time: f32, + pub contact_position: [f32; 4], + pub contact_velocity: [f32; 4], + pub contact_valid: bool, + pub active: bool, + pub secondary_active: bool, + pub selected_count: u32, + pub position: [f32; 4], + pub probe_distances: [[f32; 4]; 4], + pub probe_weights: [[f32; 4]; 2], + pub query_points: [[f32; 4]; 2], + pub query_normals: [[f32; 4]; 2], + pub query_word: u32, + pub query_scalar: f32, + pub query_valid: [bool; 2], + pub output_scalar: f32, + pub output_direction: [f32; 4], + pub output_flags: u32, +} +impl GroundWorldGrabState { + ///The shared vector comes from native global830BD4A0's initializer. It + ///must be supplied by that producer, never inferred from ground normal. + pub fn clear_inactive(&mut self, reset_direction: [f32; 4]) { + self.active = false; + self.secondary_active = false; + self.contact_flags &= 0x4fff_ffff; + self.contact_time = f32::MAX; + self.contact_position = [0.0; 4]; + self.contact_valid = false; + self.contact_velocity = [0.0; 4]; + self.observation = None; + self.probe_distances = [[-1.0; 4]; 4]; + self.probe_weights = [[0.0; 4]; 2]; + self.selected_count = 0; + self.position = [0.0; 4]; + //82D65FB8 writes two literal UnitY normals and two zero points. + self.query_points = [[0.0; 4]; 2]; + self.query_normals = [[0.0, 1.0, 0.0, 0.0]; 2]; + self.query_word = 0; + self.query_scalar = 0.0; + self.query_valid = [false; 2]; + self.output_scalar = 0.0; + self.output_direction = reset_direction; + self.output_flags &= 0x3fff_ffff; + } +} diff --git a/crates/skate-game/src/physics/input_phase.rs b/crates/skate-game/src/physics/input_phase.rs new file mode 100644 index 00000000..a7c6410e --- /dev/null +++ b/crates/skate-game/src/physics/input_phase.rs @@ -0,0 +1,214 @@ +//! Connect the animation packet to the one physical player and skeleton. +//! PlayerInput retains the original ordering; callbacks split host ownership. +#[path = "input_teleport.rs"] +mod teleport; +use super::{ + GamePhysics, SkaterRuntime, + animated_skeleton::AnimatedSkeleton, + animation_input::AnimationInput, + foot_ik::FootIk, + ground_phase::GroundLifecycle, + ground_runtime::{GroundRuntime, GroundState}, + player_input::{InputHostFrame, PlayerInputCallbacks}, + riding_outputs::RidingOutputs, + settings::PhysicsSettings, + skeleton_input_runtime::{ + CollisionInput, SkeletonInputRuntime, SkeletonOwners, SkeletonPoseInput, + }, + skeleton_output::SkeletonOutput, +}; +use skate_core::{ + animation::output::{NativeMatrix, attributes::AnimationAttribute}, + input::controller::ActionMap, + physics::{ + board_runtime::BoardRuntime, + board_toolkit::BoardToolkit, + skeleton_body::{ + SkeletonBody, SkeletonCollisionFeedback, SkeletonCollisionMode, SkeletonDrives, + SkeletonPoseErrors, + }, + }, + player::input_phase::{AnimationInputPacket, PhysicalPlayerInput, ProcessedPhysicsInput}, +}; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + packet: &AnimationInputPacket<'_>, + actions: &mut dyn ActionMap, + input_available: bool, +) -> Result { + let host = InputHostFrame { + // Native actor queries are host identity, not a physical parameter. + actor_query_56: 0, + actor_query_44: 0, + input_available, + transition_action: actions.value(71), + published_board_transform: if skater.player_input.physical.state.flag_61 != 0 { + skater.player_input.physical.teleport_output + .ok_or("Teleport State61 requires its published reset transform")? + .transform.map(|row| row.map(f32::from_bits)) + } else { super::solve::deck_frame(&physics.board) }, + //82D8ABD8 reads the state selector's retained signed counter+40, + //before this tick's CalcSuggestedState advances it at82D8AF78..94. + air_counter_40: skater.player_state.selector.post_grind_jump_counter, + }; + let collision = collision(skater); + let mut callbacks = Callbacks { + skeleton_input: &mut skater.skeleton_input, + animated: &mut skater.animated_skeleton, + skeleton_air: &mut skater.skeleton_air, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + output: &mut skater.skeleton_output, + pose_errors: &mut skater.pose_errors, + collision_mode: &mut skater.skeleton_collision, + feedback: &mut skater.collision_feedback, + collision, + ground: &mut skater.ground, + life: &mut skater.ground_lifecycle, + wipeout: &mut skater.wipeout, + riding: &mut physics.riding, + wiping_out: &mut physics.board_wiping_out, + settings: &physics.settings, + globals: &skater.animation.packet.hierarchy, + attributes: skater.animation.attributes.entries(), + actions, + packet, + teleported: false, + }; + let ground_frame = callbacks.riding.reckoning_frames.ground; + let continuation = skater.player_input.process_stage( + &mut physics.board, + &mut skater.ground_runtime, + ground_frame, + packet, + host, + &mut callbacks, + super::player_input::InputStage::ThroughTeleport, + )?; + if let Some(continuation) = continuation { + skater.player_input.process_stage( + &mut physics.board, + &mut skater.ground_runtime, + ground_frame, + packet, + host, + &mut callbacks, + super::player_input::InputStage::AfterTeleport(continuation), + )?; + } + physics.processed_flags_2468 = skater.player_input.processed.flags_2468; + Ok(callbacks.teleported) +} + +pub(super) fn update_ground( + physics: &GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + let collision = collision(skater); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + let target = skater.skeleton_input.update_ground( + &physics.board, + &physics.riding.reckoning_frames.system, + &mut skater.player_input.processed, + &mut owners, + &skater.animation.packet.hierarchy, + &collision, + physics.settings.step.simulation, + )?; + //Ground82D38000 completes GeneralUpdate;82D38008 then retains the actual + //board/animation error used when Air subsequently blends its board target. + skater.skeleton_air.capture_physics_error(&physics.board, &target); + Ok(()) +} + +pub(super) fn collision(skater: &SkaterRuntime) -> CollisionInput { + CollisionInput { + contact_4070: skater.collision_feedback.flags.compliant, + has_pose_error_4077: skater.collision_feedback.flags.has_impulse, + pose_error_16272: skater.collision_pose_error, + partial_ragdoll: skater.skeleton_collision.partial_ragdoll, + drive_weight_4028: skater.collision_feedback.drive_weight, + } +} + +struct Callbacks<'a, 'p> { + skeleton_input: &'a mut SkeletonInputRuntime, + animated: &'a mut AnimatedSkeleton, + skeleton_air: &'a mut super::skeleton_air::SkeletonAir, + body: &'a mut SkeletonBody, + drives: &'a mut SkeletonDrives, + ik: &'a mut FootIk, + animation_input: &'a mut AnimationInput, + output: &'a mut SkeletonOutput, + pose_errors: &'a mut SkeletonPoseErrors, + collision_mode: &'a mut SkeletonCollisionMode, + feedback: &'a mut SkeletonCollisionFeedback, + collision: CollisionInput, + ground: &'a mut GroundState, + life: &'a mut GroundLifecycle, + wipeout: &'a mut super::wipeout::Wipeout, + riding: &'a mut RidingOutputs, + wiping_out: &'a mut bool, + settings: &'a PhysicsSettings, + globals: &'a [NativeMatrix], + attributes: &'a [AnimationAttribute], + actions: &'a mut dyn ActionMap, + packet: &'a AnimationInputPacket<'p>, + teleported: bool, +} +impl PlayerInputCallbacks for Callbacks<'_, '_> { + fn process_skeleton( + &mut self, + board: &mut BoardRuntime, + toolkit: &BoardToolkit, + packet: &AnimationInputPacket<'_>, + physical: &mut PhysicalPlayerInput, + processed: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + self.skeleton_input.process_data( + board, + toolkit, + packet, + physical, + processed, + &mut SkeletonOwners { + animated: self.animated, + body: self.body, + drives: self.drives, + ik: self.ik, + animation_input: self.animation_input, + correction: &mut self.output.correction, + pose_errors: self.pose_errors, + }, + SkeletonPoseInput { + globals: self.globals, + attributes: self.attributes, + actions: self.actions, + }, + &self.collision, + ) + } + fn teleport( + &mut self, + board: &mut BoardRuntime, + runtime: &mut GroundRuntime, + target: NativeMatrix, + player: &mut skate_core::player::input_phase::PlayerInputState, + physical: &mut PhysicalPlayerInput, + processed: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + self.reset_player(board, runtime, target, player, physical, processed) + } +} diff --git a/crates/skate-game/src/physics/input_teleport.rs b/crates/skate-game/src/physics/input_teleport.rs new file mode 100644 index 00000000..98982f55 --- /dev/null +++ b/crates/skate-game/src/physics/input_teleport.rs @@ -0,0 +1,150 @@ +//! Ordinary local-player teleport82DB8998, operating on the live assemblies. +use super::*; +use skate_core::{ + math::{Basis3, Vector3}, + physics::{drive_frames::RetailAffineTransform, skeleton_animation_record::IDENTITY}, + player::input_phase::PlayerInputState, +}; + +impl Callbacks<'_, '_> { + pub(super) fn reset_player( + &mut self, + board: &mut BoardRuntime, + runtime: &mut GroundRuntime, + requested: NativeMatrix, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + p: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + self.life + .skeleton_controller + .request_ground(self.collision_mode)?; + let target = horizontal_spawn(requested); + player.flags_1296 = ((player.flags_1296 & 0xffef_ffff) & 0x81ff_ffff) | 0x6000_0000; + player.previous_spin_input_1360 = 0.0; + player.spin_same_direction_frames_1324 = 0; + player.dismount_request_frames_1332 = 0; + player.time_on_ground_1352 = 0.0; + player.signed_ground_time_1356 = 0.0; + player.queued_vector_1280 = [0; 4]; + player.hips_line_test_1488 = Default::default(); + player.left_line_test_1536 = Default::default(); + player.right_line_test_1584 = Default::default(); + crate::physics::player_input::reset_outputs(physical); + board.reset_physical( + self.settings.authored, + affine(target), + p.flags_2468, + self.settings.step.simulation.gravity_acceleration, + ); + crate::physics::ground_phase::reset_board_state( + self.ground, + runtime, + self.life, + self.wiping_out, + ); + self.skeleton_air.reset_board(); //Board Reset82C0606C. + self.riding.reset_for_teleport(); + { + let mut owners = SkeletonOwners { + animated: self.animated, + body: self.body, + drives: self.drives, + ik: self.ik, + animation_input: self.animation_input, + correction: &mut self.output.correction, + pose_errors: self.pose_errors, + }; + self.skeleton_input.reset_for_teleport( + &mut owners, + self.collision_mode, + self.feedback, + &mut self.output.wobble, + &mut self.life.skeleton_elapsed_16505, + ); + } + //ProcessData below reads the reset collision manager, not the snapshot + //taken before this callback entered ResetSystems. + self.collision.contact_4070 = self.feedback.flags.compliant; + self.collision.has_pose_error_4077 = self.feedback.flags.has_impulse; + self.collision.partial_ragdoll = self.collision_mode.partial_ragdoll; + self.collision.drive_weight_4028 = self.feedback.drive_weight; + //ResetSystems82DB92D0 preserves Reckoning filter histories. Seed the + //fresh deck heading and recompute its actual Ground transform. + self.riding.reckoning.reset(); + self.riding.update_ground_reckoning( + board, + crate::physics::riding_outputs::RidingPoseInputs { + com_to_deck: Vector3::new( + self.animated.record.com_to_deck_world[0], + self.animated.record.com_to_deck_world[1], + self.animated.record.com_to_deck_world[2], + ), + body_spin: self.animation_input.extra.physical_body_spin, + }, + p.flags_2468, + self.animation_input.fields.balance, + false, + ); + self.wipeout.state.reset_systems(); + self.life.skeleton_controller.effective = 0; + self.life.skeleton_controller.requested = 0; + self.life.skeleton_controller.has_request = false; + self.life.skeleton_controller.override_enabled = false; + self.life.skeleton_controller.flag_18 = false; + self.life.trajectory.pending_request = None; + self.life.trajectory.primary_valid_288 = false; + self.life.trajectory.secondary_valid_592 = false; + self.life.trajectory.result_valid_9840 = false; + player.manager_1856_counter_320 = 0; + player.probe = Default::default(); + + let toolkit = runtime.prepare_toolkit(board, p); + let packet = self.packet; + self.process_skeleton(board, &toolkit, packet, physical, p)?; + self.skeleton_input.update_teleport( + board, + &self.riding.reckoning_frames.system, + p, + &mut SkeletonOwners { + animated: self.animated, + body: self.body, + drives: self.drives, + ik: self.ik, + animation_input: self.animation_input, + correction: &mut self.output.correction, + pose_errors: self.pose_errors, + }, + self.globals, + &self.collision, + self.settings.step.simulation, + )?; + self.wipeout.state.teleport(); //82DB8DB4, separate from ResetSystems. + self.teleported = true; + Ok(()) + } +} + +fn affine(m: NativeMatrix) -> RetailAffineTransform { + RetailAffineTransform { + basis: Basis3 { + columns: std::array::from_fn(|i| [m[i][0], m[i][1], m[i][2]]), + }, + translation: Vector3::new(m[3][0], m[3][1], m[3][2]), + } +} +///82DB8A10..8BB4 flattens At before normalization, rejects length<=.001, +///then adds the original .1m world-Y teleport clearance. +fn horizontal_spawn(requested: NativeMatrix) -> NativeMatrix { + let mut result = IDENTITY; + let x = requested[2][0]; + let z = requested[2][2]; + let length = (x * x + z * z).sqrt(); + if length > f32::from_bits(0x3a83_126f) { + result[2] = [x / length, 0.0, z / length, 0.0]; + result[0] = [result[2][2], 0.0, -result[2][0], 0.0]; + } + result[3] = requested[3]; + result[3][1] += 0.1; + result +} diff --git a/crates/skate-game/src/physics/known_air.rs b/crates/skate-game/src/physics/known_air.rs new file mode 100644 index 00000000..939481da --- /dev/null +++ b/crates/skate-game/src/physics/known_air.rs @@ -0,0 +1,200 @@ +//! Native KnownAir201 lifecycle82D352D0/82D35C58/82D35920/82D36590/82D36880. +//! All phase adapters borrow the production owners; the state copy below is a +//! stack-local value while those owners are borrowed, then published once. +mod flip; +mod math; +mod output; +mod runtime; +mod settings; +use super::{GamePhysics, SkaterRuntime}; +use skate_core::air::known::{ + self, KnownAirFrame, KnownAirModeSettings, KnownAirOutput, KnownAirReckoningFields, + KnownAirSettings, KnownAirState, KnownAirWipeoutRequest, KnownAirWipeoutSettings, +}; +use skate_data::collections::Collections; +pub(crate) struct KnownAir { + pub state: KnownAirState, + settings: KnownAirSettings, + modes: [KnownAirModeSettings; 5], + wipeout: KnownAirWipeoutSettings, + flip_axis_adjustment: [f32; 4], +} +impl KnownAir { + pub fn load(data: &Collections) -> Result { + let (settings, modes) = settings::load(data)?; + Ok(Self { + settings, + modes, + wipeout: KnownAirWipeoutSettings { + air_falling_min_up_y_260: data.float( + "physics_wipeout", + "default", + "Wipeout_AirFallingMinUpY", + )?, + air_falling_max_angle_264: data.float( + "physics_wipeout", + "default", + "Wipeout_AirFallingMaxAngle", + )?, + }, + //82D8DA84 binds physics_reckoning handle1600, layout pointer1604. + flip_axis_adjustment: data + .words::<4>("physics_reckoning", "default", "FlipAxisAdjustment")? + .map(f32::from_bits), + state: KnownAirState { + landing_normal_64: [0.0, 1.0, 0.0, 0.0], + landing_heading_80: [0.0; 4], + 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: 0.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, + }, + }) + } + fn mode(&self, index: u32) -> Result { + self.modes + .get(index as usize) + .copied() + .ok_or_else(|| format!("Undefined KnownAir physics mode {index}")) + } +} +fn frame(skater: &SkaterRuntime, next: u32) -> Result { + let p = &skater.player_input.processed; + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("KnownAir requires current BoardToolkit")?; + let s = skater + .trajectory + .selector + .selection() + .ok_or("KnownAir requires a completed selected trajectory")?; + Ok(KnownAirFrame { + start_flip_reference_96: t.deck[2], + alternate_head_target_112: t.deck[3], + velocity_400: p.vectors_400_416[0].map(f32::from_bits), + ground_normal_464: p.vectors_464_480_496_512_528[0].map(f32::from_bits), + start_height_484: f32::from_bits(p.vectors_464_480_496_512_528[1][1]), + skater_up_544: p.vectors_544_560_592_608[0].map(f32::from_bits), + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + flags_2488: p.flags_2488, + next_physics_state_2500: next as i32, + delta_time_2604: p.timestep_2604, + forward_speed_2612: p.scalar_2612, + body_spin_input_2640: skater.animation_input.fields.body_spin, + selector_landing_normal_2656: s.landing_normal, + }) +} +fn reckoning(physics: &GamePhysics, skater: &SkaterRuntime) -> KnownAirReckoningFields { + let up = physics.riding.reckoning.up; + KnownAirReckoningFields { + landing_normal_1152: [up.x, up.y, up.z, 0.0], + heading_axis_1200: physics.riding.reckoning_frames.heading, + body_spin_angle_1568: skater.air_reckoning.state.spin_angle, + body_spin_speed_1572: skater.air_reckoning.state.spin_speed, + } +} +pub(super) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let frame = frame(skater, 201)?; + let mut state = skater.known_air.state; + let settings = skater.known_air.settings; + let mode = skater + .known_air + .mode(skater.player_input.processed.state_variant_index_2528)?; + let mut r = runtime::Runtime::new(physics, skater)?; + known::enter(&mut state, &frame, &settings, &mode, &mut r); + r.finish()?; + skater.known_air.state = state; + Ok(()) +} +pub(super) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let frame = frame(skater, 201)?; + let mut state = skater.known_air.state; + let settings = skater.known_air.settings; + let mode = skater + .known_air + .mode(skater.player_input.processed.state_variant_index_2528)?; + let reckoning = reckoning(physics, skater); + let mut r = runtime::Runtime::new(physics, skater)?; + known::update(&mut state, &frame, &settings, &mode, &reckoning, &mut r); + r.finish()?; + skater.known_air.state = state; + Ok(()) +} +pub(super) fn exit( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + next: u32, +) -> Result<(), String> { + let mut frame = frame(skater, next)?; + let mut state = skater.known_air.state; + let settings = skater.known_air.settings; + let mut reckoning = reckoning(physics, skater); + let mut r = runtime::Runtime::new(physics, skater)?; + known::exit(&mut state, &mut frame, &settings, &mut reckoning, &mut r); + r.finish()?; + skater.player_input.processed.scalar_2612 = frame.forward_speed_2612; + skater.air_reckoning.state.spin_angle = reckoning.body_spin_angle_1568; + skater.air_reckoning.state.spin_speed = reckoning.body_spin_speed_1572; + skater.known_air.state = state; + Ok(()) +} +///82D36590: apex observation, shared air-wipeout(true), then upside-down request. +pub(super) fn post_physics( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + let frame = frame(skater, 201)?; + let mut state = skater.known_air.state; + let settings = skater.known_air.wipeout; + let mode = skater + .known_air + .mode(skater.player_input.processed.state_variant_index_2528)?; + let mut request = KnownAirWipeoutRequest { + requested_35: false, + scalar_116: 0.0, + counter_200: 0, + }; + let mut r = runtime::Runtime::new(physics, skater)?; + known::update_post_physics(&mut state, &frame, &mode, &settings, &mut request, &mut r); + r.finish()?; + if request.counter_200 != 0 { + skater.wipeout.state.request(15, request.scalar_116); + } + skater.known_air.state = state; + Ok(()) +} +pub(super) fn fill( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result { + let frame = frame(skater, 201)?; + let state = skater.known_air.state; + let mut out = output::storage(&skater.player_input.physical.air); + let mut r = runtime::Runtime::new(physics, skater)?; + known::fill_physics_output(&state, &frame, &mut out, &mut r); + r.finish()?; + output::publish(&out, &mut skater.player_input.physical.air); + Ok(out) +} diff --git a/crates/skate-game/src/physics/known_air/flip.rs b/crates/skate-game/src/physics/known_air/flip.rs new file mode 100644 index 00000000..5beef40a --- /dev/null +++ b/crates/skate-game/src/physics/known_air/flip.rs @@ -0,0 +1,48 @@ +//! Original Reckoning ResetBodyFlip82D8E9E0 / BeginBodyFlip82D8EAB0. +use super::{math::*, runtime::Runtime}; +use skate_core::physics::skeleton_animation_record::IDENTITY; +impl Runtime<'_> { + pub(super) fn reset_flip(&mut self) { + let frames = &mut self.physics.riding.reckoning_frames; + let n = self.physics.riding.reckoning.up; + let up = rotate(&frames.body_flip, [n.x, n.y, n.z, 0.0]); + self.physics.riding.reckoning.up = xyz(up); + frames.heading = rotate(&frames.body_flip, frames.heading); + frames.body_flip = IDENTITY; + let state = &mut self.skater.air_reckoning.state; + state.flip_active = false; + state.flip_speed = 0.0; + state.flip_side = false; + state.flip_angle = 0.0; + } + pub(super) fn begin_flip(&mut self, side: bool) { + let state = &mut self.skater.air_reckoning.state; + if state.flip_active { + return; + } + self.physics.riding.reckoning_frames.body_flip = IDENTITY; + state.flip_speed = 0.0; + state.flip_angle = 0.0; + let mut basis = self.physics.riding.reckoning_frames.system; + if self.toolkit.control_sign < 0.0 { + basis[0] = scale(basis[0], -1.0); + basis[2] = scale(basis[2], -1.0); + } + let v = self.skater.animation_input.fields.body_spin * 4.0; + let lo = if -v >= 0.0 { 0.0 } else { v }; + let blend = if 1.0 - lo >= 0.0 { lo } else { 1.0 }; + let adjustment = scale(self.skater.known_air.flip_axis_adjustment, blend); + let axis = std::array::from_fn(|i| { + basis[2][i].mul_add( + adjustment[2], + basis[1][i].mul_add(adjustment[1], basis[0][i]), + ) + }); + state.flip_axis = normalized(axis).0; + if side { + state.flip_axis = scale(state.flip_axis, -1.0); + } + state.flip_side = side; + state.flip_active = true; + } +} diff --git a/crates/skate-game/src/physics/known_air/math.rs b/crates/skate-game/src/physics/known_air/math.rs new file mode 100644 index 00000000..b0f2d8ad --- /dev/null +++ b/crates/skate-game/src/physics/known_air/math.rs @@ -0,0 +1,106 @@ +//! Original KnownAir VMX geometry; independent PC estimate seeds. +use super::runtime::Runtime; +use skate_core::{ + air::known::{AffineTransform, KnownAirMath, RestoreVelocityGeometry}, + math::Vector3, +}; +pub(super) type V = [f32; 4]; +pub(super) fn dot(a: V, b: V) -> f32 { + (a[0] * b[0] + a[1] * b[1]) + a[2] * b[2] +} +pub(super) fn sub(a: V, b: V) -> V { + std::array::from_fn(|i| a[i] - b[i]) +} +pub(super) fn scale(a: V, s: f32) -> V { + a.map(|v| v * s) +} +pub(super) 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]), + ] +} +pub(super) fn normalized(v: V) -> (V, f32) { + let s = dot(v, v); + let mut r = s.sqrt().recip(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-s).mul_add(r * r, 1.0), r); + } + let l = if s == 0.0 { 0.0 } else { s * r }; + ( + if l > f32::from_bits(0x358637bd) { + scale(v, r) + } else { + [0.0; 4] + }, + l, + ) +} +pub(super) fn rotate(m: &[V; 4], v: V) -> V { + std::array::from_fn(|i| { + let x = m[0][i] * v[0]; + let y = m[1][i].mul_add(v[1], x); + m[2][i].mul_add(v[2], y) + }) +} +pub(super) fn point(m: &[V; 4], v: V) -> V { + std::array::from_fn(|i| { + let x = m[0][i].mul_add(v[0], m[3][i]); + let y = m[1][i].mul_add(v[1], x); + m[2][i].mul_add(v[2], y) + }) +} +pub(super) fn xyz(v: V) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +impl KnownAirMath for Runtime<'_> { + fn dot3(&mut self, a: V, b: V) -> f32 { + dot(a, b) + } + fn divide_vector_by_scalar(&mut self, v: V, s: f32) -> V { + let mut r = s.recip(); + for _ in 0..2 { + r = (-s).mul_add(r, 1.0).mul_add(r, r); + } + scale(v, r) + } + fn normalize_safe_with_length(&mut self, v: V) -> (V, f32) { + normalized(v) + } + fn signed_angle_about_axis(&mut self, a: V, b: V, axis: V) -> f32 { + if !(dot(a, a) * dot(b, b) > f32::from_bits(0x37800000)) { + return 0.0; + } + let a = sub(a, scale(axis, dot(axis, a))); + let b = sub(b, scale(axis, dot(axis, b))); + skate_core::riding::collision_response::signed_angle(xyz(a), xyz(b), xyz(axis)) + } + fn wrap_angle_8258db98(&mut self, mut a: f32) -> f32 { + let pi = f32::from_bits(0x40490fdb); + let tau = f32::from_bits(0x40c90fdb); + if a < -pi || !(a < pi) { + let n = (a * f32::from_bits(0x3e22f983)) as i32; + a = (-(n as f32)).mul_add(tau, a); + if !(a < pi) { + a -= tau; + } else if a < -pi { + a += tau; + } + } + a + } + fn restore_velocity_geometry_82d35998(&mut self, v: V, n: V) -> RestoreVelocityGeometry { + let tangent = sub(v, scale(n, dot(n, v))); + let downhill = cross(n, cross(n, [0.0, 1.0, 0.0, 0.0])); + RestoreVelocityGeometry { + tangential_velocity: tangent, + landing_speed_curve_input: n[1], + landing_speed_blend_source: dot(normalized(downhill).0, normalized(tangent).0), + } + } + fn transform_point_82d36880(&mut self, m: AffineTransform, p: V) -> V { + point(&m.vectors, p) + } +} diff --git a/crates/skate-game/src/physics/known_air/output.rs b/crates/skate-game/src/physics/known_air/output.rs new file mode 100644 index 00000000..1cb4d6ef --- /dev/null +++ b/crates/skate-game/src/physics/known_air/output.rs @@ -0,0 +1,68 @@ +//! Fill82D36880 overwrites every field below except the explicitly retained pair. +use skate_core::{ + air::known::{KnownAirOutput, KnownAirTrajectory}, + player::input_phase::AirOutputFields, +}; +pub(super) fn storage(previous: &AirOutputFields) -> 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: previous.vector_160.map(f32::from_bits), + 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: KnownAirTrajectory { + position: [0.0; 4], + velocity: [0.0; 4], + acceleration: [0.0; 4], + scalar_48: 0.0, + word_52: 0, + word_56: 0, + word_60: 0, + }, + trajectory_plane_samples_336: [0.0; 25], + reached_apex_436: false, + known_air_valid_437: false, + locked_trajectory_velocity_valid_452: previous.use_air_reckoning_452 != 0, + } +} +pub(super) fn publish(out: &KnownAirOutput, physical: &mut AirOutputFields) { + physical.trajectory_apex_0 = out.trajectory_apex_0.map(f32::to_bits); + physical.collision_position_16 = out.collision_position_16.map(f32::to_bits); + physical.landing_normal_32 = out.landing_normal_32.map(f32::to_bits); + physical.selector_vector_48 = out.selector_vector_48.map(f32::to_bits); + physical.trajectory_position_64 = out.trajectory_position_64.map(f32::to_bits); + physical.landing_heading_80 = out.landing_heading_80.map(f32::to_bits); + physical.selector_com_position_96 = out.selector_com_position_96.map(f32::to_bits); + physical.time_in_state_176 = out.time_in_state_176; + physical.collision_time_180 = out.collision_time_180; + physical.collision_normal_speed_188 = out.collision_normal_speed_188; + physical.time_to_apex_196 = out.time_to_apex_196; + physical.trajectory_index_220 = out.trajectory_index_220; + physical.trajectory_plane_samples_336 = out.trajectory_plane_samples_336; + physical.known_air_valid_437 = u8::from(out.known_air_valid_437); + let t = out.selected_trajectory_240; + physical.selected_trajectory_240 = [ + t.position.map(f32::to_bits), + t.velocity.map(f32::to_bits), + t.acceleration.map(f32::to_bits), + [t.scalar_48.to_bits(), t.word_52, t.word_56, t.word_60], + ]; + + physical.vector_160 = out.locked_trajectory_velocity_160.map(f32::to_bits); + physical.use_air_reckoning_452 = u8::from(out.locked_trajectory_velocity_valid_452); + physical.scalar_184 = out.time_until_collision_184; + physical.landing_normal_144 = out.landing_normal_copy_144.map(f32::to_bits); + physical.jump_height_200 = out.jump_height_200; + physical.reached_apex_436 = u8::from(out.reached_apex_436); +} diff --git a/crates/skate-game/src/physics/known_air/runtime.rs b/crates/skate-game/src/physics/known_air/runtime.rs new file mode 100644 index 00000000..b43b66a5 --- /dev/null +++ b/crates/skate-game/src/physics/known_air/runtime.rs @@ -0,0 +1,364 @@ +//! Concrete KnownAir boundary. All callbacks mutate the same live owners. +//! Fallible host queries/pose checks retain their first error for the phase caller. +use super::{GamePhysics, SkaterRuntime, math::*}; +use crate::physics::{ + footplant::FootplantFrame, input_phase, skeleton_input_runtime::SkeletonOwners, + wipeout::Observations, +}; +use skate_core::{ + air::{ + known::*, + trajectory::{Selection, Trajectory}, + }, + physics::board_toolkit::BoardToolkit, +}; +pub(super) struct Runtime<'a> { + pub physics: &'a mut GamePhysics, + pub skater: &'a mut SkaterRuntime, + pub toolkit: BoardToolkit, + selection: Selection, + local_com: V, + board_position: V, + error: Option, +} +impl<'a> Runtime<'a> { + pub fn new( + physics: &'a mut GamePhysics, + skater: &'a mut SkaterRuntime, + ) -> Result { + let selector = &skater.trajectory.selector; + let selection = *selector + .selection() + .ok_or("KnownAir requires its actual winning query result")?; + let local_com = selector + .local_com_position() + .ok_or("KnownAir requires selector local COM")?; + let board_position = selector + .board_position() + .ok_or("KnownAir requires selector board position")?; + let toolkit = skater + .player_input + .toolkit + .ok_or("KnownAir requires prepared board toolkit")?; + Ok(Self { + physics, + skater, + toolkit, + selection, + local_com, + board_position, + error: None, + }) + } + pub fn finish(self) -> Result<(), String> { + match self.error { + Some(e) => Err(e), + None => Ok(()), + } + } + fn record(&mut self, result: Result<(), String>) { + if self.error.is_none() { + self.error = result.err(); + } + } +} +pub(super) fn trajectory(t: Trajectory) -> KnownAirTrajectory { + //82E09A14/2C splats the request horizon into all four final-vector lanes. + //KnownAir Fill copies all64 bytes, including the three repeated words. + KnownAirTrajectory { + position: t.position, + velocity: t.velocity, + acceleration: t.acceleration, + scalar_48: t.duration, + word_52: t.duration.to_bits(), + word_56: t.duration.to_bits(), + word_60: t.duration.to_bits(), + } +} +fn actual(t: &KnownAirTrajectory) -> Trajectory { + Trajectory { + position: t.position, + velocity: t.velocity, + acceleration: t.acceleration, + duration: t.scalar_48, + } +} +impl KnownAirRuntime for Runtime<'_> { + fn enable_board_angular_drive_only(&mut self) { + self.physics + .board + .hook_mut() + .drive + .enable_angular_only(&mut self.skater.ground_lifecycle.board_animated_290); + } + fn set_air_collision_update_enabled(&mut self, v: bool) { + self.skater.ground_lifecycle.skeleton_controller.flag_18 = v; + } + fn set_skeleton_collision_state(&mut self, state: u32) { + let result = self + .skater + .ground_lifecycle + .skeleton_controller + .request(state, &mut self.skater.skeleton_collision); + self.record(result); + } + fn set_skeleton_physics_truck_tilt_enabled(&mut self, v: bool) { + self.skater.ground_lifecycle.skeleton_elapsed_16505 = v; + } + fn set_skeleton_inverse_kinematics_enabled(&mut self, v: bool) { + self.skater.foot_ik.enable_feet(v); + } + fn reset_reckoning_flipping(&mut self) { + self.reset_flip(); + } + fn set_footplant_flag_240(&mut self, v: bool) { + self.skater.footplant.enabled = v; + } + fn reset_footplants(&mut self) { + self.skater.footplant.reset(); + } + fn board_transform_height(&mut self) -> f32 { + self.physics.board.part_transforms()[6].translation.y + } + fn skeleton_com_position(&mut self) -> V { + self.skater.animated_skeleton.board_frames.centre_of_mass + } + fn selector_targeting_grind(&self) -> bool { + self.skater.trajectory.selector.grind_locked_to_middle() + } + fn start_grind_air_adjust_from_selector(&mut self) { + //The host BoardWorld has an explicitly empty edge collection. Its selector + //cannot produce9653; do not manufacture the required2944/2960/edge9568 data. + self.record(Err( + "KnownAir received a grind selection from an edge-free world".into(), + )); + } + fn selector_closest_trajectory_point(&mut self, com: V, zero: V) -> (i32, V) { + let t = self.selection.com_trajectory; + let mut best = 100000000.0; + let mut offset = zero; + for i in 0..100 { + let d = sub(com, t.position_at(i as f32 * f32::from_bits(0x3c888889))); + let squared = dot(d, d); + if squared > best { + return (i - 1, offset); + } + best = squared; + offset = d; + } + (0, zero) + } + fn selected_prediction(&self) -> KnownAirPrediction { + KnownAirPrediction { + trajectory: trajectory(self.selection.prediction.request.trajectory), + collision_time_48: self.selection.prediction.result.contact_time, + collision_frame_128: self.selection.prediction.result.contact_frame, + } + } + fn selector_trajectory_2704(&self) -> KnownAirTrajectory { + trajectory(self.selection.com_trajectory) + } + fn selector_contact_position(&mut self) -> V { + self.selection.prediction.result.contact_position + } + fn selector_vector_2848(&self) -> V { + self.board_position + } + fn selector_com_position_2784(&self) -> V { + self.local_com + } + fn highest_trajectory_position(&mut self, t: &KnownAirTrajectory) -> (V, f32) { + actual(t).highest_position() + } + fn selector_has_just_changed(&self) -> bool { + self.skater.trajectory.selector.just_changed() + } + fn reset_trajectory_selector(&mut self) { + self.skater.trajectory.selector.reset(); + } + fn begin_reckoning_body_flip(&mut self, side: bool) { + self.begin_flip(side); + } + fn update_air_collision(&mut self) { + let p = &self.skater.player_input.processed; + let result = self.skater.ground_lifecycle.skeleton_controller.update_air( + p.flags_2472, + f32::from_bits(p.vectors_400_416[0][1]), + &mut self.skater.skeleton_collision, + ); + self.record(result); + } + fn grind_air_adjust_started(&self) -> bool { + self.skater.skeleton_input.grind_air_started + } + fn set_grind_air_adjust_started(&mut self, v: bool) { + self.skater.skeleton_input.grind_air_started = v; + } + fn set_grind_air_adjust_activated(&mut self, v: bool) { + self.skater.skeleton_input.grind_air_active = v; + } + fn grind_air_adjust_adjusting(&self) -> bool { + self.skater.skeleton_input.grind_air_adjusting + } + fn disable_grind_foot_collision(&mut self, body: GrindFootBody) { + let part = match body { + GrindFootBody::Body3152 => 15, + GrindFootBody::Body3156 => 16, + GrindFootBody::Body3160 => 17, + GrindFootBody::Body3168 => 19, + GrindFootBody::Body3172 => 20, + GrindFootBody::Body3176 => 21, + }; + let collision = &mut self.skater.skeleton_collision; + collision.pending_reenable = true; + collision.disable_count[part] = 2; + collision.parts[part].enabled = false; + } + fn update_reckoning_air_states(&mut self, n: V, blend: f32, spin: f32, flip: f32) { + let result = self + .skater + .air_reckoning + .update( + &mut self.physics.riding, + &self.skater.player_input.processed, + self.skater.animation_input.extra.physical_body_spin, + n, + blend, + spin, + flip, + ) + .map(|_| ()); + self.record(result); + } + fn update_known_air_skeleton(&mut self, target: V) { + let collision = input_phase::collision(self.skater); + let s = &mut self.skater; + let mut owners = SkeletonOwners { + animated: &mut s.animated_skeleton, + body: &mut s.skeleton, + drives: &mut s.skeleton_drives, + ik: &mut s.foot_ik, + animation_input: &mut s.animation_input, + correction: &mut s.skeleton_output.correction, + pose_errors: &mut s.pose_errors, + }; + let packet = &s.animation.packet; + let result = s + .skeleton_input + .update_known_air( + &mut s.skeleton_air, + &mut self.physics.board, + &self.physics.riding.reckoning_frames.system, + target, + packet, + &mut s.player_input.processed, + &mut owners, + &packet.hierarchy, + &collision, + self.physics.settings.step.simulation, + ) + .map(|_| ()); + self.record(result); + } + fn set_skeleton_add_skateboard_error(&mut self, v: bool) { + self.skater.ground_lifecycle.skeleton_ground_16388 = v; + } + fn set_head_tracking_target(&mut self, target: V, valid: bool) { + self.skater.skeleton_input.head_tracking_history[5] = target; + self.skater.skeleton_input.head_tracking_active = valid; + } + fn update_board_steering_tilt(&mut self, tilt: f32) { + let p = &self.skater.player_input.processed; + self.skater.ground.steering.update( + tilt, + self.skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + } + fn shift_footplant_trajectory_to_index(&mut self, t: &mut KnownAirTrajectory, index: i32) { + let source = actual(t); + let time = index as f32 * f32::from_bits(0x3c888889); + t.position = source.position_at(time); + t.velocity = source.velocity_at(time); + } + fn update_footplant_prediction(&mut self, input: &KnownAirFootplantInput) { + let g = self.physics.settings.step.simulation.gravity_acceleration; + let f = FootplantFrame { + processed: &self.skater.player_input.processed, + toolkit: &self.toolkit, + animated: &self.skater.animated_skeleton, + body: &self.skater.skeleton, + gravity: [g.x, g.y, g.z, 0.0], + world: &self.physics.world, + }; + self.skater.footplant.update_candidate(input, &f); + } + fn update_footplant_lock(&mut self, input: &KnownAirFootplantInput) { + self.skater.footplant.update_launch(input); + } + fn update_footplant_pose(&mut self, input: &KnownAirFootplantInput) { + let g = self.physics.settings.step.simulation.gravity_acceleration; + let f = FootplantFrame { + processed: &self.skater.player_input.processed, + toolkit: &self.toolkit, + animated: &self.skater.animated_skeleton, + body: &self.skater.skeleton, + gravity: [g.x, g.y, g.z, 0.0], + world: &self.physics.world, + }; + let result = self.skater.footplant.consume_and_submit( + input, + &f, + &mut self.skater.foot_ik, + &mut self.skater.skeleton_collision, + ); + self.record(result); + } + fn board_body_velocity(&mut self) -> V { + let v = self.physics.board.bodies()[6].rates.linear_velocity; + [v.x, v.y, v.z, 0.0] + } + fn set_board_velocity(&mut self, v: V) { + for body in self.physics.board.bodies_mut() { + body.rates.linear_velocity = xyz(v); + } + } + fn board_forward_axis(&mut self) -> V { + let part = self.physics.board.part_transforms()[6]; + let c = part.basis.columns[2]; + scale( + [c[0], c[1], c[2], 0.0], + if self.skater.player_input.processed.flags_2468 & (1 << 20) != 0 { + -1.0 + } else { + 1.0 + }, + ) + } + fn check_for_air_wipeout(&mut self, use_com: bool) { + let s = &mut self.skater; + let observations = Observations { + processed: &s.player_input.processed, + board: &self.physics.riding.ground, + collision: &s.collision_feedback, + deck: crate::physics::solve::deck_frame(&self.physics.board), + input_board: s.animated_skeleton.board_frames.animation_target, + world_to_animation: s.animated_skeleton.roots.world_to_animation, + pose_error: s.collision_pose_error, + maximum_pose_error: s.collision_maximum_error, + jump_fix_frames: s.player_state.post.jump_fix_frames, + air: &s.air_reckoning.state, + system_up_y: self.physics.riding.reckoning_frames.system[1][1], + grind_locked_to_middle: s.trajectory.selector.grind_locked_to_middle(), + grind_normal: s.trajectory.selector.grind_normal(), + }; + let result = s.wipeout.check_air(&observations, use_com); + self.record(result); + } + fn reckoning_com_transform_816(&self) -> AffineTransform { + AffineTransform { + vectors: self.physics.riding.reckoning_frames.system, + } + } +} diff --git a/crates/skate-game/src/physics/known_air/settings.rs b/crates/skate-game/src/physics/known_air/settings.rs new file mode 100644 index 00000000..dde0dea5 --- /dev/null +++ b/crates/skate-game/src/physics/known_air/settings.rs @@ -0,0 +1,50 @@ +//! Original KnownAir82D35508/82D35F40/82D360B0/82D36650 stock reads. +use skate_core::{ + air::known::{KnownAirModeSettings, KnownAirSettings}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +pub(super) fn load( + data: &Collections, +) -> Result<(KnownAirSettings, [KnownAirModeSettings; 5]), String> { + let scalar = |name| data.float("physics_airstates", "default", name); + let graph = |name| -> Result, String> { + let values = data + .words::<20>("physics_airstates", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: values[4..12].try_into().unwrap(), + y: values[12..20].try_into().unwrap(), + }) + }; + let settings = KnownAirSettings { + max_heading_adjust_vs_up_y_160: graph("MaxHeadingAdjustVsUpY")?, + landing_speed_scalar_vs_ground_normal_y_240: graph("LandingSpeedScalarVsGroundNormalY")?, + flip_start_collision_time_vs_normal_y: graph("Hash_AA79C93673533C5B")?, + trajectory_error_blend_away_time_384: scalar("TrajErrorBlendAwayTime")?, + min_target_heading_velocity_420: scalar("MinTargetHeadingVel")?, + min_auto_body_speed_424: scalar("MinAutoBodySpeed")?, + max_spin_speed_428: scalar("MaxSpinSpeed")?, + frames_for_grind_air_assist_436: scalar("FramesForGrindAirAssist")?, + body_flip_min_grab_time_fraction_456: scalar("BodyFlipMinGrabTimeFraction")?, + flip_scalar: data.float("physics_reckoning", "default", "FlipScalar")?, + }; + let mut modes = Vec::with_capacity(5); + for key in ["easy", "normal", "hardcore", "motorized", "test"] { + modes.push(KnownAirModeSettings { + upside_down_falling_wipeout_enabled_2: data.boolean( + "physics_mode", + key, + "Hash_2B913C786BEFF4CD", + )?, + perfect_body_flips_28: data.boolean("physics_mode", key, "PerfectBodyFlips")?, + body_spin_speed_limit_56: data.float("physics_mode", key, "MaxAutoBodySpinSpeed")?, + }); + } + Ok(( + settings, + modes + .try_into() + .map_err(|_| "Expected five physics modes")?, + )) +} diff --git a/crates/skate-game/src/physics/landing_quality.rs b/crates/skate-game/src/physics/landing_quality.rs new file mode 100644 index 00000000..11b7657f --- /dev/null +++ b/crates/skate-game/src/physics/landing_quality.rs @@ -0,0 +1,17 @@ +//! Stock PhysicsAnimation cache +560/+576 and +592/+608, TU3 82DE6480. +use skate_core::{animation::landing_quality::Settings, point_graph::PointGraph}; +use skate_data::collections::Collections; + +pub(crate) fn load(data: &Collections) -> Result { + let graph = |name| -> Result, String> { + let words = data.words::<8>("physics_animation", "default", name)?; + Ok(PointGraph { + x: std::array::from_fn(|i| f32::from_bits(words[i])), + y: std::array::from_fn(|i| f32::from_bits(words[4 + i])), + }) + }; + Ok(Settings { + twist_spin: graph("LandingSketchyTwistSpin")?, + side_speed: graph("LandingSketchySideSpeed")?, + }) +} diff --git a/crates/skate-game/src/physics/offboard/ground_query.rs b/crates/skate-game/src/physics/offboard/ground_query.rs new file mode 100644 index 00000000..74038c2d --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query.rs @@ -0,0 +1,83 @@ +//! Borrowed scene adapter for original Biped query82764AB0/82770650/82C1EAD8. +//! All geometry and live transforms belong to the canonical collision scene. +mod edges; +mod lines; +mod transform; +mod world; +pub use world::with_world_scene; +use skate_core::{ + math::Vector3, + physics::board_world::WorldTriangle, + player::offboard::ground_query::{ + Edge, EdgeSearch, Frame, GroundQueryPacket, GroundQueryScene, LineHit, + }, +}; +#[derive(Clone, Copy, Debug)] +pub struct Bounds { + pub min: Vector3, + pub max: Vector3, +} +/// A borrowed mesh from one canonical source pool, in canonical mesh order. +pub struct Mesh<'a> { + pub local_to_world: Frame, + pub world_to_local: Frame, + pub local_bounds: Bounds, + pub matching_group: i32, + pub triangles: &'a [WorldTriangle], + /// Exact packed source surface, one entry per triangle; never inferred from tag. + pub surfaces: &'a [u16], +} +#[derive(Clone, Copy)] +pub struct Segment { + pub edge: Edge, + pub local_bounds: Bounds, +} +pub struct EdgeBody<'a> { + pub local_to_world: Frame, + pub local_bounds: Bounds, + pub segments: &'a [Segment], +} +pub struct AlternateRecord<'a> { + pub enabled: bool, + /// Original record44 and48, selected by context2948 bit1. + pub choices: [Option<(&'a EdgeBody<'a>, i32)>; 2], +} +pub enum PrimaryEdges<'a> { + /// Original826C0630: dynamic provider, then vehicle provider. + Normal { + dynamic: &'a [EdgeBody<'a>], + vehicles: &'a [EdgeBody<'a>], + }, + Alternate(&'a [AlternateRecord<'a>]), +} +pub struct IndexedEdgeBody<'a> { + pub id: u32, + pub disabled: bool, + pub body: &'a EdgeBody<'a>, +} +/// Ephemeral view only: owns no collision geometry, bodies, or cached observations. +pub struct Scene<'a> { + pub ground_pool: &'a [Mesh<'a>], + pub island_pool: &'a [Mesh<'a>], + pub conditional_pool: &'a [Mesh<'a>], + pub island_flags: u32, + pub static_edges: &'a [Segment], + pub primary_edges: PrimaryEdges<'a>, + pub indexed_edges: &'a [IndexedEdgeBody<'a>], +} +impl GroundQueryScene for Scene<'_> { + type Error = &'static str; + fn edge_candidates(&mut self, search: &EdgeSearch) -> Result, Self::Error> { + Ok(edges::candidates(self, search)) + } + fn query_lines( + &mut self, + packet: &GroundQueryPacket, + ) -> Result<[Option; 7], Self::Error> { + lines::query(self, packet) + } +} + +#[cfg(test)] +mod tests; + diff --git a/crates/skate-game/src/physics/offboard/ground_query/edges.rs b/crates/skate-game/src/physics/offboard/ground_query/edges.rs new file mode 100644 index 00000000..80f43386 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/edges.rs @@ -0,0 +1,71 @@ +use super::{Bounds, EdgeBody, PrimaryEdges, Scene, Segment, transform}; +use skate_core::player::offboard::ground_query::{Edge, EdgeSearch, Frame}; +fn append(output: &mut Vec, segments: &[Segment], frame: Frame, query: Bounds) { + for segment in segments { + if output.len() == 40 { + break; + } + if transform::overlaps(transform::bounds(frame, segment.local_bounds), query) { + output.push(Edge { + start: transform::point(frame, segment.edge.start), + end: transform::point(frame, segment.edge.end), + }); + } + } +} +fn body(output: &mut Vec, body: &EdgeBody<'_>, query: Bounds) { + if transform::overlaps( + transform::bounds(body.local_to_world, body.local_bounds), + query, + ) { + append(output, body.segments, body.local_to_world, query); + } +} +pub(super) fn candidates(scene: &Scene<'_>, search: &EdgeSearch) -> Vec { + let query = Bounds { + min: search.min, + max: search.max, + }; + let mut output = Vec::with_capacity(40); + append(&mut output, scene.static_edges, Frame::IDENTITY, query); + match &scene.primary_edges { + PrimaryEdges::Normal { dynamic, vehicles } => { + let center = transform::scale(transform::add(query.min, query.max), 0.5); + for provider in [*dynamic, *vehicles] { + for entry in provider { + let d = transform::sub(entry.local_to_world.position, center); + let squared = d.z.mul_add(d.z, d.y.mul_add(d.y, d.x * d.x)); + if squared >= 225. { + continue; + } + //82C4C4E0 checks individual segment bounds, no asset bound gate. + append(&mut output, entry.segments, entry.local_to_world, query); + if output.len() == 40 { + break; + } + } + } + } + PrimaryEdges::Alternate(records) => { + for record in *records { + if !record.enabled { + continue; + } + let choice = usize::from(search.context.selection_flags_2948 & 2 != 0); + if let Some((entry, group)) = record.choices[choice] { + if transform::matches(search.context.matching_id_2952, group) { + body(&mut output, entry, query); + } + } + } + } + } + let mut ordered: Vec<_> = scene.indexed_edges.iter().collect(); + ordered.sort_by_key(|entry| entry.id); + for entry in ordered { + if !entry.disabled { + body(&mut output, entry.body, query); + } + } + output +} diff --git a/crates/skate-game/src/physics/offboard/ground_query/lines.rs b/crates/skate-game/src/physics/offboard/ground_query/lines.rs new file mode 100644 index 00000000..d1b7478c --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/lines.rs @@ -0,0 +1,99 @@ +use super::{ + Bounds, Scene, + transform::{self, sub}, +}; +use skate_core::{ + math::Vector3, + physics::triangle_query::{TriangleLineHit, triangle_segment}, + player::offboard::ground_query::{GroundQueryPacket, LineHit}, +}; +pub(super) fn query( + scene: &Scene<'_>, + packet: &GroundQueryPacket, +) -> Result<[Option; 7], &'static str> { + let pools = [ + scene.ground_pool, + scene.island_pool, + if scene.island_flags == 3 { + scene.conditional_pool + } else { + &[] + }, + ]; + for pool in pools { + for mesh in pool { + if mesh.triangles.len() != mesh.surfaces.len() { + return Err("Biped query mesh lacks exact per-triangle packed surfaces"); + } + } + } + let mut output = [None; 7]; + for (index, line) in packet.lines.iter().enumerate() { + if !line.radius.is_finite() || line.radius < 0. { + return Err("Invalid Biped line radius"); + } + let delta = sub(line.end, line.start); + let threshold = f32::from_bits(0x37800000); + if !(delta.x.abs() > threshold || delta.y.abs() > threshold || delta.z.abs() > threshold) { + continue; + } + let low = |a: f32, b: f32| a.min(b) - line.radius; + let high = |a: f32, b: f32| a.max(b) + line.radius; + let bounds = Bounds { + min: Vector3::new( + low(line.start.x, line.end.x), + low(line.start.y, line.end.y), + low(line.start.z, line.end.z), + ), + max: Vector3::new( + high(line.start.x, line.end.x), + high(line.start.y, line.end.y), + high(line.start.z, line.end.z), + ), + }; + let mut nearest = f32::MAX; + for pool in pools { + for mesh in pool { + if !transform::matches(packet.context.matching_id_2952, mesh.matching_group) + || !transform::overlaps( + transform::bounds(mesh.world_to_local, bounds), + mesh.local_bounds, + ) + { + continue; + } + for (triangle, &packed_surface) in mesh.triangles.iter().zip(mesh.surfaces) { + let vertices = triangle + .triangle + .vertices + .map(|p| transform::point(mesh.local_to_world, p)); + let mut hit = TriangleLineHit { + position: Vector3::ZERO, + normal: Vector3::ZERO, + fraction: 0., + volume_parameter: [0.; 3], + }; + if triangle_segment(&mut hit, line.start, delta, vertices, line.radius, 0.) { + // Native outer leaf uses fsel, including its unordered choice. + let lower = if -hit.fraction >= 0. { + 0. + } else { + hit.fraction + }; + let fraction = if 1. - lower >= 0. { lower } else { 1. }; + if fraction < nearest { + nearest = fraction; + output[index] = Some(LineHit { + position: hit.position, + face_normal: transform::face(vertices), + fraction, + packed_surface, + }); + } + } + } + } + } + } + Ok(output) +} diff --git a/crates/skate-game/src/physics/offboard/ground_query/tests.rs b/crates/skate-game/src/physics/offboard/ground_query/tests.rs new file mode 100644 index 00000000..143bb48d --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/tests.rs @@ -0,0 +1,132 @@ +use super::*; +use skate_core::player::offboard::ground_query::QueryContext; +#[test] +fn indexed_edges_sort_ids_and_preserve_shared_capacity() { + let bounds = Bounds { + min: Vector3::new(-1., -1., -1.), + max: Vector3::new(1., 1., 1.), + }; + let segments = [Segment { + edge: Edge { + start: Vector3::ZERO, + end: Vector3::new(1., 0., 0.), + }, + local_bounds: bounds, + }]; + let first = EdgeBody { + local_to_world: Frame::IDENTITY, + local_bounds: bounds, + segments: &segments, + }; + let second = EdgeBody { + local_to_world: Frame { + position: Vector3::new(0., 0.25, 0.), + ..Frame::IDENTITY + }, + local_bounds: bounds, + segments: &segments, + }; + let entries = [ + IndexedEdgeBody { + id: 9, + disabled: false, + body: &first, + }, + IndexedEdgeBody { + id: 2, + disabled: false, + body: &second, + }, + ]; + let static_edges = vec![segments[0]; 39]; + let mut scene = Scene { + ground_pool: &[], + island_pool: &[], + conditional_pool: &[], + island_flags: 0, + static_edges: &static_edges, + primary_edges: PrimaryEdges::Normal { + dynamic: &[], + vehicles: &[], + }, + indexed_edges: &entries, + }; + let search = EdgeSearch { + min: bounds.min, + max: bounds.max, + frame: Frame::IDENTITY, + context: QueryContext { + selection_flags_2948: 0, + matching_id_2952: -1, + }, + narrow_forward: false, + }; + let result = scene.edge_candidates(&search).unwrap(); + assert_eq!(result.len(), 40); + assert_eq!(result[39].start.y, 0.25); +} +#[test] +fn decoded_face_uses_world_vertices_and_inclusive_bounds() { + let face = transform::face([ + Vector3::ZERO, + Vector3::new(0., 0., 2.), + Vector3::new(3., 0., 0.), + ]); + assert!((face.y - 1.).abs() < 1e-6); + let a = Bounds { + min: Vector3::ZERO, + max: Vector3::new(1., 1., 1.), + }; + let b = Bounds { + min: Vector3::new(1., 1., 1.), + max: Vector3::new(2., 2., 2.), + }; + assert!(transform::overlaps(a, b)); + assert!(transform::matches(-1, 3)); + assert!(!transform::matches(2, 3)); +} + +fn triangle(y:f32)->WorldTriangle { + use skate_core::physics::contact::RetailContactMaterial; + WorldTriangle::from_vertices([Vector3::new(-2.,y,-2.),Vector3::new(-2.,y,2.),Vector3::new(2.,y,-2.)], + RetailContactMaterial {static_friction:0.,dynamic_friction:0.,restitution:0.},123,0,[0.;3],0.).unwrap() +} +fn packet()->GroundQueryPacket { + use skate_core::player::offboard::ground_query::Line; + GroundQueryPacket {context:QueryContext {selection_flags_2948:0,matching_id_2952:7},center:Vector3::ZERO, + up:Vector3::new(0.,1.,0.),tangent:Vector3::new(0.,0.,1.), + lines:[Line {start:Vector3::new(-0.5,2.,-0.5),end:Vector3::new(-0.5,-2.,-0.5),radius:0.};7]} +} +fn mesh<'a>(triangles:&'a[WorldTriangle],surfaces:&'a[u16],matching_group:i32)->Mesh<'a> { + Mesh {triangles,surfaces,matching_group,local_to_world:Frame::IDENTITY,world_to_local:Frame::IDENTITY, + local_bounds:Bounds {min:Vector3::new(-2.,-2.,-2.),max:Vector3::new(2.,2.,2.)}} +} +#[test] +fn actual_triangle_queries_preserve_pool_ties_group_filter_and_conditional_gate() { + let triangles=[triangle(0.)];let upper=[triangle(1.)]; + let ground=[mesh(&triangles,&[11],7)];let island=[mesh(&triangles,&[22],-1)]; + let conditional=[mesh(&upper,&[33],7)]; + let mut scene=Scene {ground_pool:&ground,island_pool:&island,conditional_pool:&conditional,island_flags:0, + static_edges:&[],primary_edges:PrimaryEdges::Normal {dynamic:&[],vehicles:&[]},indexed_edges:&[]}; + let p=packet();let hit=scene.query_lines(&p).unwrap()[0].unwrap(); + assert_eq!(hit.packed_surface,11);assert_eq!(hit.fraction,0.5);assert!(hit.face_normal.y>0.99); + let mut different=p;different.context.matching_id_2952=8; + assert_eq!(scene.query_lines(&different).unwrap()[0].unwrap().packed_surface,22); + scene.island_flags=3; + assert_eq!(scene.query_lines(&p).unwrap()[0].unwrap().packed_surface,33); +} +#[test] +fn canonical_world_bridge_requires_metadata_and_queries_authored_surfaces() { + use skate_core::physics::{board_world::{BoardWorld,query_metadata::{Bounds as WorldBounds,QueryMesh,QueryMetadata,QueryPool}},drive_frames::RetailAffineTransform}; + let unannotated=BoardWorld::new(vec![triangle(0.)]); + let empty=||PrimaryEdges::Normal {dynamic:&[],vehicles:&[]}; + assert!(with_world_scene(&unannotated,empty(),&[],|scene|scene.query_lines(&packet())).is_err()); + let world=BoardWorld::with_query_metadata(vec![triangle(0.)],QueryMetadata { + packed_surfaces:vec![0x400],island_flags:0,static_edges:vec![],meshes:vec![QueryMesh { + triangle_range:0..1,local_to_world:RetailAffineTransform::IDENTITY,world_to_local:RetailAffineTransform::IDENTITY, + local_bounds:WorldBounds {min:Vector3::new(-2.,0.,-2.),max:Vector3::new(2.,0.,2.)},matching_group:7,pool:QueryPool::Ground, + }], + }).unwrap(); + let hits=with_world_scene(&world,empty(),&[],|scene|scene.query_lines(&packet())).unwrap(); + assert_eq!(hits[0].unwrap().packed_surface,0x400); +} diff --git a/crates/skate-game/src/physics/offboard/ground_query/transform.rs b/crates/skate-game/src/physics/offboard/ground_query/transform.rs new file mode 100644 index 00000000..b966b594 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/transform.rs @@ -0,0 +1,66 @@ +use super::Bounds; +use skate_core::{math::Vector3, player::offboard::ground_query::Frame}; +pub(super) fn add(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x + b.x, a.y + b.y, a.z + b.z) +} +pub(super) fn sub(a: Vector3, b: Vector3) -> Vector3 { + Vector3::new(a.x - b.x, a.y - b.y, a.z - b.z) +} +pub(super) fn scale(a: Vector3, s: f32) -> Vector3 { + Vector3::new(a.x * s, a.y * s, a.z * s) +} +fn madd(a: Vector3, s: f32, b: Vector3) -> Vector3 { + Vector3::new( + a.x.mul_add(s, b.x), + a.y.mul_add(s, b.y), + a.z.mul_add(s, b.z), + ) +} +pub(super) fn point(f: Frame, p: Vector3) -> Vector3 { + madd( + f.forward, + p.z, + madd(f.up, p.y, madd(f.right, p.x, f.position)), + ) +} +pub(super) fn bounds(f: Frame, b: Bounds) -> Bounds { + let c = point(f, scale(add(b.max, b.min), 0.5)); + let e = scale(sub(b.max, b.min), 0.5); + let abs = |v: Vector3| Vector3::new(v.x.abs(), v.y.abs(), v.z.abs()); + let e = madd( + abs(f.forward), + e.z, + madd(abs(f.up), e.y, scale(abs(f.right), e.x)), + ); + Bounds { + min: sub(c, e), + max: add(c, e), + } +} +pub(super) fn overlaps(a: Bounds, b: Bounds) -> bool { + a.min.x <= b.max.x + && a.max.x >= b.min.x + && a.min.y <= b.max.y + && a.max.y >= b.min.y + && a.min.z <= b.max.z + && a.max.z >= b.min.z +} +pub(super) fn matches(a: i32, b: i32) -> bool { + a == -1 || b == -1 || a == b +} +//82ACA4E0..550: face is formed from decoded WORLD vertices and refined twice. +pub(super) fn face([a, b, c]: [Vector3; 3]) -> Vector3 { + let u = sub(b, a); + let v = sub(c, a); + let n = Vector3::new( + (-u.z).mul_add(v.y, u.y * v.z), + (-u.x).mul_add(v.z, u.z * v.x), + (-u.y).mul_add(v.x, u.x * v.y), + ); + let q = n.z.mul_add(n.z, n.y.mul_add(n.y, n.x * n.x)); + let mut r = 1.0 / q.sqrt(); + for _ in 0..2 { + r = (r * 0.5).mul_add((-q).mul_add(r * r, 1.0), r); + } + scale(n, r) +} diff --git a/crates/skate-game/src/physics/offboard/ground_query/world.rs b/crates/skate-game/src/physics/offboard/ground_query/world.rs new file mode 100644 index 00000000..bf07042e --- /dev/null +++ b/crates/skate-game/src/physics/offboard/ground_query/world.rs @@ -0,0 +1,36 @@ +//! Ephemeral adapter over the canonical BoardWorld metadata. Geometry is borrowed. +use super::{Bounds,Frame,Mesh,PrimaryEdges,Scene,Segment,IndexedEdgeBody}; +use skate_core::{math::Vector3,physics::{board_world::{BoardWorld,query_metadata::QueryPool},drive_frames::RetailAffineTransform},player::offboard::ground_query::Edge}; + +/// Caller supplies actual dynamic-provider views from the same live scene owner. +/// Empty providers mean the authored scene actually has none, never a failed lookup. +pub fn with_world_scene( + world:&BoardWorld, + primary_edges:PrimaryEdges<'_>, + indexed_edges:&[IndexedEdgeBody<'_>], + operation:impl FnOnce(&mut Scene<'_>)->Result, +)->Result { + let metadata=world.query_metadata()?; + let triangles=world.triangles(); + let mut pools:[Vec>;3]=std::array::from_fn(|_|Vec::new()); + for source in &metadata.meshes { + let index=match source.pool {QueryPool::Ground=>0,QueryPool::Island=>1,QueryPool::Conditional=>2}; + pools[index].push(Mesh { + local_to_world:frame(source.local_to_world),world_to_local:frame(source.world_to_local), + local_bounds:Bounds {min:source.local_bounds.min,max:source.local_bounds.max}, + matching_group:source.matching_group, + triangles:&triangles[source.triangle_range.clone()], + surfaces:&metadata.packed_surfaces[source.triangle_range.clone()], + }); + } + let edges:Vec<_>=metadata.static_edges.iter().map(|s|Segment { + edge:Edge {start:s.start,end:s.end},local_bounds:Bounds {min:s.local_bounds.min,max:s.local_bounds.max}, + }).collect(); + let mut scene=Scene {ground_pool:&pools[0],island_pool:&pools[1],conditional_pool:&pools[2], + island_flags:metadata.island_flags,static_edges:&edges,primary_edges,indexed_edges}; + operation(&mut scene) +} +fn frame(f:RetailAffineTransform)->Frame { + let v=|a:[f32;3]|Vector3::new(a[0],a[1],a[2]); + Frame {right:v(f.basis.columns[0]),up:v(f.basis.columns[1]),forward:v(f.basis.columns[2]),position:f.translation} +} diff --git a/crates/skate-game/src/physics/offboard/mod.rs b/crates/skate-game/src/physics/offboard/mod.rs new file mode 100644 index 00000000..ced539f5 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/mod.rs @@ -0,0 +1,3 @@ +//! Off-board game adapters over the shared physical and scene owners. +pub(crate) mod ground_query; +pub(crate) mod pose_adjust; diff --git a/crates/skate-game/src/physics/offboard/pose_adjust.rs b/crates/skate-game/src/physics/offboard/pose_adjust.rs new file mode 100644 index 00000000..6b660786 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/pose_adjust.rs @@ -0,0 +1,30 @@ +//! Original82BD8F10 writes the existing AnimatedSkeleton board-offset owner. +use crate::physics::animated_skeleton::AnimatedSkeleton; +use skate_core::{ + physics::skeleton_animation_record::AnimationPartTransform, + player::{input_phase::ProcessedPhysicsInput, offboard::pose_adjust}, +}; +/// Reparented indices are the existing target_bones[2] and[3] loaded by82BED688. +/// No state is changed when category500/state503 eligibility is false. +pub(crate) fn update( + animated: &mut AnimatedSkeleton, + globals: &[AnimationPartTransform], + reparented_hands: [usize; 2], + processed: &ProcessedPhysicsInput, +) -> Result<(), String> { + if processed.category_2512 != 500 || processed.state_2508 == 503 { + return Ok(()); + } + let hand = pose_adjust::selected_hand(processed.flags_2476); + let actual_index = animated.bone_indices[[3, 7][hand]]; + let actual = globals + .get(actual_index) + .ok_or("Off-board pose missing actual animation hand")?; + let reparented = globals + .get(reparented_hands[hand]) + .ok_or("Off-board pose missing board-parented animation hand")?; + animated + .board_offset + .refresh_transform(pose_adjust::adjustment(actual, reparented)); + Ok(()) +} diff --git a/crates/skate-game/src/physics/offboard/settings.rs b/crates/skate-game/src/physics/offboard/settings.rs new file mode 100644 index 00000000..7d6458c4 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings.rs @@ -0,0 +1,67 @@ +//! Stock Biped settings82D7B630 and metric queries82D7AFD8/82D16680. +mod board; +mod curves; +mod metrics; +#[cfg(test)] +mod tests; +use skate_core::{ + player::offboard::{controller, movement_intent, movement_velocity}, + point_graph::PointGraph, +}; +use skate_data::{animation_metadata::AnimationMetadata, collections::Collections}; + +pub(crate) struct Settings { + pub controller: controller::Settings, + pub board: skate_core::player::offboard::ground_sync::BoardSettings, + pub metrics: [Option; 3], + ///82D310F8 full key DF759B46440F16E9. + pub movement_vs_stick_angle: PointGraph<8>, + ///82D310F8 full key2DD95B399BAE313E. + pub turn_vs_stick_angle: PointGraph<8>, +} +impl Settings { + /// Share the same native ABIN metadata loaded for SkaterAnimation. Required + /// stock fields/banks/clips fail loading; an absent matching attribute is None. + pub(crate) fn load(data: &Collections, metadata: &AnimationMetadata) -> Result { + let biped = |name| curves::load::<8>(data, "physics_biped", name); + let (sprint_blend, bounds) = biped("Hash_6B93C51256A30FB4")?; + Ok(Self { + controller: controller::Settings { + movement_intent: movement_intent::Settings { + sprint_speed: curves::load::<4>( + data, + "physics_biped", + "Hash_7209DCFDF3015EBF", + )? + .0, + normal_speed: curves::load::<16>(data, "physics_biped", "SpeedVsInput")?.0, + sprint_blend, + sprint_time_cap: bounds[2], + slide_steering: biped("AutoTurnVsAngle")?.0, + }, + movement_velocity: movement_velocity::Settings { + slope_speed_scalar: biped("Hash_31309236050A8F09")?.0, + slope_mode_speed: biped("Hash_CE45C724B30F9134")?.0, + turn_vs_speed: biped("TurnVsSpeed")?.0, + turn_delta_vs_speed: biped("TurnDeltaVsSpeed")?.0, + }, + slide_vs_slope: biped("SlideVsSlope")?.0, + slide_vs_speed: biped("SlideVsSpeed")?.0, + }, + metrics: metrics::load(metadata)?, + board: board::load(data)?, + movement_vs_stick_angle: curves::load::<8>( + data, + "physics_state_offboard", + "Hash_DF759B46440F16E9", + )? + .0, + turn_vs_stick_angle: curves::load::<8>( + data, + "physics_state_offboard", + "TurnVsStickAngle", + )? + .0, + }) + } +} diff --git a/crates/skate-game/src/physics/offboard/settings/board.rs b/crates/skate-game/src/physics/offboard/settings/board.rs new file mode 100644 index 00000000..e73c4fe6 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings/board.rs @@ -0,0 +1,31 @@ +//! Ground ctor82D305D8 passes state36 to82B97060, whose full64 class/key +//! are492026E0F096F283/D7EDBD362D7D2152 (physics_state_offboard/default). +//!82B6CC78 stores the returned collection layout at wrapper4 = state40. +use skate_core::player::offboard::ground_sync::BoardSettings; +use skate_data::collections::Collections; +pub(super) fn load(data: &Collections) -> Result { + let float = |name| data.float("physics_state_offboard", "default", name); + let vector = |name| -> Result<[f32; 4], String> { + let field = data.field("physics_state_offboard", "default", name)?; + if field.type_name != "Math::Vector3" { + return Err(format!("{name}: expected stock Vector3")); + } + let value = data + .words::<4>("physics_state_offboard", "default", name)? + .map(f32::from_bits); + if value.iter().any(|v| !v.is_finite()) { + return Err(format!("{name}: non-finite vector")); + } + Ok(value) + }; + Ok(BoardSettings { + extent_0: vector("GrabBoxSizeGrabbing")?, + extent_16: vector("GrabBoxSize")?, + offset_32: vector("GrabBoxOffset")?, + angle_436: float("GrabSplineMaxAngleToHorizontalGrabbing")?, + angle_440: float("GrabSplineMaxAngleToHorizontal")?, + margin_444: float("GrabSplineEndExclusion")?, + angle_452: float("GrabSplineAngleLimitGrabbing")?, + angle_456: float("GrabSplineAngleLimit")?, + }) +} diff --git a/crates/skate-game/src/physics/offboard/settings/curves.rs b/crates/skate-game/src/physics/offboard/settings/curves.rs new file mode 100644 index 00000000..80bc35b8 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings/curves.rs @@ -0,0 +1,41 @@ +//! PointNegGraphData has four bounds followed by N X values and N Y values. +use skate_core::point_graph::PointGraph; +use skate_data::collections::Collections; +pub(super) fn load( + data: &Collections, + class: &str, + name: &str, +) -> Result<(PointGraph, [f32; 4]), String> { + let field = data.field(class, "default", name)?; + if field.type_name != format!("Sk8::PointNegGraphData{N}") { + return Err(format!( + "Expected PointNegGraphData{N} at {class}/default/{name}" + )); + } + parse::(&field.data).map_err(|e| format!("{class}/default/{name}: {e}")) +} +pub(super) fn parse(text: &str) -> Result<(PointGraph, [f32; 4]), String> { + let hex: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + if N == 0 || !hex.is_ascii() || hex.len() != (4 + 2 * N) * 8 { + return Err("Invalid native graph payload length".into()); + } + let values = (0..4 + 2 * N) + .map(|i| { + u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16) + .map(f32::from_bits) + .map_err(|e| e.to_string()) + }) + .collect::, _>>()?; + if values.iter().any(|v| !v.is_finite()) { + return Err("Non-finite native graph value".into()); + } + let graph = PointGraph { + x: std::array::from_fn(|i| values[4 + i]), + y: std::array::from_fn(|i| values[4 + N + i]), + }; + // Duplicate authored X knots are valid and retained in source order. + if graph.x.windows(2).any(|p| p[0] > p[1]) { + return Err("Native graph X values are not ordered".into()); + } + Ok((graph, std::array::from_fn(|i| values[i]))) +} diff --git a/crates/skate-game/src/physics/offboard/settings/metrics.rs b/crates/skate-game/src/physics/offboard/settings/metrics.rs new file mode 100644 index 00000000..693cafab --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings/metrics.rs @@ -0,0 +1,45 @@ +//!82D16680 uses first matching record, status4, length=frames/fps. +//!82D164F8 multiplies the authored end by that length, including end=-1. +use skate_core::player::offboard::controller::ClipMetric; +use skate_data::animation_metadata::{AnimationMetadata, ClipMetadata}; +const CLIPS: [&str; 3] = ["NB_WALK_FWD_CYC", "NB_RUN_FWD_CYC", "NB_SPRINT_FWD_CYC"]; +pub(super) fn load(metadata: &AnimationMetadata) -> Result<[Option; 3], String> { + let one = |name| -> Result, String> { + let clip = metadata.clip(name)?; + let source = metadata + .source_for(name) + .ok_or_else(|| format!("Missing bank identity for {name}"))?; + if !source.source_bank.eq_ignore_ascii_case("OffBoard.abin") { + return Err(format!("{name}: expected actual OffBoard.abin source")); + } + metric(clip) + }; + Ok([one(CLIPS[0])?, one(CLIPS[1])?, one(CLIPS[2])?]) +} +pub(super) fn metric(clip: &ClipMetadata) -> Result, String> { + let Some(attribute) = clip.attributes.iter().find(|a| a.name == "ANIMTRANSZ") else { + return Ok(None); + }; + // All three actual stock records are scalar. Reject unsupported encodings + // instead of reading curve metadata or an unwritten native payload as a value. + if attribute.type_id != 0 { + return Err(format!( + "{}: AnimTransZ expected scalar stock record", + clip.name + )); + } + let payload = attribute + .payload_words + .first() + .ok_or_else(|| format!("{}: truncated AnimTransZ", clip.name))?; + let translation_z = f32::from_bits(*payload); + let length = f32::from_bits(clip.frames_bits) / f32::from_bits(clip.fps_bits); + let end_time = f32::from_bits(attribute.end_bits) * length; + if !translation_z.is_finite() || !end_time.is_finite() || end_time == 0.0 { + return Err(format!("{}: invalid AnimTransZ metric", clip.name)); + } + Ok(Some(ClipMetric { + translation_z, + end_time, + })) +} diff --git a/crates/skate-game/src/physics/offboard/settings/tests.rs b/crates/skate-game/src/physics/offboard/settings/tests.rs new file mode 100644 index 00000000..df156b07 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/settings/tests.rs @@ -0,0 +1,88 @@ +use super::{Settings, curves, metrics}; +use skate_data::animation_metadata::{ClipAttribute, ClipMetadata}; +fn clip() -> ClipMetadata { + ClipMetadata { + name: "NB_WALK_FWD_CYC".into(), + source_offset: 48, + fps_bits: 60.0f32.to_bits(), + frames_bits: 57.0f32.to_bits(), + base_speed_bits: 3.0f32.to_bits(), + flags_word: 0, + attributes: vec![ClipAttribute { + name: "ANIMTRANSZ".into(), + type_id: 0, + begin_bits: (-1.0f32).to_bits(), + end_bits: (-1.0f32).to_bits(), + payload_words: vec![1.5f32.to_bits()], + source_offset: 128, + }], + } +} +#[test] +fn query_uses_full_frame_duration_and_negative_untimed_end() { + let metric = metrics::metric(&clip()).unwrap().unwrap(); + assert_eq!(metric.end_time, -0.95); + assert_eq!(metric.translation_z, 1.5); + assert!((-metric.translation_z / metric.end_time - 1.5789474).abs() < 1e-6); +} +#[test] +fn first_matching_record_wins_without_a_playback_status_filter() { + let mut clip = clip(); + clip.attributes[0].begin_bits = 0.8f32.to_bits(); + clip.attributes[0].end_bits = 0.5f32.to_bits(); + let mut later = ClipAttribute { + name: "ANIMTRANSZ".into(), + type_id: 0, + begin_bits: 0, + end_bits: 1.0f32.to_bits(), + payload_words: vec![99.0f32.to_bits()], + source_offset: 192, + }; + later.begin_bits = 0; + clip.attributes.push(later); + let metric = metrics::metric(&clip).unwrap().unwrap(); + assert_eq!(metric.translation_z, 1.5); + assert_eq!(metric.end_time, 0.475); +} +#[test] +fn actual_attribute_absence_is_distinct_from_malformed_data() { + let mut clip = clip(); + clip.attributes[0].type_id = 2; + assert!(metrics::metric(&clip).is_err()); + clip.attributes.clear(); + assert!(metrics::metric(&clip).unwrap().is_none()); +} +#[test] +fn graph_bounds_are_separate_and_duplicate_knots_preserve_endpoint_jump() { + let values = [-9.0f32, -8.0, 7.0, 6.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0]; + let hex = values + .iter() + .map(|v| format!("{:08X}", v.to_bits())) + .collect::(); + let (g, bounds) = curves::parse::<3>(&hex).unwrap(); + assert_eq!(bounds, [-9.0, -8.0, 7.0, 6.0]); + assert_eq!(g.evaluate(-0.01), 2.0); + assert_eq!(g.evaluate(0.0), 3.0); + assert_eq!(g.evaluate(0.5), 3.5); + assert_eq!(g.evaluate(1.0), 4.0); + assert!(curves::parse::<4>(&hex).is_err()); +} +#[test] +#[ignore = "requires extracted private stock assets"] +fn actual_stock_settings_and_metrics_load_together() { + let root = + std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT")); + let collections = skate_data::collections::Collections::load(&root).unwrap(); + let metadata = skate_data::animation_banks::AnimationBanks::load(&root) + .unwrap() + .metadata() + .unwrap(); + let settings = Settings::load(&collections, &metadata).unwrap(); + assert!(settings.metrics.iter().all(Option::is_some)); + let speeds = settings.metrics.map(|m| { + let m = m.unwrap(); + (-1.0 / m.end_time) * m.translation_z + }); + assert!(speeds[0] > 0.0 && speeds[0] < speeds[1] && speeds[1] < speeds[2]); + assert_eq!(settings.controller.movement_intent.sprint_time_cap, 4.0); +} diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground.rs b/crates/skate-game/src/physics/offboard/skeleton_ground.rs new file mode 100644 index 00000000..e32c1e4a --- /dev/null +++ b/crates/skate-game/src/physics/offboard/skeleton_ground.rs @@ -0,0 +1,81 @@ +//! Original Biped Skeleton update82BDE060 with the existing physical owners. +mod frames; +use crate::physics::{ + skeleton_air::SkeletonAir, + skeleton_input_runtime::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}, +}; +use skate_core::{ + animation::output::NativeMatrix, + physics::{ + board_runtime::BoardRuntime, rigid_body::RetailSimulationStep, + skeleton_animation_record::AnimationPartTransform as Transform, + }, + player::input_phase::ProcessedPhysicsInput, +}; +///Actual Sync input: caller's constructed frame and Biped state1056 COM. +///retained_board_16016 belongs to the canonical SkeletonBoardFrames owner. +pub(crate) struct Input<'a> { + pub world_frame: &'a Transform, + pub centre_of_mass_1056: [f32; 4], +} +///Terminal82D8E3E0 arguments, emitted after GeneralUpdate and trajectory clear. +///The caller invokes the canonical ground_reckoning producer in this callback. +pub(crate) struct ReckoningUpdate { + pub up: [f32; 4], + pub forward: [f32; 4], + pub blend: f32, +} +impl SkeletonInputRuntime { + pub(crate) fn update_biped_ground( + &mut self, + air: &mut SkeletonAir, + board: &mut BoardRuntime, + input: Input<'_>, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + finish_reckoning: F, + ) -> Result + where + F: FnOnce(ReckoningUpdate) -> Result<(), String>, + { + let s = &mut owners.animated; + //82BDEE08 uses animation record6464+16, not the current post-IK drive. + s.board_frames.skate_root = skate_core::physics::skeleton_animation_record::compose_affine( + &s.roots.animation_to_world, + &s.record.pose[0], + ); + //82BDE310 must read the OLD root basis, before82BDE100 replaces it. + s.board_frames.update_com_lift( + &s.roots.animation_to_world, + input.centre_of_mass_1056, + f32::from_bits(0x3e75_c28f), + ); + let target = frames::prepare( + &mut s.roots, + input.world_frame, + &self.drive_frames[0], + &mut s.board_frames.retained_board_16016, + p.flags_2476, + p.flags_2484, + &mut p.flags_2468, + ); + s.board_frames.animation_target = target; + if p.flags_2480 & 0x8000 != 0 || p.flags_2484 & 1 != 0 { + //Reuse the shared Ground/Air history and actual board anchor. + s.board_frames.physical_board = air.apply_board(board, &target, true); + } + self.general_update(p, owners, globals, collision, simulation)?; + owners.animated.finish_ground(); + let root = owners.animated.roots.animation_to_world; + finish_reckoning(ReckoningUpdate { + up: root[1], + forward: root[2], + blend: 0.5, + })?; + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(target) + } +} diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs b/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs new file mode 100644 index 00000000..28ca8b5b --- /dev/null +++ b/crates/skate-game/src/physics/offboard/skeleton_ground/frames.rs @@ -0,0 +1,33 @@ +use skate_core::physics::{ + skeleton_animation_record::{AnimationPartTransform as Transform, compose_affine}, + skeleton_root::SkeletonRootFrames, +}; +pub(super) fn prepare( + roots: &mut SkeletonRootFrames, + world: &Transform, + mapped: &Transform, + retained: &mut Transform, + flags_2476: u32, + flags_2484: u32, + flags_2468: &mut u32, +) -> Transform { + let mut world = *world; + if flags_2476 & 4 != 0 { + //830BD4A0 initializer82F825F0 splats8216DEE0=-1 across all lanes. + for axis in [0, 2] { + for value in &mut world[axis] { + *value *= -1.0; + } + } + } + roots.initialize_heading = true; + roots.reset_initial_alignment(world); + if flags_2484 & 1 == 0 { + *retained = *mapped; + } + *flags_2468 |= 0x80000; + compose_affine(&world, retained) +} +#[cfg(test)] +#[path = "frames_tests.rs"] +mod tests; diff --git a/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs b/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs new file mode 100644 index 00000000..3323ee62 --- /dev/null +++ b/crates/skate-game/src/physics/offboard/skeleton_ground/frames_tests.rs @@ -0,0 +1,21 @@ +use super::*; +use skate_core::physics::skeleton_animation_record::IDENTITY; +#[test] +fn frozen_board_uses_retained_pose_and_flips_complete_axes() { + let mut roots = SkeletonRootFrames::default(); + let mut retained = IDENTITY; + retained[3] = [2., 3., 4., 0.]; + let mut mapped = IDENTITY; + mapped[3] = [9.; 4]; + let mut world = IDENTITY; + world[0][3] = 7.; + world[2][3] = 8.; + let mut flags = 2; + let output = prepare(&mut roots, &world, &mapped, &mut retained, 4, 1, &mut flags); + assert_eq!(retained[3], [2., 3., 4., 0.]); + assert_eq!(roots.animation_to_world[0][3], -7.); + assert_eq!(roots.animation_to_world[2][3], -8.); + assert_eq!(output[3][..3], [-2., 3., -4.]); + assert_eq!(flags, 0x80002); + assert!(roots.initialize_heading); +} diff --git a/crates/skate-game/src/physics/player_input/dynamic_normal.rs b/crates/skate-game/src/physics/player_input/dynamic_normal.rs new file mode 100644 index 00000000..40b454cb --- /dev/null +++ b/crates/skate-game/src/physics/player_input/dynamic_normal.rs @@ -0,0 +1,20 @@ +//! Stock bindings for original82C02388. Hashes were matched to the actual +//! collection names; XML layoutC85F0-C8190 verifies curve offset1120. +use skate_core::{physics::board_dynamic_normal::DynamicNormalSettings, point_graph::PointGraph}; +use skate_data::collections::Collections; +pub(crate) fn settings(data: &Collections) -> Result { + let f = |field| data.float("physics_reckoning", "default", field); + let values = data + .words::<16>("physics_reckoning", "default", "DynamicSpeedMaxDeltaGraphZ")? + .map(f32::from_bits); + Ok(DynamicNormalSettings { + speed_damping: f("DynamicSpeedDamping")?, //AB8F93BA080E0CB3 + up_vector_damping: f("DynamicUpVectorDamping")?, //9F15310DBBE96B8C + maximum_delta: f("DynamicSpeedMaxDelta")?, //30BF4DA4BD7AE00A + speed_scale: f("DynamicSpeedMaxDeltaXScale")?, //20F1ECA637708494 + maximum_delta_vs_speed: PointGraph { + x: values[..8].try_into().unwrap(), + y: values[8..].try_into().unwrap(), + }, + }) +} diff --git a/crates/skate-game/src/physics/player_input/grind.rs b/crates/skate-game/src/physics/player_input/grind.rs new file mode 100644 index 00000000..3d339bdd --- /dev/null +++ b/crates/skate-game/src/physics/player_input/grind.rs @@ -0,0 +1,131 @@ +//! Original82D8A828/82D8ABD8/82D875A8 ordinary empty-edge path. +//! The authored host floor explicitly contains no grind edges. This is an +//! asset declaration, not a missing-loader fallback or an inferred no-hit. +use skate_core::{player::input_phase::ProcessedPhysicsInput, point_graph::PointGraph}; +use skate_data::collections::Collections; +#[derive(Clone, Copy, Debug)] +pub(crate) struct NoGrindEdges; +#[derive(Debug)] +pub(crate) struct GrindInputState { + previous_state: u32, + engagement_counter: u32, + cooldown: u32, + pub disabled: bool, + pub suppressed: bool, + pub elapsed: f32, + pub friction_vs_time: f32, + pub previous_velocity: [u32; 4], + pub grind_history: u32, + pub secondary_history: u32, + pub grounded_frames: u32, + pub air_frames: u32, + pub low_wheel_frames: u32, + previous_candidate: bool, + curve: PointGraph<4>, +} +impl GrindInputState { + pub fn load(data: &Collections) -> Result { + //XML C7220-C70E0=320; native PointGraph X336/Y352 skips header16. + let graph = data + .words::<12>("physics_grinds", "default", "FrictionVsTime")? + .map(f32::from_bits); + //82D8A318 scorer storesC/10/14/18/1C/20 and48 tozero;82D8A638 + //resets main464/468/472/476/477/480/484 and all416outputbytes. + Ok(Self { + previous_state: 0, + engagement_counter: 0, + cooldown: 0, + disabled: false, + suppressed: false, + elapsed: 0., + friction_vs_time: 0., + previous_velocity: [0; 4], + grind_history: 0, + secondary_history: 0, + grounded_frames: 0, + air_frames: 0, + low_wheel_frames: 0, + previous_candidate: false, + curve: PointGraph { + x: graph[4..8].try_into().unwrap(), + y: graph[8..12].try_into().unwrap(), + }, + }) + } + pub fn update( + &mut self, + _world: NoGrindEdges, + p: &ProcessedPhysicsInput, + air_counter: i32, + ) -> Result<(), String> { + let state = p.state_2508; + if state != self.previous_state { + self.engagement_counter = self.engagement_counter.wrapping_add(match state { + 400 | 402 | 404 => 50, + 403 => 20, + _ => 0, + }); + self.previous_state = state; + } + self.engagement_counter = decrement(self.engagement_counter); + self.suppressed = self.engagement_counter > 151; + self.cooldown = if self.suppressed { + 90 + } else { + decrement(self.cooldown) + }; + self.disabled = p.flags_2468 & 0x1800_0000 != 0 + || p.flags_2472 & 0x8008 != 0 + || p.flags_2476 & 0x0040_0000 != 0 + || self.cooldown > 0 + || (p.category_2512 == 200 + && (p.state_timer_2664 <= f32::from_bits(0x3da3_d70a) + || air_counter <= 10)) + || p.category_2512 == 500 + || (p.flags_2472 & 4 != 0 && p.category_2512 != 400); + self.elapsed = if p.category_2512 == 400 || state == 701 { + self.elapsed + p.timestep_2604 + } else { + 0. + }; + //Empty real edge query selects standard board collision in82D89DC0 + //for ordinary ground. An active grind state needs its geometry owner. + if p.category_2512 == 400 || state == 701 { + return Err("Active grind state requires authored grind geometry".into()); + } + self.low_wheel_frames = if self.previous_candidate + && state == 100 + && p.wheel_count_2556 < 2 + && p.scalar_2652 < 0.8 + { + self.low_wheel_frames.wrapping_add(1) + } else { + 0 + }; + self.grind_history = if p.grind_words_2532_2536[1] == 2 { + 70 + } else { + decrement(self.grind_history) + }; + self.secondary_history = decrement(self.secondary_history); + self.grounded_frames = if p.category_2512 == 100 { + self.grounded_frames.wrapping_add(1) + } else { + 0 + }; + self.air_frames = if p.category_2512 == 100 { + 0 + } else { + self.air_frames.wrapping_add(1) + }; + //82D875A8 LABEL80 on edge count0 clears candidate384 and owner32. + self.previous_candidate = false; + self.previous_velocity = p.vectors_400_416[0]; + self.friction_vs_time = self.curve.evaluate(self.elapsed); + Ok(()) + } +} +fn decrement(value: u32) -> u32 { + let next = value.wrapping_sub(1); + if next & 0x8000_0000 != 0 { 0 } else { next } +} diff --git a/crates/skate-game/src/physics/player_input/ground_position.rs b/crates/skate-game/src/physics/player_input/ground_position.rs new file mode 100644 index 00000000..e86121a1 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/ground_position.rs @@ -0,0 +1,70 @@ +//! Ground-position observation from TU3 Skateboard::CalculateGroundPosition +//! 82C02840. The four wheel rigid-body centres are expressed in Reckoning752, +//! averaged laterally/longitudinally, and placed at the lowest wheel centre. +use skate_core::physics::{ + board_runtime::BoardRuntime, skeleton_animation_record::AnimationPartTransform, +}; + +pub(crate) fn ground_position(board: &BoardRuntime, ground: &AnimationPartTransform) -> [u32; 4] { + let wheels = std::array::from_fn(|i| { + let p = board.bodies()[i].rates.position; + [p.x, p.y, p.z, 0.0] + }); + wheel_ground_position(wheels, ground).map(f32::to_bits) +} + +fn wheel_ground_position(wheels: [[f32; 4]; 4], ground: &AnimationPartTransform) -> [f32; 4] { + // Native transpose clears its fourth lane; the source does not subtract + // or re-add the frame translation in this calculation. + let transpose = + std::array::from_fn(|axis| [ground[0][axis], ground[1][axis], ground[2][axis], 0.0]); + let local = wheels.map(|position| rotate(&transpose, position)); + let mut mean = + std::array::from_fn(|i| (((local[0][i] + local[1][i]) + local[2][i]) + local[3][i]) * 0.25); + let first_minimum = if local[1][1] > local[0][1] { + local[0][1] + } else { + local[1][1] + }; + // fsel chooses its negative input on an unordered comparison as well. + let next_minimum = if local[2][1] - first_minimum >= 0.0 { + first_minimum + } else { + local[2][1] + }; + mean[1] = if local[3][1] - next_minimum >= 0.0 { + next_minimum + } else { + local[3][1] + }; + rotate(&[ground[0], ground[1], ground[2]], mean) +} + +fn rotate(basis: &[[f32; 4]; 3], point: [f32; 4]) -> [f32; 4] { + std::array::from_fn(|i| { + let x = basis[0][i] * point[0]; + let xy = basis[1][i].mul_add(point[1], x); + basis[2][i].mul_add(point[2], xy) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn minimum_height_is_measured_in_the_ground_frame() { + let ground = [ + [0.0, 1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [99.0; 4], + ]; + let wheels = [ + [1.0, 2.0, 3.0, 0.0], + [2.0, 4.0, 5.0, 0.0], + [3.0, 6.0, 7.0, 0.0], + [4.0, 8.0, 9.0, 0.0], + ]; + assert_eq!(wheel_ground_position(wheels, &ground), [4.0, 5.0, 6.0, 0.0]); + } +} diff --git a/crates/skate-game/src/physics/player_input/initial.rs b/crates/skate-game/src/physics/player_input/initial.rs new file mode 100644 index 00000000..680f967b --- /dev/null +++ b/crates/skate-game/src/physics/player_input/initial.rs @@ -0,0 +1,28 @@ +//! Original82DB18A0 initialization of the represented input owner. Native +//! opaque allocation flags are replaced by zero-initialized host storage. +use skate_core::player::input_phase::{PlayerInputState, StateVariantFields}; +use skate_data::collections::Collections; +pub(super) fn player(data: &Collections) -> Result { + let mut result = PlayerInputState { + //82DB1A9C inserts E0080000; active human actor adds bit18 at307C. + flags_1296: 0xe00c_0000, + ground_history_frames_1304: 100, + ..PlayerInputState::default() + }; + //82DB19C0: the external packet's ninth vector is the -1 sentinel. + result.external_physics_cache_1008.vectors[8] = [(-1.0f32).to_bits(); 4]; + for (index, key) in ["easy", "normal", "hardcore", "motorized", "test"] + .into_iter() + .enumerate() + { + //Original full64 keys at82DB1B08..1B78; stock XML layout+60. + result.state_variants_1408[index] = StateVariantFields { + surface_override_enabled_60: u8::from(data.boolean( + "physics_mode", + key, + "Hash_5548109D7B0CB70C", + )?), + }; + } + Ok(result) +} diff --git a/crates/skate-game/src/physics/player_input/mod.rs b/crates/skate-game/src/physics/player_input/mod.rs new file mode 100644 index 00000000..1fb71897 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/mod.rs @@ -0,0 +1,166 @@ +//! Concrete original Skate3 player input phase for the authored riding world. +mod dynamic_normal; +mod grind; +mod ground_position; +mod initial; +mod output_reset; +mod pre_input; +mod reset; +mod services; +use super::{ground_runtime::GroundRuntime, riding_outputs::RidingOutputs}; +pub(crate) use grind::NoGrindEdges; +pub(crate) use output_reset::reset_outputs; +pub(crate) use services::{InputHostFrame, PlayerInputCallbacks}; +use skate_core::{ + math::Vector3, + physics::{ + board_dynamic_normal::{BoardDynamicNormal, DynamicNormalSettings}, + board_input_output::{BoardInputFrame, publish_board_input}, + board_runtime::BoardRuntime, + board_toolkit::BoardToolkit, + skeleton_animation_record::AnimationPartTransform, + }, + player::input_phase::{ + self, AnimationInputPacket, PhysicalPlayerInput, PlayerInputState, ProcessedPhysicsInput, + }, +}; +use skate_data::collections::Collections; +pub(crate) enum InputStage { + ThroughTeleport, + AfterTeleport(input_phase::InputContinuation), +} +pub(crate) struct PlayerInputRuntime { + pub player: PlayerInputState, + pub physical: PhysicalPlayerInput, + pub processed: ProcessedPhysicsInput, + pub toolkit: Option, + pub dynamic_normal: BoardDynamicNormal, + normal_settings: DynamicNormalSettings, + pub pre_input: pre_input::PreInputManager, + pub grind: grind::GrindInputState, + pending_teleport: Option, + world_edges: NoGrindEdges, +} +impl PlayerInputRuntime { + pub fn load(data: &Collections, world_edges: NoGrindEdges) -> Result { + let mut physical = PhysicalPlayerInput::default(); + reset_outputs(&mut physical); + let mut processed = ProcessedPhysicsInput::default(); + reset::reset_processed(&mut processed); + Ok(Self { + player: initial::player(data)?, + physical, + processed, + toolkit: None, + dynamic_normal: BoardDynamicNormal::new(), + normal_settings: dynamic_normal::settings(data)?, + pre_input: pre_input::PreInputManager::new(), + grind: grind::GrindInputState::load(data)?, + pending_teleport: None, + world_edges, + }) + } + pub fn request_teleport(&mut self, target: AnimationPartTransform) -> Result<(), String> { + if self.pending_teleport.is_some() { + return Err("A pending teleport must complete before replacement".into()); + } + self.pending_teleport = Some(target); + Ok(()) + } + pub fn pending_teleport(&self) -> Option { + self.pending_teleport + } + ///Run after the current board/contact solve, before ProcessOutput publishes. + pub fn update_dynamic_normal( + &mut self, + board: &BoardRuntime, + riding: &RidingOutputs, + gravity: Vector3, + dt: f32, + ) { + self.dynamic_normal.update( + board, + &riding.ground, + gravity, + dt, + self.processed.scalar_2656, + &self.normal_settings, + ); + } + ///Reset and all other component publications are coordinator-owned phases. + pub fn publish_board(&mut self, riding: &RidingOutputs) -> Result<(), String> { + let t = self + .toolkit + .as_ref() + .ok_or("Board output requires the current input toolkit")?; + publish_board_input( + &mut self.physical, + &riding.motion, + &riding.ground, + BoardInputFrame { + processed_forward: xyz(t.deck[2]), + reckoning_normal_1216: riding.reckoning.ground_normal, + reckoning_ground_up: xyz(riding.reckoning_frames.ground[1]), + retained_ground_normal_112: self.dynamic_normal.normal, + processed_flags_2476: self.processed.flags_2476, + }, + ); + Ok(()) + } + pub fn process_stage( + &mut self, + board: &mut BoardRuntime, + ground: &mut GroundRuntime, + ground_frame: AnimationPartTransform, + packet: &AnimationInputPacket<'_>, + host: InputHostFrame, + callbacks: &mut C, + stage: InputStage, + ) -> Result, String> { + let mut service = services::Services { + board, + ground, + ground_frame, + host, + callbacks, + pre_input: &mut self.pre_input, + grind: &mut self.grind, + world_edges: self.world_edges, + toolkit: &mut self.toolkit, + pending_teleport: &mut self.pending_teleport, + }; + match stage { + InputStage::ThroughTeleport => input_phase::start_input( + &mut self.player, &mut self.physical, packet, + &mut self.processed, &mut service, + ).map(Some), + InputStage::AfterTeleport(continuation) => input_phase::finish_input( + continuation, &mut self.player, &mut self.physical, packet, + &mut self.processed, &mut service, + ).map(|()| None), + } + .map_err(|e| format!("Player input: {e:?}")) + } +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[ignore = "requires private stock collections"] + fn original_player_input_settings_and_reset_load() { + let assets = std::path::PathBuf::from( + std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT"), + ); + let data = Collections::load(&assets).unwrap(); + let state = PlayerInputRuntime::load(&data, NoGrindEdges).unwrap(); + assert_eq!(state.player.flags_1296, 0xe00c0000); + assert!(state.pending_teleport().is_none()); + assert_eq!(state.physical.ground.scalar_276, -1.0); + assert_eq!(state.dynamic_normal.normal, Vector3::new(0.0, 1.0, 0.0)); + } +} + diff --git a/crates/skate-game/src/physics/player_input/output_reset.rs b/crates/skate-game/src/physics/player_input/output_reset.rs new file mode 100644 index 00000000..03839b49 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/output_reset.rs @@ -0,0 +1,43 @@ +//! Represented PhysOut reset values. Original82DE53F0 copies the templates +//! built by82DE4940; scalar zero does not replace any active producer. +use skate_core::player::input_phase::*; +const UP: RawVector = [0, 0x3f800000, 0, 0]; +pub(crate) fn reset_outputs(out: &mut PhysicalPlayerInput) { + //These are host-owned observations, outside the native PhysOut templates. + let surface = out.surface_default_mode; + let world_grab = out.component_1832_word_1876; + let anim_to_world = out.skeleton.anim_to_world_11920; + //82DE2EF8 clears these three represented Air324 bits; preserve the rest. + let handplant_flags = out.air.handplant_flags_324 & !0xb000_0000; + *out = PhysicalPlayerInput { + air: AirOutputFields { + handplant_flags_324: handplant_flags, + //82DE2F70..78 loads original world-up into144.82DE304C + //splats-1 into the final four-word trajectory descriptor lane. + landing_normal_144: UP, + selected_trajectory_240: [[0;4],[0;4],[0;4],[0xbf80_0000;4]], + ..Default::default() + }, + reckoning: SystemReckoningFields { + vector_96: UP, + ..Default::default() + }, //82DE3CC8 + ground: GroundOutputFields { + vector_64: UP, + vector_96: UP, + scalar_276: -1., + ..Default::default() + }, //82DE3728 + grinds: GrindOutputFields { + words_136_140: [u32::MAX, 0], + ..Default::default() + }, //82DE3518 + skeleton: SkeletonOutputFields { + anim_to_world_11920: anim_to_world, + ..Default::default() + }, + surface_default_mode: surface, + component_1832_word_1876: world_grab, + ..Default::default() + }; +} diff --git a/crates/skate-game/src/physics/player_input/pre_input.rs b/crates/skate-game/src/physics/player_input/pre_input.rs new file mode 100644 index 00000000..b19fd387 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/pre_input.rs @@ -0,0 +1,67 @@ +//! Original82D81610 inactive-query branch and82D2DDD0 result reset. Pending +//! geometry is explicit; this owner never treats an unimplemented query as a miss. +use skate_core::physics::skeleton_animation_record::{AnimationPartTransform, IDENTITY}; +#[derive(Clone, Debug)] +pub(crate) struct PreInputResult { + pub position: [f32; 4], + pub normal: [f32; 4], + pub frame: AnimationPartTransform, + pub vector96: [f32; 4], + pub vector112: [f32; 4], + pub vector128: [f32; 4], + pub vector144: [f32; 4], + pub scalar160: f32, + pub word164: u32, + pub scalar168: f32, + pub scalar172: f32, + pub word176: u32, + pub flags180: u32, +} +impl PreInputResult { + fn reset() -> Self { + let mut frame = IDENTITY; + frame[3] = [0.; 4]; + Self { + position: [0.; 4], + normal: [0., 1., 0., 0.], + frame, + vector96: [0.; 4], + vector112: [0., 1., 0., 0.], + vector128: [0.; 4], + vector144: [0.; 4], + scalar160: 0., + word164: 0, + scalar168: f32::from_bits(0x5015_02f9), + scalar172: f32::from_bits(0x5015_02f9), + word176: 0, + flags180: 0, + } + } +} +pub(crate) struct PreInputManager { + pub pending_geometry: bool, + pub result: PreInputResult, + pub result_counts: [u32; 3], +} +impl PreInputManager { + pub fn new() -> Self { + //82D81100 clears pending316; result reset called by ctor82D8108C. + Self { + pending_geometry: false, + result: PreInputResult::reset(), + result_counts: [0; 3], + } + } + pub fn prepare(&mut self, counter: &mut u32) -> Result<(), String> { + self.result = PreInputResult::reset(); + self.result_counts = [0; 3]; + *counter = 30; + if self.pending_geometry { + return Err( + "Pending82D811C8 trajectory batch requires its complete82D81610 geometry consumer" + .into(), + ); + } + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/player_input/reset.rs b/crates/skate-game/src/physics/player_input/reset.rs new file mode 100644 index 00000000..2e754eeb --- /dev/null +++ b/crates/skate-game/src/physics/player_input/reset.rs @@ -0,0 +1,60 @@ +//! Selective reset of the represented ProcessedPhysIn fields, TU3 82BF9EF0. +//! Packet buffers and retained flag lanes deliberately survive this reset. +use skate_core::player::input_phase::ProcessedPhysicsInput; + +pub(crate) fn reset_processed(input: &mut ProcessedPhysicsInput) { + let external = input.external_physics_1616; + let vector_832 = input.vectors_720_784_800_816_832_864[4]; + let mut reset = ProcessedPhysicsInput { + effective_anim_transform_192: [ + [1.0f32.to_bits(), 0, 0, 0], + [0, 1.0f32.to_bits(), 0, 0], + [0, 0, 1.0f32.to_bits(), 0], + [0; 4], + ], + vector_1520: input.vector_1520, + matrix_1536: input.matrix_1536, + byte_1600: input.byte_1600, + external_physics_1616: external, + probe_1792: input.probe_1792, + flags_2468: 0x2000 | (input.flags_2468 & 8), + flags_2488: input.flags_2488 & 0x001f_ffff, + state_variant_index_2528: 1, + grind_words_2532_2536: [u32::MAX, 0], + timestep_2604: f32::from_bits(0x3c88_8889), + gravity_2648: f32::from_bits(0xc11c_cccd), + actor_query_2952: u32::MAX, + ..ProcessedPhysicsInput::default() + }; + reset.external_physics_1616.flags &= 0x01ff_ffff; + reset.vectors_544_560_592_608[0] = [0, 1.0f32.to_bits(), 0, 0]; + // Raw 82BFA1D0 and 82BFA1D8 both store to +816; +832 is retained. + reset.vectors_720_784_800_816_832_864[4] = vector_832; + *input = reset; +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn reset_retains_only_native_packet_lanes_and_masks() { + let mut input = ProcessedPhysicsInput::default(); + input.flags_2468 = u32::MAX; + input.flags_2488 = u32::MAX; + input.external_physics_1616.flags = u32::MAX; + input.external_physics_1616.vectors[0] = [7; 4]; + input.vectors_720_784_800_816_832_864 = [[9; 4]; 6]; + input.vector_1520 = [11; 4]; + input.scalar_2652 = 123.0; + reset_processed(&mut input); + assert_eq!(input.flags_2468, 0x2008); + assert_eq!(input.flags_2488, 0x001f_ffff); + assert_eq!(input.external_physics_1616.flags, 0x01ff_ffff); + assert_eq!(input.external_physics_1616.vectors[0], [7; 4]); + assert_eq!(input.vectors_720_784_800_816_832_864[3], [0; 4]); + assert_eq!(input.vectors_720_784_800_816_832_864[4], [9; 4]); + assert_eq!(input.vector_1520, [11; 4]); + assert_eq!(input.scalar_2652, 0.0); + assert_eq!(input.state_variant_index_2528, 1); + } +} diff --git a/crates/skate-game/src/physics/player_input/services.rs b/crates/skate-game/src/physics/player_input/services.rs new file mode 100644 index 00000000..967c7b46 --- /dev/null +++ b/crates/skate-game/src/physics/player_input/services.rs @@ -0,0 +1,177 @@ +//! Host ownership for the original 82DB4048 input phase. Every numerical +//! preparation is performed here; the two callbacks address the shared +//! skeleton/whole-player owners that cannot be borrowed twice by the host. +use super::{ + grind::{GrindInputState, NoGrindEdges}, + pre_input::PreInputManager, +}; +use crate::physics::ground_runtime::GroundRuntime; +use skate_core::{ + physics::{ + board_runtime::BoardRuntime, board_toolkit::BoardToolkit, + skeleton_animation_record::AnimationPartTransform, + }, + player::input_phase::{ + AnimationInputPacket, InputPhaseServices, PhysicalPlayerInput, PlayerInputState, + ProcessedPhysicsInput, RawVector, + }, +}; + +#[derive(Clone, Copy)] +pub(crate) struct InputHostFrame { + pub actor_query_56: u32, + pub actor_query_44: u32, + pub input_available: bool, + /// Actual gameplay action 71 from ControllerInput::player_actions(). + pub transition_action: f32, + /// Motion output's published +0 frame, read by the native reset request. + pub published_board_transform: AnimationPartTransform, + /// Native air-state manager +40, only read by the category200 grind gate. + pub air_counter_40: i32, +} + +pub(crate) trait PlayerInputCallbacks { + fn process_skeleton( + &mut self, + board: &mut BoardRuntime, + toolkit: &BoardToolkit, + packet: &AnimationInputPacket<'_>, + physical: &mut PhysicalPlayerInput, + processed: &mut ProcessedPhysicsInput, + ) -> Result<(), String>; + + /// Complete 82DB8998 reset including board, skeleton, Reckoning, current + /// physical outputs and Ground entry. Returning an error retains the request. + fn teleport( + &mut self, + board: &mut BoardRuntime, + ground: &mut GroundRuntime, + target: AnimationPartTransform, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + processed: &mut ProcessedPhysicsInput, + ) -> Result<(), String>; +} + +pub(super) struct Services<'a, C> { + pub board: &'a mut BoardRuntime, + pub ground: &'a mut GroundRuntime, + pub ground_frame: AnimationPartTransform, + pub host: InputHostFrame, + pub callbacks: &'a mut C, + pub pre_input: &'a mut PreInputManager, + pub grind: &'a mut GrindInputState, + pub world_edges: NoGrindEdges, + pub toolkit: &'a mut Option, + pub pending_teleport: &'a mut Option, +} +impl InputPhaseServices for Services<'_, C> { + type Error = String; + fn update_pre_input_manager_82d81610( + &mut self, + player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + ) -> Result<(), String> { + self.pre_input.prepare(&mut player.manager_1856_counter_320) + } + fn reset_processed_input_82bf9ef0( + &mut self, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + super::reset::reset_processed(output); + Ok(()) + } + fn actor_query_slot_56(&mut self) -> Result { + Ok(self.host.actor_query_56) + } + fn actor_query_slot_44(&mut self) -> Result { + Ok(self.host.actor_query_44) + } + fn reset_player_probe_82d7a330( + &mut self, + player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + ) -> Result<(), String> { + // Native clears every represented field after copying it to Processed. + player.probe = Default::default(); + Ok(()) + } + fn check_teleport_82db88c8( + &mut self, + player: &mut PlayerInputState, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + // Native handles its state request before its explicit reset bit. + // Host requests remain owned until the entire reset succeeds. + if physical.state.flag_61 != 0 { + let target = self.host.published_board_transform; + self.callbacks + .teleport(self.board, self.ground, target, player, physical, output)?; + } + if player.flags_1296 & (1 << 19) != 0 { + let deck = self.board.part_transforms()[6]; + let mut target = [[0.0; 4]; 4]; + for (dst, src) in target[..3].iter_mut().zip(deck.basis.columns) { + dst[..3].copy_from_slice(&src); + } + let p = deck.translation; + target[3] = [p.x, p.y, p.z, 0.0]; + self.callbacks + .teleport(self.board, self.ground, target, player, physical, output)?; + player.flags_1296 &= !(1 << 19); + } + if let Some(target) = *self.pending_teleport { + self.callbacks + .teleport(self.board, self.ground, target, player, physical, output)?; + *self.pending_teleport = None; + } + Ok(()) + } + fn actor_input_available_slot_4(&mut self) -> Result { + Ok(self.host.input_available) + } + fn transition_action_825903c8(&mut self) -> Result { + Ok(self.host.transition_action) + } + fn calculate_ground_position_82c02840( + &mut self, + _physical: &PhysicalPlayerInput, + ) -> Result { + Ok(super::ground_position::ground_position( + self.board, + &self.ground_frame, + )) + } + fn prepare_board_toolkit_82c013f0( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + *self.toolkit = Some(self.ground.prepare_toolkit(self.board, output)); + Ok(()) + } + fn process_skeleton_82bd8918( + &mut self, + packet: &AnimationInputPacket<'_>, + physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + let toolkit = self + .toolkit + .as_ref() + .ok_or("Input toolkit must precede Skeleton::ProcessData")?; + self.callbacks + .process_skeleton(self.board, toolkit, packet, physical, output) + } + fn update_grind_manager_82d8a828( + &mut self, + _player: &mut PlayerInputState, + _physical: &mut PhysicalPlayerInput, + output: &mut ProcessedPhysicsInput, + ) -> Result<(), String> { + self.grind + .update(self.world_edges, output, self.host.air_counter_40) + } +} diff --git a/crates/skate-game/src/physics/player_state.rs b/crates/skate-game/src/physics/player_state.rs new file mode 100644 index 00000000..96fc9cab --- /dev/null +++ b/crates/skate-game/src/physics/player_state.rs @@ -0,0 +1,132 @@ +//! PhysicalPlayer startup, PostInput, state selection and state publication. +//! Native control decisions use original TU3 functions and stock collections; +//! host ownership replaces the original player component pointers. +mod post_input; +mod pre_state; +mod publication; +mod selection; +mod transition; +mod wipeout_output; +use super::{GamePhysics, skater::SkaterRuntime}; +use skate_core::{ + physics::filtered_state::{FilteredState, FilteredStateOutput}, + player::{ + lifecycle::PhysicalPlayerStateLifecycle, + selector::{StateSelector, conditions::TwoStageThresholds}, + state::PhysicalStateId, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct PlayerState { + pub lifecycle: PhysicalPlayerStateLifecycle, + pub selector: StateSelector, + pub requested_state: PhysicalStateId, + pub filtered: FilteredState, + pub filtered_output: Option, + pub ground_output: Option, + pub post: post_input::PostInputState, + pub state_flags: [bool; 36], + pub state_count: u32, + pub update_count: u32, + normal_off_ground: TwoStageThresholds, + skitching_off_ground: TwoStageThresholds, + animated_board_threshold: f32, + initialized: bool, +} +impl PlayerState { + pub fn load(data: &Collections, _mode: &str) -> Result { + let thresholds = |class| -> Result { + Ok(TwoStageThresholds { + field_856_primary: data.float(class, "default", "NaturalAirMaxDist")?, + field_856_secondary: data.float(class, "default", "NaturalAirMinDist")?, + field_7692: data.float(class, "default", "NaturalAirTime")?, + }) + }; + Ok(Self { + //82DB3008 selects the owned Sleeping object before SetPhysicsState100. + lifecycle: PhysicalPlayerStateLifecycle::new(PhysicalStateId::Sleeping), + selector: StateSelector::default(), + requested_state: PhysicalStateId::Sleeping, + filtered: FilteredState::default(), + filtered_output: None, + ground_output: None, + post: post_input::PostInputState::new(), + state_flags: [false; 36], + state_count: 0, + update_count: 0, + //82D8BD50 reads Globals260; its layout392/396/400 is physics_airstates. + normal_off_ground: thresholds("physics_airstates")?, + skitching_off_ground: thresholds("physics_state_skitching")?, + //Skeleton+24 physics_animation, original82BDC6C8 layout800. + animated_board_threshold: data.float( + "physics_animation", + "default", + "MaxDeckZAxisYForAnimatedDeck", + )?, + initialized: false, + }) + } + pub fn current(&self) -> PhysicalStateId { + self.lifecycle.active().state + } + ///ResetSystems82DB92D0 resets the selector and physical-output filter; + ///the coordinator owns board/skeleton resets and the following Ground entry. + pub fn reset_for_teleport(&mut self) { + self.filtered.reset(); + self.filtered_output = None; + self.ground_output = None; + //ResetSystems82DB92D0 clears20..44, sets48=10 and52=0. + //Current pointer16 and flags56/57 survive until Calculate writes them. + self.selector.nonspecific_collision_free_frames = 0; + self.selector.nonspecific_collision_frames = 0; + self.selector.something_colliding_frames = 0; + self.selector.two_wheel_counter = 0; + self.selector.three_wheel_counter = 0; + self.selector.post_grind_jump_counter = 0; + self.selector.air_frames = 0; + self.selector.teleport_countdown = 10; + self.selector.skitch_exit_countdown = 0; + //TrajectorySelector::Reset82D67228 clears9658, preserving9657. + self.post.trajectory_available = false; + } +} +///Call once before first input; startup constructs the toolkit from live board/reset inputs. +pub(crate) fn initialize( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + if skater.player_state.initialized { + return Ok(()); + } + transition::set(physics, skater, PhysicalStateId::PhysicsGround)?; + skater.player_state.initialized = true; + Ok(()) +} +///Runs after ProcessInput, before the chosen state's pre-update/force phase. +pub(crate) fn post_input_and_select( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + post_input::advance(physics, skater)?; + selection::advance(physics, skater) +} +///The reset/board/skeleton publications precede this selected-state FillPhysOut. +pub(crate) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + publication::publish(physics, skater) +} +///Reset continuation uses the same native SetPhysicsState path and actual entry. +pub(crate) fn enter_after_teleport( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + transition::set(physics, skater, PhysicalStateId::PhysicsGround) +} + +///Original82DB6050 prefix; coordinator calls the selected state pre-update next. +pub(crate) fn pre_state( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + pre_state::advance(physics, skater) +} diff --git a/crates/skate-game/src/physics/player_state/post_input.rs b/crates/skate-game/src/physics/player_state/post_input.rs new file mode 100644 index 00000000..66b5c89e --- /dev/null +++ b/crates/skate-game/src/physics/player_state/post_input.rs @@ -0,0 +1,231 @@ +//! Original PostInput82DB5588 with the live ballistic trajectory selector. +//! The authored world has no grind edges or grab-spline descriptors. +use super::*; +use skate_core::player::post_input::*; + +pub(crate) struct PostInputState { + pub jump_reference: [u32; 4], + pub jump_fix_frames: u32, + pub latch_frames: u32, + pub state_frames: u32, + pub heading_adjust: f32, + pub complete: bool, + pub trajectory_pending: bool, + pub trajectory_valid: bool, + pub trajectory_available: bool, + pub trajectory_new_candidate: bool, + pub candidates: CandidatePublicationFields, + pub grind: EmptyEdgePost, +} +impl PostInputState { + pub fn new() -> Self { + Self { + //Original Player ctor82DB1AAC=1000,1ACC=0,3028 clears1264. + jump_reference: [0; 4], + jump_fix_frames: 1000, + latch_frames: 0, + state_frames: 100, + heading_adjust: 0.0, + complete: false, + trajectory_pending: false, + trajectory_valid: false, + trajectory_available: false, + trajectory_new_candidate: false, + candidates: CandidatePublicationFields { + first_object_present_196: false, + first_pending_288: false, + second_object_present_500: false, + second_pending_592: false, + staged_word_12768: 0, + staged_valid_12772: 0, + staged_latched_12776: 0, + staged_pending_12780: false, + }, + grind: EmptyEdgePost::new(), + } + } +} +///Scorer/decision state that still advances in the genuine no-candidate path +///82D8AB08 ->82D86808/82D86C88/82D86DE8/82D89F58/82D739D8. +pub(crate) struct EmptyEdgePost { + free_frames: u32, + duration: u32, + engage_state: u32, + expiry: f32, + pop_frames: u32, + lean: [f32; 2], + reset_frames: u32, + previous_grind: u32, + blend: f32, +} +impl EmptyEdgePost { + fn new() -> Self { + Self { + free_frames: 0, + duration: 0, + engage_state: 0, + expiry: 0.0, + pop_frames: 0, + lean: [0.0; 2], + reset_frames: 0, + previous_grind: 0, + blend: 0.0, + } + } + fn advance(&mut self, p: &mut skate_core::player::input_phase::ProcessedPhysicsInput) { + //No candidate means82D8ACF0 skips wallassist,82D86318 skips geometry. + self.expiry = (self.expiry - p.timestep_2604).max(0.0); + self.free_frames = self.free_frames.wrapping_add(1); + if self.free_frames > 20 { + self.duration = 0; + self.engage_state = 2; + } + self.pop_frames = decrement(self.pop_frames); + for value in &mut self.lean { + *value *= f32::from_bits(0x3f68f5c3); + if value.abs() < 0.0001 { + *value = 0.0; + } + } + self.reset_frames = decrement(self.reset_frames); + if self.reset_frames > 0 { + p.flags_2476 |= 0x02000000; + } + if p.grind_words_2532_2536[0] != u32::MAX { + self.previous_grind = p.grind_words_2532_2536[0]; + } + self.blend = (self.blend + 0.0035).min(1.0); + } +} +fn decrement(v: u32) -> u32 { + let n = v.wrapping_sub(1); + if n & 0x80000000 != 0 { 0 } else { n } +} +struct Services<'a> { + heading: f32, + grind: &'a mut EmptyEdgePost, + processed: &'a mut skate_core::player::input_phase::ProcessedPhysicsInput, + trajectory: &'a mut super::super::air_trajectory::AirTrajectoryRuntime, + trajectory_input: skate_core::air::trajectory::SelectorInput, + world: &'a skate_core::physics::board_world::BoardWorld, + error: Option, +} +impl PostInputServices for Services<'_> { + fn update_grind_manager_82d8ab08(&mut self) { + self.grind.advance(self.processed); + } + fn update_trajectory_selector_82d68800(&mut self) -> u8 { + //82DB56B0 runs the real selector after GrindManager. That manager + //can change2476, so use its current output rather than a prior snapshot. + let mut input = self.trajectory_input; + input.flags_2476 = self.processed.flags_2476; + match self.trajectory.update(input, self.world) { + Ok(valid) => u8::from(valid), + Err(error) => { + self.error = Some(error); + 0 + } + } + } + fn calculate_scalar_2740_82db5e10(&mut self) -> f32 { + self.heading + } + fn register_candidate_82762ab0(&mut self, _: CandidateRegistration) { + //82762AB0 copies a grab-spline record; the inherited trait name does + //not imply engine registration or ballistic trajectory publication. + unreachable!( + "grab-spline publication requires an authored descriptor checked before this phase" + ) + } +} +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let trajectory_input = super::super::air_phase::selector_input(physics, skater)?; + let post = &mut skater.player_state.post; + let p = &mut skater.player_input.processed; + if skater.ground_lifecycle.edge.is_some() || p.category_2512 == 400 || p.state_2508 == 701 { + return Err("PostInput active grind requires its actual candidate/scorer output".into()); + } + let requests = &skater.ground_lifecycle.trajectory; + if requests.primary_valid_288 + || requests.secondary_valid_592 + || post.candidates.staged_pending_12780 + { + return Err( + "PostInput grab-spline publication requires its retained authored descriptor".into(), + ); + } + let mut player = PostInputPlayerFields { + jump_reference_1264: post.jump_reference, + flags_1296: skater.player_input.player.flags_1296, + state_frames_1304: skater.player_input.player.ground_history_frames_1304 as u32, + jump_fix_frames_1308: post.jump_fix_frames, + latch_frames_1320: post.latch_frames, + }; + let mut processed = PostInputProcessedFields { + jump_reference_848: post.jump_reference, + word_2464: 0, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + current_state_2508: p.state_2508, + state_frames_2572: post.state_frames, + jump_fix_frames_2576: post.jump_fix_frames, + scalar_2740: post.heading_adjust, + }; + let mut output = PostInputPhysOutFields { + reset_state_frames_316: skater + .player_state + .ground_output + .as_ref() + .is_some_and(|g| g.ground_32.wall_ride_exit), + //82DB5588 consumes the preceding state's actual jump publication. + capture_jump_reference_442: skater.player_input.physical.air.launched_442 != 0, + jump_reference_128: skater.player_input.physical.air.launch_velocity_128, + complete_76: post.complete, + }; + let heading = physics.riding.update_input_heading( + f32::from_bits(p.vectors_464_480_496_512_528[0][1]), + p.scalar_2612, + ); + let mut services = Services { + heading, + grind: &mut post.grind, + processed: p, + trajectory: &mut skater.trajectory, + trajectory_input, + world: &physics.world, + error: None, + }; + run_post_input( + PostInputContext { + player: &mut player, + processed: &mut processed, + phys_out: &mut output, + candidates: &mut post.candidates, + }, + &mut services, + ); + if let Some(error) = services.error.take() { + return Err(error); + } + drop(services); + //These are observations of the actual owner, never inputs to selection. + post.trajectory_pending = skater.trajectory.selector.pending(); + post.trajectory_valid = skater.trajectory.selector.valid(); + post.trajectory_available = skater.trajectory.selector.valid(); + post.trajectory_new_candidate = skater.trajectory.selector.just_changed(); + skater.player_input.player.flags_1296 = player.flags_1296; + skater.player_input.player.ground_history_frames_1304 = player.state_frames_1304 as i32; + p.flags_2468 = processed.flags_2468; + p.flags_2472 = processed.flags_2472; + p.flags_2480 = processed.flags_2480; + p.flags_2484 = processed.flags_2484; + post.jump_reference = player.jump_reference_1264; + post.jump_fix_frames = player.jump_fix_frames_1308; + post.latch_frames = player.latch_frames_1320; + post.state_frames = processed.state_frames_2572; + post.heading_adjust = processed.scalar_2740; + post.complete = output.complete_76; + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/pre_state.rs b/crates/skate-game/src/physics/player_state/pre_state.rs new file mode 100644 index 00000000..f217d208 --- /dev/null +++ b/crates/skate-game/src/physics/player_state/pre_state.rs @@ -0,0 +1,52 @@ +//! Update3_State82DB6050 prefix through grab-spline query synchronization. +//! Ground/Air/KnownAir vtable24 ->82D34DA8 ->82D2D860 predicts from the actual deck part +//! transform and its physical body's linear velocity, with a single VMADD. +use super::*; +use skate_core::physics::board::BodyId; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let player = &mut skater.player_input.player; + player.state_count_1312 = player.state_count_1312.wrapping_add(1); + //KnownAir ctor82D35130 installs82327210; vslot24 at82327228 is + //the same82D34DA8. Its target-following trajectory is not this deck sample. + if !matches!( + skater.player_state.current(), + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + ) { + return Err("PreState requires the selected state's actual PredictFutureOfDeck".into()); + } + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let velocity = physics.board.bodies()[BodyId::Deck.index()] + .rates + .linear_velocity; + let dt = skater.player_input.processed.timestep_2604; + let prediction = [ + velocity.x.mul_add(dt, deck.translation.x), + velocity.y.mul_add(dt, deck.translation.y), + velocity.z.mul_add(dt, deck.translation.z), + 0.0, + ]; + let roots = &mut skater.animated_skeleton.roots; + roots.predicted_board_position = prediction; + roots.supplied_prediction = Some(prediction); + //82DB60DC clears sticky3184, not this-update3185. + skater.foot_ik.state.contacts.support_failed = false; + let requests = &mut skater.ground_lifecycle.trajectory; + //82D74270 is PlayerGrabSpline::Sync. Its288-byte grab-spline records are + //separate from the240-byte ballistic predictions. It clears flag6 first. + //This world's triangles have no authored grab-spline descriptors, so no + //pending line batch and no query result9840 take the actual empty branch. + requests.flags_12836 &= !0x40; + if requests.pending_request.is_some() || requests.result_valid_9840 { + return Err( + "PreState has a retained grab-spline request/result requiring82D74270 synchronization" + .into(), + ); + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/publication.rs b/crates/skate-game/src/physics/player_state/publication.rs new file mode 100644 index 00000000..4bbbc9d0 --- /dev/null +++ b/crates/skate-game/src/physics/player_state/publication.rs @@ -0,0 +1,181 @@ +//! Selected-state FillPhysOut and common state82DB7580..7790. +use super::*; +use skate_core::{ + physics::{ + contact_feedback::choose_surface, + filtered_state::{FilteredStateInput, GrindState}, + }, + player::input_phase::CurrentStateFields, +}; +pub(super) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let state = skater.player_state.current(); + if !matches!( + state, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + ) { + return Err(format!( + "FillPhysOut requires the actual {state:?} output owner" + )); + } + if state == PhysicalStateId::KnownAir { + super::super::known_air::fill(physics, skater)?; + } + let p = &skater.player_input.processed; + let toolkit = skater + .player_input + .toolkit + .as_ref() + .ok_or("State output requires actual board toolkit")?; + let output = (state == PhysicalStateId::PhysicsGround) + .then(|| skater.ground.output(p, &skater.animation_input, toolkit)); + let player = &mut skater.player_input.player; + skater.player_state.state_count = player.state_count_1312; + skater.player_state.update_count = player.update_count_1316; + let flags = &mut skater.player_state.state_flags; + //State template82DE3BE8 clears bytes52..87. Only source-owned writes follow. + *flags = [false; 36]; + let mut set = |offset: usize, value: bool| flags[offset - 52] = value; + set(52, p.flags_2468 & (1 << 30) != 0); + set(54, p.flags_2468 & (1 << 29) != 0); + set(55, p.flags_2468 & (1 << 25) != 0); + set(56, p.flags_2468 & (1 << 27) != 0); + set(57, p.flags_2468 & (1 << 26) != 0); + set(58, p.flags_2468 & (1 << 28) != 0); + set(59, p.flags_2468 & (1 << 18) != 0); + set(60, skater.animation_input.fields.balance != 0.0); + set(62, p.flags_2472 & (1 << 10) != 0); + // State63 is set only by LandingOnDeckManager::Fill82D4E0E8..E0F4 + // when byte246 && time240<.02. Ordinary Ground never invokes that Fill, + // so it retains the template zero here; State65 carries Ground requests. + set(65, skater.wipeout.requests_wipeout(p)); + set(78, skater.wipeout.requests_runout(p)); + set(71, player.flags_1296 & (1 << 24) != 0); + set(73, p.flags_2476 & (1 << 24) != 0); + set(75, state.category() == 500); + set(76, state as u32 == 500); + //82DB78A8..78DC: the processed off-board request refreshes three + //outputs, then this publication consumes one and exposes State77. + if p.state_identifier_2496 == 500 { + player.dismount_request_frames_1332 = 3; + } + let dismount_requested = player.dismount_request_frames_1332 > 0; + if dismount_requested { + player.dismount_request_frames_1332 -= 1; + } + set(77, dismount_requested); + set(79, p.state_variant_index_2528 == 3); + set(80, p.flags_2484 & (1 << 21) != 0); + if let Some(output) = &output { + set(84, output.state_28.flag_84); + set(86, output.state_28.has_world_grab_intent_without_object); + if let Some(value) = output.state_28.manual_correction_write_78 { + set(78, value); + } + } + set(87, p.flags_2484 & (1 << 10) != 0); + if state == PhysicalStateId::SlideGround { + set(84, skater.slide_state.state.wall_riding); + } + let physical = &mut skater.player_input.physical; + //Common ProcessOutput82DB7044/7048 and70C0/70C4; these precede state Fill. + physical.air.flag_444 = u8::from( + skater + .trajectory + .selector + .selection() + .is_some_and(|s| s.wall_ride), + ); + physical.air.flag_441 = u8::from(skater.air_reckoning.state.flip_active); + if state == PhysicalStateId::GroundAnimation { + skater.ground_animation.fill(p, &mut physical.air); + } + physical.state = CurrentStateFields { + category_12: state.category(), + state_16: state as u32, + //ProcessOutput82DB7104..7128: selector57 requests State69 here. + //The next input/selection enters702; never reset inside selection. + flag_69: u8::from(skater.player_state.selector.request_teleport), + ..CurrentStateFields::default() + }; + skater.player_state.state_flags[69 - 52] = physical.state.flag_69 != 0; + if let Some(output) = &output { + physical.state.skitch_value_40 = output.state_28.grab_spline_object_id; + physical.state.signed_ground_step_84 = u8::from(output.state_28.flag_84); + physical.ground.vector_128 = output.ground_32.anti_flip_torque.map(f32::to_bits); + physical.ground.scalar_276 = output.ground_32.time_to_skitch; + physical.ground.flag_317 = u8::from(output.ground_32.anti_flip_nudge_present); + physical.ground.flag_318 = u8::from(output.ground_32.is_pinning); + physical.off_board.flag_304 = u8::from(output.is_grabbing_object_72_304); + } else if state == PhysicalStateId::PhysicsAir { + let air = skate_core::air::state::fill_physics_output(&skater.air_state); + physical.air.landing_normal_144 = air.landing_normal.map(f32::to_bits); + physical.air.jump_height_200 = air.jump_height; + physical.air.reached_apex_436 = u8::from(air.is_at_apex); + if let Some(value) = air.scalar_184_write { + physical.air.scalar_184 = value; + } + } else if state == PhysicalStateId::SlideGround { + //State Fill82D3B010 is a single byte from this retained Slide owner. + physical.state.signed_ground_step_84 = u8::from(skater.slide_state.state.wall_riding); + } + //ProcessOutput82DB71C4 calls SkateboardController::FillPhysOut82D76D20 + //for every selected state. The constructor82D74DD8 seeds448=0; use the + //same controller owner as state transitions and post-physics ragdoll. + physical.off_board.dropping_board_322 = u8::from(p.flags_2476 & (1 << 9) != 0); + physical.off_board.retrieving_board_323 = u8::from( + p.flags_2476 & (1 << 10) != 0 || skater.skateboard_controller.fields.state_448 == 4, + ); + + //ProcessOutput82DB7130..7180 copies the HandPlantManager animation + //flags1876 into Air304+20 one bit at a time, preserving the low28 bits. + //The same manager word is consumed by the actual input publication. + physical.air.handplant_flags_324 = (physical.air.handplant_flags_324 & 0x0fff_ffff) + | (physical.component_1832_word_1876 & 0xf000_0000); + + if let Some(projection) = output + .as_ref() + .and_then(|output| output.velocity_projection_36) + { + physical.reckoning.vector_144 = + projection.velocity_without_axis_component.map(f32::to_bits); + physical.reckoning.flag_164 = u8::from(projection.active); + } + let surface = choose_surface( + physics.riding.wheel_lines.physics_surfaces, + core::array::from_fn(|i| physics.riding.ground.parts[i].in_contact), + physics.riding.ground.collision_flags & (1 << 25) != 0, + ); + let filtered = skater.player_state.filtered.update(FilteredStateInput { + physics_category: state.category() as i32, + physics_state: state as i32, + anything_in_contact: physical.collision.flag_3477 != 0, + physics_surface_type: surface as i32, + wall_ride_exit: output + .as_ref() + .is_some_and(|output| output.ground_32.wall_ride_exit), + //Original reset records; the selected ordinary Ground state and + //authored empty-edge world do not publish the active air/grind fields. + targeting_grind: state == PhysicalStateId::KnownAir + && skater.known_air.state.targeting_grind_213, + offboard_has_landed: false, + offboard_on_deck: false, + grind: GrindState::default(), + last_grind_distance: 0.0, + }); + physical.filtered_state_0 = filtered.category as u32; + skater.player_state.filtered_output = Some(filtered); + skater.player_state.ground_output = output; + if state == PhysicalStateId::WipeoutGround { + super::wipeout_output::publish(skater); + } + if state == PhysicalStateId::Teleporting { + skater.teleport_state.publish_output(&mut skater.player_input.physical); + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/selection.rs b/crates/skate-game/src/physics/player_state/selection.rs new file mode 100644 index 00000000..2ce31ac2 --- /dev/null +++ b/crates/skate-game/src/physics/player_state/selection.rs @@ -0,0 +1,55 @@ +//! CalcSuggestedState82D8ADE8 gets actual contacts, line distances and pose. +use super::*; +use skate_core::player::selector::{ + ProcessedStateInput, StateSelectionInput, + conditions::{BoardBodyState, SkeletonAnimationState}, +}; +pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + let input = StateSelectionInput { + processed: ProcessedStateInput { + //The authored world has no grind edges. GrindInput's real empty + //edge path clears candidate384; zero flags are its reset record. + grind_type_1248: 0, + grind_candidate_1488: false, + grind_investigation_flags_1516: 0, + field_1776: p.external_physics_1616.flags as i32, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + flags_2488: p.flags_2488, + category_2512: p.category_2512, + wheel_contact_count_2556: p.wheel_count_2556 as i32, + field_2572: skater.player_state.post.state_frames, + state_timer_2664: p.state_timer_2664, + field_2732: skater.animation_input.fields.slide, + field_2744: skater.animation_input.extra.revert_direction, + trajectory_collision_time_2772: p.air_scalar_2772, + }, + skateboard_contact_count_869: physics.riding.ground.wheel_contact_count, + board_body: BoardBodyState { + field_856: physics.riding.wheel_lines.minimum_distance, + field_7692: physics.riding.ground.time_without_wheel_contact, + }, + skeleton: SkeletonAnimationState { + mode_16420: skater.skeleton_input.force_mode, + back_chain_lane_12656: skater.skeleton_input.drive_frames[0][2][1], + threshold_800: skater.player_state.animated_board_threshold, + }, + normal_off_ground: skater.player_state.normal_off_ground, + skitching_off_ground: skater.player_state.skitching_off_ground, + }; + let current = skater.player_state.current(); + let requested = skater.player_state.selector.calculate(current, &input); + skater.player_state.requested_state = requested; + if requested != current { + //82DB5FF4 restores the actual counter also used by ground-history. + skater.player_input.player.ground_history_frames_1304 = 100; + super::transition::set(physics, skater, requested)?; + } + //SystemLogic82DB6000 clears the per-frame Wipeout request accumulator. + skater.wipeout.state.clear_after_selection(); + Ok(()) +} diff --git a/crates/skate-game/src/physics/player_state/transition.rs b/crates/skate-game/src/physics/player_state/transition.rs new file mode 100644 index 00000000..d4155d41 --- /dev/null +++ b/crates/skate-game/src/physics/player_state/transition.rs @@ -0,0 +1,165 @@ +//! Native SetPhysicsState82DB8540 publication, followed by owned Enter/Exit. +use super::*; +use skate_core::{physics::board_toolkit::BoardToolkit, player::lifecycle::*}; + +struct Calls; +impl PhysicalStateCalls for Calls { + fn get_type(&mut self, state: StateBinding) -> PhysicalStateId { + state.state + } + //Host lifecycle publication is completed below before applying physical + //effects. These supported Exit methods do not inspect the active binding. + fn exit(&mut self, call: StateCall) { + assert!(matches!( + call.state.state, + PhysicalStateId::Sleeping + | PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + )); + } + fn enter(&mut self, call: StateCall) { + assert!(matches!( + call.state.state, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + )); + } +} +struct InactiveController; +impl SkateboardControllerActions for InactiveController { + fn hold_skateboard(&mut self) { + unreachable!("active controller rejected before transition") + } + fn let_go_of_skateboard(&mut self) { + unreachable!("active controller rejected before transition") + } +} +pub(super) fn set( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + requested: PhysicalStateId, +) -> Result<(), String> { + skater.player_state.requested_state = requested; + let current = skater.player_state.current(); + if current == requested { + return Ok(()); + } + //Keep the actual selector request for the coordinator; do not publish a + //state whose physical Enter/Exit owner has not been connected. + let supported = match current { + PhysicalStateId::Sleeping => requested == PhysicalStateId::PhysicsGround, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting => matches!( + requested, + PhysicalStateId::PhysicsGround + | PhysicalStateId::PhysicsAir + | PhysicalStateId::KnownAir + | PhysicalStateId::GroundAnimation + | PhysicalStateId::SlideGround + | PhysicalStateId::WipeoutGround + | PhysicalStateId::Teleporting + ), + _ => false, + }; + if !supported { + return Err(format!( + "Physical state transition {current:?} -> {requested:?} requires its native Enter/Exit production adapter" + )); + } + if skater.skateboard_controller.fields.system_on_452 { + return Err( + "Ground transition requires active SkateboardController::LetGoOfSkateboard".into(), + ); + } + let p = &mut skater.player_input.processed; + let player = &mut skater.player_input.player; + let mut data = StateChangeData { + player: PlayerStateChangeFields { + word_1312: player.state_count_1312, + previous_category_latch_1336: player.state_value_1336, + scalar_1344: player.state_timer_1344, + }, + processed: ProcessedStateChangeFields { + flags_2480: p.flags_2480, + requested_state_2500: requested as u32, + previous_state_2504: p.state_2504, + current_state_2508: p.state_2508, + current_category_2512: p.category_2512, + previous_category_2516: p.category_2516, + previous_category_latch_2520: p.player_state_value_2520, + word_2564: p.state_count_2564, + scalar_2664: p.state_timer_2664, + }, + skateboard_controller: skater.skateboard_controller.fields, + }; + skater + .player_state + .lifecycle + .set_physics_state( + requested as u32, + &mut data, + &mut Calls, + &mut InactiveController, + ) + .map_err(|e| format!("Unknown physical state {}", e.0))?; + player.state_count_1312 = data.player.word_1312; + player.state_value_1336 = data.player.previous_category_latch_1336; + player.state_timer_1344 = data.player.scalar_1344; + p.flags_2480 = data.processed.flags_2480; + p.state_2504 = data.processed.previous_state_2504; + p.state_2508 = data.processed.current_state_2508; + p.category_2512 = data.processed.current_category_2512; + p.category_2516 = data.processed.previous_category_2516; + p.player_state_value_2520 = data.processed.previous_category_latch_2520; + p.state_count_2564 = data.processed.word_2564; + p.state_timer_2664 = data.processed.scalar_2664; + skater.skateboard_controller.fields = data.skateboard_controller; + if skater.player_input.toolkit.is_none() { + skater.player_input.toolkit = Some(BoardToolkit::from_board( + &physics.board, + p.flags_2468, + p.scalar_2612, + p.vectors_464_480_496_512_528[0].map(f32::from_bits), + [0.0, 1.0, 0.0, 0.0], + )); + } + //Native82DB8540 publishes Processed state/history BEFORE old Exit. The + //same retained objects then receive Exit followed by the new Enter. + match current { + PhysicalStateId::PhysicsGround => super::super::ground_exit::exit(physics, skater), + PhysicalStateId::PhysicsAir => super::super::air_phase::exit(skater), + PhysicalStateId::KnownAir => { + super::super::known_air::exit(physics, skater, requested as u32)? + } + PhysicalStateId::GroundAnimation => super::super::ground_animation::exit(physics, skater)?, + PhysicalStateId::SlideGround => super::super::slide_state::exit(physics, skater)?, + PhysicalStateId::WipeoutGround => super::super::wipeout_states::exit(physics, skater), + PhysicalStateId::Sleeping | PhysicalStateId::Teleporting => {} //82B61BB8. + _ => unreachable!("state support checked before publication"), + } + match requested { + PhysicalStateId::PhysicsGround => super::super::ground_phase::enter(physics, skater), + PhysicalStateId::PhysicsAir => super::super::air_phase::enter(physics, skater), + PhysicalStateId::KnownAir => super::super::known_air::enter(physics, skater), + PhysicalStateId::GroundAnimation => super::super::ground_animation::enter(physics, skater), + PhysicalStateId::SlideGround => super::super::slide_state::enter(physics, skater), + PhysicalStateId::WipeoutGround => super::super::wipeout_states::enter(physics, skater), + PhysicalStateId::Teleporting => { skater.teleport_state.enter(); Ok(()) }, + _ => unreachable!("state support checked before publication"), + } +} diff --git a/crates/skate-game/src/physics/player_state/wipeout_output.rs b/crates/skate-game/src/physics/player_state/wipeout_output.rs new file mode 100644 index 00000000..0c7b5fea --- /dev/null +++ b/crates/skate-game/src/physics/player_state/wipeout_output.rs @@ -0,0 +1,45 @@ +//! Wipeout300 Fill82D3EEE8 writes the shared completed physical packet. +use super::super::SkaterRuntime; + +pub(super) fn publish(skater: &mut SkaterRuntime) { + let output = super::super::wipeout_states::fill(skater); + let physical = &mut skater.player_input.physical; + let skeleton = &mut physical.skeleton; + skeleton.over_599 = u8::from(output.over_599); + skeleton.scalar_544 = output.scalar_544; + skeleton.no_support_time_548 = output.no_support_time_548; + skeleton.response_strength_580 = output.response_strength_580; + skeleton.extra_weight_584 = output.extra_weight_584; + skeleton.time_until_teleport_576 = output.time_until_teleport_576; + skeleton.teleport_pending_604 = u8::from(output.teleport_pending_604); + skeleton.hips_right_angle_496 = output.hips_right_angle_496; + skeleton.hips_up_angle_500 = output.hips_up_angle_500; + if let Some(value) = output.response_change_588 { + skeleton.response_change_588 = value; + skeleton.response_changed_605 = 1; + } + physical.animation.collision_time_144 = output.collision_time_144; + physical.animation.profile_148 = output.profile_148; + physical.collision.flag_3479 = u8::from(output.material_ten_3479); + physical.collision.flag_3480 = u8::from(output.material_eleven_3480); + physical.collision.flag_3483 = u8::from(output.imminent_surface_twelve_3483); + physical.collision.predicted_position_64 = output.predicted_position_64.map(f32::to_bits); + if let Some(velocity) = output.retained_air_velocity_160 { + physical.air.vector_160 = velocity.map(f32::to_bits); + physical.air.use_air_reckoning_452 = 1; + } + physical.state.surface_height_32 = output.surface_height_32; + let flags = &mut skater.player_state.state_flags; + for (offset, value) in [ + (72, output.can_leave_72), (81, output.special_surface_81), + (82, output.below_surface_82), (83, output.surface_height_valid_83), + ] { + flags[offset - 52] = value; + } + // These source branches only store true, preserving earlier publication. + if output.teleport_countdown_68 { flags[68 - 52] = true; } + if output.request_teleport_69 { + flags[69 - 52] = true; + physical.state.flag_69 = 1; + } +} diff --git a/crates/skate-game/src/physics/render_pose.rs b/crates/skate-game/src/physics/render_pose.rs new file mode 100644 index 00000000..fbd7e945 --- /dev/null +++ b/crates/skate-game/src/physics/render_pose.rs @@ -0,0 +1,100 @@ +//! Finish the actual physical pose and publish it to the single render skin. +//! Call after collision/error/COM output and before the next animation update. +use super::{GamePhysics, SkaterRuntime, solve::deck_frame}; +use skate_core::animation::{foot_ik::post_physics, output}; + +pub(super) fn publish( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + average_compression: [f32; 2], +) -> Result<(), String> { + let deck = deck_frame(&physics.board); + let p = &skater.player_input.processed; + let wiping_out = skater.skeleton_collision.is_ragdoll; + skater.skeleton_output.correction.apply( + &mut skater.skeleton, + p.vectors_544_560_592_608[0].map(f32::from_bits), + wiping_out, + p.flags_2468, + p.flags_2476, + ); + let board = skater + .skeleton_output + .advance_wobble(&mut skater.skeleton, &deck); + skater.foot_ik.post_physics( + &mut skater.skeleton, + post_physics::Input { + state_id: p.state_2508, + category_id: p.category_2512, + board_body_flag_868: physics.riding.ground.part_contact_count != 0, + wipeout: skater.wipeout.requests_wipeout(p), + flags_2468: p.flags_2468, + flags_2484: p.flags_2484, + value_2664: p.state_timer_2664, + state_2520: p.player_state_value_2520, + world_to_animation: &skater.animated_skeleton.roots.world_to_animation, + board: &board, + }, + ); + let feet = skater.foot_physical.publish( + &skater.skeleton.record, + physics.settings.step.simulation.time_step, + ); + let physical = &mut skater.player_input.physical; + physical.skeleton.flag_597 = u8::from(skater.foot_ik.state.contacts.support_failed); + physical.skeleton.flag_600 = u8::from(feet.within_deck_box[0]); + physical.skeleton.flag_601 = u8::from(feet.within_deck_box[1]); + physical.skeleton.anim_to_world_11920 = skater + .animated_skeleton + .roots + .animation_to_world + .map(|v| v.map(f32::to_bits)); + physical.reckoning.vector_16 = skater + .animated_skeleton + .board_frames + .com_velocity + .map(f32::to_bits); + physical.reckoning.vector_64 = skater + .animated_skeleton + .board_frames + .centre_of_mass + .map(f32::to_bits); + let up = physics.riding.reckoning.up; + physical.reckoning.vector_96 = [up.x, up.y, up.z, 0.0].map(f32::to_bits); + + // Use this tick's evaluated animation as the base so unmapped bones keep + // the real animation. The physical worker changes mapped bones and wheels. + let mut globals = skater + .animation + .evaluator + .hierarchy(&skater.animation.pose)?; + let mut locals: Vec<_> = skater + .animation + .pose + .iter() + .copied() + .map(output::sqt_to_matrix) + .collect(); + skater.skeleton_output.publish( + &skater.animated_skeleton, + &skater.skeleton, + &physics.board, + physics.settings.step.base_truck_transforms, + skater.ground.steering.targets, + average_compression, + &mut globals, + &mut locals, + )?; + // Native output changes truck/wheel locals after globals. Recompose those + // final locals for the host skin instead of displaying the stale globals. + output::compose_hierarchy_in_place( + locals.len() as i32, + &skater.animation.evaluator.frames.parents, + 0, + &mut locals, + ) + .map_err(|e| format!("Physical render hierarchy: {e:?}"))?; + skater.render_pose = locals; + skater.pose_generation = skater.pose_generation.wrapping_add(1); + Ok(()) +} diff --git a/crates/skate-game/src/physics/riding_outputs.rs b/crates/skate-game/src/physics/riding_outputs.rs new file mode 100644 index 00000000..dc3dfa60 --- /dev/null +++ b/crates/skate-game/src/physics/riding_outputs.rs @@ -0,0 +1,350 @@ +//! Live board/query/solver outputs used by the riding gameplay owner. +//! Physical calculations stay in core; this owns stock settings and scheduling +//! state. Solved contacts and line probes remain distinct native inputs. +mod probes; +pub(crate) use probes::BoardProbes; +use skate_core::{ + air::body_spin::{self, BodySpinState}, + graph::conditions::SpeedInputs, + math::Vector3, + physics::{ + board::BodyId, + board_ground::{BoardGroundState, WheelLineHit, WheelLineState, wheel_lines}, + board_motion_output::BoardMotionOutput, + board_runtime::BoardRuntime, + board_world::BoardWorld, + }, + point_graph::PointGraph, + riding::{ + ground_orientation::{ + GroundOrientation, GroundOrientationInput, GroundOrientationSettings, + }, + reckoning_frames::ReckoningFrames, + speed_and_slope::SpeedAndSlopeSettings, + }, +}; +use skate_data::collections::Collections; + +/// Pose-side results which the native Skeleton/complete Reckoning transform own. +/// They cannot be inferred from the board body's center of mass or its up axis. +#[derive(Clone, Copy, Debug)] +pub(crate) struct RidingPoseInputs { + /// Skeleton11008 -> Processed752 in82BD8918. + pub com_to_deck: Vector3, + ///Processed2812, supplied by the actual PhysBodySpin animation attribute. + pub body_spin: f32, +} + +pub(crate) struct RidingOutputs { + pub ground: BoardGroundState, + pub wheel_lines: WheelLineState, + pub probes: BoardProbes, + pub reckoning: GroundOrientation, + pub reckoning_frames: ReckoningFrames, + pub body_spin: BodySpinState, + pub motion: BoardMotionOutput, + orientation_settings: GroundOrientationSettings, + tilt_vs_rotation: PointGraph<8>, + tilt_vs_slope: PointGraph<8>, + speed_settings: SpeedAndSlopeSettings, + maximum_ground_angle: f32, + heading_adjust_factor: f32, + pending_wheel_queries: Option<[Option; 4]>, +} +impl RidingOutputs { + ///Board82C0D680 resets CollisionInfo and both probes, preserving7692. + pub fn reset_for_teleport(&mut self) { + let elapsed = self.ground.time_without_wheel_contact; + let drag = self.ground.wheel_linear_drag; + self.ground = BoardGroundState::default(); + self.ground.time_without_wheel_contact = elapsed; + self.ground.wheel_linear_drag = drag; + self.wheel_lines = WheelLineState::default(); + self.probes.reset_results(); + //Original82C0D680 preserves query handles203C..2050. EndBoard + //still consumes the batch submitted before this input-phase reset. + } + pub fn load( + data: &Collections, + board: &BoardRuntime, + processed_flags_2468: u32, + ) -> Result { + let f = |field| data.float("physics_reckoning", "default", field); + let control = |field| { + data.words::<4>("physics_reckoning", "default", field) + .map(|v| v.map(f32::from_bits)) + }; + let curve = |field| graph(data, "physics_reckoning", field); + let deck_curve = data + .words::<20>("physics_reckoning", "default", "DeckAngleUsageVsSpeed")? + .map(f32::from_bits); + let orientation_settings = GroundOrientationSettings { + ground_normal_smoothing: control("GroundNormalSmoothing")?, + up_vector_smoothing_slow: control("UpVectorSmoothingSlow")?, + up_vector_smoothing_fast: control("UpVectorSmoothingFast")?, + dynamic_up_vs_ground_y: curve("DynamicUpVsGroundY")?, + ground_vector_blend: curve("GroundVectorBlendGraph")?, + deck_angle_usage_vs_speed: PointGraph { + x: deck_curve[4..12].try_into().unwrap(), + y: deck_curve[12..20].try_into().unwrap(), + }, + up_vector_smoothing_vs_speed: curve("UpVectorSmoothingVsSpeed")?, + up_vector_max_delta_vs_speed: curve("UpVectorMaxDeltaVsSpeed")?, + ground_blend_max_delta: f("GroundVMaxBlendDelta")?, + up_vector_max_acceleration: f("UpVectorMaxAcceleration")?, + anti_wobble_damping: f("UpVectAntiWobbleDamping")?, + extra_side_damping: f("ExtraSideDamping")?, + minimum_wheels_for_ground_blend: data.integer( + "physics_reckoning", + "default", + "MinWheelsToUseGroundVector", + )? as i32, + }; + let speed_settings = SpeedAndSlopeSettings { + turn_torque_vs_speed: graph(data, "physics_heading", "TurnTorqueVsSpeed")?, + turn_torque_vs_slope: graph(data, "physics_heading", "TurnTorqueVsSlope")?, + heading_adjust_max_speed: data.float( + "physics_heading", + "default", + "HeadingAdjustMaxSpeed", + )?, + }; + let reckoning = GroundOrientation::new(&orientation_settings); + let motion = + BoardMotionOutput::from_board(board, reckoning.ground_normal, processed_flags_2468); + let ground = BoardGroundState::default(); + let heading_adjust_factor = + speed_settings.calculate(ground.wheel_normal.y, motion.forward_speed); + Ok(Self { + ground, + wheel_lines: WheelLineState::default(), + probes: BoardProbes::default(), + reckoning, + reckoning_frames: ReckoningFrames::new(), + body_spin: BodySpinState::new(), + motion, + orientation_settings, + tilt_vs_rotation: curve("TiltVsRotGround")?, + tilt_vs_slope: curve("TiltVsSlopeGround")?, + speed_settings, + maximum_ground_angle: f("MaxAllowedGroundNormalFromUp")?, + heading_adjust_factor, + pending_wheel_queries: None, + }) + } + + ///PostInput82DB5E10 uses preceding physical outputs before state updates. + pub fn prepare_input(&mut self) { + self.heading_adjust_factor = self + .speed_settings + .calculate(self.ground.wheel_normal.y, self.motion.forward_speed); + } + + ///Original82DB5E10 reads current Processed464.y and2612 after Skeleton. + pub fn update_input_heading(&mut self, normal_y: f32, speed: f32) -> f32 { + self.heading_adjust_factor = self.speed_settings.calculate(normal_y, speed); + self.heading_adjust_factor + } + + pub fn heading_adjust_factor(&self) -> f32 { + self.heading_adjust_factor + } + + ///Ground preupdate82D37C50 ->82D38018 submits82D4E250. The host computes + ///that job directly: its heading seed, BodySpin, up filters, full transform, + ///dynamic lean and tilt. This must complete before ground forces consume it. + pub fn update_ground_reckoning( + &mut self, + board: &BoardRuntime, + pose: RidingPoseInputs, + processed_flags_2468: u32, + animation_balance: f32, + coffin: bool, + ) { + let deck = board.part_transforms()[BodyId::Deck.index()]; + //82D4E250 overrides heading1200 from input752 (raw board Z). + //The source takes the previous final frame X for the damping step. + let previous_right = self.reckoning_frames.system[0]; + self.reckoning_frames.heading = [ + deck.basis.columns[2][0], + deck.basis.columns[2][1], + deck.basis.columns[2][2], + 0.0, + ]; + body_spin::update_ground(&mut self.body_spin, pose.body_spin); + let effective = BoardMotionOutput::from_board( + board, + self.reckoning.ground_normal, + processed_flags_2468, + ); + // PrepareBoardToolkit82C01718..1744 selects +1 only for ordered >0. + let direction = if self.motion.forward_speed > 0.0 { + 1.0 + } else { + -1.0 + }; + self.reckoning.update( + &self.orientation_settings, + GroundOrientationInput { + com_to_deck: pose.com_to_deck, + ground_normal: self.ground.wheel_normal, + dynamic_up: self.ground.overall_normal, + speed: self.motion.speed, + wheel_contact_count: i32::from(self.ground.wheel_contact_count), + animation_balance, + deck_angle_curve_input: self.motion.forward_speed * direction, + board_up: vector(deck.basis.columns[1]), + board_forward: vector(deck.basis.columns[2]), + effective_board_forward: vector(effective.effective_basis.columns[2]), + previous_reckoning_right: Vector3::new( + previous_right[0], + previous_right[1], + previous_right[2], + ), + prevent_up_behind_board: coffin, + }, + ); + let up = lanes(self.reckoning.up); + self.reckoning_frames + .calculate_transform(up, lanes(self.reckoning.ground_normal)); + self.reckoning_frames + .calculate_dynamic_lean(up, lanes(self.reckoning.dynamic_up)); + self.reckoning_frames.calculate_tilt( + processed_flags_2468 & 0x100000 != 0, + &self.tilt_vs_rotation, + &self.tilt_vs_slope, + ); + } + + /// Slide82D3A890 seeds1136/1200 from Processed528/96 and calls82D8C8F0 + /// directly. Its data block is exactly the Processed snapshot82D8E5C0. + pub fn update_slide_reckoning( + &mut self, + p: &skate_core::player::input_phase::ProcessedPhysicsInput, + toolkit: &skate_core::physics::board_toolkit::BoardToolkit, + physical_body_spin: f32, + balance: f32, + ) { + let raw = |v:[u32;4]| {let v=v.map(f32::from_bits);Vector3::new(v[0],v[1],v[2])}; + let xyz = |v:[f32;4]| Vector3::new(v[0],v[1],v[2]); + let previous_right = self.reckoning_frames.system[0]; + self.reckoning_frames.heading = toolkit.deck[2]; + self.reckoning.dynamic_up = raw(p.vectors_464_480_496_512_528[4]); + body_spin::update_ground(&mut self.body_spin, physical_body_spin); + self.reckoning.update(&self.orientation_settings, GroundOrientationInput { + com_to_deck: raw(p.animation_com_to_deck_752), + ground_normal: raw(p.vectors_464_480_496_512_528[0]), + dynamic_up: raw(p.vectors_464_480_496_512_528[4]), + speed: p.scalar_2652, + wheel_contact_count: p.wheel_count_2556 as i32, + animation_balance: balance, + deck_angle_curve_input: toolkit.absolute_speed, + board_up: xyz(toolkit.deck[1]), board_forward: xyz(toolkit.deck[2]), + effective_board_forward: xyz(toolkit.effective[2]), + previous_reckoning_right: xyz(previous_right), + prevent_up_behind_board: p.flags_2476 & 0x4000_0000 != 0, + }); + let up = lanes(self.reckoning.up); + self.reckoning_frames.calculate_transform(up, lanes(self.reckoning.ground_normal)); + self.reckoning_frames.calculate_dynamic_lean(up, lanes(self.reckoning.dynamic_up)); + self.reckoning_frames.calculate_tilt(p.flags_2468 & 0x100000 != 0, + &self.tilt_vs_rotation, &self.tilt_vs_slope); + } + + /// StartSkateboardLineTests82DB6310 ->82C07788: four current wheel positions, + /// each queried0.2m along negative Reckoning up, radius0 and static-world + /// backend. World8275EA20 submits these before actor SetUpPhysics. + pub fn start_wheel_queries( + &mut self, + board: &BoardRuntime, + world: &BoardWorld, + ) -> Result<(), String> { + if self.pending_wheel_queries.is_some() { + return Err("Wheel queries were started twice without result publication".into()); + } + let lines = wheel_lines(board, self.reckoning.up); + let mut results = [None; 4]; + for (i, line) in lines.into_iter().enumerate() { + results[i] = world + .query_thin_line(line.start, line.end) + .map_err(|e| format!("Wheel{i} stock line query: {e}"))? + .map(|hit| WheelLineHit { + fraction: hit.geometry.fraction, + normal: hit.geometry.normal, + surface_tag: hit.tag, + }); + } + self.probes.start(board, world)?; + self.pending_wheel_queries = Some(results); + Ok(()) + } + + ///World8275EB10 calls EndBoard8275E6E8 after PlayerInput and before + ///PostInput/state work. These results are available to this frame's state + ///selector, independently of the subsequent solve's contact reports. + pub fn finish_wheel_queries(&mut self) -> Result<(), String> { + let hits = self.pending_wheel_queries.take() + .ok_or("EndBoard requires its submitted wheel query batch")?; + self.wheel_lines.publish(hits); + self.probes.publish() + } + + ///UpdatePostPhysics82C07D20 consumes current solved reports. + ///Contact-dependent inertia drag affects the next step. ProcessOutput + ///82C02A80 publishes actual body rates with filtered normal1216. + pub fn finish_post_physics( + &mut self, + board: &mut BoardRuntime, + board_wiping_out: bool, + processed_flags_2468: u32, + time_step: f32, + ) -> Result<(), String> { + self.ground.update( + board.contact_reports(), + &self.wheel_lines, + self.reckoning.up, + self.maximum_ground_angle, + board_wiping_out, + ); + self.ground.sample_accelerations( + core::array::from_fn(|i| board.bodies()[i].rates.linear_velocity), + time_step, + ); + self.ground.advance_contact_time(time_step); + for (body, drag) in board.bodies_mut()[..4] + .iter_mut() + .zip(self.ground.wheel_linear_drag) + { + body.inertia.linear_drag = drag; + } + self.motion = BoardMotionOutput::from_board( + board, + self.reckoning.ground_normal, + processed_flags_2468, + ); + Ok(()) + } + + pub fn graph_speeds(&self) -> SpeedInputs { + SpeedInputs { + speed: self.motion.ground_speed, + forward_speed: self.motion.forward_speed, + speed_and_slope: self.heading_adjust_factor, + } + } +} +fn vector(v: [f32; 3]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn lanes(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} +fn graph(data: &Collections, class: &str, field: &str) -> Result, String> { + let words = data + .words::<16>(class, "default", field)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[..8].try_into().unwrap(), + y: words[8..].try_into().unwrap(), + }) +} diff --git a/crates/skate-game/src/physics/riding_outputs/probes.rs b/crates/skate-game/src/physics/riding_outputs/probes.rs new file mode 100644 index 00000000..e6a579d5 --- /dev/null +++ b/crates/skate-game/src/physics/riding_outputs/probes.rs @@ -0,0 +1,120 @@ +//! The fifth/sixth board probes, kept in the same query/publication phase as +//! the wheel probes. Geometry is the world's recovered triangle dispatcher. +use skate_core::{ + math::Vector3, + physics::{ + board::BodyId, + board_ground::WheelLine, + board_probes::{self, BoardProbeHit, BoardProbeState, WallLineInput}, + board_runtime::BoardRuntime, + board_toolkit::BoardToolkit, + board_world::{BoardWorld, WorldLineHit}, + }, + player::input_phase::ProcessedPhysicsInput, +}; + +pub(crate) struct BoardProbes { + pub deck: BoardProbeState, + pub wall: BoardProbeState, + wall_line: Option, + pending: Option, +} +struct Pending { + deck: Option, + wall: Option>, +} +impl Default for BoardProbes { + fn default() -> Self { + //Skateboard ctor82C0111C starts optional wall line byte291 clear. + Self { + deck: BoardProbeState::default(), + wall: BoardProbeState::default(), + wall_line: None, + pending: None, + } + } +} +impl BoardProbes { + ///ResetBoardBody82C0D680 clears published probe records, preserving + ///query handles204C/2050 and the wall-query enable bit. + pub fn reset_results(&mut self) { + self.deck = BoardProbeState::default(); + self.wall = BoardProbeState::default(); + } + pub fn wall_contact_frame( + &self, + time2752: f32, + time2756: f32, + ) -> skate_core::riding::grounded::state::board_types::GroundContactFrame { + let v = |p: skate_core::math::Vector3| [p.x, p.y, p.z, 0.]; + skate_core::riding::grounded::state::board_types::GroundContactFrame { + vector_8032: v(self.wall.start), + vector_8048: v(self.wall.point), + vector_8064: v(self.wall.normal), + word_8080: self.wall.surface_tag, + flag_8084: self.wall.hit, + scalar_2752: time2752, + scalar_2756: time2756, + } + } + /// PostPhysics82C02138 calls82C01F10 before updating board contact output. + /// Its saved endpoints and enabled291 feed the NEXT StartBoard82DB6310. + /// Processed112 is the input phase's cached deck translation, published by + ///82C01460, not the newly solved body's current transform. + pub fn prepare_wall(&mut self, input: &ProcessedPhysicsInput, toolkit: &BoardToolkit) { + let vector = |value: [f32; 4]| Vector3::new(value[0], value[1], value[2]); + self.wall_line = board_probes::wall_probe(WallLineInput { + state: input.state_2508, + contact_normal: vector(input.vectors_464_480_496_512_528[0].map(f32::from_bits)), + skater_up: vector(input.vectors_544_560_592_608[0].map(f32::from_bits)), + deck_position: vector(toolkit.deck[3]), + time: input.time_on_ground_2752, + }); + } + pub fn start(&mut self, board: &BoardRuntime, world: &BoardWorld) -> Result<(), String> { + if self.pending.is_some() { + return Err("Board probes submitted twice without publication".into()); + } + let line = + board_probes::deck_probe(board.part_transforms()[BodyId::Deck.index()].translation); + self.deck.start(line.start); + let deck = world + .query_swept_line(line.start, line.end, board_probes::DECK_PROBE_RADIUS) + .map_err(|e| format!("Stock deck ground probe: {e}"))? + .map(hit); + let wall = if let Some(line) = self.wall_line { + self.wall.start(line.start); + Some( + world + .query_thin_line(line.start, line.end) + .map_err(|e| format!("Stock wall floor probe: {e}"))? + .map(hit), + ) + } else { + self.wall.disable(); + None + }; + self.pending = Some(Pending { deck, wall }); + Ok(()) + } + pub fn publish(&mut self) -> Result<(), String> { + let pending = self + .pending + .take() + .ok_or("Board probe publication requires a submitted batch")?; + self.deck.publish(pending.deck); + if let Some(result) = pending.wall { + self.wall.publish(result); + } else { + self.wall.disable(); + } + Ok(()) + } +} +fn hit(hit: WorldLineHit) -> BoardProbeHit { + BoardProbeHit { + point: hit.geometry.position, + normal: hit.geometry.normal, + surface_tag: hit.tag, + } +} diff --git a/crates/skate-game/src/physics/settings.rs b/crates/skate-game/src/physics/settings.rs new file mode 100644 index 00000000..e11a461e --- /dev/null +++ b/crates/skate-game/src/physics/settings.rs @@ -0,0 +1,149 @@ +//! Stock skater XML values bound to the recovered physical calculations. +use skate_core::{ + math::Vector3, + physics::{ + board_step::BoardStepSettings, + contact::RetailContactMaterial, + drive_frames::{ + AuthoredTransformInputs, RetailAffineTransform, RetailTruckTransformInputs, + authored_body_transforms, calculate_truck_transforms, + }, + drive_parameters::{RetailTruckDriveSettings, retail_truck_drive_dynamics}, + mass::{ + DeckGeometry, DeckGeometrySettings, MassShape, TruckMassSettings, WheelMassSettings, + deck_mass_properties, truck_mass_input, truck_mass_properties, wheel_mass_properties, + }, + rigid_body::{RetailBodyMassProperties, RetailSimulationStep}, + }, +}; +use skate_data::collections::Collections; + +pub(crate) struct PhysicsSettings { + pub step: BoardStepSettings, + pub masses: [RetailBodyMassProperties; 7], + pub authored: [RetailAffineTransform; 7], + pub deck_geometry: DeckGeometry, + pub truck_shape: MassShape, + pub truck_collisions: bool, + pub truck_material: RetailContactMaterial, + pub deck_material: RetailContactMaterial, + pub wheel_radius: f32, + pub wheel_material: RetailContactMaterial, + pub standard_wheel_material: RetailContactMaterial, + pub floor_material: RetailContactMaterial, + pub input_magnitude_threshold: f32, +} + +impl PhysicsSettings { + pub fn load(data: &Collections) -> Result { + let f = |class, field| data.float(class, "default", field); + let b = |class, field| data.boolean(class, "default", field); + let mass_factor = f("physics_world", "SkateboardMassFactor")?; + let wheel_radius = f("physicswheels", "WheelRadius")?; + let wheel_x_distance = f("physicswheels", "WheelXDist")?; + let geometry = AuthoredTransformInputs { + deck_mid_length: f("physicsdeck", "DeckMidLength")?, + wheel_x_distance, + truck_z_position_front: f("physicstrucks", "TruckZPosFront")?, + truck_z_position_back: f("physicstrucks", "TruckZPosBack")?, + truck_y_position: f("physicstrucks", "TruckYPos")?, + }; + let wheel = wheel_mass_properties(WheelMassSettings { + radius: wheel_radius, + mass: f("physicswheels", "WheelMass")?, + mass_factor, + }); + let truck_settings = TruckMassSettings { + wheel_radius, + wheel_x_distance, + mass_factor, + radius_scalar: f("physicstrucks", "RadiusScalar")?, + half_height_scalar: f("physicstrucks", "HalfHeightScalar")?, + mass: f("physicstrucks", "TruckMass")?, + }; + let truck = truck_mass_properties(truck_settings); + let gravity = data + .words::<4>("physics", "default", "WorldGravity")? + .map(f32::from_bits); + let simulation = RetailSimulationStep::fixed_60_hz( + 0, // Our host keeps its single board active; sleeping is not scheduled. + f("physics", "FreezingEnergy")?, + Vector3::new(gravity[0], gravity[1], gravity[2]), + ); + let deck_geometry = DeckGeometry::new(DeckGeometrySettings { + width: f("physicsdeck", "DeckWidth")?, + mid_length: geometry.deck_mid_length, + thickness: f("physicsdeck", "DeckThickness")?, + back_end_size: f("physicsdeck", "DeckBackEndSize")?, + front_end_angle_degrees: f("physicsdeck", "DeckFrontEndAngle")?, + back_end_angle_degrees: f("physicsdeck", "DeckBackEndAngle")?, + end_capsule_count: data.integer("physicsdeck", "default", "DeckEndCapsules")? as i32, + enable_deck_volume_collisions: b("physicsdeck", "DeckEnableDeckVolumeCollisions")?, + enable_end_volume_collisions: b("physicsdeck", "DeckEnableEndVolumeCollisions")?, + }); + let deck = deck_mass_properties( + &deck_geometry, + f("physicsdeck", "DeckMass")? * mass_factor, + f("physicsdeck", "DeckAngularDrag")? * simulation.frequency, + ); + let step = BoardStepSettings { + simulation, + iterations: data.integer("physics", "default", "RWMaxIterations")?, + base_truck_transforms: calculate_truck_transforms(RetailTruckTransformInputs { + deck_mid_length: geometry.deck_mid_length, + truck_z_position_front: geometry.truck_z_position_front, + truck_z_position_back: geometry.truck_z_position_back, + truck_y_position: geometry.truck_y_position, + truck_rotation_axis_angle_degrees: f("physicstrucks", "TruckRotationAxisAngle")?, + }), + truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings { + use_linear_drives: b("physicstrucks", "UseTruckDrives")?, + use_hard_linear_drives: b("physicsdeck", "UseHardDrives")?, + angular_displacement: f("physicstrucks_drives", "Angular_Hard_Displacement")?, + angular_damping: f("physicstrucks_drives", "Angular_Hard_Damping")?, + angular_strength: f("physicstrucks_drives", "Angular_Hard_Strength")?, + }), + force_point_y_offset: f("physicsdeck", "DeckForceYOffset")?, + }; + if wheel_radius <= 0.0 || mass_factor <= 0.0 || step.iterations == 0 { + return Err("Invalid stock board radius, mass factor or solver iterations".into()); + } + Ok(Self { + step, + masses: [wheel, wheel, wheel, wheel, truck, truck, deck], + authored: authored_body_transforms(geometry), + deck_geometry, + truck_shape: truck_mass_input(truck_settings).shape, + truck_collisions: b("physicstrucks", "TruckEnableVolumeCollisions")?, + truck_material: RetailContactMaterial { + static_friction: f("physicstrucks", "TruckStaticFriction")?, + dynamic_friction: f("physicstrucks", "TruckDynamicFriction")?, + restitution: f("physicstrucks", "TruckRestitution")?, + }, + deck_material: RetailContactMaterial { + static_friction: f("physicsdeck", "DeckStaticFriction")?, + dynamic_friction: f("physicsdeck", "DeckDynamicFriction")?, + restitution: f("physicsdeck", "DeckRestitution")?, + }, + wheel_radius, + wheel_material: RetailContactMaterial { + static_friction: f("physicswheels", "WheelStaticFriction")?, + dynamic_friction: f("physicswheels", "WheelDynamicFriction")?, + restitution: f("physicswheels", "WheelRestitution")?, + }, + standard_wheel_material: RetailContactMaterial { + static_friction: f("physicswheels", "WheelStaticFriction")?, + dynamic_friction: f("physicswheels", "WheelDynamicFriction")?, + restitution: f("physicswheels", "WheelRestitution")?, + }, + floor_material: RetailContactMaterial { + // Ground job8277C5D8 uses context83034F34/38/3C. The + // max/max/min combine preserves the moving volume material. + static_friction: 0.0, + dynamic_friction: 0.0, + restitution: 1.0, + }, + input_magnitude_threshold: f("inputlistener", "StickMagnitudeMinToCountHeld")?, + }) + } +} diff --git a/crates/skate-game/src/physics/skateboard_controller.rs b/crates/skate-game/src/physics/skateboard_controller.rs new file mode 100644 index 00000000..f548d625 --- /dev/null +++ b/crates/skate-game/src/physics/skateboard_controller.rs @@ -0,0 +1,12 @@ +//! Actual hand-controller lifecycle and board contact producer binding. +use skate_core::physics::board_ground::BoardGroundState; +pub(crate) use skate_core::physics::skateboard_controller::SkateboardController; + +pub(crate) fn partial_request( + controller: &SkateboardController, + ground: &BoardGroundState, +) -> bool { + //82C08450..8494 counts Board844..850 into byte868. This is not a + //geometric overlap estimate or the separate four-wheel count869. + controller.request_partial_ragdoll(ground.part_contact_count) +} diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs new file mode 100644 index 00000000..85e49669 --- /dev/null +++ b/crates/skate-game/src/physics/skater.rs @@ -0,0 +1,243 @@ +//! The game's single skater: stock graph animation and its physical skeleton. +//! Loading creates the real bodies from the animator's initial evaluated pose. +use super::{ + GamePhysics, + animated_skeleton::AnimatedSkeleton, + animation_feedback::AnimationFeedback, + animation_input::AnimationInput, + foot_ik::FootIk, + foot_physical_output::FootPhysicalOutputs, + ground_runtime::{GroundRuntime, GroundSettings, GroundState}, + player_input::{NoGrindEdges, PlayerInputRuntime}, + skeleton_body, + skeleton_input_runtime::SkeletonInputRuntime, + skeleton_output::SkeletonOutput, +}; +use crate::{graph_runtime::StockGraphs, skater_animation::SkaterAnimation}; +use bevy::prelude::Resource; +use skate_core::physics::{ + board::BodyId, + skeleton_animation_record::{IDENTITY, map_animation_parts}, + skeleton_body::{ + SkeletonBody, SkeletonCollisionMode, SkeletonDriveBatch, SkeletonDrives, SkeletonJoints, + }, +}; +use skate_data::collections::Collections; +use std::path::Path; + +#[derive(Resource)] +pub(crate) struct SkaterRuntime { + /// Completed physical pose in native animation space, read by rendering. + pub render_pose: Vec, + pub pose_generation: u64, + pub centre_of_mass_filter: skate_core::physics::centre_of_mass_filter::CentreOfMassFilter, + pub centre_of_mass_output: skate_core::physics::centre_of_mass_filter::CentreOfMassOutput, + pub animation: SkaterAnimation, + pub animated_skeleton: AnimatedSkeleton, + pub skeleton_air: super::skeleton_air::SkeletonAir, + pub air_reckoning: super::air_reckoning::AirReckoning, + pub air_state: skate_core::air::state::PhysicsAirState, + pub air_settings: super::air_phase::AirSettings, + pub known_air: super::known_air::KnownAir, + pub ground_animation: super::ground_animation::GroundAnimationRuntime, + pub ground_animation_settings: super::ground_animation::GroundAnimationSettings, + pub slide_state: super::slide_state::SlideState, + pub trajectory: super::air_trajectory::AirTrajectoryRuntime, + pub footplant: super::footplant::Footplant, + pub wipeout: super::wipeout::Wipeout, + pub wipeout_state: super::wipeout_states::WipeoutState, + pub teleport_state: super::teleport_state::Runtime, + pub skeleton: SkeletonBody, + pub skeleton_joints: SkeletonJoints, + pub skeleton_drives: SkeletonDrives, + pub skeleton_collision: SkeletonCollisionMode, + pub collision_feedback: skate_core::physics::skeleton_body::SkeletonCollisionFeedback, + pub pose_errors: skate_core::physics::skeleton_body::SkeletonPoseErrors, + pub collision_pose_error: [f32; 4], + ///Skeleton16384 is published by the completed pose-error response. Wipeout + ///runs after that publication; no fabricated pre-solve measurement exists. + pub collision_maximum_error: Option, + pub solved_drives: Option, + pub foot_ik: FootIk, + pub foot_physical: FootPhysicalOutputs, + pub player_input: PlayerInputRuntime, + pub player_state: super::player_state::PlayerState, + pub skeleton_input: SkeletonInputRuntime, + pub ground: GroundState, + pub ground_runtime: GroundRuntime, + pub ground_settings: GroundSettings, + pub ground_lifecycle: super::ground_phase::GroundLifecycle, + pub animation_input: AnimationInput, + pub animation_feedback: AnimationFeedback, + pub physical_feedback: skate_core::animation::physical_feedback::PhysicalFeedback, + pub landing_quality: skate_core::animation::landing_quality::Output, + pub landing_quality_settings: skate_core::animation::landing_quality::Settings, + pub skeleton_output: SkeletonOutput, + pub skateboard_controller: super::skateboard_controller::SkateboardController, +} + +impl SkaterRuntime { + pub fn load( + asset_root: &Path, + graphs: &StockGraphs, + physics: &GamePhysics, + mode: &str, + ) -> Result { + let data = Collections::load(asset_root)?; + // The host's current character is a custom skater with no pro selector + // or equipped physical hat. These are profile choices, not force values. + let mut animation = SkaterAnimation::load(asset_root, &data, graphs, b"")?; + let initial_hierarchy = animation.evaluate_initial_pose()?; + let mut animated_skeleton = + AnimatedSkeleton::load(asset_root, &data, &animation.evaluator.frames, false)?; + let initial_parts = map_animation_parts( + &initial_hierarchy, + &animated_skeleton.bone_indices, + &animated_skeleton.physics_frames, + )?; + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + let mut spawn = IDENTITY; + for (column, axis) in deck.basis.columns.iter().enumerate() { + spawn[column][..3].copy_from_slice(axis); + } + spawn[3] = [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ]; + let mut skeleton = skeleton_body::load( + asset_root, + &data, + &animation.evaluator.frames.source_sha256, + &initial_parts, + spawn, + physics.settings.step.simulation, + None, + )?; + animated_skeleton + .roots + .reset_initial_alignment(skeleton.animation_to_world); + animated_skeleton.board_frames.reset(spawn); + for _ in 0..2 { + animated_skeleton.board_frames.publish_centre_of_mass( + skeleton.record.centre_of_mass, + physics.settings.step.simulation.time_step, + 0, + ); + } + animated_skeleton + .board_frames + .publish_local_observations(&animated_skeleton.roots, &spawn); + let animation_input = AnimationInput::load(&data, &animation.evaluator.frames, mode)?; + let skeleton_joints = skeleton_body::load_joints( + asset_root, + &data, + &animation.evaluator.frames.source_sha256, + &initial_hierarchy, + &animation.evaluator.frames.parents, + &animated_skeleton.bone_indices, + )?; + let mut skeleton_drives = skeleton_body::load_drives( + asset_root, + &data, + &animation.evaluator.frames.source_sha256, + &initial_hierarchy, + &animated_skeleton.bone_indices, + &initial_parts, + &skeleton_joints, + skeleton.animation_to_world, + spawn, + physics.settings.step.simulation, + )?; + // Reset82BD9990 finishes by publishing both COM targets to the + // actual extra bodies. These bodies subsequently share the solver. + let initial_targets = skeleton_drives.targets.update_extra_targets( + &mut skeleton, + &animated_skeleton.board_frames.com_frame, + &animated_skeleton.board_frames.lifted_com_frame, + ); + let foot_ik = FootIk::load(&data, &animation.evaluator.frames, &animated_skeleton)?; + let skeleton_output = + SkeletonOutput::load(&data, &animation.evaluator.frames, &animated_skeleton)?; + // Actor82591448 passes network || ghost for self-pair suppression. + // This local game has neither networking nor a ghost actor. + let skeleton_collision = skeleton_body::load_collision( + asset_root, + &data, + &animation.evaluator.frames.source_sha256, + false, + )?; + let wipeout_state = super::wipeout_states::WipeoutState::load( + &data, asset_root, &animation.evaluator.frames.source_sha256, + )?; + Ok(Self { + render_pose: initial_hierarchy, + pose_generation: 0, + centre_of_mass_filter: Default::default(), + // PhysOut reset82DE53F0 clears these observations before first output. + centre_of_mass_output: skate_core::physics::centre_of_mass_filter::CentreOfMassOutput { + velocity: [0.0; 4], + acceleration: [0.0; 4], + position: [0.0; 4], + }, + animation, + landing_quality: Default::default(), + landing_quality_settings: super::landing_quality::load(&data)?, + animated_skeleton, + skeleton_air: super::skeleton_air::SkeletonAir::load(&data)?, + air_reckoning: super::air_reckoning::AirReckoning::load(&data)?, + air_state: Default::default(), + air_settings: super::air_phase::AirSettings::load(&data)?, + known_air: super::known_air::KnownAir::load(&data)?, + ground_animation: Default::default(), + ground_animation_settings: super::ground_animation::GroundAnimationSettings::load(&data)?, + slide_state: super::slide_state::SlideState::load(&data)?, + trajectory: super::air_trajectory::AirTrajectoryRuntime::load(&data)?, + footplant: super::footplant::Footplant::load(&data)?, + wipeout: super::wipeout::Wipeout::load(&data)?, + wipeout_state, + teleport_state: super::teleport_state::Runtime::new(super::teleport_state::Checkpoint { + transform: spawn, on_board: true, + }), + skeleton, + skeleton_joints, + skeleton_drives, + collision_feedback: skeleton_body::load_feedback(&data, skeleton_collision.settings)?, + skeleton_collision, + pose_errors: skate_core::physics::skeleton_body::SkeletonPoseErrors { + targets: [ + initial_targets.com, + initial_targets.lifted_com, + initial_targets.following_com, + ], + ..Default::default() + }, + collision_pose_error: [0.0; 4], + collision_maximum_error: None, + solved_drives: None, + foot_ik, + foot_physical: FootPhysicalOutputs::load(&data)?, + player_input: PlayerInputRuntime::load(&data, NoGrindEdges)?, + player_state: super::player_state::PlayerState::load(&data, mode)?, + skeleton_input: SkeletonInputRuntime { + drive_frames: initial_parts, + extra_target_positions: [ + initial_targets.com, + initial_targets.lifted_com, + initial_targets.following_com, + ], + ..Default::default() + }, + ground: GroundState::load(&data, mode, true)?, + ground_runtime: GroundRuntime::load(&data)?, + ground_settings: GroundSettings::load(&data, mode, "smooth")?, + ground_lifecycle: super::ground_phase::GroundLifecycle::new(), + animation_input, + animation_feedback: AnimationFeedback::load(&data)?, + physical_feedback: super::animation_phase::initial_feedback(), + skeleton_output, + skateboard_controller: super::skateboard_controller::SkateboardController::new(), + }) + } +} diff --git a/crates/skate-game/src/physics/skeleton_air.rs b/crates/skate-game/src/physics/skeleton_air.rs new file mode 100644 index 00000000..19f8b761 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_air.rs @@ -0,0 +1,178 @@ +//! Production animated-board and COM-controlled air Skeleton updates. +//! Original TU3 82BDDA10 / 82BDE600. All owners are the same objects used by +//! ProcessData, GeneralUpdate, the shared solve and final pose publication. +use super::skeleton_input_runtime::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}; +use skate_core::{ + animation::output::{NativeMatrix, physics_packet::PhysicsPosePacket}, + math::{Basis3, Vector3}, + physics::{ + board::BodyId, + board_animation::{BoardAnimation, BoardAnimationSettings, target_velocity}, + board_runtime::BoardRuntime, + drive_frames::RetailAffineTransform, + rigid_body::RetailSimulationStep, + skeleton_air_frames::{self, AirDismountRevert}, + skeleton_animation_record::AnimationPartTransform as Transform, + }, + player::input_phase::ProcessedPhysicsInput, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +///The original board's persistent blend history, shared across Ground/Air +///state changes. This must live alongside the physical board, not per entry. +pub(crate) struct SkeletonAir { + pub board_animation: BoardAnimation, + settings: BoardAnimationSettings, +} +impl SkeletonAir { + pub fn load(data: &Collections) -> Result { + let graph = |name| -> Result, String> { + //Original82C01900: Globals260 layout4, graphs0/80; + //stock physics_airstates/default has80-byte PointNegGraphData8. + let words = data + .words::<20>("physics_airstates", "default", name)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[4..12].try_into().unwrap(), + y: words[12..20].try_into().unwrap(), + }) + }; + Ok(Self { + board_animation: BoardAnimation::default(), + settings: BoardAnimationSettings { + slow: graph("PhysToAnimSlow")?, + fast: graph("PhysToAnimFast")?, + }, + }) + } + + ///Original82D38000 GroundUpdate ->82D38008 UpdatePhysicsError. Call after + ///Skeleton Ground GeneralUpdate and before the physical solve, every tick. + pub fn capture_physics_error(&mut self, board: &BoardRuntime, animated_target: &Transform) { + self.board_animation.capture_physics_error( + animated_target, + &matrix(board.part_transforms()[BodyId::Deck.index()]), + ); + } + + ///82C0606C; preserves the source's independently retained blending flag. + pub fn reset_board(&mut self) { + self.board_animation.reset(); + } + + ///82C041C0: publish the effective matrix to the separate deck constraint + ///anchor. It does not teleport the dynamic board assembly. + fn apply_board( + &mut self, + board: &mut BoardRuntime, + target: &Transform, + fast: bool, + ) -> Transform { + let effective = self.board_animation.apply(target, fast, &self.settings); + board.set_hook_transform(affine(effective)); + effective + } +} + +impl SkeletonInputRuntime { + ///82BDDA10. PhysicsAir passes fast=false; GroundAnimation passes true. + ///Returns Processed0..48 / Skeleton15952, the UNBLENDED animation target. + ///The effective blended matrix is published to Skeleton12496 separately. + pub fn update_animated( + &mut self, + air: &mut SkeletonAir, + board: &mut BoardRuntime, + reckoning: &Transform, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + fast_blend: bool, + ) -> Result { + let s = &mut owners.animated; + //Unlike Ground82BDF530, this does NOT force initialize_heading=true. + s.update_roots(board, reckoning, p.timestep_2604); + let target = skeleton_air_frames::prepare_animated( + &s.roots, + &mut s.board_frames, + &self.drive_frames[0], + &mut p.flags_2468, + ); + s.board_frames.physical_board = air.apply_board(board, &target, fast_blend); + self.general_update(p, owners, globals, collision, simulation)?; + //82BDDC20..54 clears only next trajectory, after GeneralUpdate. + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(target) + } + + ///82BDE600. target_com is PhysicsAir's actually integrated COM trajectory + ///position (82D34A28 r5), not a predicted board position or skeleton root. + ///The animation packet is the real82B985E8 publication, including its + ///consumed dismount-revert request and retained frame count. + pub fn update_known_air( + &mut self, + air: &mut SkeletonAir, + board: &mut BoardRuntime, + reckoning: &Transform, + target_com: [f32; 4], + packet: &PhysicsPosePacket, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + ) -> Result { + let s = &mut owners.animated; + skeleton_air_frames::update_known_air_roots( + &mut s.roots, + reckoning, + target_com, + s.record.centre_of_mass, + AirDismountRevert { + requested: packet.flags & (1 << 28) != 0, + //The native load zero-extends all32bits before fcfid. + frames: packet.air_dismount_revert_frames as u32, + goofy: p.flags_2476 & (1 << 2) != 0, + }, + ); + let target = skeleton_air_frames::prepare_known_air( + &s.roots, + &mut s.board_frames, + &self.drive_frames[0], + &mut p.flags_2468, + ); + let effective = air.apply_board(board, &target, true); + s.board_frames.physical_board = effective; + //82C04270 uses the deck BODY COM, not Part6's mass-offset transform. + let pos = board.bodies()[BodyId::Deck.index()].rates.position; + let velocity = target_velocity(effective[3], [pos.x, pos.y, pos.z, 0.0], p.timestep_2604); + for body in board.bodies_mut() { + body.rates.linear_velocity = Vector3::new(velocity[0], velocity[1], velocity[2]); + } + skeleton_air_frames::finish_known_air(&mut s.roots, &mut s.board_frames, effective); + self.general_update(p, owners, globals, collision, simulation)?; + owners.animation_input.fields.flags2468 = p.flags_2468; + //KnownAir does not reset next_trajectory at the end of this method. + Ok(target) + } +} + +fn matrix(t: RetailAffineTransform) -> Transform { + let mut m = [[0.0; 4]; 4]; + for i in 0..3 { + m[i][..3].copy_from_slice(&t.basis.columns[i]); + } + m[3] = [t.translation.x, t.translation.y, t.translation.z, 0.0]; + m +} +fn affine(m: Transform) -> RetailAffineTransform { + RetailAffineTransform { + basis: Basis3 { + columns: std::array::from_fn(|i| m[i][..3].try_into().unwrap()), + }, + translation: Vector3::new(m[3][0], m[3][1], m[3][2]), + } +} diff --git a/crates/skate-game/src/physics/skeleton_body.rs b/crates/skate-game/src/physics/skeleton_body.rs new file mode 100644 index 00000000..8424c8c4 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_body.rs @@ -0,0 +1,309 @@ +//! Stock physical body construction. Initial mapped pose and spawn are real +//! actor/physics inputs supplied by the coordinator; no pose or COM fallback. +use skate_core::{ + math::Vector3, + physics::{ + rigid_body::RetailSimulationStep, + skeleton_animation_record::AnimationPartTransform, + skeleton_body::{ + BoneSettings, HatGeometry, SkeletonBody, SkeletonBodyDefinition, SkeletonBodySettings, + }, + }, +}; +use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; +use std::path::Path; +#[path = "skeleton_body_drive_settings.rs"] +mod drive_settings; +#[path = "skeleton_body_collision_settings.rs"] +mod collision_settings; + +pub(crate) fn load_collision(asset_root: &Path, data: &Collections, bank_sha: &str, + cull_all_self_pairs: bool) -> Result { + Ok(skate_core::physics::skeleton_body::SkeletonCollisionMode::new_normal( + collision_settings::load(asset_root, data, bank_sha)?, cull_all_self_pairs)) +} + +pub(crate) fn load_feedback(data: &Collections, + settings: skate_core::physics::skeleton_body::SkeletonCollisionSettings) + -> Result { + Ok(skate_core::physics::skeleton_body::SkeletonCollisionFeedback::new( + collision_settings::feedback(data,settings)?)) +} + +/// Build the complete original26-part drive owner using the already-resolved +/// joint topology. The initial bone matrices precede physical-volume mapping. +pub(crate) fn load_drives( + asset_root: &Path, + data: &Collections, + animation_bank_sha256: &str, + initial_hierarchy: &[AnimationPartTransform], + bone_indices: &[usize; 24], + initial_mapped: &[AnimationPartTransform; 24], + joints: &skate_core::physics::skeleton_body::SkeletonJoints, + animation_to_world: AnimationPartTransform, + spawn: AnimationPartTransform, + simulation: RetailSimulationStep, +) -> Result { + use skate_core::physics::skeleton_body::{SkeletonDriveSettings, SkeletonDrives}; + if bone_indices.iter().any(|i| *i >= initial_hierarchy.len()) { + return Err("Skeleton drive initial hierarchy has missing bones".into()); + } + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + animation_bank_sha256, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Skeleton drives require24 bone records".into()); + } + let mut parents = [None; 24]; + for joint in &joints.records { + parents[joint.child] = Some(joint.parent); + } + let initial_bones = std::array::from_fn(|i| initial_hierarchy[bone_indices[i]]); + let mut collision_strength = [[0.0; 2]; 24]; + //82BE7B98..BAC builds057EDDFA341F9492=CollisionDriveScalar. + let scalar = data.float("physics_skeleton_drives", "default", "CollisionDriveScalar")?; + for part in 1..24 { + let name = format!("PART_{}", skeleton.bones[part].name); + let words = data.words::<9>("physics_skeleton_drives", "default", &name)?; + collision_strength[part] = [ + f32::from_bits(words[5]) * scalar, + f32::from_bits(words[6]) * scalar, + ]; + } + let settings = SkeletonDriveSettings { + bone: drive_settings::load(data)?, + enabled: data.boolean("animation", "default", "Use_Drives")?, + strength: [ + data.float("animation", "default", "DriveStrengthLocal")?, + data.float("animation", "default", "DriveStrengthRootLocal")?, + ], + collision_strength, + }; + SkeletonDrives::new( + &initial_bones, + initial_mapped, + &parents, + animation_to_world, + spawn, + simulation, + settings, + ) + .map_err(str::to_string) +} + +/// SkeletonData::Init82BD6C40 / closest physical ancestor82BD3720. +/// Both the rig pose and hierarchy are the actor's evaluated initial RIG_TPOSE. +pub(crate) fn load_joints( + asset_root: &Path, + data: &Collections, + animation_bank_sha256: &str, + initial_hierarchy: &[AnimationPartTransform], + hierarchy_parents: &[i32], + bone_indices: &[usize; 24], +) -> Result { + use skate_core::physics::{ + skeleton_animation_record::physics_bone_frame, + skeleton_body::{JointBone, JointSettings, SkeletonJointSettings, SkeletonJoints}, + }; + if hierarchy_parents.len() != initial_hierarchy.len() + || bone_indices.iter().any(|i| *i >= initial_hierarchy.len()) + { + return Err("Physical joint hierarchy differs from initial rig pose".into()); + } + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + animation_bank_sha256, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Physical joints require24 bone records".into()); + } + let initial_bones = std::array::from_fn(|i| initial_hierarchy[bone_indices[i]]); + let mut parents = [None; 24]; + for child in 0..24 { + let mut parent = hierarchy_parents[bone_indices[child]]; + let mut visited = 0; + while parent != -1 { + let index = usize::try_from(parent).map_err(|_| "Invalid animation parent index")?; + if index >= hierarchy_parents.len() || visited >= hierarchy_parents.len() { + return Err("Invalid or cyclic animation hierarchy".into()); + } + if let Some(physical) = bone_indices.iter().position(|bone| *bone == index) { + parents[child] = Some(physical); + break; + } + parent = hierarchy_parents[index]; + visited += 1; + } + } + let bones = std::array::from_fn(|i| { + let bone = &skeleton.bones[i]; + let [x, y, z] = bone.translation(); + JointBone { + parent_orientation: std::array::from_fn(|lane| f32::from_bits(bone.words[lane])), + joint_orientation: std::array::from_fn(|lane| f32::from_bits(bone.words[4 + lane])), + volume_frame: physics_bone_frame(bone.rotation(), [x, y, z, 0.0]), + swing_limit: f32::from_bits(bone.words[21]), + twist_limit: f32::from_bits(bone.words[20]), + } + }); + // InitAttribData stores these in ascending physical child order1..22. + let names = [ + "JOINT_NECK_NECK1", + "JOINT_SPINE3_NECK", + "JOINT_LEFT_FOREARM_HAND", + "JOINT_LEFT_ARM_FOREARM", + "JOINT_LEFT_SHOULDER_ARM", + "JOINT_SPINE3_LEFT_SHOULDER", + "JOINT_RIGHT_FOREARM_HAND", + "JOINT_RIGHT_ARM_FOREARM", + "JOINT_RIGHT_SHOULDER_ARM", + "JOINT_SPINE3_RIGHT_SHOULDER", + "JOINT_SPINE2_SPINE3", + "JOINT_SPINE1_SPINE2", + "JOINT_SPINE_SPINE1", + "JOINT_HIPS_SPINE", + "JOINT_LEFT_FOOT_TOE_BASE", + "JOINT_LEFT_LEG_FOOT", + "JOINT_LEFT_UPLEG_LEG", + "JOINT_HIPS_LEFT_LEG", + "JOINT_RIGHT_FOOT_TOE_BASE", + "JOINT_RIGHT_LEG_FOOT", + "JOINT_RIGHT_UPLEG_LEG", + "JOINT_HIPS_RIGHT_LEG", + ]; + let mut settings = Vec::with_capacity(22); + for name in names { + let words = data.words::<5>("physics_skeleton_joints", "default", name)?; + settings.push(JointSettings { + ball_joint: words[0] >> 24 != 0, + swing_angle: f32::from_bits(words[1]), + twist_angle: f32::from_bits(words[2]), + swing_ragdoll: f32::from_bits(words[3]), + twist_ragdoll: f32::from_bits(words[4]), + }); + } + let global = SkeletonJointSettings { + displacement_limit: data + .words::<4>("physics_skeleton", "default", "DisplacementLimit")? + .map(f32::from_bits), + twist_displacement_limit: data.float( + "physics_skeleton", + "default", + "TwistDisplacementLimit", + )?, + swing_displacement_limit: data.float( + "physics_skeleton", + "default", + "SwingDisplacementLimit", + )?, + enforce_swing_free: data.boolean( + "physics_skeleton_joints", + "default", + "EnforceSwingFree", + )?, + enforce_twist_free: data.boolean( + "physics_skeleton_joints", + "default", + "EnforceTwistFree", + )?, + }; + SkeletonJoints::new( + &initial_bones, + &parents, + &bones, + &settings + .try_into() + .map_err(|_| "Physical joint setting count")?, + global, + ) + .map_err(str::to_string) +} + +pub(crate) fn load( + asset_root: &Path, + data: &Collections, + animation_bank_sha256: &str, + initial_mapped_pose: &[AnimationPartTransform; 24], + spawn: AnimationPartTransform, + simulation: RetailSimulationStep, + hat_collection: Option<&str>, +) -> Result { + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + animation_bank_sha256, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Physical skeleton requires24 bone records".into()); + } + let mut sizes = [Vector3::ZERO; 24]; + let mut bones = Vec::with_capacity(24); + for (i, bone) in skeleton.bones.iter().enumerate() { + let [x, y, z] = bone.size(); + sizes[i] = Vector3::new(x, y, z); + let w = data.words::<6>( + "physics_skeleton_bones", + "default", + &format!("PART_{}", bone.name), + )?; + bones.push(BoneSettings { + mass_factor: f32::from_bits(w[0]), + ragdoll_mass_factor: f32::from_bits(w[1]), + has_collision: w[2] >> 24 != 0, + use_root_drive: (w[2] >> 16) & 255 != 0, + volume_type: w[3], + volume_scalar: f32::from_bits(w[4]), + num_parents: w[5], + }); + } + let value = |name| data.float("physics_skeleton", "default", name); + let settings = SkeletonBodySettings { + density: value("MassOfSkeleton")?, + root_radius: value("SkateRootCapsuleRadius")?, + root_half_length: value("SkateRootCapsuleLength")?, + capsule_radius_scalar: data.float( + "physics_skeleton_bones", + "default", + "CapsuleRadiusScalar", + )?, + capsule_length_scalar: value("CapsuleLengthScalar")?, + ragdoll_inverse_mass_factor: data.float( + "physics_wipeout", + "default", + "RagdollInvMassFactor", + )?, + inertia_multiply_type: data.integer("animation", "default", "InertiaMultiplyType")?, + inertia_factor: data.float("animation", "default", "InertiaMultFactor")?, + }; + let hat = if let Some(key) = hat_collection { + let vector = |name| -> Result { + let w = data + .words::<4>("physics_hat", key, name)? + .map(f32::from_bits); + Ok(Vector3::new(w[0], w[1], w[2])) + }; + Some(HatGeometry::from_offsets( + data.float("physics_hat", key, "Hash_372DF5DB27AF1C79")?, + data.float("physics_hat", key, "Thickness")?, + vector("OrientationOffset")?, + vector("PosOffset")?, + )) + } else { + None + }; + let definition = SkeletonBodyDefinition::new( + sizes, + bones.try_into().map_err(|_| "Skeleton bone count")?, + settings, + hat, + )?; + Ok(SkeletonBody::new( + definition, + initial_mapped_pose, + spawn, + simulation, + )) +} diff --git a/crates/skate-game/src/physics/skeleton_body_collision_settings.rs b/crates/skate-game/src/physics/skeleton_body_collision_settings.rs new file mode 100644 index 00000000..34d0774f --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_body_collision_settings.rs @@ -0,0 +1,75 @@ +//! Original82BE7860/82BE7280 and exact locations in stock physics_skeleton XML. +use skate_core::physics::{ + contact::RetailContactMaterial, + skeleton_body::{SkeletonCollisionSettings, SkeletonFeedbackSettings}, +}; +use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; +use std::path::Path; + +pub(super) fn load( + asset_root: &Path, + data: &Collections, + bank_sha: &str, +) -> Result { + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + bank_sha, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Skeleton collision requires24 bone records".into()); + } + let mut compliant = [false; 24]; + let mut priority = [0.0; 24]; + compliant[0] = true; //82BE7484; part0 priority is cleared by collisionReset. + for part in 1..24 { + let words = data.words::<9>( + "physics_skeleton_drives", + "default", + &format!("PART_{}", skeleton.bones[part].name), + )?; + compliant[part] = words[7] >> 24 != 0; + priority[part] = f32::from_bits(words[8]); + } + // XML baseC8840; frictionC8904, restitutionC88EC, enabledC8910, + // effectC8918 => native offsets196,172,208,216 respectively. + let friction = data.float("physics_skeleton", "default", "FrictionNormal")?; + Ok(SkeletonCollisionSettings { + enabled: data.boolean("physics_skeleton", "default", "EnableCollision")?, + normal_material: RetailContactMaterial { + static_friction: friction, + dynamic_friction: friction, + restitution: data.float("physics_skeleton", "default", "RestitutionNormal")?, + }, + compliant, + priority, + effect_time: data.float("physics_skeleton", "default", "CollisionEffectTime")?, + }) +} + +/// GlobalAttributeCollection+268 binds original low hashes7AA75DF9/2D7D2152 +/// at8289F634: physics_collision/default. Stock XML baseC6AE0 independently +/// resolves every layout offset consumed by82BD4A30 and82BD6038. +pub(super) fn feedback( + data: &Collections, + body: SkeletonCollisionSettings, +) -> Result { + let value = |name| data.float("physics_collision", "default", name); + let vector = |name| -> Result<[f32; 4], String> { + Ok(data + .words::<4>("physics_collision", "default", name)? + .map(f32::from_bits)) + }; + Ok(SkeletonFeedbackSettings { + body, + small_object_mass: data.float("physics_skeleton", "default", "SmallObjectMassThreshold")?, + ground_plane_max_distance: value("GroundPlaneMaxDist")?, //136 + ground_plane_max_angle: value("GroundPlaneMaxAngle")?, //140 + skater_scalar: value("SkaterSkeletonScalar")?, //116 + ai_scalar: value("AISkeletonScalar")?, //188 + groin_offset: vector("GroinLocalOffset")?, //0 + face_offset: vector("FaceLocalOffset")?, //16 + groin_radius: value("GroinRadius")?, //144 + face_radius: value("FaceRadius")?, //168 + }) +} diff --git a/crates/skate-game/src/physics/skeleton_body_drive_settings.rs b/crates/skate-game/src/physics/skeleton_body_drive_settings.rs new file mode 100644 index 00000000..fe326c6a --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_body_drive_settings.rs @@ -0,0 +1,56 @@ +//! Original BoneDrives constructor82BEC4A0/82BEC7B0 collection bindings. +//!82BEC50C..544 builds local=00132D1E84F0BC32;82BEC5B4..D4 builds +//! root=550B23128E0C3838. The12 transition keys preserve native offsets232..276. +use skate_core::physics::skeleton_body::{ + AnimationDriveSettings, BoneDriveSettings, DriveInterpolation, +}; +use skate_data::collections::Collections; + +pub(super) fn load(data: &Collections) -> Result { + let scalar = |name| data.float("physics_skeleton_drives", "default", name); + let animation = |key| -> Result { + let f = |name| data.float("animation_drives", key, name); + Ok(AnimationDriveSettings { + linear_strength: f("LinearStrMin")?, + linear_displacement: f("LinearDispMin")?, + angular_strength: f("AngularStrMin")?, + angular_displacement: f("AngularDispMin")?, + }) + }; + Ok(BoneDriveSettings { + animation: [animation("local")?, animation("root")?], + collision_soft_displacement: scalar("CollisionSoftDisp")?, + collision_soft_strength: scalar("CollisionSoftStrength")?, + ragdoll_soft_displacement: scalar("RagdollSoftDisp")?, + ragdoll_soft_strength: scalar("RagdollSoftStrength")?, + transition_linear: DriveInterpolation { + spring: [ + scalar("Hash_138976EAF99DF926")?, + scalar("Hash_C313093118C7A83F")?, + ], + strength: [ + scalar("Hash_1E6BDF62043784FC")?, + scalar("Hash_BD3E4F132848A588")?, + ], + damping: [ + scalar("Hash_D7652B4B19BB4747")?, + scalar("Hash_C4B564DFD233CF04")?, + ], + }, + transition_angular: DriveInterpolation { + spring: [ + scalar("Hash_14A2DDDD0A35C267")?, + scalar("Hash_24D5AA7F214A901C")?, + ], + strength: [ + scalar("Hash_6C6CCD16A0A438AD")?, + scalar("Hash_7379F8AB250BF611")?, + ], + damping: [ + scalar("Hash_28446A9E25DCC04C")?, + scalar("Hash_B59D493B3DB372C8")?, + ], + }, + transition_calls: scalar("Hash_E1B89BB09D725EEC")?, + }) +} diff --git a/crates/skate-game/src/physics/skeleton_colliders.rs b/crates/skate-game/src/physics/skeleton_colliders.rs new file mode 100644 index 00000000..4c4c67e2 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_colliders.rs @@ -0,0 +1,80 @@ +//! Bind original physical skater shapes to the bodies receiving solver reactions. +//! The collision owner selects enabled parts and materials; shapes and local-Z +//! capsule axes come from the same stock definition used to construct each part. +use skate_core::{ + math::{Basis3, Vector3}, + physics::{ + board_step::CollisionBody, + board_world::BoardWorldVolume, + collision::Sphere, + mass::MassShape, + skeleton_body::{SkeletonBody, SkeletonCollisionMode}, + world_contact::ContactPrimitive, + }, +}; + +pub(super) fn world_volumes( + skeleton: &SkeletonBody, + collision: &SkeletonCollisionMode, +) -> Result, String> { + let transforms = skeleton.part_transforms(); + let mut volumes = Vec::new(); + for (index, (part, state)) in skeleton + .definition + .parts + .iter() + .zip(&collision.parts) + .enumerate() + { + if !state.enabled { + continue; + } + if state.volume_group != 0 { + return Err(format!( + "Skater part{index} requires volume group{} world filtering", + state.volume_group + )); + } + if part.hat.is_some() { + return Err("The equipped hat requires its original cylinder collision query".into()); + } + let frame = transforms[index]; + let xyz = |value: [f32; 4]| Vector3::new(value[0], value[1], value[2]); + let center = xyz(frame[3]); + let primitive = match part.shape { + MassShape::Sphere { radius } => ContactPrimitive::Sphere(Sphere { center, radius }), + MassShape::Capsule { + radius, + half_length, + } => ContactPrimitive::Capsule { + center, + axis: xyz(frame[2]), + half_length, + radius, + }, + MassShape::RoundedBox { + half_extents, + radius, + } => ContactPrimitive::RoundedBox { + center, + basis: Basis3 { + columns: std::array::from_fn(|i| [frame[i][0], frame[i][1], frame[i][2]]), + }, + half_extents, + radius, + }, + shape => { + return Err(format!( + "Skater part{index} has no recovered collision query for {shape:?}" + )); + } + }; + volumes.push(BoardWorldVolume { + body: CollisionBody::Attached(index), + primitive, + linear_velocity: skeleton.bodies()[index].rates.linear_velocity, + material: state.material, + }); + } + Ok(volumes) +} diff --git a/crates/skate-game/src/physics/skeleton_controller.rs b/crates/skate-game/src/physics/skeleton_controller.rs new file mode 100644 index 00000000..3b947e6a --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_controller.rs @@ -0,0 +1,84 @@ +//! Shared collision-policy controller used by Ground and both Air states. +//! Requests82D911A8 and ascending/falling update82D91660 precede the solve. +use skate_core::physics::skeleton_body::SkeletonCollisionMode; + +pub(crate) struct SkeletonControllerState { + pub effective: u32, + pub requested: u32, + pub has_request: bool, + pub override_enabled: bool, + ///Native byte18 tracks upward motion/jump input; it is not an enable bit. + pub flag_18: bool, +} +impl SkeletonControllerState { + pub fn new() -> Self { + //Original inline constructor82DB2498..24B0. + Self { + effective: 0, + requested: 0, + has_request: false, + override_enabled: false, + flag_18: false, + } + } + + pub fn request_ground(&mut self, collision: &mut SkeletonCollisionMode) -> Result<(), String> { + self.request(6, collision) + } + + pub fn request( + &mut self, + requested: u32, + collision: &mut SkeletonCollisionMode, + ) -> Result<(), String> { + if let Some(mode) = self.select_request(requested) { + collision.select_driven(mode).map_err(str::to_owned)?; + } + Ok(()) + } + + ///Original request gate shared by driven and physical ragdoll adapters. + pub(crate) fn select_request(&mut self, requested: u32) -> Option { + if self.has_request && self.requested == requested { + return None; + } + let effective = if self.override_enabled { + self.requested = requested; + self.has_request = true; + //82D911D8..1264: unsigned requested-4 indexes the eight cases. + match requested { + 4 => 1, + 5..=7 => 2, + 8 => 3, + 9..=11 => requested, + _ => return None, + } + } else { + if (1..=2).contains(&self.effective) { + return None; + } + requested + }; + self.effective = effective; + let mode = effective.wrapping_sub(1); + if mode > 10 { + return None; + } + Some(mode) + } + + ///Original82D91660 changes the collision request only when byte18 changes. + pub fn update_air( + &mut self, + processed_flags_2472: u32, + velocity_y: f32, + collision: &mut SkeletonCollisionMode, + ) -> Result<(), String> { + let ascending = processed_flags_2472 & 0x8000 != 0 || velocity_y > 0.0; + if self.flag_18 != ascending { + self.flag_18 = ascending; + self.request(if ascending { 7 } else { 6 }, collision)?; + } + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/skeleton_feedback.rs b/crates/skate-game/src/physics/skeleton_feedback.rs new file mode 100644 index 00000000..f13d29db --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_feedback.rs @@ -0,0 +1,177 @@ +//! Post-solver contact reports and physical/animation errors for the live skater. +//! Engine report storage is ours; spy arithmetic and report order are native. +use super::{GamePhysics, SkaterRuntime, solve::deck_frame}; +use skate_core::physics::{ + assembly::BodySnapshot, + board_step::CollisionBody, + contact_feedback::spy_contact_jacobians, + skeleton_body::{SkeletonCollisionInput, SkeletonContactBody, SkeletonContactReport}, +}; + +pub(super) fn publish( + physics: &GamePhysics, + skater: &mut SkaterRuntime, + request_partial_ragdoll: bool, +) { + let reports = collect(physics, skater); + let p = &skater.player_input.processed; + let deck = deck_frame(&physics.board); + skater.skeleton_output.correction.observe_board( + deck[3], + skater.animated_skeleton.roots.predicted_board_position, + ); + let state = p.state_2508; + let offboard = p.category_2512 == 500 && !matches!(state, 501 | 503); + let mut point = p.vectors_464_480_496_512_528[if matches!(state, 601 | 602) { 1 } else { 2 }] + .map(f32::from_bits); + // Both82BD8280 and82BE2538 read Skeleton16424 ->Reckoning1216. + let n = physics.riding.reckoning.ground_normal; + let mut normal = [n.x, n.y, n.z, 0.0]; + if offboard && p.flags_2480 & 2 != 0 { + point = p.vectors_880_896_912_928_944[0].map(f32::from_bits); + normal = p.vectors_880_896_912_928_944[1].map(f32::from_bits); + } + let frames = skater.skeleton.part_transforms(); + skater.collision_feedback.update( + &SkeletonCollisionInput { + dt: physics.settings.step.simulation.time_step, + plane_point: point, + plane_normal: normal, + reference_velocity: if state == 201 { + skater.skeleton.record.velocities[23] + } else { + skater.skeleton_input.deck_velocity + }, + com_velocity: skater.animated_skeleton.board_frames.com_velocity, + ragdoll: skater.skeleton_collision.is_ragdoll, + disable_ground_filter: p.flags_2472 & 0x8000 != 0, + ai_collision_scalar: p.flags_2472 & 0x1000_0000 != 0, + offboard, + entering_offboard: matches!(state, 501 | 503), + category_600: p.category_2512 == 600, + request_partial_ragdoll, + physical: &skater.skeleton.record, + part_weights: &skater.skeleton.definition.animation_masses.part_weights, + body_frames: &frames, + }, + &reports, + ); + skater.skeleton_collision.finish_contact_frame(); + skater.pose_errors.targets = skater.skeleton_input.extra_target_positions; + skater.pose_errors.update( + &skater.skeleton.record, + skater.animated_skeleton.roots.animation_to_world, + &skater.skeleton_input.drive_frames, + ); + let response = if skater.skeleton_collision.partial_ragdoll { + skater.pose_errors.partial_response() + } else { + skater + .pose_errors + .normal_response(&skater.collision_feedback, [n.x, n.y, n.z, 0.0]) + }; + skater.collision_pose_error = response.impulse; + skater.collision_maximum_error = Some(response.maximum_error); + skater + .animated_skeleton + .board_frames + .publish_centre_of_mass( + skater.skeleton.record.centre_of_mass, + physics.settings.step.simulation.time_step, + p.flags_2472, + ); + skater + .animated_skeleton + .board_frames + .publish_local_observations(&skater.animated_skeleton.roots, &deck); +} + +fn collect(physics: &GamePhysics, skater: &SkaterRuntime) -> Vec { + let rows = physics.board.solved_contacts(); + let mut storage: Vec<_> = rows + .iter() + .flat_map(|row| row.words().iter().copied()) + .collect(); + let mut count = 0; + spy_contact_jacobians( + &mut storage, + rows.len() as u32, + &mut count, + physics.settings.step.simulation.frequency, + |id| { + resolve(id, physics, skater).map_or([0; 4], |body| { + let p = body.rates.position; + [p.x.to_bits(), p.y.to_bits(), p.z.to_bits(), 0] + }) + }, + ); + let mut reports = Vec::with_capacity(16); + for spy in storage[..count as usize * 28].chunks_exact(28) { + let a = CollisionBody::from_contact_id(spy[24]); + let b = CollisionBody::from_contact_id(spy[25]); + //82768418 rejects the same logical Body and equal nonnull Body+32 + //owners. Board82C067F4 and Skeleton82BE4620 both store this actor. + //Their eligible cross contacts still participate in the shared solve. + if !matches!(a, CollisionBody::StaticWorld) && !matches!(b, CollisionBody::StaticWorld) { + continue; + } + for (own, other, side_a) in [(a, b, true), (b, a, false)] { + let CollisionBody::Attached(part) = own else { + continue; + }; + if part >= 24 || reports.len() == 16 { + continue; + } + let vector = |offset| std::array::from_fn(|i| f32::from_bits(spy[offset + i])); + reports.push(SkeletonContactReport { + part, + normal: vector(0).map(|v| v * if side_a { 1.0 } else { -1.0 }), + point: vector(12), + tag: if side_a { + spy[26] & 0xffff + } else { + spy[26] >> 16 + }, + other_group: match other { + CollisionBody::StaticWorld => 0, + CollisionBody::Board(_) => physics.board.collision_group(), + CollisionBody::Attached(_) => skater.skeleton_collision.assembly_group, + }, + other_entity: None, + body_a: contact_body(resolve(spy[24], physics, skater)), + body_b: contact_body(resolve(spy[25], physics, skater)), + side_a, + solved_vector: vector(20), + }); + } + } + reports +} +fn resolve<'a>( + id: u32, + physics: &'a GamePhysics, + skater: &'a SkaterRuntime, +) -> Option<&'a BodySnapshot> { + match CollisionBody::from_contact_id(id) { + CollisionBody::StaticWorld => None, + CollisionBody::Board(id) => Some(&physics.board.bodies()[id.index()]), + CollisionBody::Attached(part) => Some(&skater.skeleton.bodies()[part]), + } +} +fn contact_body(body: Option<&BodySnapshot>) -> SkeletonContactBody { + match body { + Some(body) => { + let v = body.rates.linear_velocity; + SkeletonContactBody { + state_flags: body.state_flags, + inverse_mass: body.inertia.inverse_mass, + linear_velocity: [v.x, v.y, v.z, 0.0], + } + } + None => SkeletonContactBody { + state_flags: 1, + inverse_mass: 0.0, + linear_velocity: [0.0; 4], + }, + } +} diff --git a/crates/skate-game/src/physics/skeleton_input_general.rs b/crates/skate-game/src/physics/skeleton_input_general.rs new file mode 100644 index 00000000..9bba0f91 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_input_general.rs @@ -0,0 +1,146 @@ +//! Movement-phase continuation of the original Skeleton input owner. +use super::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}; +use crate::physics::foot_ik::{DriveOutput, PhysicalInput}; +use skate_core::{ + animation::output::NativeMatrix, + physics::{ + board_runtime::BoardRuntime, + drive_parameters::{RetailDriveParams, RetailDriveType}, + rigid_body::RetailSimulationStep, + skeleton_animation_record::AnimationPartTransform as Transform, + skeleton_body::{SkeletonDrives, SkeletonTargetInput}, + skeleton_general, + }, + player::input_phase::ProcessedPhysicsInput, +}; + +impl SkeletonInputRuntime { + ///Ground82BDF530: update actual roots/board frames, GeneralUpdate, then + ///reset next trajectory. Return the native Processed0..48 publication. + pub fn update_ground( + &mut self, + board: &BoardRuntime, + reckoning: &Transform, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + ) -> Result { + let frame = + owners + .animated + .prepare_ground(board, reckoning, p.timestep_2604, &mut p.flags_2468); + self.general_update(p, owners, globals, collision, simulation)?; + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(frame) + } + + ///Source order: root derivative -> real target writes -> discontinuity + ///reset -> IK -> bone drive update -> fixed-step gravity displacement. + pub fn general_update( + &mut self, + p: &ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + ) -> Result { + self.root_velocity = skeleton_general::root_velocity( + &mut self.previous_root_position, + owners.animated.roots.animation_to_world[3], + p.timestep_2604, + ); + let s = &owners.animated; + let target = owners.drives.targets.update_positions( + SkeletonTargetInput { + animation_hips: &s.animation_hips, + animation_board: &s.animation_board, + animation_to_world: &s.roots.animation_to_world, + inverse_board: &s.roots.inverse_board, + skate_root: &s.board_frames.skate_root, + com_frame: &s.board_frames.com_frame, + lifted_com_frame: &s.board_frames.lifted_com_frame, + teleporting: self.teleporting, + }, + owners.body, + ); + self.animation_board_to_physics = target.animation_board_to_physics; + self.extra_target_positions = [ + target.positions.com, + target.positions.lifted_com, + target.positions.following_com, + ]; + owners.pose_errors.set_targets(target.positions); + if !target.continuous { + self.reset_physical_pose(owners, simulation); + if p.state_2508 != 0 && p.state_2508 != 702 { + self.invalid_target_reset = true; + } + } + let contacts = std::array::from_fn(|i| { + let query = p.line_tests_960_1008_1056[i]; + (query.valid != 0).then(|| query.position.map(f32::from_bits)) + }); + let physical_hips = owners.body.part_transforms()[23][3]; + let drives = owners.ik.update( + owners.animated, + globals, + PhysicalInput { + state_id: p.state_2508, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2480: p.flags_2480, + //Unknown event bone -1 cannot equal either real toe index. + contact_bone: owners.animation_input.contacts.bone as usize, + physical_board: &owners.animated.board_frames.physical_board, + hips_world_position: physical_hips, + current_contacts: contacts, + }, + )?; + self.drive_frames = drives.frames; + owners.drives.update( + &drives.frames, + collision.partial_ragdoll, + collision.drive_weight_4028, + ); + skeleton_general::apply_gravity_displacement(owners.body, p.gravity_2648); + Ok(drives) + } + + ///Real82BE2798 reset is also callable by the coordinated player reset; + ///it clears correction pending but does not invent a fresh state machine. + pub fn reset_physical_pose( + &mut self, + owners: &mut SkeletonOwners<'_>, + simulation: RetailSimulationStep, + ) { + owners.correction.pending = false; + let s = &mut owners.animated; + let observation = skeleton_general::reset_to_animation( + owners.body, + &mut s.record, + &self.drive_frames, + &s.roots.animation_to_world, + s.board_frames.com_frame, + simulation, + ); + s.board_frames.centre_of_mass = observation.world_hips; + s.board_frames.previous_centre_of_mass = observation.world_hips; + s.board_frames.com_velocity = [0.; 4]; + self.reset_local_hips = observation.local_hips; + owners.pose_errors.reset_history(); + } +} + +pub(super) fn restore_board_target_drives(drives: &mut SkeletonDrives) { + let hard = RetailDriveParams { + spring_or_max_velocity: f32::from_bits(0x4415_FFFF), + damping: 0., + max_strength: f32::from_bits(0x470C_9FFF), + drive_type: RetailDriveType::HardDrive, + }; + drives.targets.dynamics[0].linear = hard; + drives.targets.dynamics[0].angular = hard; +} diff --git a/crates/skate-game/src/physics/skeleton_input_runtime.rs b/crates/skate-game/src/physics/skeleton_input_runtime.rs new file mode 100644 index 00000000..e4634cc2 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_input_runtime.rs @@ -0,0 +1,231 @@ +//! The production Skeleton::ProcessData82BD8918 boundary. All borrows point +//! at the same owners subsequently used by Ground, the solve and pose output. +//! GeneralUpdate belongs to the later movement-state phase, not PlayerInput. +#[path = "skeleton_input_general.rs"] +mod general; +#[path = "skeleton_input_teleport.rs"] +mod teleport; + +use super::{ + animated_skeleton::AnimatedSkeleton, animation_input::AnimationInput, foot_ik::FootIk, +}; +use skate_core::{ + animation::output::{NativeMatrix, attributes::AnimationAttribute}, + input::controller::ActionMap, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + board_toolkit::BoardToolkit, + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY}, + skeleton_body::{SkeletonBody, SkeletonDrives, SkeletonPoseErrors}, + skeleton_landing::LandingInput, + skeleton_output::correction::CorrectionState, + }, + player::input_phase::{AnimationInputPacket, PhysicalPlayerInput, ProcessedPhysicsInput}, + riding::ground_correction_math::dot_product as dot3, +}; + +/// Original SkeletonCollision publications. The collision owner fills these +/// from actual solved contacts; there is deliberately no default observation. +pub(crate) struct CollisionInput { + pub contact_4070: bool, + pub has_pose_error_4077: bool, + pub pose_error_16272: [f32; 4], + pub partial_ragdoll: bool, + pub drive_weight_4028: f32, +} + +pub(crate) struct SkeletonOwners<'a> { + pub animated: &'a mut AnimatedSkeleton, + pub body: &'a mut SkeletonBody, + pub drives: &'a mut SkeletonDrives, + pub ik: &'a mut FootIk, + pub animation_input: &'a mut AnimationInput, + pub correction: &'a mut CorrectionState, + pub pose_errors: &'a mut SkeletonPoseErrors, +} + +pub(crate) struct SkeletonPoseInput<'a> { + pub globals: &'a [NativeMatrix], + pub attributes: &'a [AnimationAttribute], + pub actions: &'a mut dyn ActionMap, +} + +pub(crate) struct SkeletonInputRuntime { + ///Skeleton16128, read from the actual deck before ProcessAnimAttributes. + pub deck_velocity: [f32; 4], + ///Skeleton16336 and16352, the animation root derivative and its history. + pub root_velocity: [f32; 4], + pub previous_root_position: [f32; 4], + ///Skeleton12240 from the actual target update, not animation-to-world. + pub animation_board_to_physics: Transform, + ///Skeleton11632/11648/11664 from the three extra-target positions. + pub extra_target_positions: [[f32; 4]; 3], + ///Skeleton16208, authored hips translation cached by physical reset. + pub reset_local_hips: [f32; 4], + ///Skeleton12624, rewritten by ProcessData then modified in place by IK. + ///The animation COM record retains its pre-IK copy independently. + pub drive_frames: [Transform; 24], + pub reenable_requested: bool, + pub invalid_target_reset: bool, + pub teleporting: bool, + ///Skeleton16420, changed by graph force-mode requests and reset82BE3508. + pub force_mode: u32, + ///HeadTracking32..144 and160. Reset82BE3550..35A4 preserves its settings. + pub head_tracking_history: [[f32; 4]; 8], + pub head_tracking_active: bool, + ///GrindAirAdjust ctor82D7116C initializes activated289=false. + ///Only KnownAir Update82D35D30/Exit82D3597C subsequently write this flag. + ///288, ctor82D71160; activated289 is a separate retained flag. + pub grind_air_started: bool, + pub grind_air_active: bool, + pub grind_air_adjusting: bool, +} + +impl Default for SkeletonInputRuntime { + fn default() -> Self { + //Original Skeleton ctor82BD78xx/79xx zeroes these vector histories; + //82BD7A78/84/8C clear16504/16506/16508. Frames are native identity. + Self { + deck_velocity: [0.; 4], + root_velocity: [0.; 4], + previous_root_position: [0.; 4], + animation_board_to_physics: IDENTITY, + extra_target_positions: [[0.; 4]; 3], + reset_local_hips: [0.; 4], + drive_frames: [IDENTITY; 24], + reenable_requested: false, + invalid_target_reset: false, + teleporting: false, + force_mode: 0, + head_tracking_history: [[0.0; 4]; 8], + head_tracking_active: false, + grind_air_started: false, + grind_air_active: false, + grind_air_adjusting: false, + } + } +} + +impl SkeletonInputRuntime { + ///Called by PlayerInputCallbacks::process_skeleton, after the actual + ///BoardToolkit and line tests have been published. It does not advance + ///Ground, rebuild a toolkit or submit a second physical simulation. + pub fn process_data( + &mut self, + board: &mut BoardRuntime, + _toolkit: &BoardToolkit, + packet: &AnimationInputPacket<'_>, + _physical: &mut PhysicalPlayerInput, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + pose: SkeletonPoseInput<'_>, + collision: &CollisionInput, + ) -> Result<(), String> { + let velocity = board.bodies()[BodyId::Deck.index()].rates.linear_velocity; + self.deck_velocity = [velocity.x, velocity.y, velocity.z, 0.]; + //82ADF7B8: wake sleeping state2, preserving bit3, and clear cooldown + //even for already active parts. Pool/list ownership is our engine's. + for body in owners.body.bodies_mut() { + if body.state_flags & 7 == 2 { + body.state_flags = (body.state_flags & 8) | 4; + } + body.rates.cool_down = 0; + } + let input = &mut owners.animation_input; + input.reset_processed(); + input.fields.flags2468 = p.flags_2468; + input.fields.flags2472 = p.flags_2472; + input.fields.flags2476 = p.flags_2476; + input.fields.flags2484 = p.flags_2484; + input.fields.flags2488 = p.flags_2488; + input.extra.flags2480 = p.flags_2480; + input.process( + pose.attributes, + pose.globals, + packet.publication.timestep, + packet.flags_10932, + packet.publication.flags_10375_10496_10784[1] != 0, + pose.actions, + )?; + publish_attribute_flags(input, p); + p.spin_input_2672 = input.fields.spin; + + //These authored offset producers are separate from Ground riding. + //Do not silently reuse a riding pose if a future state enables them. + let reparented_hands = owners.animated.reparented_hand_indices(); + super::offboard::pose_adjust::update( + owners.animated, pose.globals, reparented_hands, p, + )?; + if p.flags_2476 & 0x100 != 0 { + if p.flags_2476 & 0x10000 == 0 { + return Err( + "Landing-on-board pose requires UpdateLandingOnSkateboardAdjust82BDCC28".into(), + ); + } + } else { + //82D712E0 clears result48/49 first and always writes adjusting290. + //Inactive Ground leaves retained total offset/angle untouched. + self.grind_air_adjusting = false; + if self.grind_air_active { + return Err("Active air grind adjustment requires82D712E0 geometric stages".into()); + } + } + let up = p.vectors_544_560_592_608[0].map(f32::from_bits); + let com_velocity = p.vectors_544_560_592_608[3].map(f32::from_bits); + let com_delta = std::array::from_fn(|i| { + owners.body.record.centre_of_mass[i] - owners.body.record.positions[0][i] + }); + let landing = LandingInput { + filtered_state: p.filtered_state_2524, + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + balance: input.fields.balance, + physical_com_velocity_along_up: dot3(com_velocity, up), + physical_com_height: dot3(com_delta, up), + //process_pose replaces this with prior animation COM-currentboard. + animation_com_height: owners.animated.record.centre_of_mass[1], + }; + owners.animated.process_pose( + pose.globals, + landing, + packet.publication.timestep, + &mut p.flags_2468, + &mut p.flags_2472, + )?; + self.drive_frames = owners.animated.record.pose; + p.board_at_y_delta_2768 = owners.animated.board_at_y_delta; + p.flags_2472 = (p.flags_2472 & !(1 << 17)) | (u32::from(collision.contact_4070) << 17); + if collision.has_pose_error_4077 { + p.collision_pose_error_736 = collision.pose_error_16272.map(f32::to_bits); + } + p.animation_com_to_deck_752 = owners.animated.record.com_to_deck_world.map(f32::to_bits); + p.animation_com_to_deck_delta_768 = owners + .animated + .record + .com_to_deck_world_delta + .map(f32::to_bits); + //The scalar fields and ProcessedPhysicsInput share these native words. + input.fields.flags2468 = p.flags_2468; + input.fields.flags2472 = p.flags_2472; + if self.reenable_requested { + self.reenable_requested = false; + owners.ik.enable_feet(true); + general::restore_board_target_drives(owners.drives); + //Native16416 is the prediction-supplied flag;16417 heading is + //distinct and must not be cleared by the request's completion. + owners.animated.roots.supplied_prediction = None; + } + Ok(()) + } +} + +fn publish_attribute_flags(input: &AnimationInput, p: &mut ProcessedPhysicsInput) { + p.flags_2468 = input.fields.flags2468; + p.flags_2472 = input.fields.flags2472; + p.flags_2476 = input.fields.flags2476; + p.flags_2480 = input.extra.flags2480; + p.flags_2484 = input.fields.flags2484; + p.flags_2488 = input.fields.flags2488; +} diff --git a/crates/skate-game/src/physics/skeleton_input_teleport.rs b/crates/skate-game/src/physics/skeleton_input_teleport.rs new file mode 100644 index 00000000..8aa4c491 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_input_teleport.rs @@ -0,0 +1,90 @@ +//! Actual teleport skeleton stages82BE3508 and82BDF7C0. The player owns the +//! intervening ResetSystems and ProcessData calls; this is the same skeleton. +use super::{CollisionInput, SkeletonInputRuntime, SkeletonOwners}; +use skate_core::{ + animation::{foot_ik::state::FootIkState, output::NativeMatrix}, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + rigid_body::RetailSimulationStep, + skeleton_animation_record::{AnimationPartTransform as Transform, IDENTITY}, + skeleton_body::{SkeletonCollisionFeedback, SkeletonCollisionMode}, + skeleton_output::wobble::Wobble, + }, + player::input_phase::ProcessedPhysicsInput, +}; + +impl SkeletonInputRuntime { + ///82BE3508, after board reset and before player ResetSystems82DB92D0. + ///Does not replace owners with startup instances or reset unrelated rates. + pub fn reset_for_teleport( + &mut self, + owners: &mut SkeletonOwners<'_>, + mode: &mut SkeletonCollisionMode, + feedback: &mut SkeletonCollisionFeedback, + wobble: &mut Wobble, + ground_elapsed_16505: &mut bool, + ) { + owners.animated.motion.reset_board_orientation_history(); + self.reenable_requested = true; + self.invalid_target_reset = false; + *ground_elapsed_16505 = true; + mode.reset_body_state(feedback); + self.force_mode = 0; + self.head_tracking_history = [[0.0; 4]; 8]; + self.head_tracking_active = false; + owners.ik.state = FootIkState::default(); + //Native reset preserves the previously selected cached curves. + wobble.time = 0.0; + wobble.amplitude = 0.0; + wobble.direction = 1.0; + wobble.active = false; + wobble.landing = false; + owners.animated.board_frames.animation_target = IDENTITY; + owners.animated.board_offset = Default::default(); + owners.animated.board_frames.lift_height = 0.0; + } + + ///82DB8998 brackets82BDF7C0 with Skeleton16508=true/false after the real + ///ProcessData call. Reckoning816 is its freshly reset original frame. + pub fn update_teleport( + &mut self, + board: &BoardRuntime, + reckoning_frame_816: &Transform, + p: &mut ProcessedPhysicsInput, + owners: &mut SkeletonOwners<'_>, + globals: &[NativeMatrix], + collision: &CollisionInput, + simulation: RetailSimulationStep, + ) -> Result { + self.teleporting = true; + let part = board.part_transforms()[BodyId::Deck.index()]; + let mut deck = [[0.0; 4]; 4]; + for axis in 0..3 { + deck[axis][..3].copy_from_slice(&part.basis.columns[axis]); + } + deck[3] = [ + part.translation.x, + part.translation.y, + part.translation.z, + 0.0, + ]; + let skeleton = &mut owners.animated; + skeleton + .roots + .update_teleport(deck, &skeleton.animation_board, reckoning_frame_816); + let frame = skeleton.board_frames.prepare_teleport( + &skeleton.roots, + &self.drive_frames[0], + deck, + &mut p.flags_2468, + ); + let result = self.general_update(p, owners, globals, collision, simulation); + //Keep the actual lifecycle flag coherent even if host data validation fails. + self.teleporting = false; + result?; + owners.animated.finish_ground(); + owners.animation_input.fields.flags2468 = p.flags_2468; + Ok(frame) + } +} diff --git a/crates/skate-game/src/physics/skeleton_output.rs b/crates/skate-game/src/physics/skeleton_output.rs new file mode 100644 index 00000000..bced1ad0 --- /dev/null +++ b/crates/skate-game/src/physics/skeleton_output.rs @@ -0,0 +1,177 @@ +//! Production adapter for the original physical-pose job82DB6698/82DB95E8. +use super::animated_skeleton::AnimatedSkeleton; +use skate_core::{ + animation::foot_ik::drive::Geometry, + physics::{ + board::BodyId, + board_runtime::BoardRuntime, + drive_frames::RetailAffineTransform, + skeleton_animation_record::AnimationPartTransform as Transform, + skeleton_body::SkeletonBody, + skeleton_output::{self, board, wobble}, + truck_frames::steering_truck_transforms, + }, + point_graph::PointGraph, +}; +use skate_data::{animation_frames::AnimationFrames, collections::Collections}; + +pub(crate) struct SkeletonOutput { + pub pose: skeleton_output::SkeletonOutput, + pub wobble: wobble::Wobble, + wobble_settings: wobble::Settings, + pub deck_wobble: wobble::Output, + pub correction: skeleton_output::correction::CorrectionState, + compression_rest_height: f32, +} +impl SkeletonOutput { + pub fn load( + data: &Collections, + animation: &AnimationFrames, + skeleton: &AnimatedSkeleton, + ) -> Result { + let mut parents = [None; 24]; + for (part, &bone) in skeleton.bone_indices.iter().enumerate() { + let mut ancestor = *animation + .parents + .get(bone) + .ok_or("Missing output bone ancestor")?; + let mut visited = 0; + while ancestor >= 0 { + let index = ancestor as usize; + if index >= animation.parents.len() || visited >= animation.parents.len() { + return Err("Invalid physical output hierarchy".into()); + } + if let Some(parent) = skeleton.bone_indices.iter().position(|&b| b == index) { + parents[part] = Some(parent); + break; + } + ancestor = animation.parents[index]; + visited += 1; + } + } + let bone = |name: &str| { + animation + .bone_names + .iter() + .position(|n| n.eq_ignore_ascii_case(name)) + .ok_or_else(|| format!("Missing stock output bone {name}")) + }; + let scalar = |key| data.float("physics_skeleton", "default", key); + //82BD7DD0..7E88 names and stores these six ids; no authored index literals. + let board_bones = board::BoneIndices { + front_truck: bone("Truck_Front")?, + back_truck: bone("Truck_Back")?, + front_left_wheel: bone("Left_WheelFront")?, + front_right_wheel: bone("Right_WheelFront")?, + back_left_wheel: bone("Left_WheelBack")?, + back_right_wheel: bone("Right_WheelBack")?, + }; + let graph = |key| -> Result, String> { + let words = data + .words::<20>("physics_deck_wobble", "default", key)? + .map(f32::from_bits); + Ok(PointGraph { + x: words[4..12].try_into().unwrap(), + y: words[12..20].try_into().unwrap(), + }) + }; + Ok(Self { + pose: skeleton_output::SkeletonOutput { + bone_indices: skeleton.bone_indices, + geometry: Geometry::new(parents, &skeleton.physics_frames)?, + board_bones, + board_settings: board::Settings { + truck_tilt_scalar: scalar("TruckTiltScalar")?, + truck_tilt_max_angle: scalar("TruckTiltMaxAngle")?, + truck_tilt_wobble_scalar: scalar("TruckTiltWobbleScalar")?, + truck_displacement_max: scalar("TruckDisplacementMax")?, + }, + }, + wobble: wobble::Wobble::default(), + deck_wobble: wobble::Output::default(), + correction: skeleton_output::correction::CorrectionState::default(), + //8289F128 global220 = AF781D7B614DAB50 physicstrucks/default; + //82C08D04..18 copies layout16 to SkateboardBody8380. + compression_rest_height: data.float("physicstrucks", "default", "TruckYPos")?, + wobble_settings: wobble::Settings { + takeoff_tilt: graph("TiltVsTimeTakeOff")?, + landing_tilt: graph("TiltVsTimeLanding")?, + takeoff_squish: graph("SquishVsTimeTakeOff")?, + landing_squish: graph("SquishVsTimeLanding")?, + maximum_time: data.float("physics_deck_wobble", "default", "MaxTime")?, + }, + }) + } + ///82BF2F18 is called on the actual landing/takeoff transition; reverse is + ///the native stance decision passed by that transition's caller. + pub fn trigger_wobble(&mut self, landing: bool, reverse: bool) { + self.wobble.trigger(landing, reverse); + } + ///82C08968 observes the solved COM frames, not geometric part frames. + pub fn average_compressions(&self, board: &BoardRuntime) -> [f32; 2] { + let deck = to_matrix(board.body_transform(BodyId::Deck)); + let wheels = core::array::from_fn(|i| to_matrix(board.body_transform(BodyId::ORDER[i]))[3]); + skate_core::physics::contact_feedback::average_wheel_compressions( + deck, + wheels, + self.compression_rest_height, + ) + } + ///Call in82BD83E0's post-wipeout-check phase, before the pose job. + pub fn advance_wobble(&mut self, physical: &mut SkeletonBody, deck: &Transform) -> Transform { + self.deck_wobble = self.wobble.update(&self.wobble_settings); + if self.deck_wobble.sampled { + wobble::apply(self.deck_wobble, &mut physical.record.pose[0]); + physical.record.pose[0] + } else { + *deck + } + } + pub fn publish( + &self, + skeleton: &AnimatedSkeleton, + physical: &SkeletonBody, + board_runtime: &BoardRuntime, + base_trucks: [RetailAffineTransform; 2], + current_truck_targets: [f32; 2], + average_compression: [f32; 2], + globals: &mut [Transform], + locals: &mut [Transform], + ) -> Result<(), String> { + let bodies = + core::array::from_fn(|i| to_matrix(board_runtime.body_transform(BodyId::ORDER[i]))); + let trucks = steering_truck_transforms(base_trucks, current_truck_targets).map(to_matrix); + let physical_parts = core::array::from_fn(|i| physical.record.pose[i]); + self.pose + .publish( + skeleton_output::Input { + physical_parts: &physical_parts, + world_to_animation: &skeleton.roots.world_to_animation, + board: board::Input { + bodies: &bodies, + skeleton_board: &physical_parts[0], + truck_frames: &trucks, + deck_wobble_tilt: self.deck_wobble.tilt, + deck_wobble_squish: self.deck_wobble.squish, + average_compression, + }, + }, + globals, + locals, + ) + .map_err(str::to_string) + } +} +fn to_matrix(value: RetailAffineTransform) -> Transform { + let mut result = [[0.0; 4]; 4]; + for axis in 0..3 { + result[axis][..3].copy_from_slice(&value.basis.columns[axis]); + } + result[3] = [ + value.translation.x, + value.translation.y, + value.translation.z, + 0.0, + ]; + result +} diff --git a/crates/skate-game/src/physics/slide_state.rs b/crates/skate-game/src/physics/slide_state.rs new file mode 100644 index 00000000..34000190 --- /dev/null +++ b/crates/skate-game/src/physics/slide_state.rs @@ -0,0 +1,72 @@ +//! PhysicsSlideGround101 production lifecycle, original TU3 vtable823285B4. +mod settings; +mod update; +use super::{GamePhysics, SkaterRuntime}; +use skate_core::{ + math::Vector3, + physics::contact::RetailContactMaterial, + player::slide_state::{SlideSettings, SlideSurface}, +}; +pub(crate) struct SlideState { + pub state: skate_core::player::slide_state::SlideState, + settings: SlideSettings, + surfaces: Vec<(SlideSurface, RetailContactMaterial)>, + manual_scalar: f32, +} +/// Enter82D3A700. Body652 points to the deck's real dynamics; its+48 is angular +/// velocity. Preserve all other body rates, inertia and collision modes. +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + skater.ground_lifecycle.skeleton_elapsed_16505 = true; + skater.air_reckoning.state.spin_angle = 0.0; + skater.air_reckoning.state.spin_speed = 0.0; + physics + .board + .hook_mut() + .drive + .disable_animation(&mut skater.ground_lifecycle.board_animated_290); + physics.board.bodies_mut()[6].rates.angular_velocity = Vector3::ZERO; + for body in physics.board.bodies_mut() { + body.inertia.linear_drag = 0.0; + } + if p.category_2516 != 100 { + skater.ground.manual.reset(); + } + if skater.wipeout.state.mode != 1 { + skater.wipeout.state.balance = 0.0; + skater.wipeout.state.mode = 1; + } + skater.slide_state.state.enter(p.scalar_2656); + Ok(()) +} +/// Exit82D3A828 restores the board's cached standard wheel material8280. +pub(crate) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.slide_state.state.exit(); + physics.settings.wheel_material = physics.settings.standard_wheel_material; + Ok(()) +} +/// Update82D3A890: complete Reckoning, Skeleton Ground, then Slide board. +pub(crate) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("Slide requires current BoardToolkit")?; + physics.riding.update_slide_reckoning( + p, + t, + skater.animation_input.extra.physical_body_spin, + skater.animation_input.fields.balance, + ); + super::input_phase::update_ground(physics, skater)?; + update::board(physics, skater) +} +/// Fill82D3B010 writes only State84. Common output publication owns other fields. +pub(crate) fn fill(_physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.player_input.physical.state.signed_ground_step_84 = + u8::from(skater.slide_state.state.wall_riding); + Ok(()) +} +// Post vtable+40 is the same82D387A8 as Ground. The shared coordinator invokes +// Wipeout::check_ground with the actual complete solved Observations once. diff --git a/crates/skate-game/src/physics/slide_state/settings.rs b/crates/skate-game/src/physics/slide_state/settings.rs new file mode 100644 index 00000000..69d2b541 --- /dev/null +++ b/crates/skate-game/src/physics/slide_state/settings.rs @@ -0,0 +1,61 @@ +//! Direct original bindings82D94EC8..4F48,82D3AB30/AD3C/AE68 and stock schema. +use super::SlideState; +use skate_core::{ + physics::contact::RetailContactMaterial, + player::slide_state::{SlideSettings, SlideSurface}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +impl SlideState { + pub fn load(data: &Collections) -> Result { + let f = |class, field| data.float(class, "default", field); + let c = |field| curve(data, "physics_slide", "default", field); + let mut surfaces = Vec::new(); + for key in ["smooth", "rough", "slow", "slippery", "veryslow"] { + surfaces.push(( + SlideSurface { + speed_to_force: curve( + data, + "physics_surfaces", + key, + "Powerslide_SpeedToForce", + )?, + yaw_strength: data.float("physics_surfaces", key, "Powerslide_YawStrength")?, + yaw_damping: data.float("physics_surfaces", key, "Powerslide_YawDamping")?, + }, + RetailContactMaterial { + static_friction: data.float("physics_surfaces", key, "WheelStaticFriction")?, + dynamic_friction: data.float( + "physics_surfaces", + key, + "WheelDynamicFriction", + )?, + restitution: f("physicswheels", "WheelRestitution")?, + }, + )); + } + Ok(Self { + state: skate_core::player::slide_state::SlideState::new(), + settings: SlideSettings { + input_remap: c("slide_input_remap")?, + remap_vs_speed: c("Hash_DE162591FD9D91D7")?, + // Toolkit528 copies layout160;608 copies layout80, not table order. + force_vs_angle: c("Hash_91282E2CC4252731")?, + force_vs_speed: c("Hash_2AD93E5ABA231D2")?, + softest_wheel_force: f("physicswheels", "SoftestWheelPowerslideFactor")?, + softest_wheel_spin: f("physicswheels", "SoftestWheelPowerslideSpinFactor")?, + angular_force: f("physics_slide", "AngularForceScalar")?, + force_y_offset: f("physics_slide", "PowerSlideForceYOffset")?, + }, + surfaces, + manual_scalar: f("physics_manual", "PowerSlideScalar")?, + }) + } +} +fn curve(d: &Collections, c: &str, k: &str, n: &str) -> Result, String> { + let w = d.words::<20>(c, k, n)?.map(f32::from_bits); + Ok(PointGraph { + x: w[4..12].try_into().unwrap(), + y: w[12..20].try_into().unwrap(), + }) +} diff --git a/crates/skate-game/src/physics/slide_state/update.rs b/crates/skate-game/src/physics/slide_state/update.rs new file mode 100644 index 00000000..9f392da4 --- /dev/null +++ b/crates/skate-game/src/physics/slide_state/update.rs @@ -0,0 +1,232 @@ +//! Ordered UpdateSkateboard82D3A900: steering, manual, wall launch or Slide forces. +use super::*; +use crate::physics::ground_runtime::GroundInputObservations; +use skate_core::{ + physics::{ + force_queue::QueuedPointForce, + manual::controller::{self, ManualAngleMeasurement}, + }, + player::slide_state::{SlideInput, angular_correction, sliding_force}, + riding::{ + collision_response::{CollisionResponsePhysical, signed_angle}, + ground_contact_response::WallRidePhysical, + ground_force::{self, GroundForceInput}, + steering, + }, +}; +pub(super) fn board(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + // The preceding Skeleton call captured the actual unblended board target. + // Re-capture at Slide's own82D3A924 call; no tick elapses between these writes. + let target = skater.animated_skeleton.board_frames.animation_target; + skater + .skeleton_air + .capture_physics_error(&physics.board, &target); + let p = &skater.player_input.processed; + let t = skater + .player_input + .toolkit + .as_ref() + .ok_or("Slide requires current BoardToolkit")?; + let surface_index = + p.surface_mode_2540 + .checked_sub(1) + .ok_or("Slide requires the selected SurfacePhysics profile")? as usize; + let (surface, material) = skater + .slide_state + .surfaces + .get(surface_index) + .ok_or_else(|| format!("Invalid Slide surface mode {}", p.surface_mode_2540))?; + // All four actual wheel query volumes share the material copied from2552. + physics.settings.wheel_material = *material; + let edge = skater.ground_lifecycle.edge; + let mut input = skater.ground_settings.input( + t, + p, + &skater.animation_input, + &skater.ground.pumping, + skater + .ground + .pumping_settings + .mode(p.state_variant_index_2528)? + .unintentional_scalar, + &physics.riding, + &skater.animated_skeleton, + physics.settings.step.base_truck_transforms, + GroundInputObservations { + manual_drag_2724: skater.ground_lifecycle.manual_drag_2724, + trajectory_state_bits: p.external_physics_1616.flags, + edge_flags: edge.map_or(0, |e| e.flags), + edge_point: edge.map_or([0.0; 4], |e| e.point), + }, + ); + let settings = skater.ground_settings.board(); + let state = &mut skater.slide_state.state; + let tilt = steering::calculate_tilt( + settings.steering, + input.steering, + Some(&mut state.steering_push), + Some(&mut state.damped_turn), + ); + skater.ground.steering.update( + tilt, + settings.steering.tilt_blending, + p.flags_2468, + p.flags_2472, + ); + input.manual.powersliding = true; + let manual = controller::calculate( + &mut skater.ground.manual, + settings.manual, + settings.manual_mode, + &input.manual, + &mut Angle, + ) + .map_err(|e| format!("Slide manual controller: {e:?}"))?; + let normal = raw(p.vectors_464_480_496_512_528[0]); + let up = raw(p.vectors_544_560_592_608[0]); + let velocity = raw(p.vectors_400_416[0]); + let mut contact_frame = input.contact; + contact_frame.scalar_2756 = 0.0; + let response = skater.ground_runtime.contact_response_with_previous( + contact_frame, + WallRidePhysical { + board_normal: normal, + up, + velocity, + board_mass: t.total_mass, + gravity: p.gravity_2648, + speed: p.scalar_2652, + contact_count: p.wheel_count_2556 as i32, + }, + [0.0; 4], + ); + state.wall_riding = response.active_2731; + if response.animated_board_2708 { + skater + .ground_runtime + .set_animated_velocity(&mut physics.board, response.vector_2688); + for body in physics.board.bodies_mut() { + body.inertia.linear_drag = 0.0; + } + let mut launch = super::super::air_phase::launch_info(physics, skater)?; + launch.start_velocity = response.vector_2688; + launch.player_jumped = true; + let input = super::super::air_phase::selector_input(physics, skater)?; + skater.trajectory.launch(launch, input, &physics.world)?; + skater.trajectory.update(input, &physics.world)?; + //82D3AB10 branches directly to the epilogue. No ordinary force tail. + return Ok(()); + } + let slide = SlideInput { + velocity, + normal, + side: t.deck[0], + effective_forward: t.effective[2], + reference_forward: t.forward, + angular_velocity: raw(p.vectors_720_784_800_816_832_864[0]), + absolute_speed: t.absolute_speed, + surface_speed: p.scalar_2656, + slide: skater.animation_input.fields.slide, + elapsed: p.state_timer_2664, + wheel_hardness: p.scalar_2764, + }; + let displacement = angular_correction(&skater.slide_state.settings, surface, slide); + skater + .ground_runtime + .apply_angular_displacement(&mut physics.board, displacement); + let manual = manual + .angular_displacement + .map(|v| v * skater.slide_state.manual_scalar); + skater + .ground_runtime + .apply_angular_displacement(&mut physics.board, manual); + let f = &input.ground_force; + let balance = f.balance_2720; + //82D3AEC0..AF10 preserves the strict scalar branches and fsel signs. + let front_factor = if balance > 0.0 { + 0.0 + } else { + fsel(balance, 1.0, 2.0) + }; + let rear_factor = fsel(balance, if balance > 0.0 { 2.0 } else { 1.0 }, 0.0); + let front = ground_force::calculate( + settings.ground_force, + &GroundForceInput { + argument_1: f.argument_1_2752, + application_z: f.ground_scalar_1216, + argument_3: 0.0, + argument_4: front_factor, + balance, + surface_speed: f.surface_speed_2656, + axis_384: f.axis_384, + velocity_400: f.velocity_400, + axis_544: f.axis_544, + }, + ); + let rear = ground_force::calculate( + settings.ground_force, + &GroundForceInput { + argument_1: f.argument_1_2752, + application_z: -f.ground_scalar_1240, + argument_3: f.ground_scalar_1236 * 0.0, + argument_4: rear_factor, + balance, + surface_speed: f.surface_speed_2656, + axis_384: f.axis_384, + velocity_400: f.velocity_400, + axis_544: f.axis_544, + }, + ); + let sliding = sliding_force(&skater.slide_state.settings, surface, slide); + let n = physics.riding.reckoning.ground_normal; + let collision = skater + .ground_runtime + .calculate_collision_force(CollisionResponsePhysical { + flags_2472: p.flags_2472, + collision_displacement: raw(p.collision_pose_error_736), + velocity: raw(p.vectors_400_416[1]), + forward: t.travel_direction, + up, + ground_normal: [n.x, n.y, n.z, 0.0], + time_step: p.timestep_2604, + mass: t.total_mass, + }); + let q = physics.board.forces_mut(); + if let Some(hit) = collision { + q.append(QueuedPointForce { + tag: 15, + force_world: xyz(hit.force_2528), + point_body: xyz(hit.point_2544), + }); + // Original Slide caller discards the collision angular output. + } else { + q.append(point_force(4, front)); + q.append(point_force(5, rear)); + q.append(response.tag_16_force); + q.append(sliding); + } + Ok(()) +} +struct Angle; +impl ManualAngleMeasurement for Angle { + type Error = std::convert::Infallible; + fn angle_between(&mut self, a: [f32; 4], b: [f32; 4], c: [f32; 4]) -> Result { + Ok(signed_angle(xyz(a), xyz(b), xyz(c))) + } +} +fn point_force(tag: u32, f: [f32; 8]) -> QueuedPointForce { + QueuedPointForce { + tag, + force_world: Vector3::new(f[0], f[1], f[2]), + point_body: Vector3::new(f[4], f[5], f[6]), + } +} +fn xyz(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} +fn raw(v: [u32; 4]) -> [f32; 4] { + v.map(f32::from_bits) +} +fn fsel(test: f32, a: f32, b: f32) -> f32 { + if test >= -0.0 { a } else { b } +} diff --git a/crates/skate-game/src/physics/solve.rs b/crates/skate-game/src/physics/solve.rs new file mode 100644 index 00000000..a3b3a130 --- /dev/null +++ b/crates/skate-game/src/physics/solve.rs @@ -0,0 +1,95 @@ +//! One physical solve for the board, skater and original animation targets. +//! The caller publishes forces and drive targets before entering this phase. +mod assembly_contacts; +use super::{GamePhysics, SkaterRuntime, colliders, skeleton_colliders}; +use skate_core::physics::{ + board::BodyId, + board_step::{ATTACHED_REACTION_BASE, AttachedStep}, + skeleton_animation_record::{AnimationPartTransform, IDENTITY}, + skeleton_body::PART_COUNT, +}; + +pub(super) fn advance( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, + truck_targets: [f32; 2], +) -> Result<(), String> { + let board_volumes = colliders::world_volumes(&physics.board, &physics.settings); + let skeleton_volumes = + skeleton_colliders::world_volumes(&skater.skeleton, &skater.skeleton_collision)?; + // Each native assembly has its own query record and retention buffer. + // Skeleton82BE5094 passes false to82768728: its edge threshold is -1, + // whereas the board requests .999. GroundPipeline supplies the remaining + // shared values. Do not let the second query overwrite the first's rows. + let mut contacts = physics + .world + .query_primitives(&board_volumes, physics.query, physics.retention) + .to_vec(); + let mut skeleton_query = physics.query; + skeleton_query.edge_cos_bend_normal_threshold = -1.0; + contacts.extend_from_slice(physics.world.query_primitives( + &skeleton_volumes, + skeleton_query, + physics.retention, + )); + assembly_contacts::append( + &mut contacts, + &board_volumes, + &skeleton_volumes, + physics.board.collision_group(), + &skater.skeleton_collision, + )?; + physics.contact_count = contacts.len(); + let dt = physics.settings.step.simulation.time_step; + let mut joints = + skater + .skeleton_joints + .build(skater.skeleton.bodies(), ATTACHED_REACTION_BASE, dt); + let mut drives = skater.skeleton_drives.build( + skater.skeleton.bodies(), + ATTACHED_REACTION_BASE, + ATTACHED_REACTION_BASE + PART_COUNT, + dt, + ); + let bodies = skater + .skeleton + .bodies_mut() + .iter_mut() + .chain(skater.skeleton_drives.targets.bodies.iter_mut()) + .collect(); + physics.board.advance_attached( + &contacts, + truck_targets, + physics.settings.step, + AttachedStep { + bodies, + contacts: &mut [], + joints: &mut joints, + drives: &mut drives.rows, + }, + ); + skater + .skeleton + .publish_physical_record(deck_frame(&physics.board)); + // These solved rows are consumed by the actual collision/drive feedback + // phase; keep their identity and impulses after the shared solve. + skater.solved_drives = Some(drives); + Ok(()) +} + +pub(super) fn deck_frame( + board: &skate_core::physics::board_runtime::BoardRuntime, +) -> AnimationPartTransform { + let deck = board.part_transforms()[BodyId::Deck.index()]; + let mut frame = IDENTITY; + for (axis, column) in deck.basis.columns.iter().enumerate() { + frame[axis][..3].copy_from_slice(column); + } + frame[3] = [ + deck.translation.x, + deck.translation.y, + deck.translation.z, + 0.0, + ]; + frame +} diff --git a/crates/skate-game/src/physics/solve/assembly_contacts.rs b/crates/skate-game/src/physics/solve/assembly_contacts.rs new file mode 100644 index 00000000..1f5dc933 --- /dev/null +++ b/crates/skate-game/src/physics/solve/assembly_contacts.rs @@ -0,0 +1,96 @@ +//! Original single-player SkaterSkaterCollisionPipeline8276556C. +//! Board construction precedes Skeleton construction in82DB18A0. Preserve +//! that assembly order: board self (all culled), board/rider, rider self. +use skate_core::physics::{ + board_step::{BoardCollision, CollisionBody}, + board_world::BoardWorldVolume, + contact::{RetailContactInput, combine_contact_materials}, + skeleton_body::SkeletonCollisionMode, + world_contact::{PrimitivePairSettings, primitive_pair_contacts}, +}; + +pub(super) fn append( + contacts: &mut Vec, + board: &[BoardWorldVolume], + rider: &[BoardWorldVolume], + board_group: u32, + collision: &SkeletonCollisionMode, +) -> Result<(), String> { + //82765EF0 initializes the single-player (Island flags3) group bitmap. + //Rows1/2/3 are culled except2/3 against0;4<->5 is always culled. + //Its later extra4..7 exclusions run only for the online/non3 branch. + if board_group > 7 || collision.assembly_group > 7 { + return Err("Skater assembly collision requires its original group-table extension".into()); + } + if group_pair_allowed(board_group, collision.assembly_group) { + for a in board { + for b in rider { + let CollisionBody::Attached(part) = b.body else { + unreachable!() + }; + //Serialized volume+212 is source Volume+84 group. + //Stock board child constructors leave that field zero. + if !group_pair_allowed(0, collision.parts[part].volume_group) { + continue; + } + append_pair(contacts, a, b); + } + } + } + //8277AE80 suppresses the reversed assembly pair. The same-assembly + //branch8277B2C0 instead visits both directed primitive pairs and uses + //the skeleton's part bitmap. Board82C0B560 culls all7x7 self pairs. + for a in rider { + let CollisionBody::Attached(part_a) = a.body else { + unreachable!() + }; + for b in rider { + let CollisionBody::Attached(part_b) = b.body else { + unreachable!() + }; + if part_a != part_b && !collision.self_culling[part_a][part_b] { + append_pair(contacts, a, b); + } + } + } + Ok(()) +} + +fn group_pair_allowed(a: u32, b: u32) -> bool { + match (a, b) { + (0, 0 | 2..=7) | (2..=7, 0) => true, + (4, 5) | (5, 4) => false, + (4..=7, 4..=7) => true, + _ => false, + } +} + +fn append_pair(contacts: &mut Vec, a: &BoardWorldVolume, b: &BoardWorldVolume) { + //8277B0F0 and8277B2C0 enter the same8277A508 primitive walker with + //identical padding/triangle settings. Keep A/B orientation and point order. + let Some(manifold) = primitive_pair_contacts( + a.primitive, + b.primitive, + PrimitivePairSettings::skater_self_collision(), + ) else { + return; + }; + let material = combine_contact_materials(a.material, b.material); + for points in &manifold.points[..manifold.count] { + contacts.push(BoardCollision { + body_a: a.body, + body_b: b.body, + contact: RetailContactInput { + position_on_a: points.a, + position_on_b: points.b, + normal: manifold.normal, + restitution: material.restitution, + static_friction: material.static_friction, + dynamic_friction: material.dynamic_friction, + //8277A7B4/B8 packs original Volume88 tags, both zero for + //stock skater/board constructors (not the part indices). + tag: 0, + }, + }); + } +} diff --git a/crates/skate-game/src/physics/teleport_state.rs b/crates/skate-game/src/physics/teleport_state.rs new file mode 100644 index 00000000..c2079894 --- /dev/null +++ b/crates/skate-game/src/physics/teleport_state.rs @@ -0,0 +1,73 @@ +//! Host checkpoint response to original actor reset callback82592518. +//! Shared scheduling preserves request -> next input reply ->702 output -> reset. +use skate_core::{ + animation::output::actor_packet::ExternalReset, + physics::skeleton_animation_record::AnimationPartTransform, + player::{ + input_phase::{PhysicalPlayerInput, ProcessedPhysicsInput}, + teleport_state::{Output, Target, TeleportState, Update}, + }, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Checkpoint { + pub transform: AnimationPartTransform, + /// Original actor82592518 inverts checkpoint byte68. Its ground validator + ///82BFBC18 sets that byte for surface category8. Authored rideable spawn + ///surfaces supply true; an off-board checkpoint must explicitly supply false. + pub on_board: bool, +} + +pub(crate) struct Runtime { + state: TeleportState, + checkpoint: Checkpoint, + pending_reply: Option, +} +impl Runtime { + pub fn new(checkpoint: Checkpoint) -> Self { + Self { + state: TeleportState::default(), + checkpoint, + pending_reply: None, + } + } + pub fn enter(&mut self) { + self.state.enter(); + } + /// Update82D431F0 calls the actor only without Processed2468 bit1. + ///825926F8 stores the reply and marks it for the next input publication. + pub fn update(&mut self, input: &ProcessedPhysicsInput) { + if self + .state + .update(input.flags_2468, input.matrix_1536, input.byte_1600) + == Update::RequestCheckpoint + { + self.request_checkpoint(); + } + } + /// Host response for an independently verified actor-reset request. + pub fn request_checkpoint(&mut self) { + self.pending_reply = Some(Target { + transform: self.checkpoint.transform.map(|row| row.map(f32::to_bits)), + on_board: self.checkpoint.on_board, + }); + } + /// Consume at the next actor-reset publication boundary. Publish this as + ///ExternalReset plus Actor1904 bit29, then use the ordinary animation-packet + ///mapper to produce1536/1600 and2468 bit1. Never reset physical bodies here. + pub fn take_reply(&mut self) -> Option { + self.pending_reply.take().map(|reply| ExternalReset { + transform: reply.transform, + byte64: u8::from(reply.on_board), + }) + } + /// Call after ordinary physical publication so702 owns State8/61 and + ///the returned board0 transform/board272 publication for this tick. + pub fn publish_output(&self, physical: &mut PhysicalPlayerInput) -> Option { + let output = self.state.output()?; + physical.teleport_output = Some(output); + physical.state.identifier_8 = output.next_state; + physical.state.flag_61 = output.state_61; + Some(output) + } +} diff --git a/crates/skate-game/src/physics/tests/clock.rs b/crates/skate-game/src/physics/tests/clock.rs new file mode 100644 index 00000000..0edb404f --- /dev/null +++ b/crates/skate-game/src/physics/tests/clock.rs @@ -0,0 +1,56 @@ +use super::*; +use skate_core::{ + camera::{SlowMotionController, SlowMotionSettings}, + point_graph::PointGraph, +}; + +#[test] +fn camera_rate_requests_expire_and_preserve_message_order() { + let mut clock = SimulationClock::default(); + let normal = clock.period(); + let slow = SimulationRateRequest { + timestep: 1.0 / 35.4, + ticks: 1, + }; + clock.apply(slow).unwrap(); + clock.finish_tick(); + assert_eq!(clock.period(), Duration::from_nanos(28_571_400)); + clock.finish_tick(); + assert_eq!(clock.period(), normal); + + //An End followed by a new Begin must retain the new request. + clock.apply(SlowMotionController::end()).unwrap(); + clock.apply(slow).unwrap(); + clock.finish_tick(); + assert_ne!(clock.period(), normal); + clock.apply(SlowMotionController::end()).unwrap(); + clock.finish_tick(); + assert_eq!(clock.period(), normal); + + clock + .apply(SimulationRateRequest { ticks: 0, ..slow }) + .unwrap(); + for _ in 0..179 { + clock.finish_tick(); + } + assert_ne!(clock.period(), normal); + clock.finish_tick(); + assert_eq!(clock.period(), normal); +} + +#[test] +fn camera_slow_motion_waits_for_a_positive_air_prediction() { + let settings = SlowMotionSettings { + timescale: PointGraph { + x: std::array::from_fn(|i| i as f32 / 15.0), + y: std::array::from_fn(|i| i as f32 / 15.0), + }, + fps_at_scale_one: 35.0, + }; + let (mut controller, _) = SlowMotionController::begin(settings); + let waiting = controller.update(0.1, f32::NAN, settings); + assert!((waiting.timestep - 1.0 / 57.5).abs() < 1e-6); + let predicted = controller.update(0.1, 1.0, settings); + let expected = 1.0 / (60.0 - 25.0 * (0.2 / 1.1)); + assert!((predicted.timestep - expected).abs() < 1e-6); +} diff --git a/crates/skate-game/src/physics/wipeout.rs b/crates/skate-game/src/physics/wipeout.rs new file mode 100644 index 00000000..25bd4207 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout.rs @@ -0,0 +1,98 @@ +//! Shared Ground/Air wipeout requests from original TU3 82D8F9E0/82D90358. +mod observations; +mod settings; +pub(crate) use observations::Observations; +use skate_core::player::{ + input_phase::ProcessedPhysicsInput, + wipeout::{self, Mode, Requests, Settings}, +}; +use skate_data::collections::Collections; +pub(crate) struct Wipeout { + pub state: Requests, + settings: Settings, + modes: [Mode; 5], +} +impl Wipeout { + pub fn load(data: &Collections) -> Result { + let (settings, modes) = settings::load(data)?; + let mut state = Requests::new(); + state.initialize_player(); //Player82DB3024, after the component ctor. + Ok(Self { + state, + settings, + modes, + }) + } + pub fn check_ground(&mut self, input: &Observations<'_>) -> Result<(), String> { + let mode = self.mode(input.processed)?; + let frame = input.frame()?; + wipeout::check_ground(&mut self.state, &self.settings, &mode, &frame); + Ok(()) + } + pub fn check_ground_animation( + &mut self, + input: &Observations<'_>, + scale: f32, + ) -> Result<(), String> { + let mode = self.mode(input.processed)?; + let frame = input.frame()?; + wipeout::check_ground_animation(&mut self.state, &self.settings, &mode, &frame, scale); + Ok(()) + } + pub fn check_air(&mut self, input: &Observations<'_>, use_com: bool) -> Result<(), String> { + let mode = self.mode(input.processed)?; + let frame = input.frame()?; + wipeout::check_air(&mut self.state, &self.settings, &mode, &frame, use_com); + Ok(()) + } + fn mode(&self, p: &ProcessedPhysicsInput) -> Result { + self.modes + .get(p.state_variant_index_2528 as usize) + .copied() + .ok_or_else(|| { + format!( + "Undefined wipeout physics mode {}", + p.state_variant_index_2528 + ) + }) + } + ///Consumed by state selection and by the same tick's postphysics IK branch. + pub fn requests_runout(&self, p: &ProcessedPhysicsInput) -> bool { + self.state.requests_runout(&observations::request_input(p)) + } + pub fn requests_wipeout(&self, p: &ProcessedPhysicsInput) -> bool { + self.state.requests_wipeout(&observations::request_input(p)) + } +} + +///Player postphysics82BD83E0 follows contact/error publication with the +///selected state's check, before FootIK sees that tick's wipeout request. +pub(super) fn check_after_physics( + physics: &super::GamePhysics, + skater: &mut super::SkaterRuntime, +) -> Result<(), String> { + use skate_core::player::state::PhysicalStateId; + let observations = Observations { + processed: &skater.player_input.processed, + board: &physics.riding.ground, + collision: &skater.collision_feedback, + deck: super::solve::deck_frame(&physics.board), + //Ground82BDF694..6A4 stores this target into Processed0..48; + //Animated/KnownAir publish the same unblended target before board drive. + input_board: skater.animated_skeleton.board_frames.animation_target, + world_to_animation: skater.animated_skeleton.roots.world_to_animation, + pose_error: skater.collision_pose_error, + maximum_pose_error: skater.collision_maximum_error, + jump_fix_frames: skater.player_state.post.jump_fix_frames, + air: &skater.air_reckoning.state, + system_up_y: physics.riding.reckoning_frames.system[1][1], + grind_locked_to_middle: skater.trajectory.selector.grind_locked_to_middle(), + grind_normal: skater.trajectory.selector.grind_normal(), + }; + match skater.player_state.current() { + PhysicalStateId::PhysicsGround | PhysicalStateId::SlideGround => skater.wipeout.check_ground(&observations), + PhysicalStateId::GroundAnimation => skater.wipeout.check_ground_animation(&observations, 1.0), + PhysicalStateId::PhysicsAir => skater.wipeout.check_air(&observations, false), + _ => Ok(()), //Other concrete states dispatch their own postphysics check. + } +} diff --git a/crates/skate-game/src/physics/wipeout/observations.rs b/crates/skate-game/src/physics/wipeout/observations.rs new file mode 100644 index 00000000..7d3706e1 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout/observations.rs @@ -0,0 +1,95 @@ +//! Borrow the actual completed collision and physical owners; no default contacts. +use skate_core::{ + air::reckoning::AirState, + math::Vector3, + physics::{ + board_ground::BoardGroundState, skeleton_animation_record::AnimationPartTransform, + skeleton_body::SkeletonCollisionFeedback, + }, + player::{ + input_phase::ProcessedPhysicsInput, + wipeout::{Frame, RequestInput}, + }, +}; +pub(crate) struct Observations<'a> { + pub processed: &'a ProcessedPhysicsInput, + pub board: &'a BoardGroundState, + pub collision: &'a SkeletonCollisionFeedback, + pub deck: AnimationPartTransform, + ///Actual Processed0/16/32 publication, supplied by its retained producer. + pub input_board: AnimationPartTransform, + pub world_to_animation: AnimationPartTransform, + pub pose_error: [f32; 4], + ///Skeleton16384 is unavailable before the first actual postphysics feedback. + pub maximum_pose_error: Option, + pub jump_fix_frames: u32, + pub air: &'a AirState, + ///Reckoning816's up column Y, original832.Y. + pub system_up_y: f32, + pub grind_locked_to_middle: bool, + pub grind_normal: Option<[f32; 4]>, +} +impl Observations<'_> { + pub(super) fn frame(&self) -> Result { + let p = self.processed; + let c = self.collision; + let b = self.board; + Ok(Frame { + flags_2468: p.flags_2468, + flags_2472: p.flags_2472, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + category: p.category_2512, + timestep: p.timestep_2604, + time_on_ground: p.time_on_ground_2752, + speed: p.scalar_2652, + animation_up: p.vectors_544_560_592_608[0].map(f32::from_bits), + landing_angle: p.scalar_2736, + deck_velocity: p.vectors_400_416[0].map(f32::from_bits), + com_velocity: p.vectors_544_560_592_608[3].map(f32::from_bits), + jump_fix_frames: self.jump_fix_frames as i32, + deck: self.deck, + input_board: self.input_board, + world_to_animation: self.world_to_animation, + closing_velocity: vector(b.closing_velocity), + board_material_flags: b.collision_flags, + //82C08494/82C08640 publish the actual counts to868/869. + board_contact: b.part_contact_count != 0, + wheel_contact: b.wheel_contact_count != 0, + board_contact_normal: vector(b.overall_normal), + opposing_contact: b.opposing_contact, + regions_force: c.regions.map(|r| r.force), + maximum_skater_force: c.maximum_skater_force, + vehicle_force: c.maximum_group_8_force, + group_8: c.flags.group_8, + conflicting: c.flags.conflicting, + compliant: c.flags.compliant, + highest_normal: c.highest_normal, + pose_error: self.pose_error, + maximum_pose_error: self + .maximum_pose_error + .ok_or("Wipeout requires completed Skeleton pose-error feedback")?, + flip_active: self.air.flip_active, + flip_requested_speed: self.air.flip_requested_speed, + system_up_y: self.system_up_y, + grind_selected: self.grind_locked_to_middle, + grind_normal_valid: self.grind_normal.is_some(), + //Unused when invalid; retain native default up without publishing a hit. + grind_normal: self.grind_normal.unwrap_or([0.0, 1.0, 0.0, 0.0]), + }) + } +} +pub(super) fn request_input(p: &ProcessedPhysicsInput) -> RequestInput { + RequestInput { + flags_2468: p.flags_2468, + flags_2476: p.flags_2476, + flags_2480: p.flags_2480, + flags_2484: p.flags_2484, + animation_up_y: f32::from_bits(p.vectors_544_560_592_608[0][1]), + category: p.category_2512, + } +} +fn vector(v: Vector3) -> [f32; 4] { + [v.x, v.y, v.z, 0.0] +} diff --git a/crates/skate-game/src/physics/wipeout/settings.rs b/crates/skate-game/src/physics/wipeout/settings.rs new file mode 100644 index 00000000..e01cce5b --- /dev/null +++ b/crates/skate-game/src/physics/wipeout/settings.rs @@ -0,0 +1,79 @@ +//! Globals264 -> original8289D270 class EB844CFDBF928EFD/default. +//! Fields are independently mapped through the stock physics_wipeout schema. +use skate_core::{ + player::wipeout::{AirSettings, GroundSettings, Mode, Settings}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; +pub(super) fn load(data: &Collections) -> Result<(Settings, [Mode; 5]), String> { + let f = |name| data.float("physics_wipeout", "default", name); + let graph = data + .words::<20>("physics_wipeout", "default", "Hash_4F08D9BAE6831524")? + .map(f32::from_bits); + let mut modes = Vec::new(); + for name in ["easy", "normal", "hardcore", "motorized", "test"] { + modes.push(Mode { + check_squash: data.boolean("physics_mode", name, "Hash_CC890A3BDCF6290A")?, + check_bad_landing: data.boolean("physics_mode", name, "WipeoutCheckForBadLanding")?, + ground_xz: data.float("physics_mode", name, "Wipeout_GroundXZAcceleration")?, + //82D90D40..94 constructs full64 D978550D6DB4E6F7. + bad_landing_scale: data.float("physics_mode", name, "Hash_D978550D6DB4E6F7")?, + }); + } + Ok(( + Settings { + ground: GroundSettings { + vehicle_scalar: f("Wipeout_GroundVehicleScalar")?, + vehicle_contact: f("Wipeout_GroundVehicleContact")?, + skitch_contact: f("Wipeout_GroundSkitchingContact")?, + skitch_scalar: f("Wipeout_GroundSkitchingScalar")?, + skitch_arms_scalar: f("Wipeout_GroundSkitchingScalarArms")?, + skater_scalar: f("SkaterSkaterThresholdScalar")?, + max_squash: f("Wipeout_GroundMaxSquash")?, + max_squash_coffin: f("Wipeout_GroundMaxSquashCoffin")?, + max_displacement: f("Wipeout_GroundSkeletonMaxDisp")?, + max_contact: f("Wipeout_GroundSkeletonMaxContact")?, + max_arm_contact: f("Wipeout_GroundSkeletonMaxContactArms")?, + max_deck_error: f("Wipeout_GroundMaxAngularDeckError")?, + opposing_contact: f("Wipeout_GroundOpposingContact")?, + y_acceleration: f("Wipeout_GroundYAcceleration")?, + light_dmo_scalar: f("WipeoutGroundLightDMOScalar")?, + player_scalar: f("DeckAccPlayerScalar")?, + ai_scalar: f("DeckAccAIScalar")?, + skitch_acc_scalar: f("DeckAccSkitchScalar")?, + balance_total: f("Wipeout_GroundBalanceTotal")?, + balance_min_speed: f("Wipeout_GroundBalanceMinSpeed")?, + balance_base: f("Wipeout_GroundBalanceBase")?, + }, + air: AirSettings { + xz_trick: f("Wipeout_AirXZTrick")?, + y_trick: f("Wipeout_AirYTrick")?, + xz_acceleration: f("Wipeout_AirXZAcceleration")?, + y_acceleration: f("Wipeout_AirYAcceleration")?, + max_squash: f("Wipeout_AirMaxSquash")?, + max_displacement: f("Wipeout_AirSkeletonMaxDisp")?, + max_contact: f("Wipeout_AirSkeletonMaxContact")?, + max_arm_contact: f("Wipeout_AirSkeletonMaxContactArms")?, + body_flip_scalar: f("Wipeout_AirBodyFlipScalar")?, + body_flip_acc_scalar: f("DeckAccBodyFlipScalar")?, + light_dmo_scalar: f("WipeoutAirLightDMOScalar")?, + ignore_danger_frames: data.integer( + "physics_wipeout", + "default", + "FramesAtStartOfAirToIgnoreDangerZone", + )? as i32, + max_landing_speed: f("Wipeout_AirMaxSpeedIntoGround")?, + max_stairs_speed: f("Wipeout_AirMaxSpeedIntoStairs")?, + max_grind_speed: f("Wipeout_AirMaxSpeedIntoCollisionNearGrind")?, + max_landing_angle: PointGraph { + x: graph[4..12].try_into().unwrap(), + y: graph[12..20].try_into().unwrap(), + }, + }, + lean_contact_y: f("Wipeout_GroundLeanContactYThresh")?, + }, + modes + .try_into() + .map_err(|_| "Expected five wipeout physics modes".to_owned())?, + )) +} diff --git a/crates/skate-game/src/physics/wipeout_states/contact.rs b/crates/skate-game/src/physics/wipeout_states/contact.rs new file mode 100644 index 00000000..0fdbb216 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/contact.rs @@ -0,0 +1,61 @@ +//! Wipeout300's material corrections and collision-policy requests82D3EC08. +use crate::physics::skeleton_controller::SkeletonControllerState; +use skate_core::{ + physics::skeleton_body::{ + SkeletonBody, SkeletonCollisionFeedback, SkeletonCollisionMode, SkeletonJoints, + }, + player::wipeout_state::{ + State, + contact_response::{ContactResponse, ContactResponseInput}, + }, +}; + +pub(crate) fn update( + state: &mut State, + response: &mut ContactResponse, + body: &mut SkeletonBody, + feedback: &SkeletonCollisionFeedback, + controller: &mut SkeletonControllerState, + collision: &mut SkeletonCollisionMode, + joints: &mut SkeletonJoints, + ragdoll: &super::ragdoll::RagdollSetup, + contact: bool, + com_velocity: [f32; 4], + effective_axis: [f32; 4], + controls: [f32; 2], +) -> Result<(), String> { + let output = response.update( + body, + ContactResponseInput { + com_velocity_608: com_velocity, + material10_normal: feedback + .flags + .material_10 + .then_some(feedback.material_normals[0]), + material11_normal: feedback + .flags + .material_11 + .then_some(feedback.material_normals[1]), + effective_axis_224: effective_axis, + wipeout_control_2824_2828: controls, + }, + ); + state.material_ten_response = output.material10_finished_77; + // The original comparison rejects <=0.3; unordered values retain true. + let air_mode = !contact && !(state.predicted_time <= 0.3); + let air_changed = state.air_collision_mode != air_mode; + state.air_collision_mode = air_mode; + let material_changed = state.material_eleven_response != output.material11_active_73; + state.material_eleven_response = output.material11_active_73; + if !state.special_surface && (air_changed || material_changed) { + let requested = if state.material_eleven_response { + 9 + } else if air_mode { + 11 + } else { + 8 + }; + ragdoll.request(controller, requested, body, joints, collision)?; + } + Ok(()) +} diff --git a/crates/skate-game/src/physics/wipeout_states/drive_settings.rs b/crates/skate-game/src/physics/wipeout_states/drive_settings.rs new file mode 100644 index 00000000..706ad795 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/drive_settings.rs @@ -0,0 +1,39 @@ +//! Native82BE7860's per-part records and82BEB0C8/82BEB790 settings. +use skate_core::player::wipeout_state::drives::Settings; +use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; +use std::path::Path; + +pub(crate) fn load( + data: &Collections, + asset_root: &Path, + primary_bank_sha: &str, +) -> Result { + let skeleton = PhysicsSkeleton::load( + &asset_root.join("private/stock/physics-skeletons.json"), + primary_bank_sha, "PHYS_TPOSE")?; + if skeleton.bones.len() != 24 { + return Err("Wipeout requires the actual24 physical skeleton parts".into()); + } + let mut bone = [[0.0; 5]; 24]; + for part in 1..24 { + let name = format!("PART_{}", skeleton.bones[part].name); + let words = data.words::<9>("physics_skeleton_drives", "default", &name)?; + bone[part] = std::array::from_fn(|i| f32::from_bits(words[i])); + } + let roots = ["root_drive_start_scalar", "root_drive_scalar", + "root_drive_controlled_scalar", "root_drive_end_scalar"]; + let mut root = [0.0; 4]; + for (i, name) in roots.into_iter().enumerate() { + root[i] = data.float("physics_skeleton_drives", "default", name)?; + } + Ok(Settings { + bone, root, + strength: [ + data.float("animation", "default", "DriveStrengthLocal")?, + data.float("animation", "default", "DriveStrengthRootLocal")?, + ], + hook_spring: data.float("physics_animation", "default", "HookSoftDsp")?, + hook_strength: data.float("physics_animation", "default", "HookSoftStr")?, + hook_damping: data.float("physics_animation", "default", "HookSoftDmp")?, + }) +} diff --git a/crates/skate-game/src/physics/wipeout_states/lifecycle.rs b/crates/skate-game/src/physics/wipeout_states/lifecycle.rs new file mode 100644 index 00000000..ca591466 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/lifecycle.rs @@ -0,0 +1,130 @@ +//! Original Wipeout Enter82D3B5E8 and Exit82D3B990. +use super::*; +use crate::physics::{GamePhysics, SkaterRuntime}; +use skate_core::{ + animation::foot_ik::status::Mode, + math::Vector3, + physics::{board::BodyId, contact::RetailContactMaterial}, + player::wipeout_state::{body, math}, +}; + +pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + skater.ground_lifecycle.skeleton_elapsed_16505 = false; + let runtime = &mut skater.wipeout_state; + runtime.state = State::default(); + runtime.contact.reset(); + runtime.prediction.reset(); + physics.board_wiping_out = true; + physics.board.set_collision_group(7); + physics.board.bodies_mut()[BodyId::Deck.index()] + .inertia + .angular_drag = 0.0; + let material = RetailContactMaterial { + static_friction: runtime.settings.board_friction, + dynamic_friction: runtime.settings.board_friction, + restitution: runtime.settings.board_restitution, + }; + physics.settings.wheel_material = material; + physics.settings.truck_material = material; + physics.settings.deck_material = material; + skater.ground.steering.targets = [0.0; 2]; + let drive = &mut physics.board.hook_mut().drive; + drive.disable_angular(); + drive.disable_linear(); + //Do not reset the target history or the other limb ownership flags. + for limb in &mut skater.foot_ik.state.limbs { + limb.board_blend = 0.0; + limb.external_blend = 0.0; + limb.mode = Mode::Disabled; + } + skater.foot_ik.state.enable_feet(false); + drives::set_angular_root(&mut skater.skeleton_drives, &runtime.drives, 0.5); + drives::set_linear_root(&mut skater.skeleton_drives, &runtime.drives, 0.0); + let p = &skater.player_input.processed; + if p.flags_2488 & 0x0020_0000 != 0 { + body::blend_velocity( + &mut skater.skeleton, + p.vectors_880_896_912_928_944[4].map(f32::from_bits), + ); + } + if skater.wipeout.state.reasons[6] { + let normal = p.vectors_544_560_592_608[0].map(f32::from_bits); + body::remove_normal_velocity(&mut skater.skeleton, normal); + for part in physics.board.bodies_mut() { + let v = part.rates.linear_velocity; + let next = math::sub( + [v.x, v.y, v.z, 0.0], + math::scale(normal, math::dot(normal, [v.x, v.y, v.z, 0.0])), + ); + part.rates.linear_velocity = Vector3::new(next[0], next[1], next[2]); + } + physics.board.bodies_mut()[BodyId::Deck.index()] + .rates + .angular_velocity = Vector3::ZERO; + } + body::limit_velocity(&mut skater.skeleton, 20.0); + if p.flags_2468 & 4 != 0 { + skater.skeleton.apply_part_displacement( + 12, + math::scale( + p.vector_1520.map(f32::from_bits), + runtime.settings.push_force, + ), + ); + } + if p.probe_1792.byte_104 != 0 { + body::add_velocity( + &mut skater.skeleton, + p.probe_1792.vector_80.map(f32::from_bits), + ); + } + runtime.state.special_surface = p.flags_2488 & 0x4000_0000 != 0; + runtime.state.surface_height = p.collision_scalar_2924; + runtime.ragdoll.request( + &mut skater.ground_lifecycle.skeleton_controller, + if runtime.state.special_surface { 10 } else { 8 }, + &mut skater.skeleton, + &mut skater.skeleton_joints, + &mut skater.skeleton_collision, + )?; + for part in skater.skeleton.bodies_mut() { + part.inertia.linear_drag = 0.0; + } + runtime.state.move_board = p.flags_2472 & 0x800 != 0; + runtime.state.board_offset = p.vectors_720_784_800_816_832_864[3].map(f32::from_bits); + runtime.state.board_move_frames = 0; + runtime.state.predicted_time = f32::MAX; + runtime.state.airborne_frames = 15; + if p.flags_2468 & 8 != 0 { + body::set_velocity( + &mut skater.skeleton, + p.vectors_544_560_592_608[3].map(f32::from_bits), + ); + } + runtime.state.velocity = skater.animated_skeleton.board_frames.com_velocity; + Ok(()) +} + +pub(crate) fn exit(physics: &mut GamePhysics, skater: &mut SkaterRuntime) { + physics.board_wiping_out = false; + let runtime = &mut skater.wipeout_state; + physics.board.bodies_mut()[BodyId::Deck.index()] + .inertia + .angular_drag = runtime.settings.deck_angular_drag; + physics.board.set_collision_group(4); + [ + physics.settings.wheel_material, + physics.settings.truck_material, + physics.settings.deck_material, + ] = runtime.settings.standard_materials; + runtime.state.prevent_manual = false; +} + +pub(crate) fn post_physics(skater: &mut SkaterRuntime) { + skater.wipeout_state.state.post_physics( + skater.skeleton.record.velocities[23], + skater.skeleton.record.velocities[1], + skater.collision_feedback.flags.compliant, + &skater.wipeout_state.settings.recovery, + ); +} diff --git a/crates/skate-game/src/physics/wipeout_states/mod.rs b/crates/skate-game/src/physics/wipeout_states/mod.rs new file mode 100644 index 00000000..28e381fa --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/mod.rs @@ -0,0 +1,52 @@ +//! Original physical WipeoutGround300. Off-board recovery destinations are +//! separate states; this owner retains the native request and timing fields. +mod contact; +mod drive_settings; +mod lifecycle; +mod output; +mod prediction; +pub(crate) mod ragdoll; +mod settings; +mod skeleton; +mod update; +use skate_core::player::wipeout_state::{ + State, contact_response::ContactResponse, drives, profiles::Profile, +}; +use skate_data::collections::Collections; +use std::path::Path; + +pub(crate) struct WipeoutState { + pub(crate) ragdoll: ragdoll::RagdollSetup, + pub state: State, + settings: settings::Settings, + drives: drives::Settings, + profiles: [Profile; 5], + contact: ContactResponse, + prediction: prediction::Prediction, +} +impl WipeoutState { + pub fn load(data: &Collections, assets: &Path, primary_bank_sha: &str) -> Result { + //Ctor82D3B1EC..B25C full64 handle hashes, verified against the stock keys. + let names = [ + "free_fall", + "cannon_ball", + "judo_kick", + "swan_dive", + "torpedo", + ]; + let profiles = names.map(|name| settings::load_profile(data, name)); + let [a, b, c, d, e] = profiles; + Ok(Self { + ragdoll: ragdoll::RagdollSetup::load(data, assets, primary_bank_sha)?, + state: State::default(), + settings: settings::Settings::load(data)?, + drives: drive_settings::load(data, assets, primary_bank_sha)?, + profiles: [a?, b?, c?, d?, e?], + contact: ContactResponse::new(), + prediction: prediction::Prediction::new(), + }) + } +} +pub(crate) use lifecycle::{enter, exit, post_physics}; +pub(crate) use output::fill; +pub(crate) use update::advance; diff --git a/crates/skate-game/src/physics/wipeout_states/output.rs b/crates/skate-game/src/physics/wipeout_states/output.rs new file mode 100644 index 00000000..c9c1df74 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/output.rs @@ -0,0 +1,18 @@ +use crate::physics::SkaterRuntime; +use skate_core::player::wipeout_state::{output::Output, response}; + +pub(crate) fn fill(skater: &SkaterRuntime) -> Output { + let runtime = &skater.wipeout_state; + let feedback = &skater.collision_feedback; + let normal = response::response_normal( + feedback.flags.compliant.then_some(feedback.highest_normal), + runtime + .prediction + .result + .valid() + .then_some(runtime.prediction.result.landing_normal), + ); + runtime + .state + .output(&skater.skeleton.record.pose[23], normal) +} diff --git a/crates/skate-game/src/physics/wipeout_states/prediction.rs b/crates/skate-game/src/physics/wipeout_states/prediction.rs new file mode 100644 index 00000000..a5835af2 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/prediction.rs @@ -0,0 +1,86 @@ +//! Original Wipeout Update82D3C02C..C288; completed query precedes next submit. +use crate::physics::air_trajectory::AirTrajectoryRuntime; +use skate_core::{ + air::trajectory::{QueryRequest, QueryResult, Trajectory}, + physics::board_world::BoardWorld, + player::wipeout_state::State, +}; + +pub(crate) struct Prediction { + pub result: QueryResult, + pending: Option<(QueryResult, Trajectory, bool)>, +} +impl Prediction { + pub fn new() -> Self { + Self { + result: QueryResult::miss(), + pending: None, + } + } + ///Enter clears completion816; the old results are not consumed afterward. + pub fn reset(&mut self) { + self.pending = None; + self.result = QueryResult::miss(); + } + + pub fn advance( + &mut self, + state: &mut State, + world: &BoardWorld, + com_position: [f32; 4], + gravity: [f32; 4], + ) -> Result<(), String> { + if let Some((result, trajectory, surface_query)) = self.pending.take() { + self.result = result; + if surface_query { + if !result.valid() || result.surface & 0xF80 != 0x600 { + state.below_surface = false; + state.special_surface = false; + } + } else if result.valid() { + state.predicted_time = result.contact_time; + state.imminent_surface_twelve = + result.surface & 0xF80 == 0x600 && result.contact_time < 0.1; + state.predicted_position = trajectory.position_at(result.contact_time); + } else { + state.predicted_position = trajectory.position_at(3.0); + //The native miss branch preserves832 and573. + } + } + let surface_query = state.below_surface; + state.surface_query = surface_query; + let request = if surface_query { + let mut position = com_position; + position[1] = state.surface_height + 0.1; + QueryRequest { + trajectory: Trajectory { + position, + velocity: [0.0, -0.2, 0.0, 0.0], + acceleration: [0.0; 4], + duration: 1.0, + }, + radius: 0.01, + start_error: 1.0, + end_error: 1.0, + } + } else { + QueryRequest { + trajectory: Trajectory { + position: com_position, + velocity: state.velocity, + acceleration: gravity, + duration: 3.0, + }, + radius: 0.3, + start_error: 1.0, + end_error: 1.0, + } + }; + let result = AirTrajectoryRuntime::query(world, request)?; + //Host query execution is synchronous. The next tick's early control + //must see this completed result, before consuming it at the update tail. + self.result = result; + self.pending = Some((result, request.trajectory, surface_query)); + Ok(()) + } +} diff --git a/crates/skate-game/src/physics/wipeout_states/ragdoll.rs b/crates/skate-game/src/physics/wipeout_states/ragdoll.rs new file mode 100644 index 00000000..230fa80b --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/ragdoll.rs @@ -0,0 +1,71 @@ +//! Physical-owner adapter for selector82D913C0 requests8..11 and82BE7280. +mod settings; +use crate::physics::skeleton_controller::SkeletonControllerState; +use skate_core::physics::skeleton_body::{ + SkeletonBody, SkeletonCollisionFeedback, SkeletonCollisionMode, SkeletonJoints, +}; +use skate_data::collections::Collections; +use std::path::Path; + +pub(crate) struct RagdollSetup { + settings: settings::Settings, +} +impl RagdollSetup { + pub fn load(data: &Collections, assets: &Path, bank_sha: &str) -> Result { + Ok(Self { + settings: settings::Settings::load(data, assets, bank_sha)?, + }) + } + + pub fn request( + &self, + controller: &mut SkeletonControllerState, + requested: u32, + body: &mut SkeletonBody, + joints: &mut SkeletonJoints, + collision: &mut SkeletonCollisionMode, + ) -> Result<(), String> { + let Some(mode) = controller.select_request(requested) else { + return Ok(()); + }; + if !(7..=10).contains(&mode) { + return collision.select_driven(mode).map_err(str::to_owned); + } + //Request11 calls82BE3350(true), selecting normal limits while the + //body still receives ragdoll masses, materials, drag and group6. + let limits = if mode == 10 { + &self.settings.normal_limits + } else { + &self.settings.ragdoll_limits + }; + for (joint, limits) in joints.records.iter_mut().zip(limits) { + joint.parameters.words[10..14].copy_from_slice(limits); + } + collision.apply_ragdoll_properties( + body, + self.settings.inverse_mass, + self.settings.inverse_inertia, + self.settings.drag, + self.settings.materials, + ); + collision.finish_ragdoll_request(mode); + Ok(()) + } + + ///Original SetUpNormal82BE7280: limits, normal table/group, observation + ///reset, zero drags, authored animated mass/inertia/material, collision mode. + ///Call at the original physical reset owner, never at WipeoutExit alone. + pub fn restore_normal( + &self, + body: &mut SkeletonBody, + joints: &mut SkeletonJoints, + collision: &mut SkeletonCollisionMode, + feedback: &mut SkeletonCollisionFeedback, + ) { + for (joint, limits) in joints.records.iter_mut().zip(&self.settings.normal_limits) { + joint.parameters.words[10..14].copy_from_slice(limits); + } + feedback.set_up_normal(); + collision.restore_normal_properties(body); + } +} diff --git a/crates/skate-game/src/physics/wipeout_states/ragdoll/settings.rs b/crates/skate-game/src/physics/wipeout_states/ragdoll/settings.rs new file mode 100644 index 00000000..75e93b6d --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/ragdoll/settings.rs @@ -0,0 +1,97 @@ +//! Authored inputs to normal82BE58A8 and ragdoll82BE59A8/6D60. +use skate_core::physics::contact::RetailContactMaterial; +use skate_data::{collections::Collections, physics_skeleton::PhysicsSkeleton}; +use std::path::Path; + +pub(super) struct Settings { + pub normal_limits: [[u32; 4]; 22], + pub ragdoll_limits: [[u32; 4]; 22], + pub inverse_mass: bool, + pub inverse_inertia: bool, + pub drag: [f32; 2], + pub materials: [RetailContactMaterial; 2], +} +impl Settings { + pub fn load(data: &Collections, assets: &Path, bank_sha: &str) -> Result { + let skeleton = PhysicsSkeleton::load( + &assets.join("private/stock/physics-skeletons.json"), + bank_sha, + "PHYS_TPOSE", + )?; + if skeleton.bones.len() != 24 { + return Err("Ragdoll requires24 physical bones".into()); + } + let names = [ + "JOINT_NECK_NECK1", + "JOINT_SPINE3_NECK", + "JOINT_LEFT_FOREARM_HAND", + "JOINT_LEFT_ARM_FOREARM", + "JOINT_LEFT_SHOULDER_ARM", + "JOINT_SPINE3_LEFT_SHOULDER", + "JOINT_RIGHT_FOREARM_HAND", + "JOINT_RIGHT_ARM_FOREARM", + "JOINT_RIGHT_SHOULDER_ARM", + "JOINT_SPINE3_RIGHT_SHOULDER", + "JOINT_SPINE2_SPINE3", + "JOINT_SPINE1_SPINE2", + "JOINT_SPINE_SPINE1", + "JOINT_HIPS_SPINE", + "JOINT_LEFT_FOOT_TOE_BASE", + "JOINT_LEFT_LEG_FOOT", + "JOINT_LEFT_UPLEG_LEG", + "JOINT_HIPS_LEFT_LEG", + "JOINT_RIGHT_FOOT_TOE_BASE", + "JOINT_RIGHT_LEG_FOOT", + "JOINT_RIGHT_UPLEG_LEG", + "JOINT_HIPS_RIGHT_LEG", + ]; + let swing = data.float("physics_skeleton_joints", "default", "SwingRagdollScalar")?; + let twist = data.float("physics_skeleton_joints", "default", "TwistRagdollScalar")?; + let mut normal_limits = [[0; 4]; 22]; + let mut ragdoll_limits = [[0; 4]; 22]; + for (i, name) in names.into_iter().enumerate() { + let words = data + .words::<5>("physics_skeleton_joints", "default", name)? + .map(f32::from_bits); + let bone = &skeleton.bones[i + 1]; + let s = f32::from_bits(bone.words[21]); + let t = f32::from_bits(bone.words[20]); + normal_limits[i] = limits(s * words[1], t * words[2]); + ragdoll_limits[i] = limits((s * words[3]) * swing, (t * words[4]) * twist); + } + let material = |friction, restitution| -> Result<_, String> { + let friction = data.float("physics_skeleton", "default", friction)?; + Ok(RetailContactMaterial { + static_friction: friction, + dynamic_friction: friction, + restitution: data.float("physics_skeleton", "default", restitution)?, + }) + }; + let scale = f32::from_bits(0x426f_ffff); + Ok(Self { + normal_limits, + ragdoll_limits, + inverse_mass: data.boolean("physics_wipeout", "default", "DoInverseMass")?, + inverse_inertia: data.boolean("physics_wipeout", "default", "DoInverseInertia")?, + drag: [ + data.float("physics_wipeout", "default", "LinearDrag")? * scale, + data.float("physics_wipeout", "default", "AngularDrag")? * scale, + ], + materials: [ + material("FrictionRagdoll", "RestitutionRagdoll")?, + material("FrictionRagdollHead", "RestitutionRagdollHead")?, + ], + }) + } +} +fn limits(swing: f32, twist: f32) -> [u32; 4] { + let swing = if swing - 0.01 >= 0.0 { swing } else { 0.01 }; + let twist = if twist - 0.01 >= 0.0 { twist } else { 0.01 }; + [ + swing, + twist, + skate_core::trigonometry::cos(swing), + skate_core::trigonometry::cos(twist), + ] + .map(f32::to_bits) +} diff --git a/crates/skate-game/src/physics/wipeout_states/settings.rs b/crates/skate-game/src/physics/wipeout_states/settings.rs new file mode 100644 index 00000000..498bf7be --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/settings.rs @@ -0,0 +1,100 @@ +//! Stock vault bindings for WipeoutGround300 and its authored control profiles. +use skate_core::{ + player::wipeout_state::{profiles::Profile, recovery}, + point_graph::PointGraph, +}; +use skate_data::collections::Collections; + +pub(crate) struct Settings { + pub recovery: recovery::Settings, + pub remove_target_time: f32, + pub remove_drives_time: f32, + pub controlled_weight_step: f32, + pub collision_weight_step: f32, + pub board_restitution: f32, + pub board_friction: f32, + pub deck_angular_drag: f32, + pub push_force: f32, + pub standard_materials: [skate_core::physics::contact::RetailContactMaterial; 3], +} + +impl Settings { + pub(crate) fn load(data: &Collections) -> Result { + let f = |name| data.float("physics_wipeout", "default", name); + Ok(Self { + recovery: recovery::Settings { + minimum_time: f("TeleportMinTimeForAutoReset")?, + minimum_settled: f("TeleportMinTimeAfterSettlingForAutoReset")?, + maximum_time: f("TeleportMaxTime")?, + fade_time: f("TeleportAutoResetFadeOutTime")?, + over_speed: f("WipeoutOverSpeed")?, + over_minimum_time: f("WipeoutOverMinTime")?, + }, + remove_target_time: f("TimeToRemoveHook")?, + remove_drives_time: f("TimeToRemoveDrives")?, + controlled_weight_step: f("DynamicDriveWeightVelControlled")?, + collision_weight_step: f("DynamicDriveWeightVelCollision")?, + board_restitution: f("SkateboardRestitution")?, + board_friction: f("SkateboardFriction")?, + deck_angular_drag: data.float("physicsdeck", "default", "DeckAngularDrag")? + * f32::from_bits(0x426F_FFFF), + push_force: f("LivingWorldPushForce")?, + standard_materials: [ + material(data, "physicswheels", "Wheel")?, + material(data, "physicstrucks", "Truck")?, + material(data, "physicsdeck", "Deck")?, + ], + }) + } +} + +fn material( + data: &Collections, + class: &str, + prefix: &str, +) -> Result { + Ok(skate_core::physics::contact::RetailContactMaterial { + static_friction: data.float(class, "default", &format!("{prefix}StaticFriction"))?, + dynamic_friction: data.float(class, "default", &format!("{prefix}DynamicFriction"))?, + restitution: data.float(class, "default", &format!("{prefix}Restitution"))?, + }) +} + +/// The key is supplied by the original constructor's verified handle binding. +/// PointNegGraph metadata occupies four words before the eight X/Y samples. +pub(crate) fn load_profile(data: &Collections, key: &str) -> Result { + let class = "physics_wipeout_control"; + let f = |name| data.float(class, key, name); + let b = |name| data.boolean(class, key, name); + let v = |name| { + data.words::<4>(class, key, name) + .map(|words| words.map(f32::from_bits)) + }; + let graph = data.words::<20>(class, key, "SpinVsTime")?; + Ok(Profile { + roll_axis: v("RollOnGroundAxis")?, + horizontal_axis: v("HorizAlignAxis")?, + align_euler: v("AlignAxisEuler")?, + spin_vs_time: PointGraph { + x: std::array::from_fn(|i| f32::from_bits(graph[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(graph[12 + i])), + }, + direction_follow: f("Hash_1BC908CDB3520A8E")?, + torque_velocity: f("TorqueVelScalar")?, + torque_distance: f("TorqueDistScalar")?, + spin_inertia: f("SpinInertia")?, + roll_on_ground: b("RollOnGround")?, + ground_roll_torque: f("RollingOnGroundTorqueVelScalar")?, + sideways_spin: f("Hash_40B2B3C1A87A4D76")?, + forward_spin: f("Hash_9047531918285FC3")?, + horizontal_directed: b("Hash_6009FD75F9EDC436")?, + drift_maximum_speed: f("DriftMaxSpeed")?, + drift_forward: f("DriftFactorZ")?, + drift_sideways: f("DriftFactorX")?, + align_with_velocity: b("AlignWithVel")?, + align_ground_with_velocity: b("Hash_9626703A9939FE36")?, + ground_align_torque: f("AlignOnGroundTorqueVelScalar")?, + tilt_degrees: f("Hash_B65276A8CC7FB760")?, + drift_drag: f("Hash_7E6B3A99C0A33ABC")?, + }) +} diff --git a/crates/skate-game/src/physics/wipeout_states/skeleton.rs b/crates/skate-game/src/physics/wipeout_states/skeleton.rs new file mode 100644 index 00000000..c4dbd50d --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/skeleton.rs @@ -0,0 +1,93 @@ +//! Original Skeleton::UpdateWipeout82BDFA88 on the live physical owners. +use crate::physics::{ + foot_ik::PhysicalInput, + skeleton_input_runtime::{SkeletonInputRuntime, SkeletonOwners}, +}; +use skate_core::{ + animation::output::NativeMatrix, + physics::{ + skeleton_animation_record::IDENTITY, + skeleton_body::SkeletonTargetInput, + }, + player::{ + input_phase::ProcessedPhysicsInput, + wipeout_state::{drives, skeleton}, + }, +}; + +///The original r8 byte skips only target-position updates. It does not skip +///root construction, COM frames, first-state-tick IK, or ragdoll drive updates. +pub(crate) fn update( + input: &mut SkeletonInputRuntime, + owners: &mut SkeletonOwners<'_>, + processed: &ProcessedPhysicsInput, + globals: &[NativeMatrix], + settings: &drives::Settings, + start: f32, + end: f32, + controlled: f32, + extra: f32, + skip_targets: bool, +) -> Result { + let s = &mut owners.animated; + s.roots.initialize_heading = false; + skeleton::update_roots( + &mut s.roots, + &s.animation_hips, + &owners.body.record.pose[23], + owners.body.record.velocities[23], + s.board_frames.centre_of_mass, + processed.vectors_544_560_592_608[3].map(f32::from_bits), + processed.state_timer_2664, + ); + if !skip_targets { + let target = owners.drives.targets.update_positions( + SkeletonTargetInput { + animation_hips: &s.animation_hips, + animation_board: &s.animation_board, + animation_to_world: &s.roots.animation_to_world, + inverse_board: &s.roots.inverse_board, + skate_root: &s.board_frames.skate_root, + com_frame: &s.board_frames.com_frame, + lifted_com_frame: &s.board_frames.lifted_com_frame, + teleporting: input.teleporting, + }, + owners.body, + ); + input.animation_board_to_physics = target.animation_board_to_physics; + input.extra_target_positions = [ + target.positions.com, target.positions.lifted_com, + target.positions.following_com, + ]; + owners.pose_errors.set_targets(target.positions); + //82BDFA88 ignores the target continuity result: no GeneralUpdate reset. + } + s.board_frames.skate_root = s.roots.animation_to_world; + s.board_frames.update_com_lift( + &s.roots.animation_to_world, s.board_frames.centre_of_mass, 0.0); + if processed.state_timer_2664 == 0.0 { + let contacts = std::array::from_fn(|i| { + let query = processed.line_tests_960_1008_1056[i]; + (query.valid != 0).then(|| query.position.map(f32::from_bits)) + }); + let result = owners.ik.update(s, globals, PhysicalInput { + state_id: processed.state_2508, + flags_2468: processed.flags_2468, + flags_2472: processed.flags_2472, + flags_2480: processed.flags_2480, + contact_bone: owners.animation_input.contacts.bone as usize, + physical_board: &s.board_frames.physical_board, + hips_world_position: owners.body.part_transforms()[23][3], + current_contacts: contacts, + })?; + input.drive_frames = result.frames; + } + let residual = drives::update(owners.drives, &input.drive_frames, settings, + drives::Weights { + start, end, controlled, + upper_extra: if processed.flags_2484 & 0x20 != 0 { extra } else { 0.0 }, + lower_extra: if processed.flags_2484 & 0x10 != 0 { extra } else { 0.0 }, + }); + s.motion.next_trajectory = IDENTITY; + Ok(residual) +} diff --git a/crates/skate-game/src/physics/wipeout_states/update.rs b/crates/skate-game/src/physics/wipeout_states/update.rs new file mode 100644 index 00000000..a5f80c22 --- /dev/null +++ b/crates/skate-game/src/physics/wipeout_states/update.rs @@ -0,0 +1,228 @@ +//! Complete Wipeout300 update order82D3B9C8, on the game body/pose owners. +use super::*; +use crate::physics::{GamePhysics, SkaterRuntime, skeleton_input_runtime::SkeletonOwners}; +use skate_core::{ + math::Vector3, + physics::board::BodyId, + player::wipeout_state::{ + body, control_air, control_ground, control_profile_air, math, profiles, response, weights, + }, + riding::ground_correction_math::apply_world_force, +}; +const DT: f32 = f32::from_bits(0x3C88_8889); + +pub(crate) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { + let p = &skater.player_input.processed; + let runtime = &mut skater.wipeout_state; + let state = &mut runtime.state; + let contact = skater.collision_feedback.flags.any || state.below_surface; + state.airborne_frames = if contact { + -1 + } else { + state.airborne_frames.wrapping_add(1) + }; + if !state.special_surface && p.flags_2488 & 0x4000_0000 != 0 { + state.special_surface = true; + state.surface_height = p.collision_scalar_2924; + runtime.ragdoll.request( + &mut skater.ground_lifecycle.skeleton_controller, + 10, + &mut skater.skeleton, + &mut skater.skeleton_joints, + &mut skater.skeleton_collision, + )?; + } + let velocity = p.vectors_544_560_592_608[3].map(f32::from_bits); + if velocity[1] > 5.0 && velocity[1] - state.velocity[1] > 5.0 { + body::limit_velocity(&mut skater.skeleton, 5.0); + } + state.velocity = velocity; + let speed = math::length(velocity); + response::retained_velocity( + state, + &mut skater.skeleton, + &mut skater.skeleton_drives, + &runtime.drives, + contact, + p.flags_2468, + velocity, + ); + let position = p.vectors_544_560_592_608[2].map(f32::from_bits); + if state.special_surface { + state.below_surface = position[1] - state.surface_height < 0.0; + body::special_surface(&mut skater.skeleton, state.surface_height); + } + let effective = p + .effective_anim_transform_192 + .map(|row| row.map(f32::from_bits)); + let controls = skater.animation_input.extra.wipeout_control; + let gesture = skater.animation_input.extra.wipeout_gesture; + if !state.below_surface { + contact::update( + state, + &mut runtime.contact, + &mut skater.skeleton, + &skater.collision_feedback, + &mut skater.ground_lifecycle.skeleton_controller, + &mut skater.skeleton_collision, + &mut skater.skeleton_joints, + &runtime.ragdoll, + contact, + velocity, + effective[2], + controls, + )?; + let drag = if state.slow && contact { + if state.over { + math::clamp(state.settled_time * 0.15 + 0.05, 0.0, 0.5) + } else { + 0.05 + } + } else { + 0.0 + }; + body::set_wipeout_drag(&mut skater.skeleton, drag); + } + state.time += DT; + state.response_time += DT; + state.extra_weight_zero_time += DT; + if state.extra_weight > 0.0 { + state.extra_weight_zero_time = 0.0; + } + state.response_scalar = 0.0; + state.slow_time += DT; + state.maximum_speed = if speed - state.maximum_speed >= 0.0 { + speed + } else { + state.maximum_speed + }; + if speed > 2.0 { + state.slow_time = 0.0; + } + response::update_counter(state, contact); + let com = skater.animated_skeleton.board_frames.centre_of_mass; + let mut control_applied = false; + if state.below_surface || state.imminent_surface_twelve { + control_air::update(state, &mut skater.skeleton, com, controls, true); + } else if p.flags_2484 & 0x100 != 0 && state.response_time >= 0.1 { + let normal = response::response_normal( + skater + .collision_feedback + .flags + .compliant + .then_some(skater.collision_feedback.highest_normal), + runtime + .prediction + .result + .valid() + .then_some(runtime.prediction.result.landing_normal), + ); + response::trigger(state, &mut skater.skeleton, normal, controls); + } else if state.airborne_frames > 15 && !state.over { + profiles::prepare(state, &runtime.profiles, gesture, controls); + let profile = &runtime.profiles[state.profile]; + control_profile_air::update( + state, + &mut skater.skeleton, + com, + &effective, + controls, + profile, + ); + profiles::drift(state, &mut skater.skeleton, profile); + control_applied = true; + } else if (!contact && state.airborne_frames > 15) || state.over { + control_applied = control_air::update(state, &mut skater.skeleton, com, controls, contact); + } else { + profiles::prepare(state, &runtime.profiles, gesture, controls); + control_ground::update( + state, + &mut skater.skeleton, + com, + &effective, + state.predicted_position, + &runtime.profiles[state.profile], + &skater.animated_skeleton.roots.animation_to_world, + &skater.animated_skeleton.record.pose, + ); + control_applied = true; + } + let weight = weights::update( + state, + weights::Settings { + remove_target_time: runtime.settings.remove_target_time, + remove_drives_time: runtime.settings.remove_drives_time, + collision_step: runtime.settings.collision_weight_step, + controlled_step: runtime.settings.controlled_weight_step, + }, + contact, + control_applied, + p.flags_2472, + gesture, + ); + skater.ground.steering.update( + 0.0, + skater.air_settings.steering_blend, + p.flags_2468, + p.flags_2472, + ); + drives::set_angular_root( + &mut skater.skeleton_drives, + &runtime.drives, + weight.target_weight, + ); + let mut owners = SkeletonOwners { + animated: &mut skater.animated_skeleton, + body: &mut skater.skeleton, + drives: &mut skater.skeleton_drives, + ik: &mut skater.foot_ik, + animation_input: &mut skater.animation_input, + correction: &mut skater.skeleton_output.correction, + pose_errors: &mut skater.pose_errors, + }; + skeleton::update( + &mut skater.skeleton_input, + &mut owners, + p, + &skater.animation.packet.hierarchy, + &runtime.drives, + weight.start, + weight.end, + weight.controlled, + weight.extra, + state.retained_velocity_active, + )?; + if state.move_board && (state.board_move_frames as i32) < 21 { + let deck = physics.board.part_transforms()[BodyId::Deck.index()].translation; + let point = skater + .player_input + .toolkit + .as_ref() + .ok_or("Wipeout board force requires the current BoardToolkit")? + .deck[3]; + apply_world_force( + &mut physics.board.bodies_mut()[BodyId::Deck.index()], + deck, + vector(state.board_offset), + vector(point), + ); + state.board_move_frames = state.board_move_frames.wrapping_add(1); + } + state.manage_recovery( + p.flags_2468, + p.flags_2472, + p.flags_2484, + p.timestep_2604, + &runtime.settings.recovery, + ); + let gravity = physics.settings.step.simulation.gravity_acceleration; + runtime.prediction.advance( + state, + physics.world(), + position, + [gravity.x, gravity.y, gravity.z, 0.0], + ) +} +fn vector(v: [f32; 4]) -> Vector3 { + Vector3::new(v[0], v[1], v[2]) +} diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs new file mode 100644 index 00000000..559aaba0 --- /dev/null +++ b/crates/skate-game/src/skater_animation.rs @@ -0,0 +1,278 @@ +//! The skater's stock graph -> tree -> pose -> physics-packet owner. +//! Physical observations come from the preceding completed simulation output. +mod state; +use crate::{ + animation_pose::PoseEvaluator, + graph_host::{ + action::ActionHost, + motion::{MotionHost, MotionPhysical}, + }, + graph_runtime::StockGraphs, +}; +use skate_core::graph::intents::IntentMap; +use skate_core::{ + animation::{ + body_tilt, + output::{ + self, Sqt, + attributes::PacketAttributes, + packet_reset::{self, AdditionalResetFields, RESET_POSE}, + physics_packet::{self, PhysicsPosePacket}, + }, + physical_feedback::PhysicalFeedback, + playback::PlaybackContext, + playback_tree::{Evaluation, PoseCommand}, + riding_fakie, + skeleton_input::name::encode, + }, + graph::{conditions::ConditionInputs, controller::Controller}, + riding::push_behaviors::PushFootFrame, +}; +use skate_data::collections::Collections; +use std::path::Path; + +/// Completed native publications, never inferred from a render transform. +pub(crate) struct AnimationPhysical { + pub conditions: ConditionInputs, + pub feedback: PhysicalFeedback, + pub body_tilt: body_tilt::Physical, + pub fakie: riding_fakie::Physical, + ///PhysOutAnimation158/157, consumed by IsRidingGoofy. + pub physical_stance: (bool, bool), + pub foot_frame: PushFootFrame, + ///PhysOut72 byte311 (true means a board is present). + ///both PlayAnimation82BB54D4 and Phase1 consume the byte without inversion. + pub board_present: bool, + pub physical_28_byte75: bool, + pub time_since_teleport: f32, +} + +pub(crate) struct SkaterAnimation { + pub action: ActionHost, + pub motion: MotionHost, + pub evaluator: PoseEvaluator, + pub pose: Vec, + pub packet: PhysicsPosePacket, + pub attributes: PacketAttributes, + pub action_controller: Controller, + pub motion_controller: Controller, + state: state::AnimationState, + pub ticks: u64, +} + +impl SkaterAnimation { + pub fn stance(&self) -> (bool, bool) { + (self.state.fakie(), self.state.mirrored()) + } + /// Actor8259147C evaluates the stored initialization pose before creating + /// the physical skeleton. Initialize82B97E38 selects rig_Tpose; + /// 82D181D0 stores it at13484, and82531050 emits that pose command. + /// This is separate from both the graph's first update and AnimOut reset. + pub fn evaluate_initial_pose(&mut self) -> Result, String> { + self.pose = self.evaluator.evaluate(&[PoseCommand::Pose { + name: "RIG_TPOSE".into(), + }])?; + self.evaluator.hierarchy(&self.pose) + } + + pub fn load( + root: &Path, + data: &Collections, + graphs: &StockGraphs, + pro_skater: &[u8], + ) -> Result { + let banks = skate_data::animation_banks::AnimationBanks::load(root)?; + let evaluator = PoseEvaluator::from_banks(&banks)?; + let state = state::AnimationState::new(true); + let count = evaluator.frames.bone_names.len(); + let mut motion = MotionHost::from_graph( + &graphs.motion, + data, + banks.metadata()?, + PlaybackContext { + is_switch: Some(state.switch()), + is_mirrored: Some(state.mirrored()), + board_available: None, + pro_skater: encode(pro_skater), + transition_override: None, + }, + ) + .map_err(|error| format!("Stock MotionGraph initialization: {error}"))?; + motion.animation.set_hierarchy( + &evaluator.frames.bone_names, + &evaluator.frames.mirror_indices, + )?; + //82858810 registers these three static poses in this exact order. + use skate_core::animation::posture::PosturePose; + for posture in [PosturePose::Stiff, PosturePose::Slouch, PosturePose::Buff] { + let pose = evaluator.frames.named_pose(posture.name())?; + if pose.samples.len() != count { + return Err(format!( + "Stock posture {} has {} bones; skater hierarchy has {count}", + posture.name(), + pose.samples.len() + )); + } + } + motion.animation.posture_bank_valid = true; + motion.animation.skater_animation_flags = Some(state.flags); + Ok(Self { + action: ActionHost::from_graph(&graphs.action, data) + .map_err(|error| format!("Stock ActionGraph initialization: {error}"))?, + motion, + evaluator, + pose: Vec::new(), + attributes: PacketAttributes::default(), + state, + action_controller: Controller::new(graphs.action.runtime.program.topology.states.len()), + motion_controller: Controller::new(graphs.motion.runtime.program.topology.states.len()), + //Represented pose fields after AnimOutPhysIn Reset82590028. + packet: PhysicsPosePacket { + bone_count: count as u32, + hierarchy: vec![RESET_POSE; count], + local: vec![RESET_POSE; count], + timestep: f32::from_bits(0x3c88_8889), + foot_surface_ids: [0; 2], + flags: 0, + board_flipped: false, + mirrored: false, + riding_switch: false, + riding_fakie: false, + weight_forwards: false, + regular_stance: false, + air_dismount_revert_frames: 0, + }, + ticks: 0, + }) + } + + ///Actor Phase1/2/3 plus SetUpPhysics:82592FD8,82593128,82593230,82593640. + ///GenerateActionGraphIntents has completed before this call. Keep one MG + ///intent map across both controllers; native preupdate clears only attrs. + pub fn advance( + &mut self, + graphs: &StockGraphs, + dt: f32, + action_intents: &IntentMap, + physical: AnimationPhysical, + reset_fields: &mut AdditionalResetFields, + ) -> Result<(), String> { + self.publish_physical(physical)?; + self.action.action_intents.clone_from(action_intents); + self.motion.action_intents.clone_from(action_intents); + //HasAnimAttribute82BA3A78 reads the preceding cached animation + //attributes. Specific flag27 is observed before this frame's MG update. + self.action.animation_attributes.clear(); + self.action + .animation_attributes + .extend_from_slice(self.motion.animation.tree_attributes()); + self.action.is_tricking = Some(self.motion.flags.doing_trick); + // Specific MotionGraph+8 is its own persistent filtered-intent map + // (82D0B660), initially cleared by825953B0. Only authored filtering + // behaviors write it; raw actor/controller intents do not replace it. + std::mem::swap( + &mut self.action.motion_intents, + &mut self.motion.animation.motion_intents, + ); + self.action_controller + .update(&graphs.action.runtime.program, dt, &mut self.action); + std::mem::swap( + &mut self.action.motion_intents, + &mut self.motion.animation.motion_intents, + ); + if !self.action.errors.is_empty() { + return Err(self.action.errors.join("\n")); + } + self.motion.animation.begin_graph_update(); + self.motion_controller + .update(&graphs.motion.runtime.program, dt, &mut self.motion); + if !self.motion.errors.is_empty() { + return Err(self.motion.errors.join("\n")); + } + self.state.flags = self + .motion + .animation + .skater_animation_flags + .ok_or("MotionGraph lost the SkaterAnim flag owner")?; + + self.motion.animation.apply_parameters()?; + self.motion.animation.advance(dt, self.state.phase); + //Collect before pose evaluation consumes clip history;82B98980 then + //uses those exact records for board/mirror/switch event publication. + self.motion.animation.refresh_tree_attributes()?; + self.state + .apply_stance_events(self.motion.animation.tree_attributes()); + let commands = self.motion.animation.evaluate_pose(Evaluation { + cull_threshold: self.state.cull_threshold(), + update_history: true, + })?; + self.pose = self.evaluator.evaluate(&commands)?; + let hierarchy = self.evaluator.hierarchy(&self.pose)?; + let local: Vec<_> = self + .pose + .iter() + .copied() + .map(output::sqt_to_matrix) + .collect(); + self.attributes.replace_from( + &self.motion.animation.motion_attributes, + self.motion.animation.tree_attributes(), + ); + self.state.prepare_publication(); + // Actor SetUpPhysics82593640 resets the output after animation has + // evaluated, before publishing this pose and the actor-owned fields. + packet_reset::reset(&mut self.packet, reset_fields) + .map_err(|error| format!("Animation packet reset: {error:?}"))?; + physics_packet::publish_evaluated( + &mut self.state.publication, + &hierarchy, + &local, + &mut self.packet, + //Actor825936AC..D4 passes this fixed physical step even when + //the graph/clip clock differs or camera slow motion is active. + f64::from(f32::from_bits(0x3c888889)), + b"signup", + ) + .map_err(|e| format!("Skater pose publication: {e:?}"))?; + self.state.finish_publication(); + self.motion.animation.skater_animation_flags = Some(self.state.flags); + self.ticks += 1; + Ok(()) + } + + fn publish_physical(&mut self, p: AnimationPhysical) -> Result<(), String> { + let speed = p + .conditions + .speeds + .ok_or("Animation requires completed board speed output")?; + let stance = (self.state.fakie(), self.state.mirrored()); + self.action.stance = Some(stance); + self.action.condition_inputs = p.conditions.clone(); + self.motion.condition_inputs = p.conditions; + self.motion.playback_context.is_mirrored = Some(self.state.mirrored()); + self.motion.playback_context.is_switch = Some(self.state.switch()); + self.motion.playback_context.board_available = Some(p.board_present); + self.motion.physical = Some(MotionPhysical { + turning: p.feedback.turning, + stance, + forward_speed: speed.forward_speed, + time_since_teleport: p.time_since_teleport, + is_switch: self.state.switch(), + foot_frame: Some(p.foot_frame), + }); + self.motion.physical_stance = Some(p.physical_stance); + self.motion.crouching_physical = Some(p.feedback.crouching); + self.motion.body_tilt_physical = Some(p.body_tilt); + self.motion.fakie_physical = Some(p.fakie); + self.motion.pumping_acceleration = Some(p.feedback.pumping_acceleration); + + //8259303C..78: on-board category keeps this true even when the + //off-board owner's board-present byte is false. 82B971A8 copies it + //straight into flag17. It is not a no-board bit. + let board_attached_or_onboard = p.board_present || !p.physical_28_byte75; + self.state.flags = + (self.state.flags & !(1 << 17)) | (u32::from(board_attached_or_onboard) << 17); + self.motion.animation.skater_animation_flags = Some(self.state.flags); + Ok(()) + } +} diff --git a/crates/skate-game/src/skater_animation/state.rs b/crates/skate-game/src/skater_animation/state.rs new file mode 100644 index 00000000..ff6ee47f --- /dev/null +++ b/crates/skate-game/src/skater_animation/state.rs @@ -0,0 +1,87 @@ +//! State shared by graph construction, stance events and physics publication. +use skate_core::animation::{ + output::{attributes::AnimationAttribute, physics_packet::SkaterPublicationState}, + skeleton_input::name::encode, +}; + +pub(super) struct AnimationState { + pub flags: u32, + pub publication: SkaterPublicationState, + /// Base animator14652: ctor82D17CDC sets zero; virtual40/82531200 writes it. + pub phase: f32, +} + +impl AnimationState { + pub fn new(local_player: bool) -> Self { + //82B975F4 selects bit27;82B97674 selects bit17 and clears the + //orientation,mirror,fakie,weight and request bits used by this owner. + //Unrepresented allocator/padding bits are not gameplay state here. + Self { + flags: (u32::from(local_player) << 27) | 0x0002_0000, + phase: 0.0, + publication: SkaterPublicationState { + orientation_bit31: false, + mirrored: false, + riding_fakie: false, + weight_on_nose: false, + //82B975B8..82B97674: GOOFY natural stance, NATURAL relative. + natural_stance: 1, + relative_stance: 0, + request_bit16: false, + request_bit15: false, + air_dismount_revert_requested: false, + air_dismount_revert_frames: 0, + signal: None, + }, + } + } + + pub fn mirrored(&self) -> bool { + self.flags & 0x4000_0000 != 0 + } + pub fn fakie(&self) -> bool { + self.flags & 0x2000_0000 != 0 + } + pub fn switch(&self) -> bool { + self.publication.relative_stance == 1 + } + + ///82B98980 calls82D19010's first-match query. No scalar/status test: + ///presence in the collected tree attribute list toggles the state once. + pub fn apply_stance_events(&mut self, attributes: &[AnimationAttribute]) { + let present = |name: &[u8]| attributes.iter().any(|a| a.name == encode(name)); + if present(b"animboardbackward") { + self.flags ^= 0x8000_0000; + } + if present(b"mirrored") { + self.flags ^= 0x4000_0000; + } + if present(b"switch") { + self.publication.relative_stance = i32::from(self.publication.relative_stance == 0); + } + } + + pub fn prepare_publication(&mut self) { + let p = &mut self.publication; + p.orientation_bit31 = self.flags & 0x8000_0000 != 0; + p.mirrored = self.flags & 0x4000_0000 != 0; + p.riding_fakie = self.flags & 0x2000_0000 != 0; + p.weight_on_nose = self.flags & 0x1000_0000 != 0; + p.request_bit16 = self.flags & 0x0001_0000 != 0; + p.request_bit15 = self.flags & 0x0000_8000 != 0; + p.air_dismount_revert_requested = self.flags & 0x0010_0000 != 0; + } + + pub fn finish_publication(&mut self) { + self.flags &= !(0x0001_0000 | 0x0000_8000 | 0x0010_0000); + } + + ///SkaterAnim virtual172/82B98078, selected by local-player bit27. + pub fn cull_threshold(&self) -> f32 { + f32::from_bits(if self.flags & 0x0800_0000 != 0 { + 0x3c23_d70a + } else { + 0x3dcc_cccd + }) + } +} diff --git a/crates/skate-game/src/tests/action_host.rs b/crates/skate-game/src/tests/action_host.rs new file mode 100644 index 00000000..a2b4b4ce --- /dev/null +++ b/crates/skate-game/src/tests/action_host.rs @@ -0,0 +1,184 @@ +use crate::graph_host::action::*; +use crate::graph_host::action_nodes::Parameter; +use crate::graph_host::action_nodes::{ActionInstance, ActionInstances, ActionOperation}; +use crate::graph_runtime::OperationRemap; +use skate_core::graph::controller::{Frame, Host}; + +fn parameter(mg_intent: &str, value: f32) -> Parameter { + Parameter { + name: None, + mg_intent: Some(mg_intent.into()), + ag_intent: None, + text: None, + float_bits: Some(value.to_bits()), + boolean_byte: None, + default_value: None, + scale: None, + on_update: false, + filters: [0; 4], + } +} + +#[test] +fn const_callbacks_publish_then_remove_motion_intent() { + let instance = ActionInstance { + operation: ActionOperation::CreateConstMgIntent, + config: parameter("Crouch", 1.0), + parameters: Vec::new(), + }; + // Conditions are interleaved with behaviors in the source binding. A + // compact behavior ID must select its mapped operation and configuration. + let mut earlier = instance.clone(); + earlier.config = parameter("WrongSourceOperation", 9.0); + let mut host = ActionHost::new( + ActionInstances::new(vec![earlier, instance]), + OperationRemap { + behaviors: vec![1], + conditions: vec![0], + hooks: vec![], + }, + ); + Host::begin( + &mut host, + 0, + [0; 6], + &Frame { + dt: 0.0, + current: None, + last: None, + state_times: Vec::new(), + }, + ); + assert_eq!(host.motion_intents.get("Crouch"), Some(&1.0)); + assert!(!host.motion_intents.contains_key("WrongSourceOperation")); + Host::end( + &mut host, + 0, + [0; 6], + &Frame { + dt: 0.0, + current: None, + last: None, + state_times: Vec::new(), + }, + ); + assert!(!host.motion_intents.contains_key("Crouch")); +} + +#[test] +fn pushing_clock_starts_fresh_when_graph_reenters_behavior() { + let mut config = parameter("Pushing", 0.0); + config.ag_intent = Some("Pushing".into()); + let instance = ActionInstance { + operation: ActionOperation::CreateMgTimeIntent, + config, + parameters: Vec::new(), + }; + let mut host = ActionHost::new( + ActionInstances::new(vec![instance]), + OperationRemap { + behaviors: vec![0], + conditions: vec![], + hooks: vec![], + }, + ); + let frame = Frame { + dt: 0.125, + current: None, + last: None, + state_times: vec![], + }; + host.action_intents.insert("Pushing", 1.0); + host.allocate(0, &frame); + for _ in 0..3 { + host.update(0, [0; 6], &frame); + } + assert_eq!(host.motion_intents.get("Pushing"), Some(&0.375)); + host.end(0, [0; 6], &frame); + assert!(!host.motion_intents.contains_key("Pushing")); + host.allocate(0, &frame); // TU382BBCB50 initializes each instance to zero. + host.update(0, [0; 6], &frame); + assert_eq!(host.motion_intents.get("Pushing"), Some(&0.125)); +} + +#[test] +fn native_intent_aliases_share_action_motion_and_end_lifecycle() { + use crate::graph_host::motion::MotionAnimation; + use skate_core::animation::playback_parameters::ParameterInputs; + use skate_core::graph::{ + activation::ConditionHost, + conditions::{ActionCondition, Comparison, NumericCondition}, + }; + use skate_data::animation_metadata::AnimationMetadata; + + // These case variants both occur in the authored ActionGraph. No animation + // tree is needed to exercise its real persistent MotionAnimation intent map. + for pair in [ + ["Fingerflip", "FingerFlip"], + ["OB_OBJECTMVX", "OB_ObjectMvX"], + ] { + for [first, second] in [pair, [pair[1], pair[0]]] { + let create = |name: &str| { + let mut config = parameter(name, 0.0); + config.ag_intent = Some(name.into()); + ActionInstance { + operation: ActionOperation::CreateMgIntent, + config, + parameters: vec![], + } + }; + let condition = ActionInstance { + operation: ActionOperation::Condition(ActionCondition::HasIntent { + name: first.into(), + numeric: NumericCondition { + comparison: Comparison::Equal, + threshold: 0.75, + absolute: false, + }, + }), + config: parameter(first, 0.0), + parameters: vec![], + }; + let mut host = ActionHost::new( + ActionInstances::new(vec![create(first), create(second), condition]), + OperationRemap { + behaviors: vec![0, 1], + conditions: vec![2], + hooks: vec![], + }, + ); + let mut motion = MotionAnimation::from_metadata(AnimationMetadata::parse( + r#"{"version":1,"source_bank":"intent-test","source_sha256":"0000000000000000000000000000000000000000000000000000000000000000","source_bytes":48,"clips":[],"unsupported_trees":[]}"# + ).unwrap()); + let frame = Frame { + dt: 1.0 / 60.0, + current: None, + last: None, + state_times: vec![], + }; + host.action_intents.insert(first, 0.25); + assert_eq!(host.action_intents.insert(second, 0.75), Some(0.25)); + assert_eq!(host.action_intents.len(), 1); + assert_eq!(host.condition_activation(0, &frame), 1); + host.begin(0, [0; 6], &frame); + assert_eq!(host.motion_intents.get(second), Some(&0.75)); + host.action_intents.insert(first, 1.25); + host.begin(1, [0; 6], &frame); + assert_eq!(host.motion_intents.len(), 1); + + // Preserve the production coordinator's single-map handoff. + std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); + assert_eq!(motion.motion_intent(first), Some(1.25)); + assert_eq!(motion.motion_intent(second), Some(1.25)); + std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); + host.end(0, [0; 6], &frame); + std::mem::swap(&mut host.motion_intents, &mut motion.motion_intents); + assert_eq!(motion.motion_intent(first), None); + assert_eq!(motion.motion_intent(second), None); + assert!(motion.motion_intents.is_empty()); + assert_eq!(host.action_intents.remove(first), Some(1.25)); + assert!(!host.action_intents.contains_key(second)); + assert!(host.errors.is_empty(), "{:?}", host.errors); + } + } +} diff --git a/crates/skate-game/src/tests/air_playback.rs b/crates/skate-game/src/tests/air_playback.rs new file mode 100644 index 00000000..d1c46cf0 --- /dev/null +++ b/crates/skate-game/src/tests/air_playback.rs @@ -0,0 +1,174 @@ +//! Real controller/graph/physical/pose/camera regression across a level edge. +use super::*; +use skate_core::player::state::PhysicalStateId; + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn stock_push_off_edge_runs_air_and_landing_in_production() { + roll_off(None); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn airborne_stick_reaches_physical_spin() { + for stick in [-32767, 32767] { + roll_off(Some(stick)); + } +} + +fn roll_off(air_stick: Option) { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + // Keep this air fixture's open edge now that the production level has a + // downhill ramp. The dedicated ramp regression uses the unmodified world. + physics.world = BoardWorld::new(physics.world.triangles().iter().copied() + .filter(|face| !(face.tag == 1 + && face.triangle.vertices.iter().any(|vertex| vertex.z > 4.0))) + .collect()); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut previous = skater.player_state.current(); + let mut saw_air = false; + let mut landed_frames = 0; + let mut airborne_frames = 0; + for tick in 0..1000 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick >= 12 && !saw_air { 0x1000 } else { 0 }, + triggers: [0; 2], + left: [if saw_air { air_stick.unwrap_or(0) } else { 0 }, 0], + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + let result = frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ); + let state = skater.player_state.current(); + if state != previous { + eprintln!( + "Roll-off tick{tick}: {previous:?} -> {state:?}; deck={:?}; velocity={:?}; wipeout={:?}", + physics.board.bodies()[6].rates.position, + physics.board.bodies()[6].rates.linear_velocity, + skater.wipeout.state + ); + previous = state; + } + result.unwrap_or_else(|e| { + panic!( + "Roll-off tick{tick}: {e}; selected={:?}; flags={:08x}/{:08x}/{:08x}; board={:?}", + skater.player_state.requested_state, + skater.player_input.processed.flags_2468, + skater.player_input.processed.flags_2472, + skater.player_input.processed.flags_2476, + physics.board.bodies()[6].rates + ) + }); + saw_air |= state.category() == 200; + if saw_air && air_stick.is_some() { + airborne_frames += 1; + eprintln!( + "Air stick={air_stick:?} frame{airborne_frames}: AG={:?}/{:?} MG={:?}/{:?} attr={}/{} speed={} angle={} heading={:?} target={:?} deck={:?}", + controls.action_intents.get("BodySpin"), + controls.action_intents.get("PhysBodySpin"), + skater + .animation + .motion + .animation + .motion_intents + .get("BodySpin"), + skater + .animation + .motion + .animation + .motion_intents + .get("PhysBodySpin"), + skater.animation_input.fields.body_spin, + skater.animation_input.extra.physical_body_spin, + skater.air_reckoning.state.spin_speed, + skater.air_reckoning.state.spin_angle, + physics.riding.reckoning_frames.heading, + skater.animated_skeleton.roots.animation_to_world[2], + physics.board.part_transforms()[6].basis.columns[2] + ); + if airborne_frames == 16 { + assert!( + skater.air_reckoning.state.spin_angle.abs() > 0.001, + "Raw airborne stick did not reach physical angular integration" + ); + return; + } + } + if saw_air + && state == PhysicalStateId::PhysicsGround + && physics.riding.ground.wheel_contact_count > 0 + { + landed_frames += 1; + } + assert!(camera.frame.is_some()); + assert_eq!(skater.pose_generation, physics.ticks); + if landed_frames >= 120 { + break; + } + } + assert!(saw_air, "Raw pushing never entered the actual Air family"); + assert!( + landed_frames >= 120, + "Air never returned to stable grounded riding" + ); + eprintln!( + "Completed roll-off/landing: ticks={}; grounded_after_air={landed_frames}", + physics.ticks + ); +} + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn stock_starting_ramp_reaches_floor_without_air() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + for tick in 0..600 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if tick >= 12 { 0x1000 } else { 0 }, + triggers: [0; 2], left: [0; 2], right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update(&mut actions, physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, skater.player_input.physical.scoring.capabilities_204); + frame::advance(&mut physics, &mut skater, &mut controls, + &graphs, &mut actions, true, &mut camera) + .unwrap_or_else(|e| panic!("Ramp tick{tick}: {e}")); + assert_ne!(skater.player_state.current().category(), 200, + "Ramp entered air at tick{tick}, deck={:?}", physics.board.bodies()[6].rates.position); + let deck = physics.board.bodies()[6].rates.position; + if deck.z > 19.0 { + assert!(deck.y < ground::FLOOR_HEIGHT + 0.5, "Did not descend to floor: {deck:?}"); + assert!(physics.riding.ground.wheel_contact_count > 0); + eprintln!("Reached lower floor without air at tick{tick}: {deck:?}"); + return; + } + } + panic!("Did not reach the lower floor within 600 ticks"); +} \ No newline at end of file diff --git a/crates/skate-game/src/tests/animation_pose.rs b/crates/skate-game/src/tests/animation_pose.rs new file mode 100644 index 00000000..f615a528 --- /dev/null +++ b/crates/skate-game/src/tests/animation_pose.rs @@ -0,0 +1,133 @@ +use super::*; + +#[test] +#[ignore = "requires the user's decoded stock animation assets"] +fn stock_pose_commands_sample_complete_bones_and_preserve_tree_order() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"); + let evaluator = PoseEvaluator::load(Path::new(&root)).unwrap(); + assert_eq!(evaluator.frames.parents.len(), 36); + assert_eq!(evaluator.frames.clip_count(), 2672 + 652); + for name in evaluator.frames.clip_names() { + let clip = evaluator.frames.clip(name).unwrap(); + let end = (clip.frames.len() - 1) as f32 / f32::from_bits(clip.fps_bits); + let commands = [ + PoseCommand::Clip { + name: clip.name.clone(), + time: 0.0, + previous_time: 0.0, + loops: 0, + }, + PoseCommand::Clip { + name: clip.name.clone(), + time: end * 0.5, + previous_time: 0.0, + loops: 0, + }, + PoseCommand::Blend { weight: 0.375 }, + PoseCommand::Clip { + name: clip.name.clone(), + time: end, + previous_time: end * 0.5, + loops: 1, + }, + PoseCommand::ChannelBlend { + weight: 0.25, + use_channels_from_weights: false, + }, + ]; + let pose = evaluator.evaluate(&commands).unwrap(); + assert_eq!(pose.len(), 36, "{}", clip.name); + assert!( + pose.iter().all(|s| s + .scale + .iter() + .chain(&s.rotation) + .chain(&s.translation) + .all(|v| v.is_finite())), + "{}", + clip.name + ); + // Regular stance takes every native AddBindPose branch. Include the + // trajectory-relative mode used by the source's one-shot mirror flag. + let mut wrapped = commands.to_vec(); + wrapped.extend([ + PoseCommand::Pose { + name: "RIG_TPOSE".into(), + }, + PoseCommand::Add { motion_is_a: true }, + PoseCommand::Pose { + name: "BOARD_BACKWARDS".into(), + }, + PoseCommand::Add { motion_is_a: false }, + PoseCommand::Pose { + name: "BOARD_BACKWARDS_IK".into(), + }, + PoseCommand::Add { motion_is_a: true }, + PoseCommand::Mirror { trajectory_mode: 2 }, + PoseCommand::Mirror { trajectory_mode: 1 }, + ]); + let pose = evaluator.evaluate(&wrapped).unwrap(); + assert!( + pose.iter().all(|s| s + .scale + .iter() + .chain(&s.rotation) + .chain(&s.translation) + .all(|v| v.is_finite())), + "{}", + clip.name + ); + let hierarchy = evaluator.hierarchy(&pose).unwrap(); + assert!( + hierarchy.iter().flatten().flatten().all(|v| v.is_finite()), + "{}", + clip.name + ); + } + // Native SetDBContent82D1B45C overwrites equal keys; last RIG_TPOSE wins. + assert_eq!( + evaluator + .frames + .reference_pose(0xA46D20) + .unwrap() + .samples + .len(), + 36 + ); + assert_eq!( + evaluator + .frames + .named_pose("RIG_TPOSE") + .unwrap() + .source_offset, + 0xA46D20 + ); + assert_eq!( + evaluator + .frames + .named_pose("BOARD_BACKWARDS") + .unwrap() + .source_offset, + 0xA473D0 + ); + assert_eq!(evaluator.frames.bone_names[0], "TRAJECTORY"); + assert_eq!(evaluator.frames.bone_names[25], "SKATEBOARD_ROOT"); +} + +#[test] +fn additive_pose_keeps_motion_channel_and_reference_rotation_order() { + let motion = Sqt { + scale: [1.0; 4], + rotation: [1.0, 0.0, 0.0, 0.0], + translation: [2.0, 3.0, 4.0, 0.25], + }; + let reference = Sqt { + scale: [2.0; 4], + rotation: [0.0, 1.0, 0.0, 0.0], + translation: [7.0, 11.0, 13.0, 0.75], + }; + let result = pose_add::add(motion, reference, true); + assert_eq!(result.rotation, [0.0, 0.0, -1.0, 0.0]); + assert_eq!(result.translation, [5.0, 14.0, 9.0, 0.25]); + assert_eq!(pose_add::add(motion, reference, false).translation[3], 0.75); +} diff --git a/crates/skate-game/src/tests/graph_runtime.rs b/crates/skate-game/src/tests/graph_runtime.rs new file mode 100644 index 00000000..0ef1e908 --- /dev/null +++ b/crates/skate-game/src/tests/graph_runtime.rs @@ -0,0 +1,105 @@ +use super::*; +use skate_core::graph::activation::Child; +use skate_data::state_graph::binding::{ + Expression as BoundExpression, Operation, State as BoundState, Transition as BoundTransition, +}; + +fn binding() -> Binding { + Binding { + states: vec![BoundState { + element: 0, + name: "root".into(), + parent: None, + children: Vec::new(), + behaviors: vec![0], + transitions: vec![0], + expression: Some(0), + enabled: 1, + active: 1, + interruptibility: 2, + interrupt_ancestor: Some(0), + }], + transitions: vec![BoundTransition { + element: 1, + owner: 0, + target: Some(0), + enabled: 1, + priority: 3, + expression: Some(0), + hooks: vec![2], + }], + expressions: vec![BoundExpression { + element: 2, + enabled: 1, + operator: 1, + children: vec![Node::Operation(1)], + }], + operations: vec![ + Operation { + element: 3, + parent: Node::State(0), + kind: OperationKind::Behavior, + name: "behavior".into(), + enabled: 1, + condition_mask: None, + parameters: vec![6], + }, + Operation { + element: 4, + parent: Node::Expression(0), + kind: OperationKind::Condition, + name: "condition".into(), + enabled: 1, + condition_mask: Some(3), + parameters: vec![7, 8], + }, + Operation { + element: 5, + parent: Node::Transition(0), + kind: OperationKind::Hook, + name: "hook".into(), + enabled: 1, + condition_mask: None, + parameters: Vec::new(), + }, + ], + root: 0, + } +} + +#[test] +fn preserves_topology_and_authored_operation_identity() { + let compiled = CompiledGraph::from_binding(&binding()).unwrap(); + + assert_eq!(compiled.program.root, 0); + assert_eq!(compiled.program.topology.states[0].interruptibility, 2); + assert_eq!(compiled.program.topology.transitions[0].target, 0); + assert_eq!(compiled.program.topology.transitions[0].priority, 3); + assert_eq!(compiled.program.activation.state_expressions, [Some(0)]); + assert_eq!( + compiled.program.activation.transition_expressions, + [Some(0)] + ); + assert_eq!( + compiled.program.activation.expressions[0].children, + [Child::Condition(0)] + ); + assert_eq!(compiled.program.activation.conditions[0].mask, 3); + assert_eq!(compiled.program.behaviors[0].owner, 0); + assert_eq!(compiled.program.state_behaviors, [vec![0]]); + assert_eq!(compiled.program.transition_hooks, [vec![0]]); + assert_eq!(compiled.operations.behaviors, [0]); + assert_eq!(compiled.operations.conditions, [1]); + assert_eq!(compiled.operations.hooks, [2]); +} + +#[test] +fn refuses_an_unresolved_transition_instead_of_choosing_a_fallback() { + let mut source = binding(); + source.transitions[0].target = None; + + assert_eq!( + CompiledGraph::from_binding(&source).unwrap_err(), + GraphCompileError::UnresolvedTransitionTarget { transition: 0 } + ); +} diff --git a/crates/skate-game/src/tests/physics_startup.rs b/crates/skate-game/src/tests/physics_startup.rs new file mode 100644 index 00000000..ec4a7a84 --- /dev/null +++ b/crates/skate-game/src/tests/physics_startup.rs @@ -0,0 +1,456 @@ +use super::*; + +#[test] +#[ignore = "requires the user's converted stock skater and graph assets"] +fn stock_skater_startup_builds_the_graphs_and_physical_body_from_the_same_rig() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let manifest = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &manifest).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + // This regression isolates grounded controls on the original flat fixture. + // The production box is only six metres wide: sustained kickturning leaves + // it at x=-3.32, correctly selecting KnownAir and ForcePhysics2. Separate + // air_playback tests exercise the shipped course and deliberate roll-off. + let corners = [ + Vector3::new(-20.0, ground::HEIGHT, -20.0), + Vector3::new(20.0, ground::HEIGHT, -20.0), + Vector3::new(20.0, ground::HEIGHT, 20.0), + Vector3::new(-20.0, ground::HEIGHT, 20.0), + ]; + physics.world = BoardWorld::new([[0, 2, 1], [0, 3, 2]].map(|indices| { + skate_core::physics::board_world::WorldTriangle::from_vertices( + indices.map(|i| corners[i]), physics.settings.floor_material, 0, + skate_core::physics::collision::TriangleFeature::ONE_SIDED, [1.0; 3], 0.0, + ).unwrap() + }).to_vec()); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + assert_eq!( + skater.animation.pose.len(), + skater.animation.evaluator.frames.bone_names.len() + ); + assert_eq!(skater.skeleton.bodies().len(), 26); + //InitJoints uses the raw animation frames, while physical part setup + //orthonormalizes its frames. Coincident startup anchors are not a source + //guarantee; validate finite geometry and the real compiled joint rows. + let parts = skater.skeleton.part_transforms(); + let point = |part: usize, anchor: &[u32]| -> [f32; 3] { + let p: [f32; 3] = std::array::from_fn(|i| f32::from_bits(anchor[i])); + std::array::from_fn(|i| { + let x = parts[part][0][i].mul_add(p[0], parts[part][3][i]); + let y = parts[part][1][i].mul_add(p[1], x); + parts[part][2][i].mul_add(p[2], y) + }) + }; + for joint in &skater.skeleton_joints.records { + let child = point(joint.child, &joint.frames.words[4..8]); + let parent = point(joint.parent, &joint.frames.words[12..16]); + assert!(child.into_iter().chain(parent).all(f32::is_finite)); + } + let joints = skater.skeleton_joints.build( + skater.skeleton.bodies(), + 8, + physics.settings.step.simulation.time_step, + ); + assert_eq!(joints.len(), 22); + let drives = skater.skeleton_drives.build( + skater.skeleton.bodies(), + 8, + 34, + physics.settings.step.simulation.time_step, + ); + assert_eq!(drives.rows.len(), 48); + assert!( + drives + .rows + .iter() + .all(|drive| (8..38).contains(&drive.frame_a_body.reaction_index) + && (8..38).contains(&drive.frame_b_body.reaction_index)) + ); + assert_eq!( + skater.skeleton_drives.targets.transform(2)[3], + skater.animated_skeleton.board_frames.lifted_com_frame[3] + ); + assert_eq!( + skater.skeleton_drives.targets.transform(3)[3], + skater.animated_skeleton.board_frames.com_frame[3] + ); + assert!( + joints + .iter() + .all(|j| (8..34).contains(&j.reaction_a) && (8..34).contains(&j.reaction_b)) + ); + let com = skater.skeleton.record.centre_of_mass; + assert!(com.iter().all(|v| v.is_finite())); + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + assert!(com[1] > deck.translation.y); + for (body, part) in skater + .skeleton + .bodies() + .iter() + .zip(skater.skeleton.part_transforms()) + { + assert!(body.inertia.inverse_mass.is_finite() && body.inertia.inverse_mass > 0.0); + assert!(part.into_iter().flatten().all(f32::is_finite)); + } + assert!( + skater + .skeleton + .record + .velocities + .iter() + .flatten() + .all(|v| *v == 0.0) + ); + let volumes = + skeleton_colliders::world_volumes(&skater.skeleton, &skater.skeleton_collision).unwrap(); + assert_eq!(volumes.len(), 23); + let before = skater.skeleton.bodies().map(|body| body.rates.position); + let render_before = skater.render_pose.clone(); + let mut controls = PlayerControls::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut input = crate::input::ControllerInput::default(); + let physical_step = physics.settings.step.simulation.time_step; + let normal_period = physics.clock.period(); + camera + .simulation_rate_requests + .push(skate_core::camera::SimulationRateRequest { + timestep: 1.0 / 35.0, + ticks: 1, + }); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut input.player_actions(), + false, + &mut camera, + ) + .unwrap(); + assert_eq!(physics.ticks, 1); + assert!(camera.simulation_rate_requests.is_empty()); + assert!(physics.clock.period() > normal_period); + assert_eq!(physics.settings.step.simulation.time_step, physical_step); + assert_eq!(skater.player_input.processed.timestep_2604, physical_step); + //Ctor does not publish Ground before the initial input reset. The stock + //graph enters Ground on the next tick and its Begin writes ForcePhysics1. + assert_eq!(skater.skeleton_input.force_mode, 0); + assert!( + camera.frame.is_some(), + "First frame did not publish the gameplay camera" + ); + eprintln!( + "First gameplay frame: deck={:?}; COM={:?}; feet={:?}; camera={:?}", + physics.board.bodies()[6].rates.position, + skater.skeleton.record.centre_of_mass, + [ + skater.skeleton.record.pose[15][3], + skater.skeleton.record.pose[19][3] + ], + camera.frame + ); + assert_eq!(skater.pose_generation, physics.ticks); + assert_ne!(render_before, skater.render_pose); + assert!( + skater + .render_pose + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()) + ); + assert_eq!(skater.solved_drives.as_ref().unwrap().rows.len(), 48); + assert_ne!( + before, + skater.skeleton.bodies().map(|body| body.rates.position) + ); + // User crash: standing kickturn after spawn, left stick nearly full left, + // no right-stick/trigger/button input. Stock Riding retains ForcePhysics1. + let mut saw_kickturn_balance = false; + for tick in 0..1800 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: 0, + triggers: [0; 2], + left: if (12..612).contains(&tick) { + [-32768, 256] + } else { + [0; 2] + }, + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physical_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|e| { + panic!( + "Standing kickturn tick{tick}: {e}; force_mode={}; board_axis_y={}", + skater.skeleton_input.force_mode, skater.skeleton_input.drive_frames[0][2][1] + ) + }); + assert_eq!( + skater.skeleton_input.force_mode, 1, + "Stock Ground ForcePhysics Begin was lost at standing tick{tick}; state={:?}; deck={:?}; contacts={}", + skater.player_state.current(), physics.board.bodies()[6].rates.position, + physics.riding.ground.wheel_contact_count + ); + saw_kickturn_balance |= skater.animation_input.fields.balance != 0.0; + } + assert!( + saw_kickturn_balance, + "Standing input never reached the stock kickturn balance" + ); + eprintln!( + "Standing kickturn and release completed1800ticks; mode={}; balance_seen={saw_kickturn_balance}", + skater.skeleton_input.force_mode + ); + // Use raw signed axes and XInput button bits through the same converter, + // history, Pad and action map as the game. This verifies connectivity; + // matched original-game recordings and hardware polling remain separate. + let start = physics.board.bodies()[6].rates.position; + let mut saw_push = false; + let mut saw_steering = false; + for tick in 0..180 { + let left = match tick { + 0..20 => [if tick % 2 == 0 { 8191 } else { -8191 }, 0], + 90..120 => [32767, 0], + 120..150 => [-32768, 0], + 150..180 => [32767, 32767], + _ => [0; 2], + }; + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: if (20..130).contains(&tick) { 0x1000 } else { 0 }, + triggers: [0; 2], + left, + right: [0; 2], + }); + if tick < 20 { + assert_eq!( + input.mapped_actions[0][..2], + [0.0; 2], + "Sub-dead-zone stick drift reached gameplay actions" + ); + } + let mut action = input.player_actions(); + controls.update( + &mut action, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance(&mut physics, &mut skater, &mut controls, &graphs, + &mut action, true, &mut camera).unwrap_or_else(|e| panic!( + "Production tick{tick}: {e}; state={:?}; flags={:08x}; contacts={}; speed={}; targets={:?}; force_mode={}; board_axis_y={}", + skater.player_state.current(), skater.player_input.processed.flags_2468, + physics.riding.ground.wheel_contact_count, physics.riding.motion.ground_speed, + skater.ground.steering.targets, skater.skeleton_input.force_mode, + skater.skeleton_input.drive_frames[0][2][1])); + saw_push |= skater.player_input.processed.flags_2468 & (1 << 25) != 0; + saw_steering |= skater + .ground + .steering + .targets + .iter() + .any(|v| v.abs() > 0.001); + if matches!(tick, 119 | 149 | 179) { + eprintln!( + "Raw stick={left:?}; mapped={:?}; physical Turn={}; trucks={:?}", + &input.mapped_actions[0][..2], + skater.animation_input.fields.turn, + skater.ground.steering.targets + ); + assert!( + skater.animation_input.fields.turn.abs() > 0.001, + "Raw stick input did not reach physical steering" + ); + } + } + let end = physics.board.bodies()[6].rates.position; + assert_eq!(physics.clock.period(), normal_period); + eprintln!( + "Completed riding: deck={end:?}; COM={:?}; wheel contacts={}; speed={}; truck targets={:?}; push={saw_push}; steering={saw_steering}", + skater.skeleton.record.centre_of_mass, + physics.riding.ground.wheel_contact_count, + physics.riding.motion.ground_speed, + skater.ground.steering.targets + ); + assert!( + saw_push, + "Stock push animation never reached physical push input" + ); + assert!( + saw_steering, + "Stock steering never reached truck drive targets" + ); + assert!( + (end.x - start.x).hypot(end.z - start.z) > 0.01, + "Pushing did not move the live board" + ); + assert!( + physics.riding.ground.wheel_contact_count > 0, + "Grounded riding lost every wheel contact on the flat test surface" + ); + assert!( + end.y > ground::HEIGHT, + "The live deck penetrated the flat test surface" + ); + assert!(camera.frame.is_some(), "Gameplay camera never published"); + assert_eq!(skater.pose_generation, physics.ticks); + assert_eq!(skater.animation.ticks, physics.ticks); + let speed_before_brake = physics.riding.motion.ground_speed.abs(); + let mut saw_brake = false; + for tick in 180..360 { + input.sample_raw_for_test(skate_core::input::xbox::XboxState { + buttons: 0x2000, + triggers: [0; 2], + left: [0; 2], + right: [0; 2], + }); + let mut action = input.player_actions(); + controls.update( + &mut action, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut action, + true, + &mut camera, + ) + .unwrap_or_else(|e| panic!("Braking tick{tick}: {e}")); + // Brake82BDB0D4 writes the scalar consumed by Ground forces; + // flags2468 bit29 is the separate ManualBrake attribute. + saw_brake |= skater.animation_input.fields.brake > 0.0; + } + assert!(saw_brake, "Stock braking never reached physical input"); + assert!( + physics.riding.motion.ground_speed.abs() < speed_before_brake, + "Braking did not reduce the moving board's speed" + ); + assert!(physics.riding.ground.wheel_contact_count > 0); + eprintln!( + "Completed braking: speed={} -> {}; deck={:?}", + speed_before_brake, + physics.riding.motion.ground_speed, + physics.board.bodies()[6].rates.position + ); +} + +#[test] +#[ignore = "requires private stock skater, animation banks and collections"] +fn stock_wheel_hardness_reaches_ground_force_and_torque() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let manifest = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &manifest).unwrap(); + let mut samples = Vec::new(); + for hardness in [0.0_f32, 0.7, 1.0] { + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let input = crate::input::ControllerInput::default(); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut input.player_actions(), + false, + &mut camera, + ) + .unwrap(); + + // Replay identical observed sideways travel at the Ground input boundary. + // Only the published profile input changes; run the actual Ground adapter. + let p = &mut skater.player_input.processed; + p.scalar_2764 = hardness; + p.vectors_400_416 = [[1.0_f32, 0.0, 3.0, 0.0].map(f32::to_bits); 2]; + p.scalar_2656 = 10.0_f32.sqrt(); + skater.ground.state.elapsed_2648 = 0.25; + physics.board.forces_mut().clear(); + let outcome = ground_phase::advance(&mut physics, &mut skater).unwrap(); + assert!(matches!( + outcome, + skate_core::riding::grounded::state::board::GroundBoardOutcome::Ordinary(_) + )); + let side = physics + .board + .forces() + .entries() + .iter() + .find(|f| f.tag == 1) + .unwrap(); + samples.push(( + side.force_world.x, + physics.board.bodies()[6].rates.torque_acceleration.y, + )); + } + // Original82D92AD4 multiplies side friction by lerp(softest,1,hardness). + let data = skate_data::collections::Collections::load(root).unwrap(); + let softest = data + .float("physicswheels", "default", "SoftestWheelSideFrictionScalar") + .unwrap(); + assert!( + samples[2].0.abs() > 0.001, + "Sideways travel produced no side force: {samples:?}" + ); + for (index, hardness) in [0.0_f32, 0.7, 1.0].into_iter().enumerate() { + let expected = (1.0 - softest).mul_add(hardness, softest); + assert!( + (samples[index].0 / samples[2].0 - expected).abs() < 0.0001, + "Profile hardness was lost before live friction: {samples:?}" + ); + } + // Original82D92BBC also changes straighten duration, reaching deck torque. + assert!( + (samples[0].1 - samples[2].1).abs() > 0.001, + "Profile hardness was lost before live straighten torque: {samples:?}" + ); + eprintln!("Ground wheel-hardness force/torque samples: {samples:?}"); +} +#[test] +#[ignore = "requires the user's converted stock collection assets"] +fn stock_geometry_loads_and_reaches_the_live_board_solver() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let mut physics = GamePhysics::load(std::path::Path::new(&root)).unwrap(); + let volumes = colliders::world_volumes(&physics.board, &physics.settings); + assert_eq!(volumes.len(), 21); // Four wheels, two trucks, 15 stock deck children. + for _ in 0..120 { + physics.advance_board().unwrap(); + } + assert!(physics.contact_count > 0); + let reports = physics.board.contact_reports(); + assert!( + !reports.is_empty(), + "actual solved contacts must reach gameplay feedback" + ); + assert!(reports.len() <= 16); + assert!(reports.iter().any(|report| report.part.index() < 4)); + assert!( + reports + .iter() + .all(|report| report.normal_force_on_a.y.is_finite()) + ); + assert_eq!(physics.ticks, 120); + let deck = physics.board.part_transforms()[BodyId::Deck.index()]; + assert!(deck.translation.y > ground::HEIGHT); +} diff --git a/crates/skate-game/src/tests/wipeout_playback.rs b/crates/skate-game/src/tests/wipeout_playback.rs new file mode 100644 index 00000000..e6ad432b --- /dev/null +++ b/crates/skate-game/src/tests/wipeout_playback.rs @@ -0,0 +1,221 @@ +//! Real Xbox bail chord through input, stock graphs, physical state and pose. +use super::*; +use skate_core::{input::xbox::XboxState, player::state::PhysicalStateId}; + +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn raw_controller_bail_runs_stock_wipeout_for_120_ticks() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let mut settled_ticks = 0; + let mut requested_at = None; + let mut entered_at = None; + let mut saw_action_request = false; + for tick in 0..600 { + let request = settled_ticks >= 60 && requested_at.is_none(); + if request { + requested_at = Some(tick); + } + //8259BDCC..BEF0 requires exact full trigger endpoints, both stick + //buttons and a fresh edge. Supply one raw packet, then release it. + input.sample_raw_for_test(XboxState { + buttons: if request { 0x00C0 } else { 0 }, + triggers: if request { [255; 2] } else { [0; 2] }, + left: [0; 2], + right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update( + &mut actions, + physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204, + ); + frame::advance( + &mut physics, + &mut skater, + &mut controls, + &graphs, + &mut actions, + true, + &mut camera, + ) + .unwrap_or_else(|error| { + panic!( + "Raw bail tick{tick}: {error}; state={:?}; requested={requested_at:?}; entered={entered_at:?}", + skater.player_state.current() + ) + }); + let action_request = skater.animation.action.action_intents.get("WipeOutRequest"); + if action_request == Some(&1.0) { + assert!( + requested_at.is_some(), + "Bail intent appeared before the raw chord" + ); + saw_action_request = true; + } + let state = skater.player_state.current(); + if requested_at.is_none() { + assert_ne!( + state, + PhysicalStateId::WipeoutGround, + "Bailed before controller request" + ); + if state == PhysicalStateId::PhysicsGround + && physics.riding.ground.wheel_contact_count > 0 + { + settled_ticks += 1; + } else { + settled_ticks = 0; + } + } else if state == PhysicalStateId::WipeoutGround && entered_at.is_none() { + assert!( + saw_action_request, + "State300 entered without stock ActionGraph bail intent" + ); + entered_at = Some(tick); + } + assert_finite(&physics, &skater, tick); + assert_eq!( + skater.pose_generation, physics.ticks, + "Stale pose at tick{tick}" + ); + assert!( + camera.frame.is_some(), + "Missing gameplay camera at tick{tick}" + ); + if let Some(entered) = entered_at { + if tick - entered >= 120 { + eprintln!( + "Raw bail completed: request={requested_at:?}, state300={entered}, final_tick={tick}" + ); + return; + } + } + } + assert!( + requested_at.is_some(), + "Never settled for60 consecutive grounded ticks" + ); + assert!( + saw_action_request, + "Raw chord never reached stock ActionGraph WipeOutRequest" + ); + panic!("Did not complete120 ticks after state300: entered={entered_at:?}"); +} + +fn assert_finite(physics: &GamePhysics, skater: &SkaterRuntime, tick: usize) { + for body in physics + .board + .bodies() + .iter() + .chain(skater.skeleton.bodies()) + { + for vector in [ + body.rates.position, + body.rates.linear_velocity, + body.rates.angular_velocity, + ] { + assert!( + [vector.x, vector.y, vector.z] + .into_iter() + .all(f32::is_finite), + "Nonfinite physical body at tick{tick}: {body:?}" + ); + } + } + assert!( + physics.board.part_transforms().iter().all(|part| part + .basis + .columns + .iter() + .flatten() + .all(|v| v.is_finite())), + "Nonfinite board orientation at tick{tick}" + ); + assert!( + skater + .skeleton + .part_transforms() + .iter() + .flatten() + .flatten() + .all(|v| v.is_finite()), + "Nonfinite physical skeleton pose at tick{tick}" + ); + assert!( + !skater.animation.pose.is_empty(), + "Missing animation pose at tick{tick}" + ); + assert!( + skater.animation.pose.iter().all(|sample| sample + .scale + .iter() + .chain(&sample.rotation) + .chain(&sample.translation) + .all(|v| v.is_finite())), + "Nonfinite animation at tick{tick}" + ); +} + +/// Exercise the actual actor checkpoint reply, not a forced physical state. +#[test] +#[ignore = "requires private stock animation banks and collections"] +fn authored_checkpoint_reply_runs_teleport_state_and_restores_riding() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"); + let root = std::path::Path::new(&root); + let assets = skate_data::GameAssets::load(root).unwrap(); + let graphs = crate::graph_runtime::StockGraphs::load(root, &assets).unwrap(); + let mut physics = GamePhysics::load(root).unwrap(); + let mut skater = SkaterRuntime::load(root, &graphs, &physics, "normal").unwrap(); + let mut controls = PlayerControls::default(); + let mut input = crate::input::ControllerInput::default(); + let mut camera = crate::camera::CameraRuntime::load(root).unwrap(); + let checkpoint = physics.board.bodies()[6].rates.position; + let mut saw_ready = false; + let mut restored_ticks = 0; + for tick in 0..160 { + if tick == 60 { + assert!((physics.board.bodies()[6].rates.position.z - checkpoint.z).abs() > 0.1); + skater.teleport_state.request_checkpoint(); + } + input.sample_raw_for_test(XboxState { + buttons: if (12..60).contains(&tick) { 0x1000 } else { 0 }, + triggers: [0; 2], left: [0; 2], right: [0; 2], + }); + let mut actions = input.player_actions(); + controls.update(&mut actions, physics.settings.step.simulation.time_step, + physics.settings.input_magnitude_threshold, + skater.player_input.physical.scoring.capabilities_204); + frame::advance(&mut physics, &mut skater, &mut controls, &graphs, + &mut actions, true, &mut camera) + .unwrap_or_else(|error| panic!("Checkpoint tick{tick}: {error}")); + if tick == 60 { + assert_eq!(skater.player_state.current(), PhysicalStateId::Teleporting); + let output = skater.player_input.physical.teleport_output + .expect("702 must publish the captured reply"); + assert_eq!((output.next_state, output.state_61, output.board_272), (100, 1, 1)); + assert_eq!(f32::from_bits(output.transform[3][2]), checkpoint.z); + saw_ready = true; + } else if tick == 61 { + assert!(saw_ready); + assert_eq!(skater.player_state.current(), PhysicalStateId::PhysicsGround); + let position = physics.board.bodies()[6].rates.position; + assert!((position.x - checkpoint.x).abs() < 0.05); + assert!((position.z - checkpoint.z).abs() < 0.05); + assert!(!skater.skeleton_collision.is_ragdoll); + } + if tick > 61 && skater.player_state.current() == PhysicalStateId::PhysicsGround { + restored_ticks += 1; + } + assert_finite(&physics, &skater, tick); + } + assert!(saw_ready && restored_ticks > 60); +} diff --git a/crates/skate-game/src/verification.rs b/crates/skate-game/src/verification.rs new file mode 100644 index 00000000..d02dfb65 --- /dev/null +++ b/crates/skate-game/src/verification.rs @@ -0,0 +1,93 @@ +//! Opt-in startup verification, with no in-game tool UI. +use crate::{ + animation::AnimationStatus, + app::FrameSet, + config::Config, + input::ControllerInput, + physics::{GamePhysics, PlayerControls, SkaterRuntime}, +}; +use bevy::{ + prelude::*, + render::view::screenshot::{Screenshot, ScreenshotCaptured, save_to_disk}, +}; + +#[derive(Resource, Default)] +struct Verification { + elapsed: f32, + requested: bool, + captured: bool, +} + +pub(crate) struct VerificationPlugin; +impl Plugin for VerificationPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, verify.in_set(FrameSet::Verification)); + } +} +fn verify( + mut commands: Commands, + time: Res>, + config: Res, + animation: Res, + input: Res, + physics: Res, + controls: Res, + skater: Res, + camera: Res, + mut state: ResMut, + mut exit: MessageWriter, +) { + let Some(path) = &config.verification_capture else { + return; + }; + state.elapsed += time.delta_secs(); + if animation.ready && state.elapsed > 4. && !state.requested { + commands + .spawn(Screenshot::primary_window()) + .observe(save_to_disk(path.clone())) + .observe( + |_: On, mut state: ResMut| { + state.captured = true; + }, + ); + state.requested = true; + } + if state.captured && state.elapsed > 6. { + let input_report = format!( + "Game integration capture (not a Skate 3 parity verdict)\nExecutable: {}\nPolls: {}\nConsumed batches: {}\nStatus: {:?}\nPacket numbers: {:?}\nActions 64..81 by device: {:?}\nDerived ticks: {}\nIntents: {:?}\nPhysics ticks: {}\nContacts: {}\nBody positions: {:?}\nPhysical pose publications: {}\nGraph animation ticks: {}\nTruck targets: {:?}\nGround speed: {}\nGameplay camera shot: {}\nCamera frame: {:?}\n", + std::env::current_exe() + .map_or_else(|_| "unavailable".into(), |p| p.display().to_string()), + input.publications, + input.consumed_batches, + input.status, + input.packet_numbers, + input.mapped_actions, + controls.ticks, + controls.intents, + physics.ticks, + physics.contact_count, + physics.board.bodies().map(|body| body.rates.position), + skater.pose_generation, + skater.animation.ticks, + skater.ground.steering.targets, + physics.riding.motion.ground_speed, + camera.selected_shot(), + camera.frame, + ); + let input_report = format!( + "{input_report}Native OnBoard/OffBoard clips available: {}\n", + skater.animation.evaluator.frames.clip_count() + ); + if let Err(error) = std::fs::write(path.with_extension("input.txt"), input_report) { + eprintln!("Cannot save controller verification: {error}"); + exit.write(AppExit::error()); + return; + } + info!("GAME_VERIFY_OK"); + exit.write(AppExit::Success); + } else if state.elapsed > 45. { + error!("Game startup/capture timed out"); + exit.write(AppExit::error()); + } +} diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs new file mode 100644 index 00000000..99b774f2 --- /dev/null +++ b/crates/skate-game/src/world.rs @@ -0,0 +1,71 @@ +use crate::assets::AssetManifest; +use bevy::prelude::*; + +/// World transform owner. Animation only writes descendant bone transforms. +#[derive(Component)] +pub(crate) struct PlayerRoot; + +pub(crate) struct WorldPlugin; +impl Plugin for WorldPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(ClearColor(Color::srgb(0.065, 0.08, 0.10))) + .insert_resource(GlobalAmbientLight { + color: Color::WHITE, + brightness: 350., + ..default() + }) + .add_systems(Startup, spawn); + } +} +fn spawn( + mut commands: Commands, + server: Res, + manifest: Res, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + commands + .spawn((PlayerRoot, Transform::default(), Visibility::default())) + .with_children(|parent| { + parent.spawn(SceneRoot(server.load( + GltfAssetLabel::Scene(0).from_asset(manifest.0.character_scene.clone()), + ))); + }); + let colors = [ + Color::srgb(0.16, 0.19, 0.21), + Color::srgb(0.48, 0.35, 0.22), + Color::srgb(0.30, 0.43, 0.48), + ]; + for (quads, color) in crate::physics::ground::surfaces().into_iter().zip(colors) { + let positions: Vec<[f32; 3]> = quads.into_iter().flat_map(|vertices| { + [0, 2, 1, 0, 3, 2].map(|i| { + let v = vertices[i]; + [v.x, v.y, v.z] + }) + }).collect(); + let mut mesh = Mesh::new( + bevy::mesh::PrimitiveTopology::TriangleList, + bevy::asset::RenderAssetUsages::default(), + ).with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.compute_flat_normals(); + commands.spawn(( + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: color, + perceptual_roughness: 0.9, + double_sided: true, + cull_mode: None, + ..default() + })), + Transform::default(), + )); + } + commands.spawn(( + DirectionalLight { + illuminance: 11000., + shadows_enabled: true, + ..default() + }, + Transform::from_xyz(4., 7., 4.).looking_at(Vec3::ZERO, Vec3::Y), + )); +}