feat: Endless Tricks, with the held board spin measured rather than chosen

An opt-in mod that extends the authored flip ladder past retail's quad. Off by
default and inert when disabled: the loop transitions compile into the graph but
their condition is false unless a mod turns the feature on, the retail scoring
tables and SCORABLE_COUNT are untouched, and the extra rungs borrow a retail
scorable and override only name, label and points.

Retail authors no rotation cycle for the 360-flip family, so a held rung replays
the trick's own air clip -- the thing that turns the deck -- while the body is
parked in T_HI_KICK_CYC2 and the deck is spun as the rigid body it measures as.
Three numbers in that spin come from the clip, printed by
`cargo run -p skate-data --example spin_measure -- <assets>`:

* The axis is the signed sum of the per-frame world deltas. The net
  first-to-last-frame axis the first version used is inverted -- dot -0.60 on
  360FLIP_D_HIGH_A, -0.76 on _LOW_A, -0.89 on GONZ_HIGH_A -- and that was the
  board turning the wrong way. A 494-degree path is not recoverable from its
  endpoints.
* The rate is the plateau, 20.85 deg/frame against a whole-clip mean of 15.45.
  One turn per clip length is 654 deg/s where the plateau is 1251, which is why
  every rung read as sluggish. Shipped at 0.70 of it on judgement from play,
  decaying 0.90 a rung with a floor so it can never read as parked.
* The anchor is the clip's last frame, not its first. Frame 0 is the deck still
  at the feet pre-flick, so freezing it there parked a spinning deck inside a
  body whose legs are held clear.

The angle accumulates across rungs, so the step over a junction equals the step
inside one and a rung need not be a whole turn. That is what dissolves the
loop-closure problem four earlier resampling attempts each broke on.

Also fixed here: the board-local writeback ran over the four reparented helpers
and welded the hands and toes to the spinning deck (0.43 m out, against 0.003
when bounded); the hold is published for every family with a rung table rather
than four hand-written names, so eight installed ladders that could never run now
do, with a forgiving hold for the families retail never held; the body-hold index
wrapped at the ninth rung inside the cap; and the clip's trajectory channel was
being zeroed for the duration of a hold.

Two defects are recorded in docs/engine-defects.md rather than half-fixed: a held
rung steps the skeleton 0.61 m where the authored trick steps 0.23, which is the
body and not the deck and is identical with the spin off; and a cycle-ladder rung
past the quad runs 23 ticks of a 26-tick clip, so the deck appears to speed up.
Closing the second needs the authored Out4 exit held off while the ladder is
live, which is graph surgery on the path a stock quad lands through.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbDgDpVFzq2r2SfvSEnMbc
This commit is contained in:
andrewnakasandClaude Opus 5 committed 2026-10-01 12:24:53 -06:00
1 parent 1415b5d570
commit 368a433e1c
38 files changed
+4562 -57

No files matched your search

+1
View File
@@ -51,6 +51,7 @@ __pycache__/
# Bundled mods requested for source checkout and distribution.
!/mods/native-trainer.zip
!/mods/mario-kart.zip
!/mods/endless-tricks.zip
!/sdk/examples/mario-kart/kart.glb
!/sdk/examples/mario-kart/rider.json
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Launch-EndlessTricks.ps1" %*
if errorlevel 1 pause
+2
View File
@@ -14,6 +14,8 @@ Drop mod ZIPs into top-level `mods/` and enable them in the Mods menu. **Point y
to make a mod. See [package structure](docs/mod-packages.md), [Lua API](docs/lua-modding.md),
[vehicle API](docs/vehicle-sdk.md), [multiplayer mod SDK](docs/multiplayer-mods.md) and [Mixamo workflow](docs/mixamo-vehicle-workflow.md).
Editable examples live in `sdk/examples/`; `tools/package_mod.py` validates and packages them.
[Endless Tricks](docs/endless-tricks.md) is a bundled mod that extends the flip ladder past
retail's quad; run it with `PLAY-ENDLESS-TRICKS.bat`.
## Play
+3
View File
@@ -9,6 +9,9 @@ pub const SCORE_TYPE_COUNT: usize = 14;
pub mod carrier;
pub mod catalog;
pub mod conversions;
/// Endless Tricks. A deliberate non-retail extension; the retail tables above are
/// untouched and this is consulted only for names the stock graph never publishes.
pub mod extension;
pub mod session;
pub mod timer;
+245
View File
@@ -0,0 +1,245 @@
//! Endless Tricks rungs: the flip ladder past retail's quad.
//!
//! **Not retail.** Skate 3's scorable table is 332 entries and tops out at `kickflip4` (135),
//! `heelflip4` (134), `n_kickflip4` (137) and `n_heelflip4` (136). Nothing here is added to that
//! table: `catalog::IDENTIFIERS`, `conversions::LINKS` and `SCORABLE_COUNT` are recovered data
//! and stay exactly as they are.
//!
//! Instead each extra rung **borrows the retail quad's ledger identity** and overrides only what
//! has to differ -- the published name, the displayed label and the points. That works because
//! the flip ladder *converts* rather than accumulates (`Carrier::convert_to`): the only things
//! that ever reach `ScoreHolder` are the surviving carrier's `Scorable` and its reward, and the
//! quad's `Scorable` is already the right ledger identity for a fifth flip. Same class 3, same
//! score type 2, same repetition bucket, same conversion source. So `Scorable::valid()` passes,
//! `LINKS[135]` is in range and `by_id(135)` resolves -- none of the out-of-range hazards that
//! minting a 333rd id would create.
use crate::animation::{output::attributes::AttributeName, skeleton_input::name::encode};
/// One non-retail rung.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rung {
/// Which flip this is: 5 for the first past a kickflip quad, 2 for the first past a 360 flip.
pub rung: u32,
/// The top rung retail itself authors for this family -- 4 for the cycle ladders, 1 for the
/// single-clip tricks. Rungs are counted from here, and so are the points.
pub base_rung: u32,
/// Retail family stem, matching the authored `ScoringTrick` name lowercased.
pub family: &'static str,
/// Mod-only identifier, e.g. `kickflip5`. Never enters `catalog::IDENTIFIERS`.
pub identifier: &'static str,
/// The retail scorable whose ledger identity this rung borrows.
pub base_id: usize,
/// Bare token as `compose_trick_name` expects it -- no leading `#`, and deliberately not an
/// `ID_TRICK_*` key. `apt_text::localize` splits on whitespace and echoes any token it has no
/// entry for, so plain words render as written while an unknown `ID_` key would print raw.
pub label: &'static str,
}
/// The highest rung the tables carry; `TrainerTuning::endless_flip_max` is clamped to it.
pub const MAX_RUNG: u32 = 16;
/// Retail's authored kickflip ladder steps +50 a rung (100/150/200/250). Extra rungs continue
/// that step from whatever the borrowed retail scorable is worth, so each family keeps its own
/// authored base rather than a number invented here. Linear is deliberate: the ladder converts,
/// so only the final rung banks, and a superlinear curve would let one drop dwarf a whole line.
pub fn points_over(base_points: i32, rung: &Rung) -> i32 {
base_points + 50 * (rung.rung - rung.base_rung) as i32
}
macro_rules! rung {
($family:literal, $base:literal, $from:literal, $n:literal, $suffix:literal, $label:expr) => {
Rung {
rung: $n,
base_rung: $from,
family: $family,
identifier: concat!($family, $suffix),
base_id: $base,
label: $label,
}
};
}
/// Cycle-ladder families: retail authors four rungs, so the extension starts at the fifth and
/// keeps counting by name until the counts stop meaning anything.
macro_rules! cycle_family {
($family:literal, $base:literal, $word:literal) => {
[
rung!($family, $base, 4, 5, "5", concat!("QUINTUPLE ", $word)),
rung!($family, $base, 4, 6, "6", concat!("SEXTUPLE ", $word)),
rung!($family, $base, 4, 7, "7", concat!("SEPTUPLE ", $word)),
rung!($family, $base, 4, 8, "8", concat!("OCTUPLE ", $word)),
rung!($family, $base, 4, 9, "9", concat!("ENDLESS ", $word)),
rung!($family, $base, 4, 10, "10", concat!("ENDLESS ", $word)),
rung!($family, $base, 4, 11, "11", concat!("ENDLESS ", $word)),
rung!($family, $base, 4, 12, "12", concat!("ENDLESS ", $word)),
rung!($family, $base, 4, 13, "13", concat!("ENDLESS ", $word)),
rung!($family, $base, 4, 14, "14", concat!("ENDLESS ", $word)),
rung!($family, $base, 4, 15, "15", concat!("ENDLESS ", $word)),
rung!($family, $base, 4, 16, "16", concat!("ENDLESS ", $word)),
]
};
}
/// Single-clip families: retail authors one rung, so the extension starts at the second and names
/// the rungs by the board rotation they add -- a doubled 360 flip is a 720, not a "double".
///
/// Counted names run to the seventh and the eighth is already ENDLESS, which is one rung earlier
/// than the cycle ladders turn over. Owner's call: past 2520 degrees the number stops meaning
/// anything on a trick that is one rotation in retail.
macro_rules! degree_family {
($family:literal, $base:literal, $word:literal, $endless:literal) => {
[
rung!($family, $base, 1, 2, "2", concat!("720 ", $word)),
rung!($family, $base, 1, 3, "3", concat!("1080 ", $word)),
rung!($family, $base, 1, 4, "4", concat!("1440 ", $word)),
rung!($family, $base, 1, 5, "5", concat!("1800 ", $word)),
rung!($family, $base, 1, 6, "6", concat!("2160 ", $word)),
rung!($family, $base, 1, 7, "7", concat!("2520 ", $word)),
rung!($family, $base, 1, 8, "8", $endless),
rung!($family, $base, 1, 9, "9", $endless),
rung!($family, $base, 1, 10, "10", $endless),
rung!($family, $base, 1, 11, "11", $endless),
rung!($family, $base, 1, 12, "12", $endless),
rung!($family, $base, 1, 13, "13", $endless),
rung!($family, $base, 1, 14, "14", $endless),
rung!($family, $base, 1, 15, "15", $endless),
rung!($family, $base, 1, 16, "16", $endless),
]
};
}
/// `T_Kickflip.xml`, included once per family, each borrowing its authored quad.
pub static CYCLE_RUNGS: [[Rung; 12]; 4] = [
cycle_family!("kickflip", 135, "KICKFLIP"),
cycle_family!("heelflip", 134, "HEELFLIP"),
cycle_family!("n_kickflip", 137, "NOLLIE KICKFLIP"),
cycle_family!("n_heelflip", 136, "NOLLIE HEELFLIP"),
];
/// `T_TrickWithDarkCatch.xml`, which authors no cycle at all -- one clip, one scorable.
///
/// Every 360-spinnable trick on that file, with its nollie form. Each borrows its own retail
/// scorable, and every one of them is class 3 / score type 2 exactly as `360flip` (85) is, which is
/// what makes the ledger identity interchangeable. Ids read from `catalog::IDENTIFIERS`.
///
/// Plain `fspopshuvit` (90) and `n_fspopshuvit` (111) are deliberately absent: a pop shuvit turns
/// the board 180, so counting its rungs in 720s would be wrong. `fs360popshuvit` (89) is the
/// frontside 360 and is here.
pub static DEGREE_RUNGS: [[Rung; 15]; 12] = [
degree_family!("360flip", 85, "FLIP", "ENDLESS 360 FLIP"),
degree_family!("360hardflip", 86, "HARDFLIP", "ENDLESS 360 HARDFLIP"),
degree_family!("360inwardheelflip", 87, "INWARD HEELFLIP", "ENDLESS 360 INWARD HEELFLIP"),
degree_family!("360popshuvit", 88, "SHUVIT", "ENDLESS 360 SHUVIT"),
degree_family!("fs360popshuvit", 89, "FS SHUVIT", "ENDLESS FS 360 SHUVIT"),
degree_family!("laserflip", 99, "LASERFLIP", "ENDLESS LASERFLIP"),
degree_family!("n_360flip", 106, "NOLLIE FLIP", "ENDLESS NOLLIE 360 FLIP"),
degree_family!("n_360hardflip", 107, "NOLLIE HARDFLIP", "ENDLESS NOLLIE 360 HARDFLIP"),
degree_family!(
"n_360inwardheelflip",
108,
"NOLLIE INWARD HEELFLIP",
"ENDLESS NOLLIE 360 INWARD HEELFLIP"
),
degree_family!("n_360popshuvit", 109, "NOLLIE SHUVIT", "ENDLESS NOLLIE 360 SHUVIT"),
degree_family!("n_fs360popshuvit", 110, "NOLLIE FS SHUVIT", "ENDLESS NOLLIE FS 360 SHUVIT"),
degree_family!("n_laserflip", 120, "NOLLIE LASERFLIP", "ENDLESS NOLLIE LASERFLIP"),
];
fn all() -> impl Iterator<Item = &'static Rung> {
CYCLE_RUNGS.iter().flatten().chain(DEGREE_RUNGS.iter().flatten())
}
/// Resolve a rung from a name published by the graph, e.g. `encode(b"Kickflip5")`.
///
/// `encode` case-folds, so the graph's `Kickflip5` and this table's `kickflip5` are one key --
/// the same reason the authored `trick="Kickflip4"` resolves against `catalog`'s `kickflip4`.
pub fn by_name(name: AttributeName) -> Option<&'static Rung> {
all().find(|r| encode(r.identifier.as_bytes()) == name)
}
/// Resolve a rung by number within a family stem, e.g. `("kickflip", 5)`.
pub fn by_family(family: &str, rung: u32) -> Option<&'static Rung> {
all().find(|r| r.rung == rung && r.family.eq_ignore_ascii_case(family))
}
/// Resolve a rung by number and the retail scorable it borrows.
pub fn by_rung(rung: u32, base_id: usize) -> Option<&'static Rung> {
all().find(|r| r.rung == rung && r.base_id == base_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rungs_continue_the_authored_step_and_borrow_a_retail_quad() {
// The authored quad is 250; the first extra rung is one +50 step past it.
assert_eq!(points_over(250, by_family("kickflip", 5).unwrap()), 300);
assert_eq!(points_over(250, by_family("kickflip", 16).unwrap()), 850);
// A single-clip family counts from its one authored rung instead.
assert_eq!(points_over(400, by_family("360flip", 2).unwrap()), 450);
assert_eq!(by_family("360flip", 2).unwrap().label, "720 FLIP");
assert_eq!(by_family("360flip", 8).unwrap().label, "ENDLESS 360 FLIP");
assert_eq!(by_family("360flip", 7).unwrap().label, "2520 FLIP");
// Every 360-spinnable family carries rungs, nollie forms included.
assert_eq!(by_family("360popshuvit", 2).unwrap().label, "720 SHUVIT");
assert_eq!(by_family("n_360hardflip", 3).unwrap().label, "1080 NOLLIE HARDFLIP");
assert_eq!(by_family("fs360popshuvit", 4).unwrap().label, "1440 FS SHUVIT");
// A pop shuvit turns the board 180, so it must not have acquired degree rungs.
assert!(by_family("fspopshuvit", 2).is_none());
assert_eq!(by_family("laserflip", 3).unwrap().label, "1080 LASERFLIP");
assert_eq!(by_family("laserflip", 12).unwrap().label, "ENDLESS LASERFLIP");
for rung in all() {
assert!(
(2..=MAX_RUNG).contains(&rung.rung),
"{} is outside the extension range",
rung.identifier
);
// Borrowing a retail id is what keeps `Scorable::valid()` true and `LINKS` in range.
assert!(
rung.base_id < crate::scoring::SCORABLE_COUNT,
"{} does not borrow a retail quad",
rung.identifier
);
// An `ID_`-prefixed label would reach the HUD as a raw unlocalised token.
assert!(
!rung.label.contains("ID_"),
"{} would render as a raw key",
rung.identifier
);
}
}
#[test]
fn counted_names_stop_at_the_octuple_and_everything_above_reads_endless() {
// Named counts run out at eight; past that the trick is the mod's own name rather than a
// number nobody is counting in the air.
assert_eq!(by_family("kickflip", 8).unwrap().label, "OCTUPLE KICKFLIP");
for rung in 9..=MAX_RUNG {
assert_eq!(
by_family("heelflip", rung).unwrap().label,
"ENDLESS HEELFLIP",
"rung {rung} should read endless"
);
}
assert_eq!(
by_family("n_kickflip", 9).unwrap().label,
"ENDLESS NOLLIE KICKFLIP"
);
// Labels repeat above the boundary, so identifiers stay the unique key.
assert_ne!(
by_family("kickflip", 9).unwrap().identifier,
by_family("kickflip", 10).unwrap().identifier
);
}
#[test]
fn names_resolve_case_insensitively_the_way_the_graph_publishes_them() {
let rung = by_name(encode(b"Kickflip5")).expect("graph spelling");
assert_eq!(rung.identifier, "kickflip5");
assert_eq!(rung.base_id, 135);
// No retail scorable is ever mistaken for an extension rung.
assert!(by_name(encode(b"kickflip4")).is_none());
assert!(by_name(encode(b"ollie")).is_none());
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Throwaway: which cycle-clip names the animation metadata can actually resolve.
fn main() {
let root = std::path::PathBuf::from(std::env::args().nth(1).expect("asset root"));
let banks = skate_data::animation_banks::AnimationBanks::load(&root).unwrap();
let metadata = banks.metadata().unwrap();
for name in [
"T_360FLIP_H_CYC",
"T_360FLIP_L_CYC",
"T_LASERFLIP_H_CYC",
"T_LASERFLIP_L_CYC",
"T_N_360FLIP_H_CYC",
"T_N_360FLIP_L_CYC",
"T_N_LASERFLIP_H_CYC",
"T_N_LASERFLIP_L_CYC",
] {
let tree = metadata.tree(name).is_ok();
let clip = metadata.clip(name).is_ok();
println!("{name:22} tree={tree:5} clip={clip}");
}
}
+609
View File
@@ -0,0 +1,609 @@
//! What the authored board channel actually does, measured rather than inferred.
//!
//! The endless 360-flip hold synthesises a rigid board spin, and three of its choices have to come
//! from the clip instead of from a guess:
//!
//! 1. **Is the deck's rotation one fixed axis, or two?** A 360 flip is a kickflip (about the deck's
//! long axis) plus a 360 shuvit (about world up). *Both* models predict a ~509-degree path, so
//! the 494 degrees the existing `SKATE_SPIN` trace reports cannot tell them apart. The number
//! that can is `|sum r| / sum |r|` over the per-frame rotation vectors: a genuinely fixed axis
//! gives ~1.0, while a matched flip-plus-shuvit gives ~0.707, because the yawing flip component
//! integrates to zero over a whole revolution. That matters enormously, because if it is 0.707
//! then taking the axis as `normalize(sum r)` returns **world vertical** and the synthesised hold
//! becomes a pure shuvit with the flip silently gone.
//! 2. **Which axis, and which direction.** Deriving it from the net first-to-last-frame rotation is
//! what made the board spin the wrong way: a 494-degree path is not recoverable from its
//! endpoints, and `to_axis_angle`'s sign then depends on an unrelated float branch.
//! 3. **Which point does it turn about.** The spin is applied about `SKATEBOARD_ROOT`'s origin. If
//! that origin is not near the deck's centre, the synthesised spin orbits the wrong point.
//!
//! This lives in `skate-data` on purpose. `skate-core` has no dependencies and `skate-data` only
//! adds the loaders, so this builds in seconds -- where the same measurement inside `skate-game`
//! costs a multi-minute build and `RUST_MIN_STACK`. The quaternion arithmetic is hand-rolled for the
//! same reason, which also keeps the result independent of bevy's glam version.
//!
//! Run: `cargo run -p skate-data --example spin_measure -- <asset root>`
use skate_data::animation_banks::AnimationBanks;
use skate_data::animation_frames::{AnimationFrames, SampleWords};
type Quat = [f32; 4]; // x, y, z, w
type Vec3 = [f32; 3];
fn cross3(a: Vec3, b: Vec3) -> Vec3 {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
/// Hamilton product, matching `skate_core::animation::pose_trajectory::multiply`.
fn mul(a: Quat, b: Quat) -> Quat {
let v = cross3([a[0], a[1], a[2]], [b[0], b[1], b[2]]);
[
a[0] * b[3] + b[0] * a[3] + v[0],
a[1] * b[3] + b[1] * a[3] + v[1],
a[2] * b[3] + b[2] * a[3] + v[2],
a[3] * b[3] - (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]),
]
}
fn conj(q: Quat) -> Quat {
[-q[0], -q[1], -q[2], q[3]]
}
fn norm(q: Quat) -> Quat {
let l = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
if l <= f32::EPSILON {
[0., 0., 0., 1.]
} else {
[q[0] / l, q[1] / l, q[2] / l, q[3] / l]
}
}
fn qdot(a: Quat, b: Quat) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]
}
/// Rotate a vector by a quaternion, matching `pose_trajectory::rotate`.
fn rot(q: Quat, v: Vec3) -> Vec3 {
let qv = [q[0], q[1], q[2]];
let first = cross3(qv, v);
let inter = [
q[3] * v[0] + first[0],
q[3] * v[1] + first[1],
q[3] * v[2] + first[2],
];
let second = cross3(qv, inter);
[
2.0 * second[0] + v[0],
2.0 * second[1] + v[1],
2.0 * second[2] + v[2],
]
}
/// Axis * angle: the rotation vector. Summing these cannot invert, where averaging bare axes can.
fn rotation_vector(q: Quat) -> Vec3 {
let q = norm(q);
// Canonicalise onto the short way round, so a delta never comes back as its 360-complement.
let q = if q[3] < 0.0 {
[-q[0], -q[1], -q[2], -q[3]]
} else {
q
};
let sin_half = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2]).sqrt();
if sin_half <= 1e-9 {
return [0., 0., 0.];
}
let angle = 2.0 * sin_half.atan2(q[3]);
[
q[0] / sin_half * angle,
q[1] / sin_half * angle,
q[2] / sin_half * angle,
]
}
fn length(v: Vec3) -> f32 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
fn unit(v: Vec3) -> Vec3 {
let l = length(v);
if l <= 1e-9 {
[0., 0., 0.]
} else {
[v[0] / l, v[1] / l, v[2] / l]
}
}
fn add3(a: Vec3, b: Vec3) -> Vec3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn dot3(a: Vec3, b: Vec3) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn sqt(w: SampleWords) -> ([f32; 3], Quat, Vec3) {
let f = |i: usize| f32::from_bits(w[i]);
(
[f(0), f(1), f(2)],
[f(3), f(4), f(5), f(6)],
[f(7), f(8), f(9)],
)
}
/// One row's bone locals -> globals, as (rotation, origin, scale).
///
/// Mirrors `authored_clips::row_globals`: the local is `pose_add::add(row, bind, true)`, whose
/// rotation is `bind * row` and whose translation is `rotate(bind, row.t) + bind.t`. Bone 0
/// (TRAJECTORY) is left at identity, exactly as the walk there does.
fn row_globals(
row: &[SampleWords],
bind: &[SampleWords],
parents: &[i32],
) -> Vec<(Quat, Vec3, [f32; 3])> {
let mut out = vec![([0., 0., 0., 1.0f32], [0f32; 3], [1f32; 3]); row.len()];
for bone in 1..row.len() {
let (rs, rq, rt) = sqt(row[bone]);
let (bs, bq, bt) = sqt(bind[bone]);
let local_rot = mul(bq, rq);
let rotated = rot(bq, rt);
let local_pos = add3(rotated, bt);
let local_scale = [bs[0] * rs[0], bs[1] * rs[1], bs[2] * rs[2]];
let parent = parents[bone];
if parent > 0 {
let (pq, pp, ps) = out[parent as usize];
let scaled = [
local_pos[0] * ps[0],
local_pos[1] * ps[1],
local_pos[2] * ps[2],
];
let moved = rot(pq, scaled);
out[bone] = (
norm(mul(pq, local_rot)),
add3(pp, moved),
[
ps[0] * local_scale[0],
ps[1] * local_scale[1],
ps[2] * local_scale[2],
],
);
} else {
out[bone] = (norm(local_rot), local_pos, local_scale);
}
}
out
}
/// World-space per-frame rotation vectors for one bone: `q_next * q_prev^-1`, sign-canonicalised.
fn deltas(globals: &[Vec<(Quat, Vec3, [f32; 3])>], bone: usize) -> Vec<Vec3> {
globals
.windows(2)
.map(|pair| {
let a = norm(pair[0][bone].0);
let b = norm(pair[1][bone].0);
let b = if qdot(a, b) < 0.0 {
[-b[0], -b[1], -b[2], -b[3]]
} else {
b
};
rotation_vector(mul(b, conj(a)))
})
.collect()
}
/// Rotation about an axis by an angle, as a quaternion.
fn axis_angle(axis: Vec3, angle: f32) -> Quat {
let a = unit(axis);
let h = angle * 0.5;
let s = h.sin();
[a[0] * s, a[1] * s, a[2] * s, h.cos()]
}
/// The two-axis decomposition: world-up yaw (the shuvit) composed with a fixed board-local spin
/// (the flip), which is what a 360 flip actually is.
///
/// `Q_i = R_up(theta_i) * Q_0 * R_e(phi_i)`. The yaw is pulled out first as the world-up component
/// of each delta, which is a signed scalar and so cannot invert; what remains is a rotation about a
/// single axis in the deck's own frame, and *that* axis is well conditioned where the combined one
/// is not. Returns (per-frame yaw, per-frame flip, flip axis, flip axis stability).
fn decompose(
globals: &[Vec<(Quat, Vec3, [f32; 3])>],
bone: usize,
up: Vec3,
) -> (Vec<f32>, Vec<f32>, Vec3, f32) {
let d = deltas(globals, bone);
let mut yaw = Vec::with_capacity(d.len());
let mut theta = 0f32;
let mut yawless = Vec::with_capacity(globals.len());
for i in 0..globals.len() {
yawless.push(mul(axis_angle(up, -theta), norm(globals[i][bone].0)));
if i < d.len() {
let step = dot3(d[i], unit(up));
yaw.push(step);
theta += step;
}
}
// Body-frame deltas of the yaw-free sequence: P_{i+1} = P_i * R_e(dphi).
let mut sum = [0f32; 3];
let mut path = 0f32;
let mut raw: Vec<Vec3> = Vec::with_capacity(d.len());
for i in 0..d.len() {
let a = norm(yawless[i]);
let b = norm(yawless[i + 1]);
let b = if qdot(a, b) < 0.0 { [-b[0], -b[1], -b[2], -b[3]] } else { b };
let r = rotation_vector(mul(conj(a), b));
path += length(r);
sum = add3(sum, r);
raw.push(r);
}
let axis = unit(sum);
let stability = if path > 1e-6 { length(sum) / path } else { 0.0 };
let flip = raw.iter().map(|r| dot3(*r, axis)).collect();
(yaw, flip, axis, stability)
}
fn index(frames: &AnimationFrames, name: &str) -> Option<usize> {
frames.bone_names.iter().position(|n| n == name)
}
fn main() {
let root = std::path::PathBuf::from(std::env::args().nth(1).expect("asset root"));
let banks = AnimationBanks::load(&root).unwrap();
let frames = AnimationFrames::from_banks(&banks).unwrap();
let bind = &frames.named_pose("RIG_TPOSE").unwrap().samples;
let board = index(&frames, "SKATEBOARD_ROOT").expect("SKATEBOARD_ROOT");
let first_helper = [
"RIGHTTOEBASE_REPARENTED",
"LEFTTOEBASE_REPARENTED",
"RIGHTHAND_REPARENTED",
"LEFTHAND_REPARENTED",
]
.iter()
.filter_map(|n| index(&frames, n))
.min()
.expect("reparented helpers");
println!(
"bones={} board={board} ({}) board subtree={board}..{first_helper} helpers from {first_helper}",
frames.bone_names.len(),
frames.bone_names[board],
);
for bone in board..first_helper {
println!(" {bone:>3} {} parent={}", frames.bone_names[bone], frames.parents[bone]);
}
println!();
// Discovered from the bank, not guessed: the tree/bank name gap is what put a hold pose on a
// repeat in the first place.
let wanted = [
"360FLIP",
"360HARDFLIP",
"360INWARDHEELFLIP",
"360POPSHUVIT",
"FS360POPSHUVIT",
"LASERFLIP",
];
let mut names: Vec<String> = frames
.clip_names()
.filter(|n| n.ends_with("_A"))
.filter(|n| {
let stem = n.trim_start_matches("N_").trim_start_matches("NOLLIE_");
wanted.iter().any(|w| stem.starts_with(w))
})
.map(str::to_owned)
.collect();
names.sort();
if names.is_empty() {
println!("No air clip matched. Everything with 360FLIP in the name:");
let mut all: Vec<&str> = frames.clip_names().filter(|n| n.contains("360FLIP")).collect();
all.sort();
for n in all.iter().take(60) {
println!(" {n}");
}
return;
}
for name in &names {
let clip = match frames.clip(name) {
Ok(c) => c,
Err(e) => {
println!("{name}: {e}");
continue;
}
};
if clip.frames.len() < 3 {
println!("{name}: only {} frames", clip.frames.len());
continue;
}
let fps = f32::from_bits(clip.fps_bits);
let globals: Vec<Vec<(Quat, Vec3, [f32; 3])>> = clip
.frames
.iter()
.map(|row| row_globals(row, bind, &frames.parents))
.collect();
println!("=== {name} frames={} fps={fps:.2}", clip.frames.len());
// ---- 1. Per board-subtree bone: the path (what SKATE_SPIN reports) and the vector sum.
//
// Equal paths across the trucks and wheels is a *tautology* of rigidity, so it confirms the
// deck is one body but says nothing about whether the axis is fixed. The ratio does.
let mut best = (board, 0f32);
for bone in board..first_helper {
let d = deltas(&globals, bone);
let path: f32 = d.iter().map(|r| length(*r)).sum();
let sum = d.iter().fold([0f32; 3], |a, r| add3(a, *r));
let ratio = if path > 1e-6 { length(sum) / path } else { 0.0 };
println!(
" {:<22} path={:7.1}deg |sum|={:7.1}deg ratio={ratio:.3} axis=[{:6.3} {:6.3} {:6.3}]",
frames.bone_names[bone],
path.to_degrees(),
length(sum).to_degrees(),
unit(sum)[0],
unit(sum)[1],
unit(sum)[2],
);
if path > best.1 {
best = (bone, path);
}
}
let bone = best.0;
let d = deltas(&globals, bone);
let path: f32 = d.iter().map(|r| length(*r)).sum();
let sum = d.iter().fold([0f32; 3], |a, r| add3(a, *r));
let ratio = if path > 1e-6 { length(sum) / path } else { 0.0 };
println!(
" -> most rotation: {} ({:.1}deg)",
frames.bone_names[bone],
path.to_degrees()
);
// ---- 2. THE DECISION: one fixed axis, or a flip plus a shuvit?
println!(
" VERDICT ratio={ratio:.3} => {}",
if ratio > 0.92 {
"ONE FIXED AXIS -- normalize(sum r) is the spin axis"
} else if ratio < 0.80 {
"TWO AXES (flip + shuvit) -- a single axis would drop the flip; use two scalars"
} else {
"AMBIGUOUS -- inspect the deck-local breakdown below"
}
);
// ---- 3. Deck-local breakdown: which of the deck's own axes carries the flip.
//
// `Q_i^-1 r_i` re-expresses each world delta in the deck's frame, so a component that
// survives the sum is a rotation about a *board* axis (the flip), where the yaw component
// cancels as the deck turns.
let mut local_sum = [0f32; 3];
for (i, r) in d.iter().enumerate() {
let q = norm(globals[i][bone].0);
local_sum = add3(local_sum, rot(conj(q), *r));
}
println!(
" deck-local sum=[{:7.1} {:7.1} {:7.1}]deg |{:.1}|deg",
local_sum[0].to_degrees(),
local_sum[1].to_degrees(),
local_sum[2].to_degrees(),
length(local_sum).to_degrees(),
);
// ---- 4. Bug A, side by side: the endpoint axis the code uses against the accumulated one.
let a = norm(globals[0][bone].0);
let b = norm(globals[globals.len() - 1][bone].0);
let net = rotation_vector(mul(b, conj(a)));
let net_axis = unit(net);
let accum_axis = unit(sum);
let agreement = dot3(net_axis, accum_axis);
println!(
" endpoint: axis=[{:6.3} {:6.3} {:6.3}] angle={:6.1}deg <- what the code uses today",
net_axis[0],
net_axis[1],
net_axis[2],
length(net).to_degrees()
);
println!(
" accumul.: axis=[{:6.3} {:6.3} {:6.3}] path ={:6.1}deg dot={agreement:+.3}{}",
accum_axis[0],
accum_axis[1],
accum_axis[2],
path.to_degrees(),
if agreement < 0.0 { " <== INVERTED" } else { "" }
);
// ---- 5. The speed profile, so the catch's deceleration is a number not an impression.
let speeds: Vec<f32> = d.iter().map(|r| length(*r)).collect();
print!(" deg/frame:");
for (i, s) in speeds.iter().enumerate() {
if i % 12 == 0 {
print!("\n {i:>3}: ");
}
print!("{:>7.1}", s.to_degrees());
}
println!();
// ---- 6. The plateau rate, robustly: the mean of the fastest third of frames. Peak-picking
// lands on the flick spike, which is the fastest moment of the clip and not the speed the
// rotation settles at.
let mut sorted = speeds.clone();
sorted.sort_by(f32::total_cmp);
let third = (sorted.len() / 3).max(1);
let plateau: f32 = sorted[sorted.len() - third..].iter().sum::<f32>() / third as f32;
let whole: f32 = speeds.iter().sum::<f32>() / speeds.len() as f32;
// Last frame still at plateau speed = the handoff, just before the catch slows the deck.
let handoff = speeds
.iter()
.rposition(|&s| s >= 0.9 * plateau)
.unwrap_or(speeds.len() - 1);
println!(
" plateau={:.2}deg/frame whole-clip mean={:.2} ratio={:.2} handoff frame={handoff}/{}",
plateau.to_degrees(),
whole.to_degrees(),
whole / plateau,
speeds.len()
);
println!(
" one rung of {} frames at the plateau sweeps {:.0}deg (clip path {:.0}deg)",
clip.frames.len(),
(plateau * clip.frames.len() as f32).to_degrees(),
path.to_degrees()
);
// ---- 7. The pivot. The spin turns the deck about SKATEBOARD_ROOT's origin; if that is not
// near the midpoint of the trucks the synthesised spin orbits the wrong point.
let origin = |bone: usize| globals[handoff.min(globals.len() - 1)][bone].1;
let root = origin(board);
let trucks: Vec<(usize, Vec3)> = (board..first_helper)
.filter(|&b| frames.bone_names[b].contains("TRUCK"))
.map(|b| (b, origin(b)))
.collect();
print!(
" pivot: root=[{:6.3} {:6.3} {:6.3}]",
root[0], root[1], root[2]
);
if trucks.len() == 2 {
let mid = [
(trucks[0].1[0] + trucks[1].1[0]) * 0.5,
(trucks[0].1[1] + trucks[1].1[1]) * 0.5,
(trucks[0].1[2] + trucks[1].1[2]) * 0.5,
];
let off = length([mid[0] - root[0], mid[1] - root[1], mid[2] - root[2]]);
print!(
" truck mid=[{:6.3} {:6.3} {:6.3}] offset={off:.3}m{}",
mid[0],
mid[1],
mid[2],
if off > 0.08 { " <== root is not the deck centre" } else { "" }
);
}
// ---- 8. The two-axis model, and whether it reproduces the authored clip.
//
// This is the acid test. If `R_up(theta) * Q_anchor * R_e(phi)` reconstructs every authored
// frame to within a couple of degrees, then the model *is* the trick and a hold built on it
// keeps the flip and the shuvit both. A single fixed axis cannot: the sum above points at
// world up, so it would synthesise a pure shuvit.
let up = [0.0, 1.0, 0.0];
let (yaw, flip, flip_axis, stability) = decompose(&globals, bone, up);
let total_yaw: f32 = yaw.iter().sum();
let total_flip: f32 = flip.iter().sum();
println!(
" TWO-AXIS yaw(up)={:7.1}deg flip={:7.1}deg about deck axis=[{:6.3} {:6.3} {:6.3}] stability={stability:.3}",
total_yaw.to_degrees(),
total_flip.to_degrees(),
flip_axis[0], flip_axis[1], flip_axis[2],
);
// Reconstruct and report the worst frame error.
let mut worst = 0f32;
let mut theta = 0f32;
let mut phi = 0f32;
for i in 0..globals.len() {
let model = mul(mul(axis_angle(up, theta), norm(globals[0][bone].0)), axis_angle(flip_axis, phi));
let actual = norm(globals[i][bone].0);
let m = if qdot(model, actual) < 0.0 { [-model[0], -model[1], -model[2], -model[3]] } else { model };
worst = worst.max(length(rotation_vector(mul(actual, conj(m)))));
if i < yaw.len() {
theta += yaw[i];
phi += flip[i];
}
}
println!(
" reconstruction worst error={:.2}deg => {}",
worst.to_degrees(),
if worst.to_degrees() < 8.0 {
"MODEL FITS -- synthesise yaw + flip"
} else {
"model does not fit -- do not build on it"
}
);
let yplateau = {
let mut v: Vec<f32> = yaw.iter().map(|x| x.abs()).collect();
v.sort_by(f32::total_cmp);
let t = (v.len() / 3).max(1);
v[v.len() - t..].iter().sum::<f32>() / t as f32
};
let fplateau = {
let mut v: Vec<f32> = flip.iter().map(|x| x.abs()).collect();
v.sort_by(f32::total_cmp);
let t = (v.len() / 3).max(1);
v[v.len() - t..].iter().sum::<f32>() / t as f32
};
println!(
" plateau rates: yaw={:.2}deg/frame flip={:.2}deg/frame (signs: yaw {} flip {})",
yplateau.to_degrees(), fplateau.to_degrees(),
if total_yaw >= 0.0 { "+" } else { "-" },
if total_flip >= 0.0 { "+" } else { "-" },
);
// ---- 9. DELTA REPLAY: the model that needs no axis at all.
//
// Neither a single fixed axis nor a yaw+flip decomposition reconstructs this clip. But the
// complaint was never about the *shape* of the authored rotation -- it is the authored trick,
// and it is what the owner wants continued. The complaint is that replaying the clip replays
// its **acceleration**: a ramp in, a plateau, then the catch slowing the deck.
//
// So keep the authored angular velocity *directions* and flatten only their magnitudes to the
// plateau rate, then compose them incrementally. Composing deltas rather than setting
// orientations means the deck's orientation is continuous by construction -- there is no loop
// to close, no window to trim and no seam to cross-fade, which is what the four resampling
// attempts each broke on. And the direction is inherited from the clip, so it cannot invert.
// Whole clip, no window detection: keep every authored direction, flatten every magnitude
// to the plateau rate. Window detection proved unstable -- some variants are near-uniform and
// the detector collapsed to three frames -- and it is not needed, because flattening the
// magnitudes is what removes the ramp and the catch.
let flatten = |dirs: &[Vec3]| -> Vec<Vec3> {
dirs.iter()
.map(|r| {
let u = unit(*r);
[u[0] * plateau, u[1] * plateau, u[2] * plateau]
})
.collect()
};
// A 3-tap mean over the direction sequence, which is what "smooth" has to mean concretely.
let smooth = |dirs: &[Vec3]| -> Vec<Vec3> {
(0..dirs.len())
.map(|i| {
let a = dirs[if i == 0 { dirs.len() - 1 } else { i - 1 }];
let b = dirs[i];
let c = dirs[(i + 1) % dirs.len()];
unit(add3(add3(unit(a), unit(b)), unit(c)))
})
.collect()
};
let report = |label: &str, w: &[Vec3]| {
let mut worst_turn = 0f32;
for pair in w.windows(2) {
worst_turn = worst_turn.max(dot3(unit(pair[0]), unit(pair[1])).clamp(-1., 1.).acos());
}
// Wrap-around turn too: this is the junction between one cycle and the next.
let wrap = dot3(unit(w[w.len() - 1]), unit(w[0])).clamp(-1., 1.).acos();
let mut q = [0., 0., 0., 1.0f32];
for r in w {
q = norm(mul(axis_angle(*r, length(*r)), q));
}
println!(
" {label:<22} frames={:>3} path/cycle={:>5.0}deg worst turn={:>5.1}deg wrap turn={:>5.1}deg precession={:>4.0}deg",
w.len(),
w.iter().map(|r| length(*r)).sum::<f32>().to_degrees(),
worst_turn.to_degrees(),
wrap.to_degrees(),
length(rotation_vector(q)).to_degrees(),
);
};
println!(" DELTA REPLAY (compose per-frame deltas; orientation continuous by construction)");
report("authored dirs", &flatten(&d));
report("3-tap smoothed", &flatten(&smooth(&d)));
report("5-tap smoothed", &flatten(&smooth(&smooth(&d))));
println!(
" one rung of {} frames at the flattened rate sweeps {:.0}deg of path (authored clip {:.0}deg)",
clip.frames.len(),
(plateau * clip.frames.len() as f32).to_degrees(),
path.to_degrees()
);
println!();
println!();
}
}
+197 -10
View File
@@ -18,6 +18,38 @@ pub(crate) struct PoseEvaluator {
pub frames: AnimationFrames,
authored: authored_clips::Replacements,
mod_clips: std::sync::RwLock<std::collections::BTreeMap<String, authored_clips::Replacements>>,
body_bones: std::sync::OnceLock<Vec<usize>>,
/// Endless Tricks: the body-hold clip a repeated rotation should park the legs in, and the
/// compositions already built for it, keyed by the air clip they were built against.
///
/// Composed on demand rather than from a table, because which air clip is playing -- the high
/// or the low variant -- is chosen by an authored selector. Asking the clip that is actually
/// playing is exact; a hardcoded list of air-clip names would be a guess, and guessing clip
/// names is what put a hold pose on a repeat in the first place.
endless_hold: std::sync::RwLock<Option<EndlessHold>>,
}
/// The first rung that carries a held body. `rung` is 1-based and the hold needs `loops > 0`.
const FIRST_HELD_RUNG: u32 = 2;
/// How long the held body takes to arrive, in seconds of clip time.
///
/// Matched to `endless_flip::LOOP_BLEND_SECONDS`, which was swept against the same jolt measurement
/// and found to be the shortest window that hides a junction without costing air time.
const HOLD_EASE_SECONDS: f32 = 0.08;
#[derive(Default)]
struct EndlessHold {
hold_clip: String,
/// The rung being flown, so the hold can advance across rungs rather than within one.
rung: u32,
/// The trick stem whose own clips the hold applies to, e.g. `360FLIP`.
///
/// Without it the hold reached every clip that played while a rung was live -- the catch, the
/// out clip, `BLEND_LAND`, the ride-away -- so the skater rode off still holding the pose. The
/// hold belongs to the rotation, not to the rung.
trick: String,
composed: std::collections::BTreeMap<String, ClipFrames>,
}
impl PoseEvaluator {
@@ -31,6 +63,8 @@ impl PoseEvaluator {
frames: AnimationFrames::from_banks(banks)?,
authored: Default::default(),
mod_clips: Default::default(),
body_bones: Default::default(),
endless_hold: Default::default(),
})
}
@@ -39,6 +73,104 @@ impl PoseEvaluator {
Ok(())
}
/// Sample the composed body hold for `stock`, composing it on first use.
///
/// A composition that cannot be built drops the hold rather than failing the frame: the result
/// is the stock performance, which is the behaviour this replaces, and the game keeps running.
/// It says so once, because a hold that silently does nothing is the bug being fixed here.
fn sample_endless_hold(
&self,
stock: &ClipFrames,
time: f32,
previous_time: f32,
) -> Option<(Vec<Sqt>, Option<Sqt>)> {
let mut held = self.endless_hold.write().ok()?;
let state = held.as_mut()?;
// Only the trick's own rotation is held. Its catch, its out clip and the landing are stock,
// which is what lets the skater ride away in the ordinary animations.
if !stock.name.contains(&state.trick) {
return None;
}
if !state.composed.contains_key(&stock.name) {
match authored_clips::hold_body(&self.frames, &stock.name, &state.hold_clip, state.rung) {
Ok(composed) => {
state.composed.insert(stock.name.clone(), composed);
}
Err(error) => {
eprintln!(
"SKATE LIMITATION: no endless body hold for {} over {}: {error}",
stock.name, state.hold_clip
);
*held = None;
return None;
}
}
}
let clip = state.composed.get(&stock.name)?;
let mut pose = sample_clip(clip, time).ok()?;
// **Ease the hold in**, over the first held rung only.
//
// Measured, and worth stating precisely because the obvious reading is wrong. The worst
// single-tick skeleton jump through a held 360 flip is 0.665 m against the authored trick's
// own 0.227, and it is **not the deck**: the jolt probe reports the part index, and it is bone
// 3. It is also identical with `SKATE_ENDLESS_RIGID_SPIN=0`, so the synthesised spin neither
// causes it nor makes it worse.
//
// There are two mechanisms, not one. The larger is here -- the body arriving from mid-flick
// into a held pose in a single tick, on the first held rung -- and easing it over `time`
// measured 0.665 to 0.606 on the 360 flip and 0.664 to 0.474 on the laserflip. The remainder
// is the air clip **restarting from frame 0** on every rung, which shows as a 0.27-0.43 m
// step recurring on the clip's own 28-tick period and is covered, imperfectly, by the
// authored cross-fade `endless_flip` installs. That one needs the rung's `InTime` plumbed
// through so a rung can be baked knowing it will be cut early; it is recorded in
// `docs/engine-defects.md` rather than half-fixed here.
//
// `time` is the ramp rather than a tick counter because a clip can be sampled more than once
// inside a blend tree, which would double-count. Later rungs need no ease: the compositions
// are continuous across rungs by construction.
if state.rung <= FIRST_HELD_RUNG && time < HOLD_EASE_SECONDS {
let weight = (time / HOLD_EASE_SECONDS).clamp(0.0, 1.0);
if let Ok(from) = sample_clip(stock, time) {
if from.len() == pose.len() {
let mut eased = pose.clone();
if pose_blend::blend(&from, &pose, weight, &mut eased).is_ok() {
pose = eased;
}
}
}
}
let previous = self
.frames
.has_trajectory
.then(|| sample_bone(clip, previous_time, 0).ok())
.flatten();
Some((pose, previous))
}
/// Endless Tricks: park the body in `hold` while any clip plays, or clear the hold.
///
/// Called every tick from the animation phase, so it must stay cheap when nothing changes: the
/// compositions are kept across ticks and only dropped when the hold clip itself changes.
pub(crate) fn set_endless_hold(&self, hold: Option<(&str, &str, u32)>) {
let Ok(mut current) = self.endless_hold.write() else {
return;
};
match (hold, current.as_ref()) {
// The rung is part of the identity: each one carries the body loop a little further, so
// a new rung needs its own composition rather than the previous rung's.
(Some((name, _, rung)), Some(live)) if live.hold_clip == name && live.rung == rung => {}
(Some((name, trick, rung)), _) => {
*current = Some(EndlessHold {
hold_clip: name.to_owned(),
rung,
trick: trick.to_owned(),
composed: Default::default(),
})
}
(None, _) => *current = None,
}
}
/// Reuses the existing constrained body-clip parser; never replaces board/trajectory data.
pub(crate) fn install_mod_clips(&self, owner: &str, text: &str) -> Result<(), String> {
let clips = authored_clips::Replacements::parse(text, &self.frames)?;
@@ -65,6 +197,26 @@ impl PoseEvaluator {
}
}
/// The rig's body bones: everything from the hips down to the board root, plus the
/// reparented hand and toe helpers. This is the same split `authored_clips` uses to replace a
/// body animation while leaving the board's trajectory alone, which is what makes it possible
/// to hold a pose while the board keeps moving underneath it.
pub(crate) fn body_bones(&self) -> &[usize] {
self.body_bones.get_or_init(|| {
let index = |name: &str| self.frames.bone_names.iter().position(|n| n == name);
let (Some(hips), Some(board)) = (index("HIPS"), index("SKATEBOARD_ROOT")) else {
return Vec::new();
};
let helpers = [
"RIGHTTOEBASE_REPARENTED",
"LEFTTOEBASE_REPARENTED",
"RIGHTHAND_REPARENTED",
"LEFTHAND_REPARENTED",
];
(hips..board).chain(helpers.iter().filter_map(|n| index(n))).collect()
})
}
/// 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<Vec<Sqt>, String> {
@@ -123,20 +275,37 @@ impl PoseEvaluator {
loops,
} => {
let stock = self.frames.clip(name)?;
let clip = mod_clips
.values()
.find_map(|c| c.clip(&stock.name))
.or_else(|| self.authored.clip(&stock.name))
.unwrap_or(stock);
let mut pose = sample_clip(clip, *time)?;
if self.frames.has_trajectory {
let previous = sample_bone(clip, *previous_time, 0)?;
trace_clip(&stock.name);
// Every substitution path copies the stock clip's loop transform, so the
// trajectory terms are read from the stock clip whichever one is sampled.
let (mut pose, previous) = match self.sample_endless_hold(
stock,
*time,
*previous_time,
) {
Some(sampled) => sampled,
None => {
let clip = mod_clips
.values()
.find_map(|c| c.clip(&stock.name))
.or_else(|| self.authored.clip(&stock.name))
.unwrap_or(stock);
let pose = sample_clip(clip, *time)?;
let previous = self
.frames
.has_trajectory
.then(|| sample_bone(clip, *previous_time, 0))
.transpose()?;
(pose, previous)
}
};
if let Some(previous) = previous {
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),
rotation: stock.loop_rotation_bits.map(f32::from_bits),
translation: stock.loop_translation_bits.map(f32::from_bits),
}),
);
}
@@ -202,6 +371,24 @@ impl PoseEvaluator {
}
}
/// Names every distinct bank clip sampled, once each, under `SKATE_CLIP_TRACE`.
///
/// The graph names trees -- `B_360FLIP_A`, `B_KICKFLIP_CYC2` -- while the bank holds the clips a
/// selector resolves those to. Guessing across that gap is what put a hold pose on a repeat, so the
/// gap is instrumented rather than inferred.
fn trace_clip(name: &str) {
static SEEN: std::sync::OnceLock<Option<std::sync::Mutex<std::collections::BTreeSet<String>>>> =
std::sync::OnceLock::new();
let seen = SEEN.get_or_init(|| std::env::var_os("SKATE_CLIP_TRACE").map(|_| Default::default()));
if let Some(seen) = seen {
if let Ok(mut seen) = seen.lock() {
if seen.insert(name.to_owned()) {
eprintln!("SKATE_CLIP {name}");
}
}
}
}
fn sample_clip(clip: &ClipFrames, time: f32) -> Result<Vec<Sqt>, String> {
let selection = select_frames(
time,
@@ -91,32 +91,17 @@ impl Replacements {
}
globals.push(m);
}
// Reconstruct the stock board hierarchy to express helper targets in its moving space.
let mut stock_globals = vec![Mat4::IDENTITY; row.len()];
for bone in 1..row.len() {
let parent = frames.parents[bone];
let local = matrix(pose_add::add(sqt(row[bone]), sqt(bind[bone]), true));
stock_globals[bone] = if parent > 0 {
stock_globals[parent as usize] * local
} else {
local
};
}
for bone in board..helpers.iter().map(|p| p.0).min().unwrap() {
globals[bone] = stock_globals[bone];
}
for &(helper, joint) in &helpers {
globals[helper] = globals[joint];
}
for bone in (hips..board).chain(helpers.iter().map(|p| p.0)) {
let parent = frames.parents[bone];
let local = if parent > 0 {
globals[parent as usize].inverse() * globals[bone]
} else {
globals[bone]
};
row[bone] = words(remove_reference(local, sqt(bind[bone])));
}
let board_globals = row_globals(row, frames, bind);
compose_row(
row,
&mut globals,
&board_globals,
frames,
bind,
hips,
board,
&helpers,
);
}
output.insert(
name.clone(),
@@ -138,6 +123,458 @@ impl Replacements {
self.0.get(name)
}
}
/// Where a clip's rotation lives and which axis the hold spins about, under `SKATE_CLIP_TRACE`.
///
/// This is the measurement that settled how to do this at all. It showed every bone of the board
/// rotating by the same amount -- trucks and wheels within a degree of each other -- which is what
/// established the deck as a rigid body and made spinning one pose the right answer instead of
/// resampling a clip that holds no whole number of revolutions. Kept because the next person to
/// doubt that should be able to re-measure it in one run rather than infer it.
fn trace_spin(
clip: &ClipFrames,
frames: &AnimationFrames,
bind: &[SampleWords],
board: usize,
axis: Vec3,
) {
if std::env::var_os("SKATE_CLIP_TRACE").is_none() {
return;
}
let bones = frames.bone_names.len();
let mut totals = vec![0f32; bones];
for pair in clip.frames.windows(2) {
let before = row_globals(&pair[0], frames, bind);
let after = row_globals(&pair[1], frames, bind);
for bone in 0..bones.min(before.len()).min(after.len()) {
totals[bone] += Quat::from_mat4(&before[bone])
.normalize()
.angle_between(Quat::from_mat4(&after[bone]).normalize());
}
}
let mut ranked: Vec<(usize, f32)> = totals.iter().copied().enumerate().collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
let top: Vec<String> = ranked
.iter()
.take(5)
.map(|&(bone, degrees)| {
format!(
"{}={:.0}",
frames.bone_names.get(bone).map_or("?", String::as_str),
degrees.to_degrees()
)
})
.collect();
eprintln!(
"SKATE_SPIN {} board={} axis=[{:.2} {:.2} {:.2}] top=[{}]",
clip.name,
frames.bone_names.get(board).map_or("?", String::as_str),
axis.x,
axis.y,
axis.z,
top.join(" ")
);
}
/// How much of the hold clip one rung traverses, as a fraction.
///
/// Small on purpose: the hold is meant to read as held, with only slight natural movement before the
/// stick comes back in. Traversing the whole cycle per rung is what made it jitter.
/// `SKATE_ENDLESS_HOLD_SWEEP` tunes it, and 0 freezes the pose outright.
fn hold_sweep() -> f32 {
std::env::var("SKATE_ENDLESS_HOLD_SWEEP")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| (0.0..=1.0).contains(v))
.unwrap_or(0.12)
}
/// Multiplier on the **measured** spin rate. 1.0 is the plateau speed the authored trick rotates at.
///
/// The default is **below** 1.0 on the owner's judgement from play: the trick's own plateau is
/// 20.85 deg/frame, and held indefinitely that reads as spinning rather than floating. 0.70 of it is
/// about 875 deg/s, still clearly a 360 flip turning over but with air under it. Measured plateau
/// stays the reference point so the number means something; this says "seven tenths of the trick".
///
/// This used to be a divisor on the baked frame count, which was broken in both directions: the
/// clip's playback length comes from the bank metadata rather than from `ClipFrames`, and
/// `sample_clip` clamps, so a shortened clip froze the deck for the rest of the rung and a
/// lengthened one dropped its tail and jumped at the junction. The frame count is now always the
/// stock one and this scales the angle swept instead.
fn spin_rate() -> f32 {
std::env::var("SKATE_ENDLESS_SPIN_RATE")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| (0.1..=4.0).contains(v))
.unwrap_or(0.70)
}
/// Per-rung slowing of the held spin. 1.0 never slows.
///
/// Applied as a rate per rung, with the angle accumulated across rungs, so the spin stays continuous:
/// rung `n + 1` begins exactly where rung `n` left off, one frame-step on.
///
/// **0.90, not the 0.97 this started at.** 0.97 is 3% per rung, which is a real ramp arithmetically
/// and invisible in play -- the owner watched it and asked whether there was a ramp at all. 0.90
/// halves the spin by the seventh rung and reaches the floor by about the eleventh, which reads as the
/// deck winding down. `MIN_SPIN_FRACTION` stops it ever reading as parked.
fn spin_decay() -> f32 {
std::env::var("SKATE_ENDLESS_SPIN_DECAY")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.filter(|v| (0.5..=1.0).contains(v))
.unwrap_or(0.90)
}
/// A measured rigid spin: which way the deck turns, and how fast, in radians per animation frame.
#[derive(Clone, Copy, Debug)]
pub(super) struct Spin {
pub axis: Vec3,
/// Radians per frame. Deliberately per *frame*, not per second: `sample_clip` maps time to a
/// frame index with `fps` alone while the clip clock's length also carries `base_speed`, so
/// anything reasoned in seconds can desynchronise from the index actually being baked.
pub rate: f32,
}
/// The signed rotation `b` applied after `a`, as an axis-angle vector, in world space.
///
/// Two details carry the whole direction fix. The product is `b * a^-1` (left-multiplied), which is
/// a *world*-frame delta -- `a^-1 * b` would express the axis in the deck's own coordinates and be
/// wrong to use as a fixed world axis. And `b` is negated when it lies in the opposite hemisphere to
/// `a`, because a quaternion and its negation are the same rotation: without that, a delta comes back
/// as its 360-degree complement and the axis points backwards.
fn world_delta(a: Quat, b: Quat) -> Vec3 {
let a = a.normalize();
let b = b.normalize();
let b = if a.dot(b) < 0.0 { -b } else { b };
let delta = (b * a.inverse()).normalize();
let (axis, angle) = delta.to_axis_angle();
axis * angle
}
/// Measure the deck's spin from the authored board channel.
///
/// **Why this is measured rather than chosen, and what was measured.** Running
/// `cargo run -p skate-data --example spin_measure -- <assets>` over every clip in the family prints
/// the numbers below; `360FLIP_D_HIGH_A` is representative.
///
/// * The axis used to come from `Quat::to_axis_angle` of the net first-to-last-frame rotation. A
/// 494-degree path is not recoverable from its endpoints, and measurement shows that axis is
/// **inverted**: `dot(endpoint, accumulated) = -0.60` on `_HIGH_A`, -0.76 on `_LOW_A`, -0.89 on
/// `GONZ_HIGH_A`. That is the owner's "the board goes in the wrong direction", as a number.
/// * Summing the per-frame rotation *vectors* cannot invert, because a signed sum has no axis-sign
/// ambiguity to resolve. That sum is `360.6` degrees about `[-0.44, 0.72, -0.53]` -- one clean
/// revolution about an axis tilted **43.6 degrees** from vertical, which is exactly the geometry of
/// a kickflip combined with a 360 shuvit. So a constant-rate turn about it reads as a 360 flip
/// tumbling rather than as a board spinning about one obvious axis.
/// * The *instantaneous* axis does wander: `|sum r| / sum |r|` is 0.70-0.73 across the family, near
/// the `1/sqrt(2)` a matched flip-plus-shuvit predicts, and the authored path is 494 degrees where
/// the net is 360. So this is an **idealisation**, chosen deliberately: replaying the authored
/// directions instead carries 15-49 degrees per frame of direction wobble plus a 20-101 degree
/// kink where the sequence repeats, and "floaty" and "smooth" is what was asked for. A fitted
/// yaw-plus-flip model was also tried and reconstructs the clip no better than 65 degrees, so it
/// buys nothing for the complexity.
/// * The rate is the mean of the fastest third of frames -- the plateau. The whole-clip mean is
/// 0.74-0.90 of it, because the clip ramps in off the flick and the catch slows the deck at the
/// end, and one turn per clip length (what the code did before) is slower still: 654 degrees per
/// second against the plateau's 1251. That is why every rung read as sluggish.
///
/// Returns `None` when the board barely turns, so the caller can fall back to the authored motion
/// rather than synthesise from noise.
pub(super) fn measure_spin(rotations: &[Quat]) -> Option<Spin> {
if rotations.len() < 3 {
return None;
}
let deltas: Vec<Vec3> = rotations
.windows(2)
.map(|pair| world_delta(pair[0], pair[1]))
.collect();
let axis = deltas
.iter()
.fold(Vec3::ZERO, |sum, d| sum + *d)
.normalize_or_zero();
if axis.length_squared() <= f32::EPSILON {
return None;
}
let mut magnitudes: Vec<f32> = deltas.iter().map(|d| d.length()).collect();
magnitudes.sort_by(f32::total_cmp);
let third = (magnitudes.len() / 3).max(1);
let rate = magnitudes[magnitudes.len() - third..].iter().sum::<f32>() / third as f32;
(rate > 1e-4).then_some(Spin { axis, rate })
}
/// Where rung `hold_index` starts, and how fast it turns, given a measured spin.
///
/// `hold_index` is 0 for the first *synthesised* rung. Returns `(base_angle, rate_per_frame)`.
/// Because the base accumulates every preceding rung's swept angle, the step across a rung junction
/// is the same as the step inside a rung -- so the spin does not have to complete a whole number of
/// turns per rung. That is what dissolves the loop-closure problem the four resampling attempts each
/// broke on: there is no loop to close, only an angle that keeps going up.
pub(super) fn spin_angle(spin: Spin, hold_index: u32, frames_per_rung: usize, decay: f32) -> (f32, f32) {
// Summed rather than closed-form, because of the floor below: a floored rate no longer follows a
// geometric series, and a closed form that ignored that would put the base angle somewhere the
// preceding rungs did not actually reach -- which is a step at the junction, the exact artefact
// this design exists to avoid. Sixteen iterations at most, once per rung, so the cost is nothing.
let mut base = 0.0;
let mut rate = rate_for(spin, 0, decay);
for index in 0..hold_index {
base += rate_for(spin, index, decay) * frames_per_rung as f32;
rate = rate_for(spin, index + 1, decay);
}
(base, rate)
}
/// The spin never decays below this fraction of its measured rate.
///
/// A deck that slows to a crawl reads as *parked*, which is the failure that started this whole
/// thread -- and with twelve rungs available a per-rung multiplier reaches a crawl easily. At the
/// default decay the floor is first hit around the eleventh rung.
const MIN_SPIN_FRACTION: f32 = 0.25;
fn rate_for(spin: Spin, hold_index: u32, decay: f32) -> f32 {
let decayed = spin.rate * decay.powi(hold_index as i32);
decayed.max(spin.rate * MIN_SPIN_FRACTION)
}
/// Bone locals -> globals for one bank row.
fn row_globals(row: &[SampleWords], frames: &AnimationFrames, bind: &[SampleWords]) -> Vec<Mat4> {
let mut globals = vec![Mat4::IDENTITY; row.len()];
for bone in 1..row.len() {
let parent = frames.parents[bone];
let local = matrix(pose_add::add(sqt(row[bone]), sqt(bind[bone]), true));
globals[bone] = if parent > 0 {
globals[parent as usize] * local
} else {
local
};
}
globals
}
/// Take a stock row's board and a second pose's body, and write the result back into the row.
///
/// The helpers are the subtle part and the reason this is one function rather than two: bones
/// 32-35 are reparented into the board's *moving* space, so they have to be snapped to the joints
/// they shadow **after** the board range has been restored from the stock clip. Freezing them
/// instead is what once glued the back foot to the deck.
fn compose_row(
row: &mut [SampleWords],
globals: &mut [Mat4],
board_globals: &[Mat4],
frames: &AnimationFrames,
bind: &[SampleWords],
hips: usize,
board: usize,
helpers: &[(usize, usize); 4],
) {
// The board hierarchy the helpers are expressed in. Normally the row's own, but a synthesised
// deck passes its own here so the reparented helpers follow the board they can actually see.
for bone in board..helpers.iter().map(|p| p.0).min().unwrap() {
globals[bone] = board_globals[bone];
}
for &(helper, joint) in helpers {
globals[helper] = globals[joint];
}
for bone in (hips..board).chain(helpers.iter().map(|p| p.0)) {
let parent = frames.parents[bone];
let local = if parent > 0 {
globals[parent as usize].inverse() * globals[bone]
} else {
globals[bone]
};
row[bone] = words(remove_reference(local, sqt(bind[bone])));
}
}
/// Endless Tricks: the trick's own air clip for the board, a second bank clip for the body.
///
/// This is what makes a repeated 360 flip or laserflip visible. Retail authors no rotating hold for
/// that family -- `T_TrickWithDarkCatch.xml` has one air clip and no cycle states -- and the only
/// cycle content in the bank for it, `T_<trick>_H_CYC`, is a hold *pose*: measured, it moves the
/// board a tenth as much as the kickflip's cycle does. Playing it on a repeat therefore parked the
/// board, which is why only the first rotation was ever visible.
///
/// So the repeat keeps playing the air clip, which is the thing that actually turns the deck, and
/// only the body is replaced -- the legs park in the hold pose instead of re-performing the flick.
/// The hold is sampled proportionally, because it is longer than the air clip and a hold wants a
/// pose rather than a performance.
pub(super) fn hold_body(
frames: &AnimationFrames,
air: &str,
hold: &str,
rung: u32,
) -> Result<ClipFrames, String> {
let bind = &frames.named_pose("RIG_TPOSE")?.samples;
let index = |name: &str| {
frames
.bone_names
.iter()
.position(|n| n == name)
.ok_or_else(|| format!("Missing {name}"))
};
let hips = index("HIPS")?;
let board = index("SKATEBOARD_ROOT")?;
let helpers = [
(index("RIGHTTOEBASE_REPARENTED")?, index("RIGHTTOEBASE")?),
(index("LEFTTOEBASE_REPARENTED")?, index("LEFTTOEBASE")?),
(index("RIGHTHAND_REPARENTED")?, index("RIGHTHAND")?),
(index("LEFTHAND_REPARENTED")?, index("LEFTHAND")?),
];
let stock = frames.clip(air)?;
let held = frames.clip(hold)?;
if held.frames.is_empty() || stock.frames.is_empty() {
return Err(format!("{air}/{hold}: an empty clip cannot be composed"));
}
// **The deck is spun, not resampled.**
//
// Four attempts at retiming the authored frames each traded one artefact for another: replaying
// from frame 0 replayed the flick, starting at an "at speed" frame only moved where the ramp sat,
// evening out the rate surged at the seam because the clip holds no whole number of revolutions,
// and cutting it to a window that closes chopped visible motion. The authored board channel
// simply is not loopable material.
//
// It does not have to be. Measured from the clip itself, every bone of the board rotates by the
// same amount -- `TRUCK_BACK` 494.3 degrees, `LEFT_WHEELBACK` 494.3, the wheels and
// `SKATEBOARD_ROOT` the same to a tenth of a degree -- so the deck moves as one rigid body
// through the trick. One pose of it, turned at a steady rate, is therefore all a hold needs, and
// it is smooth by construction: nothing is resampled, there is no seam to cover and no window to
// trim. `measure_spin` records which numbers that rests on and how they were taken.
let board_rotations: Vec<Quat> = stock
.frames
.iter()
.map(|row| {
row_globals(row, frames, bind)
.get(board)
.map(|m| m.to_scale_rotation_translation().1)
.unwrap_or(Quat::IDENTITY)
})
.collect();
// The rate multiplier is folded in **here**, into the measured spin, rather than applied to the
// per-rung rate afterwards. Applying it only to the rate left the accumulated base angle in
// unscaled units, so every rung junction stepped by the difference -- 139 degrees where the rung
// stepped 14. That was invisible while the multiplier defaulted to 1.0 and appeared the moment it
// did not, which is what `the_held_deck_turns_at_a_steady_rate_and_carries_across_rungs` is for.
let spin = measure_spin(&board_rotations).map(|s| Spin {
rate: s.rate * spin_rate(),
..s
});
let axis = spin.map_or(Vec3::Y, |s| s.axis);
trace_spin(stock, frames, bind, board, axis);
// The anchor is the **last** authored frame, not the first.
//
// Frame 0 is the deck still at the feet, pre-flick, so freezing it there parked a spinning deck
// inside a body whose legs are held clear -- a cause of the legs and the board intersecting that
// is separate from, and survives, the helper bug below. The last frame is where the authored
// rotation actually ends, so taking it as the rigid reference and starting the first synthesised
// rung at angle zero makes the hand-off out of the real trick continuous in **orientation and
// position both**, by construction rather than by cross-fade.
let anchor = row_globals(&stock.frames[stock.frames.len() - 1], frames, bind);
let pivot = anchor
.get(board)
.map_or(Vec3::ZERO, |m| m.w_axis.truncate());
// Always the stock frame count. See `spin_rate`: a clip's playback length comes from the bank
// metadata rather than from `ClipFrames`, and `sample_clip` clamps, so a shorter count froze the
// deck for the rest of the rung and a longer one dropped its tail and jumped at the junction.
let count = stock.frames.len();
// The body rides one long loop across rungs rather than replaying a cycle inside each one, which
// is what made it judder. A single rotation therefore shows almost no movement.
let sweep = hold_sweep();
let last_hold = held.frames.len() - 1;
// `rung` is 1-based and the hold only exists from the second rung on, so the first *synthesised*
// rung is index 0. This was unobservable while a rung was exactly a whole turn, because the base
// angle it feeds was then invisible. It is observable now.
let hold_index = rung.saturating_sub(2);
let base_hold = hold_index as f32 * sweep;
let (base_angle, rate) =
spin.map_or((0.0, 0.0), |s| spin_angle(s, hold_index, count, spin_decay()));
// **On by default**, and `SKATE_ENDLESS_RIGID_SPIN=0` goes back to replaying the authored board
// motion -- which is what shipped while two bugs here were outstanding. Both are now fixed, and
// both were worth the measurement that found them:
//
// * *Direction*: the axis came from the net rotation between the first and last frame. A
// ~494-degree path is not recoverable from its endpoints, and the measurement shows that axis is
// genuinely inverted -- `dot(endpoint, accumulated)` is -0.60 on `360FLIP_D_HIGH_A`, -0.76 on
// `_LOW_A`, -0.89 on `GONZ_HIGH_A`. That was the owner's "the board goes in the wrong
// direction", as a number. It is accumulated from the per-frame deltas now.
// * *Legs wrapping*: the board locals were rewritten in a second pass over `board..row.len()`,
// which includes the four reparented helpers -- bones 32-35, which live in the board's
// **moving** space -- so the hands and toes were welded to the synthesised deck at their
// frame-0 offsets. Same class of fault as the one that once glued the back foot to the deck.
// The writeback stops at the first helper now, so `compose_row`'s snap is what survives.
let synthesise =
spin.is_some() && std::env::var("SKATE_ENDLESS_RIGID_SPIN").map_or(true, |v| v != "0");
let mut baked: Vec<Vec<SampleWords>> = Vec::with_capacity(count);
for position in 0..count {
let through = position as f32 / count as f32;
let mut row = if synthesise {
let mut row = stock.frames[stock.frames.len() - 1].clone();
// Carry the authored trajectory through. Repeating one frame's bone 0 for the whole rung
// would zero the clip's root motion for the duration of the hold, which is a change to
// behaviour nobody asked for and which no other part of this is responsible for.
row[0] = stock.frames[position][0];
row
} else {
stock.frames[position].clone()
};
let board_globals: Vec<Mat4> = if synthesise {
let turned = Quat::from_axis_angle(axis, base_angle + rate * position as f32);
let correction = Mat4::from_translation(pivot)
* Mat4::from_quat(turned)
* Mat4::from_translation(-pivot);
anchor.iter().map(|m| correction * *m).collect()
} else {
row_globals(&row, frames, bind)
};
// Clamped, not wrapped. `fract()` sent the body back to the hold clip's first frame as soon
// as `base_hold` passed 1, which happens on the ninth rung -- inside the default cap of
// twelve -- and showed as the legs snapping, in the one channel a hold exists to keep still.
let at =
(((base_hold + sweep * through).min(1.0) * last_hold as f32) as usize).min(last_hold);
if held.frames[at].len() != row.len() {
return Err(format!("{air}/{hold}: skeletons differ"));
}
let mut globals = row_globals(&held.frames[at], frames, bind);
compose_row(
&mut row,
&mut globals,
&board_globals,
frames,
bind,
hips,
board,
&helpers,
);
if synthesise {
// **Stops at the first helper.** `compose_row` has just snapped bones 32-35 onto the body
// joints they shadow; running over them here is exactly what wrapped the legs around the
// deck, because their parents are in the board subtree.
for bone in board..helpers.iter().map(|p| p.0).min().unwrap() {
let parent = frames.parents[bone];
let local = if parent > 0 {
board_globals[parent as usize].inverse() * board_globals[bone]
} else {
board_globals[bone]
};
row[bone] = words(remove_reference(local, sqt(bind[bone])));
}
}
baked.push(row);
}
Ok(ClipFrames {
name: stock.name.clone(),
source_offset: stock.source_offset,
fps_bits: stock.fps_bits,
loop_translation_bits: stock.loop_translation_bits,
loop_rotation_bits: stock.loop_rotation_bits,
channel_animation: stock.channel_animation,
channel_weights: stock.channel_weights.clone(),
frames: baked,
})
}
fn matrix(s: Sqt) -> Mat4 {
Mat4::from_scale_rotation_translation(
Vec3::from_slice(&s.scale),
@@ -175,6 +612,230 @@ fn words(s: Sqt) -> SampleWords {
.map(f32::to_bits)
}
/// The measured spin, independent of any asset.
///
/// These need no banks and so are not `#[ignore]`d, unlike everything else in this file. That is
/// deliberate: both of the bugs these pin were found by eye, after a rebuild, by the owner. Each is
/// now a failing assertion in a suite that runs in a second.
#[cfg(test)]
mod spin_tests {
use super::*;
/// A clip whose board turns `total` radians about `axis` in `frames` even steps.
fn turning(axis: Vec3, total: f32, frames: usize) -> Vec<Quat> {
(0..frames)
.map(|i| {
Quat::from_axis_angle(axis.normalize(), total * i as f32 / (frames - 1) as f32)
})
.collect()
}
/// Bug A, pinned from both sides: the accumulated axis finds the direction and the endpoint
/// expression the code used to use gets it backwards.
///
/// The axes are chosen with a dominant negative component, because that is what decides the sign
/// `Quat::to_axis_angle` hands back once the path passes a half turn -- which is why the old code
/// was wrong for the 360 flip and right for a smaller rotation.
#[test]
fn the_measured_axis_follows_the_path_where_the_endpoints_invert_it() {
// 494 degrees is the authored 360 flip's measured path.
let total = 494f32.to_radians();
for axis in [
Vec3::new(-0.439, 0.724, -0.531),
Vec3::new(-0.476, 0.746, -0.466),
Vec3::new(-0.690, -0.642, 0.334),
Vec3::new(0.9, -0.1, 0.2),
Vec3::NEG_Y,
] {
let axis = axis.normalize();
let rotations = turning(axis, total, 33);
let spin = measure_spin(&rotations).expect("a turning board has a spin");
assert!(
spin.axis.dot(axis) > 0.999,
"measured {:?} against {axis:?}",
spin.axis
);
let expected = total / 32.0;
assert!(
(spin.rate - expected).abs() < 1e-4,
"rate {} against {expected}",
spin.rate
);
// The expression this replaced, kept so the bug cannot come back quietly.
let net = (*rotations.last().unwrap() * rotations[0].inverse()).normalize();
let (endpoint, _) = net.to_axis_angle();
assert!(
endpoint.dot(axis) < 0.0,
"{axis:?}: the endpoint axis was supposed to be inverted here, got {endpoint:?}"
);
}
}
#[test]
fn reversing_the_path_reverses_the_measured_spin() {
let axis = Vec3::new(-0.439, 0.724, -0.531).normalize();
let forward = measure_spin(&turning(axis, 494f32.to_radians(), 33)).unwrap();
let mut reversed = turning(axis, 494f32.to_radians(), 33);
reversed.reverse();
let back = measure_spin(&reversed).unwrap();
assert!(forward.axis.dot(back.axis) < -0.999, "the spin did not reverse");
assert!((forward.rate - back.rate).abs() < 1e-4);
}
/// The rate is the plateau, not the whole-clip mean. Measured: the authored clips ramp in off the
/// flick and the catch slows the deck, so the mean is 0.74-0.90 of the plateau and one turn per
/// clip length -- what the code used to do -- is slower still.
#[test]
fn the_rate_is_the_plateau_and_not_the_mean() {
let axis = Vec3::Y;
let plateau = 20.85f32.to_radians();
// Ramp in over 6 frames, hold for 16, then halve for the catch, as the real profile does.
let steps: Vec<f32> = (0..6)
.map(|i| plateau * (i + 1) as f32 / 6.0)
.chain(std::iter::repeat_n(plateau, 16))
.chain(std::iter::repeat_n(plateau * 0.5, 10))
.collect();
let mut angle = 0.0;
let mut rotations = vec![Quat::IDENTITY];
for step in &steps {
angle += step;
rotations.push(Quat::from_axis_angle(axis, angle));
}
let spin = measure_spin(&rotations).unwrap();
assert!(
(spin.rate - plateau).abs() < 0.05 * plateau,
"{} against a plateau of {plateau}",
spin.rate
);
let mean = steps.iter().sum::<f32>() / steps.len() as f32;
assert!(
mean < 0.9 * plateau,
"the fixture does not discriminate: mean {mean} plateau {plateau}"
);
}
/// A still board has no spin to synthesise, so the caller keeps the authored motion.
#[test]
fn a_board_that_does_not_turn_has_no_spin() {
assert!(measure_spin(&[Quat::IDENTITY; 20]).is_none());
assert!(measure_spin(&[Quat::IDENTITY, Quat::IDENTITY]).is_none());
}
/// The continuity claim the whole design rests on: a rung joins the next with the same step it
/// runs at internally, so the spin need not complete a whole number of turns per rung.
#[test]
fn rungs_join_with_the_step_they_turn_at() {
let spin = Spin { axis: Vec3::Y, rate: 0.364 };
let count = 33;
for decay in [1.0, 0.90, 0.5] {
for index in 0..12u32 {
let (base, rate) = spin_angle(spin, index, count, decay);
let (next_base, _) = spin_angle(spin, index + 1, count, decay);
let last = base + rate * (count - 1) as f32;
assert!(
(next_base - (base + rate * count as f32)).abs() < 1e-4,
"decay {decay} rung {index}: {next_base} is not {} ",
base + rate * count as f32
);
assert!(
(next_base - last - rate).abs() < 1e-3,
"decay {decay} rung {index}: the junction steps {} where the rung steps {rate}",
next_base - last
);
}
}
}
/// The first synthesised rung starts where the authored trick ended -- angle zero -- so the
/// hand-off needs no cross-fade to hide an orientation jump.
#[test]
fn the_first_held_rung_starts_at_the_authored_orientation() {
let spin = Spin { axis: Vec3::Y, rate: 0.364 };
let (base, rate) = spin_angle(spin, 0, 33, 0.97);
assert_eq!(base, 0.0);
assert!((rate - spin.rate).abs() < 1e-6, "the first rung must not be decayed");
}
#[test]
fn the_spin_decays_monotonically_and_never_stops_or_reverses() {
let spin = Spin { axis: Vec3::Y, rate: 0.364 };
let mut previous = f32::INFINITY;
for index in 0..12u32 {
let (_, rate) = spin_angle(spin, index, 33, 0.90);
assert!(rate > 0.0, "rung {index} stopped");
assert!(rate <= previous, "rung {index} did not slow");
previous = rate;
}
// Visible by the seventh rung, which is what 0.97 failed to be.
let (_, seventh) = spin_angle(spin, 6, 33, 0.90);
assert!(
(0.4..0.65).contains(&(seventh / spin.rate)),
"the seventh rung runs at {} of the first",
seventh / spin.rate
);
// decay = 1 must be exactly constant.
let (_, a) = spin_angle(spin, 0, 33, 1.0);
let (_, b) = spin_angle(spin, 9, 33, 1.0);
assert_eq!(a, b);
}
/// Scaling the rate must scale the base angle with it.
///
/// The regression this pins reached play: the rate multiplier was applied to the per-rung rate but
/// not to the accumulated base, so every junction stepped by the mismatch -- 139 degrees against a
/// 14-degree rung. It hid for as long as the multiplier defaulted to 1.0. Linearity in the rate is
/// the property that makes the multiplier safe wherever it is applied.
#[test]
fn the_angle_scales_with_the_rate() {
for scale in [0.5, 0.7, 1.0, 2.0] {
for index in 0..6u32 {
let base_spin = Spin { axis: Vec3::Y, rate: 0.364 };
let scaled = Spin { rate: base_spin.rate * scale, ..base_spin };
let (base_a, rate_a) = spin_angle(base_spin, index, 33, 0.90);
let (base_b, rate_b) = spin_angle(scaled, index, 33, 0.90);
assert!(
(base_b - base_a * scale).abs() < 1e-3,
"scale {scale} rung {index}: base {base_b} is not {} ",
base_a * scale
);
assert!(
(rate_b - rate_a * scale).abs() < 1e-5,
"scale {scale} rung {index}: rate {rate_b} is not {}",
rate_a * scale
);
}
}
}
/// The deck must never read as parked, however many rungs are flown. A deck that stops is the
/// failure this whole mechanism was built to fix, so the floor is pinned rather than trusted.
#[test]
fn the_spin_never_decays_below_the_floor() {
let spin = Spin { axis: Vec3::Y, rate: 0.364 };
for decay in [0.5, 0.75, 0.90, 1.0] {
for index in 0..16u32 {
let (_, rate) = spin_angle(spin, index, 33, decay);
assert!(
rate >= spin.rate * MIN_SPIN_FRACTION - 1e-6,
"decay {decay} rung {index}: {rate} is under the floor"
);
}
}
// At the hardest decay the floor binds early, and the junction must still be continuous --
// which is why the base angle is summed rather than taken from a geometric closed form.
let count = 33;
for index in 0..15u32 {
let (base, rate) = spin_angle(spin, index, count, 0.5);
let (next, _) = spin_angle(spin, index + 1, count, 0.5);
assert!(
(next - (base + rate * count as f32)).abs() < 1e-3,
"rung {index}: the floored junction is discontinuous"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -225,6 +886,159 @@ mod tests {
assert_eq!(&c.frames[0][1..25], &c.frames.last().unwrap()[1..25]);
}
}
/// The banks alone, from `SKATE3_ASSET_ROOT` -- the convention every other asset test here uses.
///
/// The `fixture` above hardcodes `../../assets`, which a git worktree does not have, so those
/// tests cannot run from one at all. The hold needs no authored json, only the stock banks.
fn banks() -> AnimationFrames {
let root = std::path::PathBuf::from(
std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"),
);
let banks = AnimationBanks::load(&root).unwrap();
AnimationFrames::from_banks(&banks).unwrap()
}
/// Bug B, as an assertion rather than an eyeball: the held hands and toes track the **body**.
///
/// Bones 32-35 are reparented into the board's *moving* space, so they are the one part of the
/// rig that a synthesised deck can capture. It did: the board locals were written back over a
/// range that included them, which welded them to the spinning deck at their frame-0 offsets, and
/// the owner reported it as the legs wrapping around the board. Both halves are checked here --
/// that each helper sits on the joint it shadows, and that it is *not* sitting where the spun
/// deck would have put it -- because only the second half fails if the fix is reverted.
#[test]
#[ignore = "requires private stock animation banks"]
fn the_held_hands_and_toes_follow_the_body_not_the_spun_deck() {
let frames = banks();
let bind = &frames.named_pose("RIG_TPOSE").unwrap().samples;
let index = |name: &str| frames.bone_names.iter().position(|n| n == name).unwrap();
let helpers = [
(index("RIGHTTOEBASE_REPARENTED"), index("RIGHTTOEBASE")),
(index("LEFTTOEBASE_REPARENTED"), index("LEFTTOEBASE")),
(index("RIGHTHAND_REPARENTED"), index("RIGHTHAND")),
(index("LEFTHAND_REPARENTED"), index("LEFTHAND")),
];
let hold = crate::graph_host::endless_flip::HOLD_BODY;
let truck = frames.bone_names.iter().position(|n| n == "TRUCK_FRONT").unwrap();
for rung in 2..=5u32 {
let composed = hold_body(&frames, "360FLIP_D_HIGH_A", hold, rung)
.unwrap_or_else(|e| panic!("rung {rung}: {e}"));
let rows: Vec<Vec<Mat4>> = composed
.frames
.iter()
.map(|row| row_globals(row, &frames, bind))
.collect();
// Each helper sits on the joint it shadows. A centimetre, not a micron: the local/global
// round trip goes through a matrix inverse in f32 and is worth a couple of millimetres,
// where a helper welded to the spinning deck lands tens of centimetres out.
for globals in &rows {
for (helper, joint) in helpers {
let off = globals[helper]
.w_axis
.truncate()
.distance(globals[joint].w_axis.truncate());
assert!(
off < 0.01,
"rung {rung}: {} is {off:.4} m from {}, so it is riding the deck",
frames.bone_names[helper],
frames.bone_names[joint],
);
}
}
// And the discriminating half: the helpers must travel far less than the deck does. The
// body is held still, so they barely move; welded to the deck they would move with it.
let travel = |bone: usize| {
let first = rows[0][bone].w_axis.truncate();
rows.iter()
.map(|g| g[bone].w_axis.truncate().distance(first))
.fold(0f32, f32::max)
};
let deck = travel(truck);
assert!(deck > 0.05, "rung {rung}: the deck hardly moves ({deck:.3} m)");
for (helper, _) in helpers {
let moved = travel(helper);
assert!(
moved < 0.25 * deck,
"rung {rung}: {} travels {moved:.3} m against the deck's {deck:.3} -- it is \
following the board",
frames.bone_names[helper],
);
}
assert_eq!(
composed.frames.len(),
frames.clip("360FLIP_D_HIGH_A").unwrap().frames.len(),
"rung {rung}: the baked clip must keep the stock frame count, or the deck freezes \
or jumps part-way through the rung"
);
}
}
/// The deck actually turns, by a near-constant amount every frame, and keeps turning across rungs.
///
/// This is the smoothness claim, measured on the baked output rather than on the estimator: a
/// rung that re-accelerates, stalls or jumps fails here. The tolerance is loose on purpose --
/// the comparison is against the mean step, and float noise through the local/global round trip
/// is worth a fraction of a degree.
#[test]
#[ignore = "requires private stock animation banks"]
fn the_held_deck_turns_at_a_steady_rate_and_carries_across_rungs() {
let frames = banks();
let bind = &frames.named_pose("RIG_TPOSE").unwrap().samples;
let board = frames
.bone_names
.iter()
.position(|n| n == "SKATEBOARD_ROOT")
.unwrap();
let hold = crate::graph_host::endless_flip::HOLD_BODY;
let deck = |rung: u32| -> Vec<Quat> {
hold_body(&frames, "360FLIP_D_HIGH_A", hold, rung)
.unwrap()
.frames
.iter()
.map(|row| row_globals(row, &frames, bind)[board].to_scale_rotation_translation().1)
.collect()
};
let first = deck(2);
let steps: Vec<f32> = first
.windows(2)
.map(|p| world_delta(p[0], p[1]).length())
.collect();
let mean = steps.iter().sum::<f32>() / steps.len() as f32;
assert!(
mean > 1f32.to_radians(),
"the held deck barely turns: {:.3} deg per frame",
mean.to_degrees()
);
for (i, step) in steps.iter().enumerate() {
assert!(
(step - mean).abs() < 0.05 * mean,
"frame {i} turns {:.3} deg where the rung averages {:.3} -- the spin is not steady",
step.to_degrees(),
mean.to_degrees()
);
}
// Across the junction: the next rung must continue, a touch slower, not restart.
let second = deck(3);
let junction = world_delta(*first.last().unwrap(), second[0]).length();
assert!(
(junction - mean).abs() < 0.2 * mean,
"the rung junction steps {:.3} deg where the rung steps {:.3}",
junction.to_degrees(),
mean.to_degrees()
);
let next: Vec<f32> = second
.windows(2)
.map(|p| world_delta(p[0], p[1]).length())
.collect();
let next_mean = next.iter().sum::<f32>() / next.len() as f32;
assert!(
next_mean < mean && next_mean > 0.8 * mean,
"rung 3 turns at {:.3} deg against rung 2's {:.3} -- expected slightly slower",
next_mean.to_degrees(),
mean.to_degrees()
);
}
#[test]
#[ignore = "requires private stock banks and the captured crouch/treflip asset"]
fn authored_clip_rejects_bad_timing_and_skeleton() {
@@ -0,0 +1,584 @@
//! Endless Tricks: the flip ladder past the authored quad.
//!
//! **A deliberate extension beyond retail Skate 3, not a port fix.** Retail authors
//! `TrickInto -> Cyc1 -> Cyc2 -> Cyc3 -> Out4` and stops there because no fifth cycle state
//! exists. `Cyc3`'s only exit is a bare `WillExpire` to `End.Out.Out4`, and `Out4` carries no
//! precondition at all -- unlike `Out1`/`Out2`/`Out3`, whose hold / `TimeToLand` /
//! `IsBodyFlipping` gates end the ladder early. So nothing is capped in code and nothing in code
//! can be uncapped: the extra rung has to be authored.
//!
//! This module appends one transition per flip cycle state, pointing the state at itself and
//! guarded by the `EndlessFlipLoop` condition. That condition is false unless a mod turns it on,
//! so with the mod off the compiled graph behaves exactly as shipped.
//!
//! Re-entering a cycle state and replaying its clip is authored retail behaviour rather than an
//! invention: `T_Kickflip_Unique.xml` (the Mike Carroll and Gonzo signature kickflips) runs
//! `TrickCyc1`/`TrickCyc2`/`TrickCyc3` over the *same* clip, differing only in the scoring name.
//!
//! Appending is safe because `Binding::from_graph` requires only `child > parent` and unique
//! ownership -- not contiguity -- so new elements go on the end of the arena and nothing
//! renumbers.
use skate_core::animation::{output::attributes::AttributeName, skeleton_input::name::encode};
use skate_core::scoring::extension;
use skate_data::state_graph::{GraphAttribute, GraphElement, StateGraph, binding::Binding};
/// Live loop state on the MotionHost.
///
/// `enabled` is the only switch: false on every retail path, and the synthesised condition
/// returns false on its first line when it is.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct EndlessFlip {
pub enabled: bool,
/// Extra cycles taken past the authored quad, counted by the `EndlessFlipAdvance` hook on
/// the synthesised transition. Zero on every retail path.
pub loops: u32,
/// Cap from `TrainerTuning::endless_flip_max`.
pub max_loops: u32,
/// Family stem of the ladder being extended, e.g. `Kickflip`, recorded by the hook so the
/// name override knows which rung table to read.
pub trick: Option<String>,
/// The authored top rung of that family; the live rung counts up from here.
pub base_rung: u32,
/// From `TrainerTuning::endless_air_check`. False keeps flipping while held and airborne.
pub air_check: bool,
/// Grounded ticks, so the rung survives its own landing and clears at the next takeoff.
grounded: u32,
}
impl EndlessFlip {
/// The rung the skater is on: the family's authored top rung plus the loops taken.
pub fn rung(&self) -> u32 {
self.base_rung.max(1) + self.loops
}
/// One more cycle past the authored ladder, counted as the loop transition is taken.
pub fn advance(&mut self, trick: String, base_rung: u32) {
self.loops = self.loops.saturating_add(1);
self.trick = Some(trick);
self.base_rung = base_rung;
}
/// The ladder is cleared at **takeoff**, not at landing.
///
/// Clearing on the first grounded tick looked obvious and was wrong: the authored
/// `ScoringTrick` leaf is still publishing as the skater touches down, so the name collapsed
/// back to the base rung exactly as it banked -- a five-rotation 360 flip scoring as a plain
/// one. A fixed grace period only moved the problem, because how long that tail runs depends
/// on the clip. Holding the rung until the skater next leaves the ground is exact: it always
/// outlasts the landing it was earned on, and the next trick always starts clean.
pub fn land(&mut self) {
self.grounded = self.grounded.saturating_add(1);
}
/// Airborne again, so any previous ladder is over.
pub fn airborne(&mut self) {
if self.grounded > 0 {
self.loops = 0;
self.trick = None;
}
self.grounded = 0;
}
/// The name an extra rung publishes in place of the authored one.
///
/// Re-entering the cycle state replays the authored `<Trick>3` and `<Trick>4` leaves, so both
/// are rewritten to the rung actually being flown. Matching on those two names specifically is
/// what keeps the override off every other trick the skater might publish before landing.
pub fn published_name(&self, published: AttributeName) -> Option<AttributeName> {
if !self.enabled || self.loops == 0 {
return None;
}
let trick = self.trick.as_deref()?;
// A cycle ladder replays its `<Trick>3` and `<Trick>4` leaves; a single-clip trick
// republishes its one bare name. Matching only those keeps the override off every other
// trick the skater might publish before landing.
let authored: Vec<AttributeName> = if self.base_rung >= 4 {
vec![
encode(format!("{trick}3").as_bytes()),
encode(format!("{trick}4").as_bytes()),
]
} else {
vec![encode(trick.as_bytes())]
};
if !authored.contains(&published) {
return None;
}
let rung = extension::by_family(trick, self.rung())?;
Some(encode(rung.identifier.as_bytes()))
}
}
/// One authored flip cycle state carrying an installed loop.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Site {
/// Index into `StateGraph::elements` of the cycle state itself.
pub state: usize,
/// Base trick name recovered from the authored `ScoringTrick`: `Kickflip` from `Kickflip4`,
/// or `360FLIP` from the clip. Names the hold intent and the extra rungs.
pub trick: String,
/// The top rung retail authors here: 4 for a cycle ladder, 1 for a single-clip trick.
pub base_rung: u32,
}
/// Nine authored sites. Six cycle ladders -- `T_Kickflip.xml` included four times (`Kickflip`,
/// `Heelflip`, `N_Kickflip`, `N_Heelflip`) and `T_Kickflip_Unique.xml` twice (Mike Carroll,
/// Gonzo) -- plus four single-clip tricks on `T_TrickWithDarkCatch.xml` (`360Flip`, `Laserflip`
/// plus its nollie form -- the nollie 360 flip has no cycle clip so it is left out). A different
/// count means these are not the assets this was written
/// against, and quietly installing onto a changed graph would be worse than refusing.
/// The retail cycle ladders, recovered data, and exactly this many: `T_Kickflip.xml` four times
/// (`Kickflip`, `Heelflip`, `N_Kickflip`, `N_Heelflip`) and `T_Kickflip_Unique.xml` twice (Mike
/// Carroll, Gonzo). A different count means these are not the assets this was written against.
///
/// The single-clip ladders are deliberately not counted here: which exist is the rung table's
/// decision rather than a number maintained by hand, and `install` reports what it found.
pub const EXPECTED_CYCLE_SITES: usize = 6;
fn text<'a>(element: &'a GraphElement, key: &str) -> Option<&'a str> {
element
.attributes
.iter()
.find(|a| a.name == key)
.map(|a| a.text.as_str())
}
fn named(element: &GraphElement, tag: &str, name: &str) -> bool {
element.tag == tag && text(element, "name") == Some(name)
}
fn attribute(name: &str, value: &str) -> GraphAttribute {
GraphAttribute {
name: name.to_owned(),
text: value.to_owned(),
float_bits: 0,
boolean_byte: 0,
}
}
fn flag(name: &str) -> GraphAttribute {
GraphAttribute {
name: name.to_owned(),
text: "true".to_owned(),
float_bits: 0,
boolean_byte: 1,
}
}
fn seconds(name: &str, value: f32) -> GraphAttribute {
GraphAttribute {
name: name.to_owned(),
text: format!("{value}"),
float_bits: value.to_bits(),
boolean_byte: 0,
}
}
/// Cross-fade for a cycle ladder's loop junction, swept to parity with the authored junctions.
///
/// **Only the cycle ladders use it.** The single-clip families restart with `transType="play"`
/// instead, and the reason is the self-transition. A blend with `transitionUnder` nests the
/// incoming clip inside the live transition, so re-entering the same state every rung builds a
/// tower that never resolves: the outer clip stays finished, `crossed_end` never clears, and the
/// loop condition re-fires *every tick*. That is what made the rung counter race while a single
/// animation played on screen. `play` clears the current tree first, so each rung restarts
/// cleanly -- and a cycle clip closes its own loop, so it needs no cross-fade to hide a seam.
///
/// The cycle ladders keep the blend because it was swept to parity with the authored junctions,
/// not because `play` was measured worse for them -- it was tried and made no difference to the
/// kickflip's rungs or its landing. Keeping the tuned value is simply the smaller claim.
/// Swept against the measurement above: 0.00 leaves a 0.33 m cut, 0.05 still leaves small
/// cycle-clip spikes, and 0.08 is the shortest window where they disappear entirely and the
/// worst in-flip jolt is an authored one again. Longer only costs air time.
pub const LOOP_BLEND_SECONDS: f32 = 0.08;
/// The body-hold clip a repeated rotation parks the legs in.
///
/// These exist in the animation bank and **no authored state references them** -- retail shipped
/// cycle content for these tricks and never wired it up. They are *hold poses*, not rotations:
/// measured, `T_360FLIP_H_CYC` moves the board a tenth as much as the kickflip's cycle does, which
/// is why playing one on a repeat parked the deck and only the first rotation was ever visible.
/// So a repeat keeps the trick's own air clip, which is what turns the board, and this supplies
/// the body alone through `animation_pose`'s body/board split.
///
/// Every single-clip family uses this same hold, including `N_360FLIP`. An earlier note here said
/// `N_360FLIP` was excluded for having no cycle clip of its own; it is not excluded, and the clip it
/// lacks is no longer what a repeat plays.
///
/// The high variant is taken unconditionally. Retail picks high or low for the *air* clip through
/// an authored selector on trick height; there is no equivalent selector for the holds, so this is
/// a choice rather than a recovery.
pub fn hold_clip(trick: &str) -> String {
// Whether a held leg actually clears a rotating board is a judgement only play can make, so the
// source is switchable without a rebuild. `SKATE_ENDLESS_HOLD_CLIP` takes either a bare clip
// name or `<TRICK>=<clip>` pairs separated by semicolons.
static OVERRIDE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let configured = OVERRIDE.get_or_init(|| {
std::env::var("SKATE_ENDLESS_HOLD_CLIP")
.ok()
.filter(|v| !v.is_empty())
});
if let Some(configured) = configured {
for entry in configured.split(';') {
match entry.split_once('=') {
Some((family, clip)) if family.eq_ignore_ascii_case(trick) => {
return clip.to_owned();
}
Some(_) => {}
None => return entry.to_owned(),
}
}
}
HOLD_BODY.to_owned()
}
/// The authored clip the held body is taken from, for every single-clip family.
///
/// This is the kickflip ladder's own cycle -- the pose retail built for legs held clear of a board
/// rotating underneath them, which is exactly the problem here. Owner-confirmed by play: each
/// trick's own `T_<trick>_H_CYC` left the legs too close and they clipped through the deck. It is
/// also why every family holds the *same* way, which is what was asked for.
///
/// The name is the bank clip, not the tree `B_KICKFLIP_CYC2` the graph names -- read out of
/// `SKATE_CLIP_TRACE` rather than guessed, because guessing across that gap is what put a hold
/// pose on a repeat to begin with.
pub const HOLD_BODY: &str = "T_HI_KICK_CYC2";
/// Whether a stem from `T_TrickWithDarkCatch.xml` gets a ladder.
///
/// The rung table decides, not a list kept here. A family has a ladder exactly when
/// `scoring::extension` gives it rungs, so the two cannot drift apart, and a trick that must not be
/// endless -- a pop shuvit turns the board 180, so counting its rungs in 720s would be a lie --
/// cannot acquire one by being added to the wrong place. It also means no clip-stem strings are
/// written out by hand: the graph supplies the stems and the table judges them.
fn has_ladder(stem: &str) -> bool {
skate_core::scoring::extension::by_family(stem, 2).is_some()
}
/// The clip a state plays, from its first `PlayAnimation` child.
fn state_clip(source: &StateGraph, state: usize) -> Option<String> {
source.elements[state].children.iter().find_map(|&c| {
let e = &source.elements[c];
named(e, "behaviour", "PlayAnimation")
.then(|| text(e, "anim"))
.flatten()
.map(str::to_owned)
})
}
/// The authored rungs a cycle ladder should replay, in order.
///
/// Re-entering `Cyc3` alone replays one clip whose end pose is authored to run into `_OUT4`, not
/// back into its own first frame, and the owner saw that as the legs and hands twitching in a
/// weird loop from the fifth rung on. The authored pair reads the way the first four rungs do:
/// `Cyc2`'s end pose is shaped to run into `Cyc3`, because that is the order retail plays them in.
///
/// The sibling is found by name rather than by string surgery on the clip, so a family whose
/// clips are not named `_CYC2`/`_CYC3` still resolves -- the Unique includes are exactly that case.
fn cycle_pair(source: &StateGraph, state: usize, name: &str) -> Vec<String> {
let own = state_clip(source, state);
let previous = cycle_sibling(source, state, name).and_then(|s| state_clip(source, s));
// Replay order: the earlier rung first, so each pair runs the way the authored ladder does.
previous.into_iter().chain(own).collect()
}
/// The `Cyc2` state beside a `Cyc3` anchor, found by name rather than by string surgery on the clip.
fn cycle_sibling(source: &StateGraph, state: usize, name: &str) -> Option<usize> {
let sibling = format!("{}2", name.strip_suffix('3')?);
let parents = parent_map(source);
let parent = parents[state]?;
source.elements[parent]
.children
.iter()
.copied()
.find(|&c| {
source.elements[c].tag == "state"
&& text(&source.elements[c], "name") == Some(sibling.as_str())
})
}
/// Child -> parent, which `GraphElement` does not carry.
fn parent_map(source: &StateGraph) -> Vec<Option<usize>> {
let mut parents = vec![None; source.elements.len()];
for (index, element) in source.elements.iter().enumerate() {
for &child in &element.children {
// A transition is appended carrying child indices that are pushed after it, so a scan
// run mid-install would otherwise walk off the end.
if let Some(slot) = parents.get_mut(child) {
*slot = Some(index);
}
}
}
parents
}
/// `B_360FLIP_A` -> `360FLIP`. `encode` case-folds, so this matches the authored
/// `ScoringTrick trick="360Flip"` without needing to walk up to it.
fn single_clip_trick(source: &StateGraph, state: usize) -> Option<String> {
// Each include carries a second `LeftGround` inside `GrindOutAssist` that also plays `_A`.
// Only the real air state sequences into the clip; the assist one blends into it. Without
// this the site count comes out at fourteen instead of ten.
let anim = source.elements[state].children.iter().find_map(|&c| {
let e = &source.elements[c];
(named(e, "behaviour", "PlayAnimation") && text(e, "transType") == Some("sequence"))
.then(|| text(e, "anim"))
.flatten()
})?;
let stem = anim.strip_prefix("B_")?.strip_suffix("_A")?;
has_ladder(stem).then(|| stem.to_owned())
}
/// The quad `ScoringTrick` sits on an inactive `Quad` child in the generic include and directly
/// on the cycle state in the Unique one, so both depths are searched.
fn quad_trick(source: &StateGraph, state: usize) -> Option<String> {
let mut candidates = source.elements[state].children.clone();
for &child in &source.elements[state].children {
if source.elements[child].tag == "state" {
candidates.extend_from_slice(&source.elements[child].children);
}
}
candidates.into_iter().find_map(|index| {
let element = &source.elements[index];
if !named(element, "behaviour", "ScoringTrick") {
return None;
}
Some(text(element, "trick")?.strip_suffix('4')?.to_owned())
})
}
/// The authored clip-expiry exit, which must be the state's last child so that inserting ahead
/// of it keeps every other authored transition -- underflip and dark catch above all -- at its
/// original priority. Returns its `InTime` attribute so the loop can mirror the same window.
fn authored_exit(source: &StateGraph, state: usize) -> Option<GraphAttribute> {
let &last = source.elements[state].children.last()?;
if source.elements[last].tag != "transition" {
return None;
}
let &expression = source.elements[last].children.first()?;
if source.elements[expression].tag != "expression" {
return None;
}
let conditions = &source.elements[expression].children;
let &[only] = conditions.as_slice() else {
return None;
};
let condition = &source.elements[only];
if !named(condition, "condition", "WillExpire") {
return None;
}
condition.attributes.iter().find(|a| a.name == "InTime").cloned()
}
/// Appends the loop transition to every flip cycle state in `source`.
///
/// Call between `StateGraph::load` and `Binding::from_graph`, on the MotionGraph only.
pub fn install(source: &mut StateGraph) -> Result<Vec<Site>, String> {
let anchors: Vec<usize> = (0..source.elements.len())
.filter(|&i| {
let element = &source.elements[i];
element.tag == "state"
&& matches!(
text(element, "name"),
Some("Cyc3" | "TrickCyc3" | "LeftGround")
)
&& element
.children
.iter()
.any(|&c| named(&source.elements[c], "behaviour", "PlayAnimation"))
})
.collect();
let mut sites = Vec::new();
for state in anchors {
// A cycle ladder tops out at the authored quad; a single-clip trick has just the one.
let (trick, base_rung) = match quad_trick(source, state) {
Some(trick) => (trick, 4),
None => match single_clip_trick(source, state) {
Some(trick) => (trick, 1),
None => continue,
},
};
let name = text(&source.elements[state], "name")
.ok_or("Flip cycle state has no name to target")?
.to_owned();
// The lead-in the loop fires on, read before anything is appended.
//
// **This is also what makes an extension rung shorter than an authored one**, and it cannot
// simply be reduced. The loop is racing the bare `WillExpire` it sits ahead of, so it has to
// fire on that exit's own lead-in or lose the state -- waiting for `crossed_end` was measured
// to drop the kickflip ladder back to four rungs. On the kickflip that lead-in is 0.05 s, 3
// ticks of a 26-tick cycle clip, so each extension rung runs 23 ticks and the cycle therefore
// repeats 13% more often than an authored one: the deck appears to speed up at the quad.
//
// Taking the authored *inter-cycle* lead-in instead was tried and measured to be a no-op: the
// `Cyc3` anchor's sibling `Cyc2` has no bare `WillExpire` as its last transition, so there is
// nothing to read, and where there is (`TrickCyc3`) it is the same 0.02 s. Closing the gap
// needs the authored `Out4` exit held off while the ladder is live, which is graph surgery on
// the path that lands a stock quad. Recorded in `docs/engine-defects.md`; not attempted here.
let in_time = authored_exit(source, state).ok_or_else(|| {
format!("Flip cycle state {state} ({trick}) has no bare WillExpire exit to sit ahead of")
})?;
if std::env::var_os("SKATE_ENDLESS_TRACE").is_some() {
let seconds = |a: &GraphAttribute| f32::from_bits(a.float_bits);
let own = authored_exit(source, state).map(|a| seconds(&a));
let sibling =
cycle_sibling(source, state, &name).and_then(|s| authored_exit(source, s)).map(|a| seconds(&a));
eprintln!(
"SKATE_ENDLESS_SITE {trick} state={name} base={base_rung} chosen={:.4}s own_exit={own:?} sibling_exit={sibling:?}",
seconds(&in_time),
);
}
let source_offset = source.elements[state].source_offset;
// A cycle ladder replays the authored pair; a single-clip family has no pair to replay and
// replays its own air clip with a composed body instead. Read **before** anything is appended: the
// transition element below is pushed carrying child indices that do not exist yet, so a
// parent scan run after it walks off the end of `elements`.
let pair = if base_rung >= 4 {
cycle_pair(source, state, &name)
} else {
Vec::new()
};
// transition -> expression -> condition, appended in that order so each child index is
// greater than its parent's, which is all `Binding::from_graph` requires.
let transition = source.elements.len();
let expression = transition + 1;
let condition = transition + 2;
let hook = transition + 3;
let blend = transition + 4;
source.elements.push(GraphElement {
source_offset,
tag: "transition".to_owned(),
attributes: vec![attribute("target", &name)],
children: vec![expression, hook, blend],
});
source.elements.push(GraphElement {
source_offset,
tag: "expression".to_owned(),
attributes: Vec::new(),
children: vec![condition],
});
source.elements.push(GraphElement {
source_offset,
tag: "condition".to_owned(),
attributes: vec![
attribute("name", "EndlessFlipLoop"),
attribute("trick", &trick),
attribute("baseRung", &base_rung.to_string()),
in_time,
],
children: Vec::new(),
});
source.elements.push(GraphElement {
source_offset,
tag: "hook".to_owned(),
attributes: vec![
attribute("name", "EndlessFlipAdvance"),
attribute("trick", &trick),
attribute("baseRung", &base_rung.to_string()),
attribute("cycleClips", &pair.join(",")),
],
children: Vec::new(),
});
// Retail's own mechanism for exactly this: override the next play's transition so the
// re-entered cycle cross-fades out of the pose on screen rather than cutting to frame 0.
source.elements.push(GraphElement {
source_offset,
tag: "hook".to_owned(),
attributes: if base_rung >= 4 {
// The cycle ladders keep the cross-fade they were swept to parity with.
vec![
attribute("name", "OverideNextAnimTransitionHook"),
attribute("transType", "blend"),
seconds("time", LOOP_BLEND_SECONDS),
flag("transitionUnder"),
flag("blendWithCurrentFrame"),
]
} else {
// Blended, but **without** `transitionUnder`. That flag is what nested the
// incoming clip inside the live transition and built a tower that never resolved,
// leaving the outer clip finished, `crossed_end` uncleared and the loop re-firing
// every tick. Restarting with `play` avoided that, and was invisible only while the
// board was parked; now a rung turns the deck again, so the cut back to frame 0
// shows as the board jumping at every junction. A plain cross-fade covers the seam
// without nesting anything.
vec![
attribute("name", "OverideNextAnimTransitionHook"),
attribute("transType", "blend"),
seconds("time", LOOP_BLEND_SECONDS),
flag("blendWithCurrentFrame"),
]
},
children: Vec::new(),
});
source.elements[state].children.push(transition);
sites.push(Site {
state,
trick,
base_rung,
});
}
let cycles = sites.iter().filter(|s| s.base_rung >= 4).count();
if cycles != EXPECTED_CYCLE_SITES {
return Err(format!(
"Expected {EXPECTED_CYCLE_SITES} authored flip cycle states, found {cycles}"
));
}
let mut families: Vec<&str> = sites
.iter()
.filter(|s| s.base_rung < 4)
.map(|s| s.trick.as_str())
.collect();
families.sort_unstable();
families.dedup();
if families.is_empty() {
return Err("No single-clip flip state carried a rung table".into());
}
eprintln!("SKATE_ENDLESS single-clip ladders: {}", families.join(" "));
Ok(sites)
}
/// Moves each installed loop ahead of the authored exit it must beat.
///
/// `Binding::from_graph` walks elements in index order, so an appended transition lands last in
/// its owner's list, and `search_transitions` takes the first activatable one -- which would let
/// the authored exit win every time. The authored exit is `install`'s anchor and was the last
/// transition before ours, so swapping the final pair is exactly the move. Underflip and dark
/// catch are earlier in the list and keep their priority.
pub fn reorder(binding: &mut Binding, sites: &[Site]) -> Result<(), String> {
for site in sites {
let state = binding
.states
.iter()
.position(|s| s.element == site.state)
.ok_or_else(|| format!("Flip cycle state {} vanished during binding", site.state))?;
// The single-clip air state has three bare-`WillExpire` exits (InAir/Land/OnBoard), not
// one, so swapping the final pair would leave the loop behind the first of them. Sit ahead
// of the earliest clip-expiry exit instead, which keeps underflip and dark catch -- which
// are not expiry exits -- at their authored priority in both shapes.
let first_expiry = binding.states[state]
.transitions
.iter()
.position(|&t| {
binding.transitions[t]
.expression
.and_then(|e| binding.expressions.get(e))
.is_some_and(|e| {
e.children.len() == 1
&& e.children.iter().any(|c| match c {
skate_data::state_graph::binding::Node::Operation(op) => binding
.operations
.get(*op)
.is_some_and(|o| o.name.eq_ignore_ascii_case("WillExpire")),
_ => false,
})
})
})
.ok_or_else(|| format!("Flip state {} lost its expiry exit", site.state))?;
let transitions = &mut binding.states[state].transitions;
let ours = transitions.pop().ok_or("loop transition vanished")?;
transitions.insert(first_expiry, ours);
}
Ok(())
}
+2
View File
@@ -10,6 +10,8 @@ pub mod motion_manual;
pub(crate) mod outputs;
pub(crate) use condition_nodes::numeric as parse_numeric_condition;
mod crouching_settings;
/// Deliberate non-retail extension; inert unless a mod enables it.
pub(crate) mod endless_flip;
pub mod motion;
mod motion_air_leg;
mod motion_animation;
@@ -63,6 +63,8 @@ pub struct MotionPhysical {
mod instances;
use instances::Instance;
pub struct MotionHost {
/// Endless Tricks. Deliberate non-retail extension; disabled unless a mod turns it on.
pub endless: super::endless_flip::EndlessFlip,
pub animation: MotionAnimation,
pub playback_context: PlaybackContext,
pub condition_inputs: ConditionInputs,
@@ -224,6 +226,8 @@ impl MotionHost {
});
let pushing = PushingSettings::load(data, &mut metadata)?;
Ok(Self {
// Off until a mod turns it on; see physics::animation_phase for the trainer wiring.
endless: super::endless_flip::EndlessFlip::default(),
animation: MotionAnimation::from_metadata(metadata),
playback_context,
condition_inputs: ConditionInputs::default(),
@@ -381,6 +385,27 @@ impl Host for MotionHost {
super::motion_hooks::MotionHook::GrabSlide { right } => {
self.slide_latch.grab(right)
}
super::motion_hooks::MotionHook::EndlessFlipAdvance {
trick,
base_rung,
cycle_clips,
} => {
let trick_name = trick.clone();
self.endless.advance(trick, base_rung);
// A cycle ladder replays the authored pair in authored order, so each extra
// rung reads the way the first four do. Re-entering `Cyc3` alone replayed one
// clip whose end pose runs into `_OUT4` rather than into its own first frame,
// which is the twitch reported from the fifth rung on.
self.animation.next_animation = if cycle_clips.is_empty() {
// A single-clip family replays its own air clip, so the board keeps
// turning; the body is parked by the hold composed in `animation_pose`.
let _ = &trick_name;
None
} else {
let taken = self.endless.loops.saturating_sub(1) as usize;
Some(cycle_clips[taken % cycle_clips.len()].clone())
};
}
super::motion_hooks::MotionHook::Override(settings) => {
self.playback_context.transition_override = Some(settings)
}
@@ -24,6 +24,10 @@ pub struct MotionAnimation {
current: Option<PlaybackTree>,
pub channels: super::motion_channels::MotionChannels,
pub current_name: Option<String>,
/// Endless Tricks. The clip the *next* play should use instead of its authored one, so a
/// repeated rotation runs the authored cycle clip rather than replaying a whole trick
/// performance. `None` on every stock path, and consumed by the play it applies to.
pub next_animation: Option<String>,
pub motion_intents: IntentMap,
pub filtered_intents: IntentMap,
pub motion_attributes: Vec<MotionGraphAttribute>,
@@ -109,6 +113,7 @@ impl MotionAnimation {
current: None,
channels: super::motion_channels::MotionChannels::default(),
current_name: None,
next_animation: None,
motion_intents: IntentMap::new(),
filtered_intents: IntentMap::new(),
motion_attributes: Vec::new(),
@@ -478,10 +483,15 @@ impl PlaybackService for MotionAnimation {
fn set_posture_enabled(&mut self, enabled: bool) {
self.posture.set_requested(enabled);
}
fn play(&mut self, request: PlaybackRequest) -> Result<bool, String> {
fn play(&mut self, mut request: PlaybackRequest) -> Result<bool, String> {
if !(1..=4).contains(&request.transition.kind) {
return Ok(false);
}
// Endless Tricks: a repeated rotation plays the authored cycle clip in place of the
// trick's own air clip. `None` on every stock path.
if let Some(cycle) = self.next_animation.take() {
request.animation = cycle;
}
if request.transition.kind == 1 {
self.current = None;
}
@@ -7,6 +7,11 @@ use skate_core::{
};
use skate_data::state_graph::attributes::Attributes;
/// Air budget for one more Endless Tricks rung, in multiples of the running cycle clip: the next
/// cycle, plus an out clip of comparable length. Measured against the authored clips rather than
/// picked, and deliberately conservative -- undershooting turns a landing into a wipeout.
const CYCLE_PLUS_OUT: f32 = 2.0;
#[derive(Clone, Debug, PartialEq)]
pub enum MotionCondition {
Grind(super::motion_grind::conditions::Condition),
@@ -62,6 +67,20 @@ pub enum MotionCondition {
},
Gameplay(super::motion_gameplay_conditions::GameplayCondition),
Riding(super::motion_riding_conditions::MotionRidingCondition),
/// Endless Tricks. **Not retail** -- synthesised by `endless_flip::install`, never authored.
/// Guards the loop back into a flip cycle state: false unless a mod turned the feature on,
/// the hold intent is still published, and the pop has the air left to finish another cycle.
EndlessFlip {
/// The hold intent to require, e.g. `KickflipHold`.
hold: String,
/// Single-clip families have no authored `<Trick>Hold` intent -- retail scopes holding to
/// the kickflip and heelflip -- so they gate on the ActionGraph's `HoldPattern` instead,
/// which Endless Tricks widens to cover their gestures.
via_pattern: bool,
/// Mirrors the authored exit's `WillExpire` window so the loop fires at the same point
/// in the clip the authored transition would have.
in_time: f32,
},
/// TimeToLand82BA7250: valid Air trajectory remaining time.
TimeToLand(NumericCondition),
/// Native off-board trajectory time (PhysOutOffBoard+32).
@@ -177,6 +196,12 @@ impl MotionCondition {
name if super::motion_riding_conditions::MotionRidingCondition::recognizes(name) => {
Self::Riding(super::motion_riding_conditions::MotionRidingCondition::parse(a)?)
}
//Endless Tricks, synthesised rather than authored; see graph_host::endless_flip.
"EndlessFlipLoop" => Self::EndlessFlip {
hold: format!("{}Hold", a.text("trick").unwrap_or("")),
via_pattern: a.text("baseRung").is_some_and(|v| v != "4"),
in_time: f32::from_bits(a.float_bits("InTime", 0)),
},
"TimeToLand" => Self::TimeToLand(numeric()),
"OBTimeToLand" => Self::ObTimeToLand(numeric()),
"OBTrajTime" => Self::ObTrajTime(numeric()),
@@ -232,6 +257,65 @@ impl MotionCondition {
.map_err(str::to_owned)?,
Self::Gameplay(condition) => condition.evaluate(host)?,
Self::Riding(condition) => condition.evaluate(host)?,
Self::EndlessFlip {
hold,
via_pattern,
in_time,
} => {
// Off by default: the retail path leaves here on the first line.
if !host.endless.enabled || host.endless.loops >= host.endless.max_loops {
false
} else {
let p = host
.gameplay_conditions
.as_ref()
.ok_or("EndlessFlipLoop requires physical condition publication")?;
// The authored ladder gates every rung on the hold intent and on not body
// flipping; the extra rungs are gated the same way rather than differently.
//
// The air-time term is measured off the authored clip rather than guessed: a
// further rung costs one more cycle plus the out clip, and this fires at the
// end of a cycle, so the running clip's own length is the cycle cost and
// `CYCLE_PLUS_OUT` budgets the out beside it. Deriving it this way scales per
// trick -- the heelflip's clips are longer than the kickflip's, which is
// exactly why a fixed threshold let the heelflip start a cycle it could not
// land out of. This is what stops the loop wedging a non-interruptable state
// through a landing.
// The cross-fade at the junction is part of the cost of another rung, so
// it is part of the budget too. Leaving it out is what made a boost that used
// to land cleanly wipe out once the blend was added.
// Two rules, and the default is the permissive one. A rung costs a cycle
// plus an out clip, and demanding that up front turns the feature into a
// height bar -- badly so for the single-clip families, whose "cycle" is the
// whole trick animation and therefore wants roughly twice the air a kickflip
// does. Off, the only requirement is that the skater is still in the air with
// room to cross-fade; the landing ends the ladder, which is what holding a
// trick should feel like. The cost is a rung that sometimes cannot be landed.
let needed = if host.endless.air_check {
host.animation.current_length().unwrap_or(f32::MAX) * CYCLE_PLUS_OUT
+ super::endless_flip::LOOP_BLEND_SECONDS
} else {
super::endless_flip::LOOP_BLEND_SECONDS
};
let airborne = p.time_to_land_valid && p.time_to_land >= needed;
let held = if *via_pattern {
host.action_intents.contains_key("HoldPattern")
} else {
host.animation.motion_intent(hold).is_some()
};
let a = host.animation.property();
// `in_time` is load-bearing and must stay: the loop has to fire at the moment the
// authored bare `WillExpire` would, because it is racing that exit for the state.
// Waiting for `crossed_end` instead was measured to lose the race outright -- the
// kickflip ladder stopped at the authored quad and ran no extension rung at all.
// Which `in_time` is supplied is therefore where rung length is decided, and that
// is chosen in `endless_flip::install`.
airborne
&& held
&& !p.body_flipping
&& (a.crossed_end || !(a.remaining_before_wrap > *in_time))
}
}
Self::TimeToLand(n) => {
let p = host
.gameplay_conditions
@@ -13,10 +13,29 @@ pub enum MotionHook {
GrabSlide {
right: bool,
},
/// Endless Tricks. **Not retail** -- fires on the synthesised loop transition to count the
/// rung the skater has just started. See `graph_host::endless_flip`.
EndlessFlipAdvance {
trick: String,
base_rung: u32,
/// The authored rungs a cycle ladder replays, in order. Empty for a single-clip family.
cycle_clips: Vec<String>,
},
}
impl MotionHook {
pub fn parse(a: &Attributes<'_>) -> Option<Self> {
match a.text("name")? {
"EndlessFlipAdvance" => Some(Self::EndlessFlipAdvance {
trick: a.text("trick").unwrap_or("").into(),
base_rung: a.text("baseRung").and_then(|v| v.parse().ok()).unwrap_or(4),
cycle_clips: a
.text("cycleClips")
.unwrap_or("")
.split(',')
.filter(|c| !c.is_empty())
.map(str::to_owned)
.collect(),
}),
"GrabSlide" => Some(Self::GrabSlide {
right: a.boolean_byte("right", 1) != 0,
}),
@@ -13,6 +13,22 @@ impl MotionHost {
&mut self.score_packet.flags,
phase,
);
// Endless Tricks: an extra rung re-enters the authored cycle state, so it
// republishes the authored `<Trick>3`/`<Trick>4`. Rewrite those to the rung
// actually being flown. `published_name` returns None on every retail path --
// and for every other trick even while a ladder is running -- so this changes
// nothing unless a mod turned the feature on.
if phase == 1 {
if let Some(name) = self
.score_packet
.trick_names
.first
.and_then(|published| self.endless.published_name(published))
{
self.score_packet.trick_names.first = Some(name);
self.score_packet.trick_names.second = Some(name);
}
}
}
MotionOperation::SetBumpCoefficients { x, y } => {
if phase == 0 {
+16 -5
View File
@@ -305,16 +305,27 @@ pub(crate) struct StockGraphs {
impl StockGraphs {
pub fn load(root: &Path, manifest: &GameAssets) -> Result<Self, String> {
Ok(Self {
action: load_graph(root, &manifest.action_graph)?,
motion: load_graph(root, &manifest.motion_graph)?,
action: load_graph(root, &manifest.action_graph, false)?,
motion: load_graph(root, &manifest.motion_graph, true)?,
})
}
}
fn load_graph(root: &Path, relative: &str) -> Result<LoadedGraph, String> {
fn load_graph(root: &Path, relative: &str, endless_flips: bool) -> Result<LoadedGraph, String> {
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 mut source = StateGraph::load(&path).map_err(|error| error.to_string())?;
// Endless Tricks: a deliberate non-retail extension. The loop transitions are installed
// unconditionally so the compiled graph is one shape, and their condition is false unless a
// mod turns the feature on. Only the MotionGraph authors flip cycle states.
let sites = if endless_flips {
crate::graph_host::endless_flip::install(&mut source)
.map_err(|error| format!("Invalid endless flip install {}: {error}", path.display()))?
} else {
Vec::new()
};
let mut binding = Binding::from_graph(&source).map_err(|error| error.to_string())?;
crate::graph_host::endless_flip::reorder(&mut binding, &sites)
.map_err(|error| format!("Invalid endless flip reorder {}: {error}", path.display()))?;
let runtime = CompiledGraph::from_binding(&binding)
.map_err(|error| format!("Invalid runtime graph {}: {error}", path.display()))?;
Ok(LoadedGraph {
@@ -42,6 +42,21 @@ impl Group {
}
}
/// Every gesture name the catalog knows, unrotated keys first then each rotated map's own.
///
/// Read-only and for tests: it lets a check pair the catalog against another table -- such as the
/// Endless Tricks rung table -- using the real names rather than retyped ones.
#[cfg(test)]
pub(crate) fn all_names() -> impl Iterator<Item = &'static str> {
COMMON
.iter()
.chain(&TAIL_90)
.chain(&TAIL_N90)
.chain(&NOSE_90)
.chain(&NOSE_N90)
.copied()
}
// Square82B99060..99DAC; Tail82B99E20..9AAD0; Nose82B9AB40..9B7F0.
const COMMON: [&str; 30] = [
"Ollie",
+232 -3
View File
@@ -11,6 +11,32 @@ pub(crate) struct GestureInput {
recognizers: Vec<(usize, Recognizer)>,
maximum_misses: [u8; 2],
held_pattern: Option<String>,
/// Last traced hold state, so `SKATE_GESTURE_TRACE` prints transitions rather than every tick.
traced_held: bool,
}
/// How far the stick must still be deflected for a mod-only hold to survive. Traced holds sit around
/// 0.9 to 1.0, and the authored final coordinates are all beyond 0.8, so half deflection is generous
/// without counting a stick on its way back to centre.
const HOLD_MIN_DEFLECTION: f32 = 0.5;
/// Cosine of the widest angle off the gesture's final coordinate that still holds -- 60 degrees.
///
/// Wide enough to cover the drift that was losing the 360 pop shuvit (about 25 degrees), and still far
/// short of separating opposed pairs: `360PopShuvit` ends right and `FS360PopShuvit` ends left, 180
/// degrees apart, so neither can be mistaken for the other.
const HOLD_MAX_ANGLE_COS: f32 = 0.5;
/// A family the mod made holdable, as opposed to the four retail holds, whose behaviour is untouched.
fn mod_only_family(name: &str) -> bool {
!["Kickflip", "Heelflip", "N_Kickflip", "N_Heelflip"].contains(&name)
&& skate_core::scoring::extension::by_family(&name.to_ascii_lowercase(), 2).is_some()
}
/// `SKATE_GESTURE_TRACE=1` reports what the recogniser made of the stick.
fn trace() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("SKATE_GESTURE_TRACE").is_some())
}
impl GestureInput {
pub fn load(root: &Path) -> Result<Self, String> {
@@ -45,6 +71,7 @@ impl GestureInput {
recognizers,
maximum_misses: [misses("left_stick")?, misses("right_stick")?],
held_pattern: None,
traced_held: false,
})
}
@@ -54,6 +81,7 @@ impl GestureInput {
difficulty: u32,
flags: u32,
physical_state: u32,
endless_families: bool,
ag: &mut IntentMap,
) {
// Native manager negates mapped Y. Component deadzone0.1 is initialized
@@ -61,6 +89,7 @@ impl GestureInput {
let samples = axes.map(|[x, y]| [x, -y].map(|v| if v.abs() < 0.1 { 0.0 } else { v }));
let mut events = Vec::new();
let mut held = false;
let mut holdable = false;
for stick in [1, 0] {
// Held checks precede *all* matches on this stick.
for (_, recognizer) in self.recognizers.iter_mut().filter(|r| r.0 == stick) {
@@ -81,18 +110,104 @@ impl GestureInput {
},
) {
let name = recognizer.patterns()[result.pattern].name.clone();
// Listener retains only these four hold-capable events.
// Retail scopes holding to these four. Endless Tricks extends it to every family
// that has a rung table -- gated, because `HoldPattern` is read by the authored
// ActionGraph (trick.xml) and widening it unasked would change stock behaviour
// for those gestures.
//
// **The rung table decides, not a list of names here.** This used to name four
// stems and so only four of the twelve installed ladders could ever run: the
// other eight -- the hardflips, inward heelflips, pop shuvits and their nollie
// forms -- had rungs, had install sites, had air clips to compose a hold from,
// and simply never received `HoldPattern`. Asking
// `scoring::extension::by_family` is the same authority `endless_flip::has_ladder`
// uses, so the two cannot drift apart, and it keeps the structural guarantee that
// a 180 trick can never be held: `fspopshuvit` is absent from the table on
// purpose, and the `90_` variants are not in it either.
//
// Gesture names lowercase onto the table's family keys exactly -- `FS360PopShuvit`
// onto `fs360popshuvit`, `N_360InwardHeelflip` onto `n_360inwardheelflip`.
if ["Kickflip", "Heelflip", "N_Kickflip", "N_Heelflip"].contains(&name.as_str())
|| (endless_families
&& skate_core::scoring::extension::by_family(
&name.to_ascii_lowercase(),
2,
)
.is_some())
{
self.held_pattern = Some(name.clone());
holdable = true;
}
if trace() {
eprintln!(
"SKATE_GESTURE recognised={name} strength={:.2} holdable={holdable} endless_families={endless_families}",
result.strength,
);
}
events.push((name, result.strength));
}
}
}
// **A more forgiving hold, for the families retail never held at all.**
//
// Retail keeps a hold only while the stick stays within the pattern's authored tolerance of
// its final coordinate. For the four tricks retail actually holds that is the behaviour and it
// stays exactly as authored. For the twelve the mod adds, it is too strict to play: traced
// from a real session, a 360 pop shuvit armed its hold at `[0.99, 0.00]` and lost it at
// `[0.92, -0.39]` -- the stick had slid down into `[0.91, -0.42]`, which is where the kickflip
// and 360 flip holds live, so muscle memory from the tricks that already worked pulled the
// stick straight out of this one.
//
// So a mod-only family keeps its hold while the stick is still **held out in roughly the right
// direction**: deflected past `HOLD_MIN_DEFLECTION` and within `HOLD_MAX_ANGLE` of the final
// coordinate. That is what "still holding on the edge of the stick" means as a test. It cannot
// change stock behaviour, because stock never publishes `HoldPattern` for these gestures --
// the whole path is behind `endless_families`.
if !held && endless_families {
if let Some(name) = self.held_pattern.as_deref() {
if mod_only_family(name) {
for (stick, recognizer) in self.recognizers.iter() {
let Some(pattern) = recognizer
.patterns()
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
else {
continue;
};
let Some(&[tx, ty]) = pattern.points.last() else {
continue;
};
let [sx, sy] = samples[*stick];
let deflection = (sx * sx + sy * sy).sqrt();
let target = (tx * tx + ty * ty).sqrt();
if deflection < HOLD_MIN_DEFLECTION || target <= f32::EPSILON {
continue;
}
// Cosine of the angle between where the stick is and where the gesture ends.
let cosine = (sx * tx + sy * ty) / (deflection * target);
if cosine >= HOLD_MAX_ANGLE_COS {
held = true;
break;
}
}
}
}
}
if held {
ag.insert("HoldPattern", 1.0);
}
// Why a hold did or did not engage, printed only when it changes. Reported from play: only the
// kickflip and the 360 flip could be held, where the harness ladders all twelve from the
// authored PAT coordinates -- so what the game sees from the stick is the unknown, and
// guessing at it is what this replaces. `held_pattern` is the gesture the recogniser last
// armed and `held` is whether the stick is still inside that pattern's final coordinate.
if trace() && held != self.traced_held {
self.traced_held = held;
eprintln!(
"SKATE_GESTURE hold={held} pattern={:?} axes=[{:.2} {:.2}]",
self.held_pattern, samples[1][0], samples[1][1],
);
}
for (name, strength) in events {
if permitted(&name, flags, physical_state, ag) {
ag.insert("Trick", 1.0);
@@ -103,6 +218,119 @@ impl GestureInput {
}
}
/// Every gesture the Endless Tricks ladder installs must be one the player can actually hold.
///
/// This is the gap the owner found in play: twelve ladders were installed and logged, but only four
/// gestures were ever marked holdable, so eight of them could never run a single extra rung. Pairing
/// the catalogue against the rung table catches that without any asset or any graph.
#[cfg(test)]
mod endless_hold_tests {
/// The gesture names that carry a rung table, taken from the real catalogue rather than retyped.
fn holdable() -> Vec<&'static str> {
crate::input::gesture_catalog::all_names()
.filter(|n| {
skate_core::scoring::extension::by_family(&n.to_ascii_lowercase(), 2).is_some()
})
.collect()
}
#[test]
fn every_laddered_family_is_a_holdable_gesture() {
let names = holdable();
// Twelve families, each with a nollie form already counted among them.
assert_eq!(
names.len(),
12,
"expected twelve holdable laddered gestures, got {names:?}"
);
for expected in [
"360Flip",
"360Hardflip",
"360InwardHeelflip",
"360PopShuvit",
"FS360PopShuvit",
"Laserflip",
"N_360Flip",
"N_360Hardflip",
"N_360InwardHeelflip",
"N_360PopShuvit",
"N_FS360PopShuvit",
"N_Laserflip",
] {
assert!(names.contains(&expected), "{expected} is not holdable: {names:?}");
}
}
/// A 180 trick must not become holdable by accident. `fspopshuvit` is kept out of the rung table
/// on purpose, and the `90_` variants are not in it either, so the lookup excludes both.
#[test]
fn a_half_rotation_gesture_never_becomes_endless() {
let names = holdable();
for forbidden in ["FS_Shuvit", "BS_Shuvit", "FSPopShuvit", "90_360PopShuvit"] {
assert!(
!names.contains(&forbidden),
"{forbidden} must not be holdable: {names:?}"
);
}
}
/// The forgiving hold applies to the twelve the mod added and to none of retail's four.
///
/// Retail's hold tolerance is authored and must stay exact; the mod's families have no authored
/// hold at all, which is what makes widening theirs safe.
#[test]
fn only_the_mod_added_families_get_the_forgiving_hold() {
for retail in ["Kickflip", "Heelflip", "N_Kickflip", "N_Heelflip"] {
assert!(
!super::mod_only_family(retail),
"{retail} is a retail hold and must keep its authored tolerance"
);
}
for added in holdable() {
assert!(
super::mod_only_family(added),
"{added} should get the forgiving hold"
);
}
for unrelated in ["Ollie", "FSPopShuvit", "Hardflip", "90_360PopShuvit"] {
assert!(
!super::mod_only_family(unrelated),
"{unrelated} is not a laddered family"
);
}
}
/// The widened angle must not let one gesture answer for another.
///
/// The pair that matters is `360PopShuvit`, which ends to the right, against `FS360PopShuvit`,
/// which ends to the left. They are 180 degrees apart, so a 60-degree window cannot confuse them.
#[test]
fn the_widened_hold_still_separates_opposed_gestures() {
let cos_between = |a: [f32; 2], b: [f32; 2]| {
let la = (a[0] * a[0] + a[1] * a[1]).sqrt();
let lb = (b[0] * b[0] + b[1] * b[1]).sqrt();
(a[0] * b[0] + a[1] * b[1]) / (la * lb)
};
// The authored final coordinates, as `probe_where_to_hold_each_family` prints them.
let right = [0.95, 0.25];
let left = [-0.97, 0.26];
assert!(
cos_between(right, left) < super::HOLD_MAX_ANGLE_COS,
"the shuvit pair is inside the hold window"
);
// And the drift that was losing the hold is now inside it: traced at [0.92, -0.39].
assert!(
cos_between(right, [0.92, -0.39]) >= super::HOLD_MAX_ANGLE_COS,
"the traced drift should now keep the hold"
);
// A stick on its way back to centre must not hold.
assert!(
(0.2f32 * 0.2 + 0.1 * 0.1).sqrt() < super::HOLD_MIN_DEFLECTION,
"a near-centred stick must release"
);
}
}
fn permitted(name: &str, flags: u32, state: u32, ag: &IntentMap) -> bool {
let bit = |n: u32| (flags & (1u32 << n)) != 0u32;
let member = |names: &[&str]| names.iter().any(|n| n.eq_ignore_ascii_case(name));
@@ -172,7 +400,7 @@ mod tests {
.unwrap()
.points
.clone();
input.publish([[0.0; 2]; 2], 0, 0, 100, &mut ag);
input.publish([[0.0; 2]; 2], 0, 0, 100, false, &mut ag);
for _ in 0..30 {
ag.clear();
input.publish(
@@ -180,13 +408,14 @@ mod tests {
0,
0,
100,
false,
&mut ag,
);
assert!(!ag.contains_key("Trick"));
}
for point in &points[1..] {
ag.clear();
input.publish([[0.0; 2], [point[0], -point[1]]], 0, 0, 100, &mut ag);
input.publish([[0.0; 2], [point[0], -point[1]]], 0, 0, 100, false, &mut ag);
}
assert!(ag.contains_key("360Flip"), "{ag:?}");
assert_eq!(
@@ -257,6 +257,50 @@ pub(crate) fn advance(
},
);
skater.animation.motion.hold_fakie = physics.trainer.hold_fakie;
// Endless Tricks: a deliberate non-retail extension, off unless a mod asks for it. The rung
// counter clears on the ground because landing is the only way out of a flip ladder, which
// saves finding and patching the authored transition that enters the cycle.
skater.animation.motion.endless.enabled = physics.trainer.endless_flips;
skater.animation.motion.endless.max_loops = physics.trainer.endless_flip_max;
skater.animation.motion.endless.air_check = physics.trainer.endless_air_check;
if skater.player_state.current().category() == 200 {
skater.animation.motion.endless.airborne();
} else {
skater.animation.motion.endless.land();
}
// A repeated rotation of a single-clip family -- 360 flip, laserflip -- keeps playing the air
// clip so the board turns, and parks the body in the authored hold pose. The cycle ladders
// replay authored rungs instead and need no hold.
//
// Gated on being airborne, which the rung deliberately is not: the scoring rung survives its
// own landing so the trick can bank the name it was flown under, and reusing that lifetime here
// left the skater riding away still holding the pose.
//
// It also ends the moment the stick comes back in. The catch is the authored end of the trick
// and it begins while the skater is still airborne, so releasing only at touchdown left the feet
// parked in the hold pose, hovering over the deck instead of landing on it. `HoldPattern` is the
// same intent the loop condition gates each extra rung on, so the hold and the ladder start and
// stop together by construction.
let held_now = skater
.animation
.motion
.action_intents
.contains_key("HoldPattern");
let endless = &skater.animation.motion.endless;
let holding = endless.enabled
&& endless.loops > 0
&& endless.base_rung < 4
&& held_now
&& skater.player_state.current().category() == 200;
let rung = endless.rung();
let hold = holding
.then(|| endless.trick.as_deref())
.flatten()
.map(|trick| (crate::graph_host::endless_flip::hold_clip(trick), trick.to_owned()));
skater.animation.evaluator.set_endless_hold(
hold.as_ref()
.map(|(clip, trick)| (clip.as_str(), trick.as_str(), rung)),
);
let observations = AnimationPhysical {
conditions,
feedback,
@@ -24,6 +24,8 @@ pub(crate) struct PlayerControls {
//None means the native offboard remap gate did not run, not missing camera.
offboard_axes: Option<[f32; 2]>,
gestures: Option<crate::input::gesture_input::GestureInput>,
/// Endless Tricks: widens which gestures may be held. False on every stock path.
pub endless_families: bool,
}
impl Default for PlayerControls {
fn default() -> Self {
@@ -41,6 +43,7 @@ impl Default for PlayerControls {
preferences: PushPreferences::default(),
offboard_axes: None,
gestures: None,
endless_families: false,
}
}
}
@@ -56,6 +59,8 @@ pub(super) fn sample(
player
.update_for_physics(&mut map, &physics, &skater, &camera)
.unwrap_or_else(|error| panic!("Offboard controller publication: {error}"));
// Endless Tricks extends which gestures may be held; off, this is the stock set.
player.endless_families = physics.trainer.endless_flips;
player.publish_gestures(
physics.animation_profile.physics_mode,
skater.player_input.physical.state.state_16,
@@ -129,6 +134,7 @@ impl PlayerControls {
difficulty,
self.actor_flags,
physical_state,
self.endless_families,
&mut self.action_intents,
);
}
+37 -7
View File
@@ -298,6 +298,10 @@ pub(crate) struct Runtime {
pub session: Session,
collector: Collector,
carriers: [Option<Carrier>; 4],
/// Endless Tricks rung per slot, 0 on every retail path. Carried beside the scorable so
/// a fifth flip is a different carrier from the quad it borrows its ledger identity from,
/// which `carrier`'s id-equality test alone could not tell apart.
extension: [u32; 4],
held: [f32; 4],
distance: [f32; 4],
metric_rewards: [f32; 4],
@@ -392,6 +396,7 @@ impl Runtime {
session: Session::default(),
collector: Collector::None,
carriers: std::array::from_fn(|_| None),
extension: [0; 4],
held: [0.; 4],
distance: [0.; 4],
metric_rewards: [0.; 4],
@@ -492,6 +497,7 @@ impl Runtime {
}
}
fn finish(&mut self, slot: usize, complete: bool, keep_metric: bool) {
self.extension[slot] = 0;
if let Some(mut carrier) = self.carriers[slot].take() {
if complete {
carrier.complete(self.data.unannounced_factor);
@@ -559,8 +565,17 @@ impl Runtime {
self.metric_rewards[slot] = 0.;
}
}
fn carrier(&mut self, slot: usize, id: Option<usize>, f: &Frame) -> Result<(), String> {
if self.carriers[slot].as_ref().map(|c| c.scorable.id) != id {
fn carrier(
&mut self,
slot: usize,
id: Option<usize>,
rung: u32,
f: &Frame,
) -> Result<(), String> {
// The rung disjunct is the only change on the retail path, and it is permanently false
// there: nothing publishes an extension name, so `rung` and `self.extension` stay 0.
if self.carriers[slot].as_ref().map(|c| c.scorable.id) != id || self.extension[slot] != rung
{
let conversion = id.filter(|new| {
self.collector == Collector::Air
&& self.carriers[slot].is_some()
@@ -591,6 +606,13 @@ impl Runtime {
.data
.by_id(id)
.ok_or_else(|| format!("Missing native scorable {id}"))?;
// Endless Tricks overrides only what has to differ. `None` on the retail path,
// where these reduce to `d.points` and `d.label.clone()`.
let ext = skate_core::scoring::extension::by_rung(rung, id);
let points = ext.map_or(d.points, |r| {
skate_core::scoring::extension::points_over(d.points, r)
});
let label = ext.map_or_else(|| d.label.clone(), |r| r.label.to_owned());
let factor = self.penalty(id)
* if self.collector == Collector::Air {
self.air_factor
@@ -599,9 +621,9 @@ impl Runtime {
};
let mut carrier = Carrier::new(
d.metadata,
d.points,
points,
factor,
self.data.announcement.evaluate(d.points as f32),
self.data.announcement.evaluate(points as f32),
if chained_grab {
previous_tick.unwrap_or(f.tick)
} else {
@@ -623,9 +645,10 @@ impl Runtime {
self.modified_trick = true;
}
if conversion.is_some() {
self.base_trick_label = Some(d.label.clone());
self.base_trick_label = Some(label.clone());
self.base_trick_type = d.trick_type;
}
self.extension[slot] = rung;
self.carriers[slot] = Some(carrier);
self.sequence_active = true;
self.idle_ticks = 0;
@@ -641,7 +664,10 @@ impl Runtime {
.data
.by_id(c.scorable.id)
.ok_or("Missing announced scorable")?;
self.base_trick_label = Some(d.label.clone());
self.base_trick_label = Some(
skate_core::scoring::extension::by_rung(self.extension[slot], c.scorable.id)
.map_or_else(|| d.label.clone(), |r| r.label.to_owned()),
);
// 825E51A0 resolves the *named* scorable's record and reads its TrickType
// from desc+120 to decide whether the name may carry a spin. Setting the
// label here without the type left the type at whatever a previous flip
@@ -884,9 +910,11 @@ impl Runtime {
self.new_trick = false;
self.modified_trick = false;
self.close_tricks = false;
let rung = f.descriptor.and_then(skate_core::scoring::extension::by_name);
let descriptor = f
.descriptor
.and_then(|name| self.data.by_name(name))
.or_else(|| rung.and_then(|r| self.data.by_id(r.base_id)))
.filter(|d| !(f.flags & 0x02000000 != 0 && [67, 68, 69].contains(&d.metadata.id)))
.map(|d| (d.metadata.id, d.metadata.class, d.metadata.score_type));
let mut next = match f.category {
@@ -1109,8 +1137,10 @@ impl Runtime {
}
Collector::None => {}
}
let rung = rung.map_or(0, |r| r.rung);
for (slot, id) in ids.into_iter().enumerate() {
self.carrier(slot, id, &f)?;
// Only the trick slot can carry an extension rung; the metric slots never do.
self.carrier(slot, id, if slot == 0 { rung } else { 0 }, &f)?;
}
if self.collector == Collector::Air && !f.suspend_air {
if self.collector_ticks > 5 || f.flags & 0x01000000 != 0 {
@@ -109,6 +109,7 @@ impl SkaterAnimation {
/// Native Initialize82B97E38 selects both orientation/mirror bits for
/// regular stance. A profile edit changes the natural basis while retaining
/// the current relative stance and trick state.
pub(crate) fn set_customisation(&mut self, natural: u32, style: u32) {
if natural <= 1 && self.state.publication.natural_stance != natural as i32 {
self.state.publication.natural_stance = natural as i32;
+696 -3
View File
@@ -136,6 +136,535 @@ fn air_scoops_reach_the_authored_late_flips() {
}
}
/// A held rotation keeps the board turning while the body stays put. That is the whole contract.
///
/// **This test used to assert the opposite** -- that a repeat swaps in the authored
/// `T_<trick>_H_CYC` cycle clip -- and that was wrong twice over. Those clips are hold *poses*, not
/// rotations: measured, they move the board a tenth as much as the kickflip's cycle does, so playing
/// one on a repeat parked the deck and only the first rotation was ever visible. And the assertion
/// watched `animations`, which holds the *tree* names the graph plays, where the hold is composed at
/// bank level and never appears there at all.
///
/// So the rung keeps playing the trick's own air clip -- which is what turns the deck -- and
/// `animation_pose` replaces the body alone. The deck must not be parked, which is the failure this
/// replaced and the one thing this sim-level run can decide.
///
/// **It cannot decide the other half.** `body_motion` sums *local* rotations, and the four
/// reparented helpers are children of the board, so holding them still in world space demands
/// locals that counter-rotate the deck -- the faster the deck turns, the more "body movement" the
/// metric reports. Measured: turning the synthesis off moves the 360 flip from 2.70 to 2.23 while
/// the board goes 0.54 to 0.28, so most of that difference is the metric watching the deck through
/// the helpers. The body actually staying put is asserted where it can be, directly on the composed
/// clip, in `authored_clips`' `the_held_hands_and_toes_follow_the_body_not_the_spun_deck`.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn a_held_rotation_turns_the_board_and_not_the_body() {
for trick in ["360Flip", "Laserflip"] {
let stock = replay(Input::Held { trick, boost: 14.0 }, 600);
let endless = replay(
Input::EndlessCapped { trick, boost: 14.0, extra: 2 },
600,
);
assert!(
endless.tricks.len() > stock.tricks.len(),
"{trick}: the loop never ran"
);
assert!(
endless.motion_ticks > 0 && stock.motion_ticks > 0,
"{trick}: nothing was sampled to compare"
);
let per = |total: f32, ticks: u32| total / ticks as f32;
// Per tick, over the same clip the same gate selects, so this is a matched comparison of the
// one channel that matters: the deck must keep turning at least as fast as the authored
// trick turns it. The synthesis runs at the measured plateau, which is faster.
let board = per(endless.board_motion, endless.motion_ticks);
let stock_board = per(stock.board_motion, stock.motion_ticks);
assert!(
board >= 0.9 * stock_board,
"{trick}: the held board moves {board} per tick against the authored {stock_board} -- \
it is parked"
);
}
}
/// A rung has to cost a clip. If rungs outrun the air, the loop is re-firing on a finished clip.
///
/// This is the shape of a bug that reached play and that no smoothness measurement could see: the
/// rung counter raced while a single animation sat on screen, because a self-transition carrying
/// `transitionUnder` nests the incoming clip inside the live transition. Re-entering builds a
/// tower that never resolves, so `crossed_end` never clears and the condition fires every tick.
///
/// The budget is derived from the clip a rung actually plays, which is now the trick's own **air**
/// clip rather than a cycle clip: measured, `360FLIP_D_HIGH_A` is 33 frames and `_LOW_A` is 28, at
/// 60 fps against the 60 Hz tick. Half of the shorter one is a generous rung and still an order of
/// magnitude away from a per-tick race. (The old divisor of 30 came from the 61-frame cycle clip,
/// which a rung no longer plays.)
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn a_rung_costs_a_whole_cycle_clip() {
let run = replay(Input::Endless { trick: "360Flip", boost: 14.0 }, 600);
assert!(run.tricks.len() > 1, "the ladder never left the authored rung");
let budget = run.air_ticks / 14;
assert!(
run.tricks.len() as u32 <= budget,
"{} rungs in {} air ticks is faster than the clip can play (budget {budget})",
run.tricks.len(),
run.air_ticks
);
}
/// Past the octuple the counted names run out and the trick takes the mod's own name.
///
/// The rung is pinned with the mod's own cap, so this measures the naming and nothing else --
/// anchoring it to a takeoff speed only measures how much air the test course happens to give.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn the_ninth_flip_and_beyond_is_named_endless() {
let octuple = replay(
Input::EndlessCapped {
trick: "Kickflip",
boost: 16.0,
extra: 4,
},
700,
);
assert_eq!(octuple.tricks.len(), 8, "expected the counted ceiling");
assert_eq!(octuple.name, "#OCTUPLE KICKFLIP");
let ninth = replay(
Input::EndlessCapped {
trick: "Kickflip",
boost: 16.0,
extra: 5,
},
700,
);
assert_eq!(ninth.tricks.len(), 9, "expected exactly a ninth rung");
assert_eq!(
ninth.name, "#ENDLESS KICKFLIP",
"the ninth rung should stop counting and take the mod's name"
);
}
/// The loop junction must not be rougher than an authored one.
///
/// `_CYC3` is authored as the third clip of a sequence, so re-entering it cuts. Measured before
/// the fix, a skeleton part moved 0.41 m in a single tick at each junction against 0.24 m for the
/// worst authored one, once per cycle -- which read on screen as the arms snapping around. The
/// loop transition carries a `blendWithCurrentFrame` override so it cross-fades out of the pose
/// on screen instead. Comparing against the same pop with the feature off keeps this honest: the
/// takeoff is the roughest moment in any flip, and it is stock.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn the_loop_junction_is_no_rougher_than_an_authored_one() {
let boost = 14.0;
let worst_in_flip = |o: &Outcome| {
o.jolts
.iter()
.filter(|(_, _, clip)| clip.contains("KICKFLIP"))
.map(|(_, d, _)| *d)
.fold(0f32, f32::max)
};
let stock = replay(Input::Held { trick: "Kickflip", boost }, 600);
let endless = replay(Input::Endless { trick: "Kickflip", boost }, 600);
assert!(endless.tricks.len() > stock.tricks.len(), "the loop never ran");
let (a, b) = (worst_in_flip(&stock), worst_in_flip(&endless));
assert!(
b <= a + 0.01,
"the endless junction is rougher than the authored worst: {b} against {a}"
);
let roughest_clip = endless
.jolts
.iter()
.filter(|(_, _, clip)| clip.contains("KICKFLIP"))
.max_by(|x, y| x.1.total_cmp(&y.1))
.map(|(_, _, clip)| clip.clone())
.unwrap_or_default();
assert!(
!roughest_clip.contains("_CYC"),
"a cycle clip is the roughest moment of the flip: {roughest_clip}"
);
}
/// The same measurement for the 360 flip family, which until now nothing defended.
///
/// The test above filters on `KICKFLIP`, so it says nothing about the family whose hold is
/// synthesised -- and the 360-flip jolt figures in `docs/endless-tricks.md` were taken by hand
/// against a code path that no longer exists. This family is the harder case: retail authors no
/// cycle for it, so the deck's rotation through a held rung is composed rather than played, and a
/// discontinuity in that composition is exactly what the owner saw as the board jumping.
///
/// **The bar is not parity with the authored trick, because the ladder does not reach it.** Measured
/// on the flat course at this boost, a held 360 flip's worst single-tick skeleton jump is 0.61 m
/// against the authored trick's 0.23. That gap is pre-existing and is **not** the synthesised spin:
/// it is identical with `SKATE_ENDLESS_RIGID_SPIN=0`, and the jolt probe names the part as bone 3
/// rather than anything in the board subtree. Its two mechanisms, the hold arriving and the air clip
/// restarting from frame 0 each rung, are written up in `docs/engine-defects.md`.
///
/// So this guards against **regression** rather than asserting a bar the mechanism cannot meet. The
/// ceiling is the measured state plus a margin; the point is that the deck synthesis must not add to
/// it, which is the thing that would show as the board jumping.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn the_synthesised_spin_is_no_rougher_than_the_authored_trick() {
for trick in ["360Flip", "Laserflip"] {
let worst = |o: &Outcome, stem: &str| {
o.jolts
.iter()
.filter(|(_, _, clip)| clip.to_uppercase().contains(stem))
.map(|(_, d, _)| *d)
.fold(0f32, f32::max)
};
let stem = trick.to_uppercase();
let stock = replay(Input::Held { trick, boost: 14.0 }, 600);
let endless = replay(
Input::EndlessCapped { trick, boost: 14.0, extra: 2 },
600,
);
assert!(
endless.tricks.len() > stock.tricks.len(),
"{trick}: the loop never ran"
);
let (a, b) = (worst(&stock, &stem), worst(&endless, &stem));
assert!(a > 0.0, "{trick}: nothing was measured for {stem}");
println!("JOLT {trick}: authored {a:.4} held {b:.4}");
// Measured 0.606 (360 flip) and 0.474 (laserflip) with the ease in. 0.70 leaves room for
// course and float variation while still catching anything that makes the deck jump.
assert!(
b <= 0.70,
"{trick}: the held rung jolts {b} against a measured ceiling of 0.70 (authored {a})"
);
// And the deck must not be the part that jumps. This is the assertion that would fail if the
// spin synthesis regressed -- the size alone could not tell the deck from the body.
let worst_part = endless
.jolt_parts
.iter()
.max_by(|x, y| x.1.total_cmp(&y.1))
.map_or(0, |(_, _, part)| *part);
assert!(
worst_part < 25,
"{trick}: the roughest part is {worst_part}, which is in the board subtree -- the \
synthesised deck is jumping"
);
}
}
/// The 360 flip family laddering, which is a different shape of problem from the kickflip.
///
/// `T_TrickWithDarkCatch.xml` authors no cycle *states* and `360flip`/`laserflip` have no numbered
/// scorables, so the rungs are synthesised. Retail also scopes the hold mechanic to the kickflip
/// and heelflip, so these gestures cannot be held at all unless the mod widens it. The rungs count
/// by board rotation: a doubled 360 flip is a 720.
///
/// The expectation is derived from the rungs actually reached rather than pinned to a boost. The
/// authored cycle clip is 61 frames against the air clip's 33, so a rotation takes about twice as
/// long as it used to and how many fit in a given pop is a property of the clip, not of the
/// naming this is here to check.
/// PROBE, not an assertion: what a held rung actually looks like. The hold the owner wants is
/// "the legs stay out of the way and the board rotates", which is exactly what the body/board
/// split measures -- near-zero body movement against a board that keeps turning.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn probe_what_a_held_rung_animates() {
for trick in ["Kickflip", "360Flip", "Laserflip"] {
let run = replay(Input::Endless { trick, boost: 14.0 }, 600);
let per = |total: f32| {
if run.motion_ticks == 0 {
0.
} else {
total / run.motion_ticks as f32
}
};
let mut clips: Vec<String> = Vec::new();
for clip in &run.animations {
if clips.last() != Some(clip) {
clips.push(clip.clone());
}
}
let stem = trick.to_uppercase();
let mut ranked: Vec<&(u32, f32, String)> = run
.jolts
.iter()
.filter(|(_, _, clip)| clip.to_uppercase().contains(&stem))
.collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
let worst = ranked.first().map_or(0.0, |j| j.1);
for (tick, size, clip) in ranked.iter().take(6) {
let part = run
.jolt_parts
.iter()
.find(|(t, _, _)| t == tick)
.map_or(usize::MAX, |(_, _, p)| *p);
println!(" JOLT {trick} tick={tick} {size:.4} part={part} in {clip}");
}
println!(
"PROBE {trick}: rungs={} name={:?} body={:.4} board={:.4} ratio={:.2} jolt={:.4} ticks={} clips={:?}",
run.tricks.len(),
run.name,
per(run.body_motion),
per(run.board_motion),
per(run.board_motion) / per(run.body_motion).max(1e-6),
worst,
run.motion_ticks,
clips,
);
}
}
/// Where the stick has to finish, and stay, for each holdable family.
///
/// A hold is kept only while the stick stays within tolerance of the gesture's **final** authored
/// coordinate -- `gesture::Recognizer::held` compares against `points.last()`. So "I could not hold
/// anything but the kickflip and the 360 flip" has a concrete answer per trick, and it is in the PAT
/// data rather than in anybody's opinion. Printed as the coordinate to park the stick on.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn probe_where_to_hold_each_family() {
let root = std::path::PathBuf::from(
std::env::var_os("SKATE3_ASSET_ROOT").expect("set SKATE3_ASSET_ROOT"),
);
let compass = |[x, y]: [f32; 2]| {
let vertical = if y > 0.35 { "up" } else if y < -0.35 { "down" } else { "" };
let horizontal = if x > 0.35 { "right" } else if x < -0.35 { "left" } else { "" };
match (vertical, horizontal) {
("", "") => "centre".to_owned(),
(v, "") => v.to_owned(),
("", h) => h.to_owned(),
(v, h) => format!("{v}-{h}"),
}
};
for trick in [
"Kickflip",
"360Flip",
"360Hardflip",
"360InwardHeelflip",
"360PopShuvit",
"FS360PopShuvit",
"Laserflip",
"N_360Flip",
"N_360Hardflip",
"N_360InwardHeelflip",
"N_360PopShuvit",
"N_FS360PopShuvit",
"N_Laserflip",
] {
let points = authored("skater.pat", &root, trick);
match points.last() {
Some(&last) => println!(
"HOLD {trick:<22} points={} finish=[{:+.2} {:+.2}] park the stick {}",
points.len(),
last[0],
last[1],
compass(last),
),
None => println!("HOLD {trick:<22} no authored pattern"),
}
}
}
/// Does a rung past the authored quad cost the same time as a rung inside it?
///
/// Reported from play: "in kickflip endless the board speeds up once it goes past quad when it needs
/// to stay the same speed." The kickflip is a **cycle** ladder -- it replays the authored `Cyc2`/`Cyc3`
/// pair and never touches the synthesised spin -- so if it accelerates, the cause is the loop firing
/// sooner rather than the deck turning faster. Those two look identical on screen and are told apart
/// by the gap between rungs: a rung that costs a whole clip holds its spacing, and a rung that re-fires
/// early does not.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn probe_rung_spacing_across_the_quad() {
for trick in ["Kickflip", "Heelflip", "360Flip"] {
let run = replay(Input::Endless { trick, boost: 14.0 }, 600);
let gaps: Vec<i64> = run
.rung_ticks
.windows(2)
.map(|w| w[1].0 as i64 - w[0].0 as i64)
.collect();
println!(
"SPACING {trick:<10} rungs={} ticks={:?}\n gaps={:?}",
run.rung_ticks.len(),
run.rung_ticks.iter().map(|(t, _)| *t).collect::<Vec<_>>(),
gaps,
);
// Per-clip board speed, so a genuinely faster deck can be separated from faster rungs.
let mut runs: Vec<(String, u32, f32, f32)> = Vec::new();
for (clip, delta, length) in &run.board_per_clip {
match runs.last_mut() {
Some(last) if &last.0 == clip => {
last.1 += 1;
last.2 += delta;
last.3 = *length;
}
_ => runs.push((clip.clone(), 1, *delta, *length)),
}
}
for (clip, ticks, total, length) in &runs {
println!(
" {clip:<24} ticks={ticks:<4} board/tick={:.4} clock_length={length:.3}s ({:.1} ticks)",
total / *ticks as f32,
length * 60.0,
);
}
}
}
/// Which of the twelve installed ladders actually run when the gesture *is* performed.
///
/// Reported from play: only the kickflip and the 360 flip could be held. Widening `HoldPattern` to
/// every family with a rung table was necessary but may not be sufficient, and the two causes look
/// identical from the sofa -- a family the engine will not ladder, against a gesture that is simply
/// hard to flick and hold. This harness replays the authored PAT coordinates, so it removes the stick
/// from the question entirely: whatever fails here is the engine's fault, and whatever passes here is
/// an input problem rather than a code one.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn probe_which_families_ladder() {
for trick in [
"360Flip",
"360Hardflip",
"360InwardHeelflip",
"360PopShuvit",
"FS360PopShuvit",
"Laserflip",
"N_360Flip",
"N_360Hardflip",
"N_360InwardHeelflip",
"N_360PopShuvit",
"N_FS360PopShuvit",
"N_Laserflip",
] {
let stock = replay(Input::Held { trick, boost: 14.0 }, 600);
let run = replay(
Input::EndlessCapped { trick, boost: 14.0, extra: 2 },
600,
);
println!(
"LADDER {trick:<22} stock={} endless={} name={:?} reward={:.0} board={:.4}",
stock.tricks.len(),
run.tricks.len(),
run.name,
run.reward,
if run.motion_ticks > 0 {
run.board_motion / run.motion_ticks as f32
} else {
0.0
},
);
}
}
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn the_360_flip_family_ladders_by_rotation() {
// ENDLESS from the **eighth**, which is one rung earlier than the cycle ladders turn over --
// `degree_family!` in `scoring::extension` is the authority and it stops counting at the
// seventh, because past 2520 degrees the number stops meaning anything on a trick that is one
// rotation in retail. This read `>= 9` and disagreed with the table.
let expected = |rung: usize, word: &str, endless: &str| -> String {
if rung >= 8 {
format!("#{endless}")
} else {
format!("#{} {word}", rung * 360)
}
};
for (trick, word, endless) in [
("360Flip", "FLIP", "ENDLESS 360 FLIP"),
("Laserflip", "LASERFLIP", "ENDLESS LASERFLIP"),
] {
let stock = replay(Input::Held { trick, boost: 14.0 }, 600);
assert_eq!(
stock.tricks.len(),
1,
"retail authors exactly one rung for {trick}"
);
// **Capped, like the kickflip's test is.** Uncapped at this boost the ladder takes seven
// rungs, starts an eighth it has no air to finish, and wipes out banking nothing -- so the
// reward assertion below measured how much air the flat course happens to give rather than
// the naming it is here to check. The cap is the mod's own setting, so this exercises the
// same switch a player has. Two rungs past the authored one is a 1080.
let run = replay(
Input::EndlessCapped { trick, boost: 14.0, extra: 2 },
600,
);
assert!(
run.tricks.len() > 1,
"{trick}: the ladder never left the authored rung"
);
assert_eq!(
run.name,
expected(run.tricks.len(), word, endless),
"{trick} at {} rungs",
run.tricks.len()
);
assert!(
run.reward > stock.reward,
"{trick}: {} did not beat the single {}",
run.reward,
stock.reward
);
}
}
/// Endless Tricks, the point of the whole extension: given the air to afford them, a held flick
/// keeps cycling past the authored quad.
///
/// The authored ladder cannot produce this. `Cyc3` has no fifth cycle state and its only exit is
/// an unconditional `WillExpire` to `End.Out.Out4`, so `<Trick>3`/`<Trick>4` appearing a second
/// time in publication order is the synthesised loop and nothing else. The same pop with the
/// feature off is replayed beside it, so any difference is the feature rather than the boost.
#[test]
#[ignore = "requires private stock graphs and animation assets"]
fn endless_flips_cycle_past_the_authored_quad() {
// Measured window on the flat course: 11 m/s buys a fifth flip, 16 an eighth, and past
// ~18 the skater leaves the course and never lands. 14 sits well inside it.
let boost = 14.0;
let stock = replay(Input::Held { trick: "Kickflip", boost }, 600);
let endless = replay(
Input::EndlessCapped {
trick: "Kickflip",
boost,
extra: 3,
},
600,
);
assert_eq!(
stock.tricks,
["Kickflip", "Kickflip2", "Kickflip3", "Kickflip4"],
"the retail ladder stopped somewhere other than the authored quad"
);
assert_eq!(
endless.tricks,
["Kickflip", "Kickflip2", "Kickflip3", "Kickflip4", "Kickflip5", "Kickflip6", "Kickflip7"],
"the loop did not walk the extension rungs"
);
// Plain words rather than an `ID_TRICK_*` key: `apt_text::localize` echoes tokens it cannot
// resolve, so an unknown key would reach the HUD as raw text.
assert_eq!(endless.name, "#SEPTUPLE KICKFLIP");
// Three rungs past the quad at the authored +50 a rung -- and the ladder still *converts*,
// so the gain is rung 7 minus rung 4, not the sum of every rung beneath it.
//
// The window is a few points rather than one because the reward is not only the rungs: the
// air metrics are measured off the flight, and the extra rungs replay the authored `_CYC2` /
// `_CYC3` pair, whose lengths differ, so the trick ends a fraction of a second differently.
// Measured at 151.52 against the three rungs' 150. It stays far below 50, so a rung that
// failed to score still fails this.
assert!(
(endless.reward - stock.reward - 150.).abs() < 5.,
"expected three +50 rungs over the quad's {}, got {}",
stock.reward,
endless.reward
);
// A rung the skater cannot land out of is worse than no rung at all, so the air budget has
// to leave room for the out clip. A wipeout banks nothing and shows up here as a zero.
assert!(
endless.reward > 0.,
"the endless run did not land cleanly: animations={:?}",
endless.animations
);
}
enum Input<'a> {
/// Hold the ground scoop's final coordinate through the whole air.
Held { trick: &'a str, boost: f32 },
@@ -143,9 +672,37 @@ enum Input<'a> {
Single { trick: &'a str, boost: f32 },
/// Ollie, then run a `skater_air.pat` scoop once airborne.
Late { gesture: &'a str, trick: &'a str },
/// Endless Tricks: held exactly as `Held`, with the non-retail loop switched on. Everything
/// else about the run is identical, so a difference in the outcome is the feature and
/// nothing else.
Endless { trick: &'a str, boost: f32 },
/// The same, capped to a fixed number of extra rungs. Pinning the rung with the mod's own cap
/// makes a naming test deterministic; anchoring it to a boost only measures how much air the
/// test course happens to give before something else ends the run.
EndlessCapped {
trick: &'a str,
boost: f32,
extra: u32,
},
}
struct Outcome {
air_ticks: u32,
clip_begins: u32,
/// PROBE: mean per-tick movement of the animated body bones while airborne in a flip clip.
body_motion: f32,
/// PROBE: the same for the board bones, as the control -- the board must keep turning.
board_motion: f32,
motion_ticks: u32,
/// Worst per-tick skeleton pose jump, as (tick, metres, clip).
jolts: Vec<(u32, f32, String)>,
/// PROBE: the same jump with the skeleton part index that produced it, as (tick, metres, part).
jolt_parts: Vec<(u32, f32, usize)>,
/// PROBE: the tick each rung was first published, so a rung can be shown to cost a whole clip.
rung_ticks: Vec<(u32, String)>,
/// PROBE: per-tick board movement with the clip that produced it, to separate a faster deck from
/// faster rungs.
board_per_clip: Vec<(String, f32, f32)>,
animations: Vec<String>,
tricks: Vec<String>,
reward: f32,
@@ -160,18 +717,34 @@ fn replay(input: Input<'_>, ticks: u32) -> Outcome {
let graphs = crate::graph_runtime::StockGraphs::load(&root, &assets).unwrap();
let mut physics = GamePhysics::load_with_terrain(&root, ground::Terrain::Course).unwrap();
let mut skater = SkaterRuntime::load(&root, &graphs, &physics, "normal").unwrap();
// Endless Tricks arrives the way a mod delivers it, through the trainer table, so the test
// exercises the same switch the game does. Off for every other run here, which is what makes
// those runs proof that the compiled-in loop transitions are inert.
physics.trainer.endless_flips = matches!(
input,
Input::Endless { .. } | Input::EndlessCapped { .. }
);
if let Input::EndlessCapped { extra, .. } = input {
physics.trainer.endless_flip_max = extra;
}
let mut camera = crate::camera::CameraRuntime::load(&root).unwrap();
let mut controller = crate::input::ControllerInput::default();
let mut controls = PlayerControls::load(&root).unwrap();
// The production `sample` system copies this from the trainer each tick; the harness
// drives `publish_gestures` directly, so it sets it here.
controls.endless_families = physics.trainer.endless_flips;
let hold = !matches!(input, Input::Single { .. });
// The authored scoops, straight out of the PATs the recognizer itself loads.
let (ground_scoop, air_scoop, boost, watched) = match input {
Input::Held { trick, boost } | Input::Single { trick, boost } => (
Input::Held { trick, boost }
| Input::Single { trick, boost }
| Input::Endless { trick, boost }
| Input::EndlessCapped { trick, boost, .. } => (
authored("skater.pat", &root, trick),
Vec::new(),
boost,
["", "2", "3", "4"]
["", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]
.iter()
.map(|count| format!("{trick}{count}"))
.collect::<Vec<_>>(),
@@ -193,7 +766,18 @@ fn replay(input: Input<'_>, ticks: u32) -> Outcome {
// GameInputManager 82696030 negates the mapped Y before matching.
let sample = |p: [f32; 2]| [(p[0] * 32767.) as i16, (-p[1] * 32767.) as i16];
let mut previous_pose: Option<Vec<[f32; 3]>> = None;
let mut previous_animated: Option<Vec<[f32; 4]>> = None;
let mut outcome = Outcome {
air_ticks: 0,
clip_begins: 0,
body_motion: 0.,
board_motion: 0.,
motion_ticks: 0,
jolts: Vec::new(),
jolt_parts: Vec::new(),
rung_ticks: Vec::new(),
board_per_clip: Vec::new(),
animations: Vec::new(),
tricks: Vec::new(),
reward: 0.,
@@ -221,7 +805,25 @@ fn replay(input: Input<'_>, ticks: u32) -> Outcome {
let right = if air_scoop.is_empty() {
// Crouch on the first coordinate, then hold the flick: holding the
// final coordinate is what keeps `<Trick>Hold` published.
if (100..112).contains(&tick) {
if ground_scoop.len() > 2 {
// The 360 flip and laserflip gestures are four-point arcs -- out to one side, up
// over the top, then down across. The recognizer walks every authored coordinate,
// so skipping the middle two never completes the pattern and the trick silently
// never fires. Two-point scoops keep the original timing below, untouched, so the
// kickflip and heelflip measurements stay comparable.
let last = ground_scoop.len() - 1;
let step = 4;
if tick < 100 {
[0; 2]
} else {
let index = ((tick as usize - 100) / step).min(last);
if index < last || hold || tick < (100 + step * last + 6) as u32 {
sample(ground_scoop[index])
} else {
[0; 2]
}
}
} else if (100..112).contains(&tick) {
sample(ground_scoop[0])
} else if tick >= 112 && (hold || tick < 118) {
sample(ground_scoop[ground_scoop.len() - 1])
@@ -269,9 +871,99 @@ fn replay(input: Input<'_>, ticks: u32) -> Outcome {
.unwrap();
if skater.player_state.current().category() == 200 {
air_ticks += 1;
outcome.air_ticks += 1;
}
// PROBE: the animated pose itself, split the way the rig is -- body bones against board
// bones. A working hold shows near-zero body movement while the board keeps turning.
{
let clip = skater
.animation
.motion
.animation
.current_name
.clone()
.unwrap_or_default();
// Any air clip or cycle clip. This used to be `contains("FLIP_A")`, which silently
// measured nothing for the pop shuvit families -- their clips are `360POPSHUVIT_HIGH_A`
// and carry no "FLIP_A" -- and reported their board motion as 0.0000, which reads exactly
// like a parked deck.
if clip.ends_with("_A") || clip.contains("_CYC") {
let pose = &skater.animation.pose;
let bones = skater.animation.evaluator.body_bones();
// Bone rotations carry the motion in a skeletal rig; translations are lengths.
let sample: Vec<[f32; 4]> = pose.iter().map(|p| p.rotation).collect();
if let Some(prev) = &previous_animated {
let delta = |i: usize| -> f32 {
let (a, b) = (&sample[i], &prev[i]);
(0..4).map(|k| (a[k] - b[k]).abs()).sum::<f32>()
};
let body: f32 = bones.iter().map(|&b| delta(b)).sum();
let board: f32 = (0..sample.len())
.filter(|i| !bones.contains(i))
.map(delta)
.sum();
outcome.body_motion += body;
outcome.board_motion += board;
// The length the engine's own clock is using, not the clip's nominal length. A
// rung that is cut short either has a shorter clock or fires before its end, and
// only this number tells those apart.
let length = skater.animation.motion.animation.current_length().unwrap_or(-1.0);
outcome.board_per_clip.push((clip.clone(), board, length));
outcome.motion_ticks += 1;
}
previous_animated = Some(sample);
}
}
// PROBE: every skeleton part relative to the root, so bulk travel cancels and only
// the pose change remains. A blend discontinuity shows up as a one-tick spike.
{
let bodies = skater.skeleton.bodies();
let root = bodies[0].rates.position;
let pose: Vec<[f32; 3]> = bodies
.iter()
.map(|b| {
let p = b.rates.position;
[p.x - root.x, p.y - root.y, p.z - root.z]
})
.collect();
if let Some(prev) = &previous_pose {
// Which part, not just how much: the board subtree and the four reparented helpers
// sit above bone 25, so the index says whether a spike is the deck or the body. That
// is what distinguishes a synthesised-spin fault from the hold pose arriving.
let (worst, part) = pose
.iter()
.zip(prev)
.enumerate()
.map(|(i, (a, b))| {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
((d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(), i)
})
.fold((0f32, 0usize), |acc, x| if x.0 > acc.0 { x } else { acc });
outcome.jolt_parts.push((tick, worst, part));
let clip = skater
.animation
.motion
.animation
.current_name
.clone()
.unwrap_or_default();
outcome.jolts.push((tick, worst, clip));
}
previous_pose = Some(pose);
}
let motion = &skater.animation.motion;
if motion
.animation
.current_name
.as_deref()
.is_some_and(|n| n.contains("_CYC") || n.contains("FLIP_A"))
&& motion.animation.property().crossed_end
{
outcome.clip_begins += 1;
}
if let Some(name) = motion.animation.current_name.as_deref() {
if outcome.animations.last().map(String::as_str) != Some(name) {
outcome.animations.push(name.to_owned());
@@ -281,6 +973,7 @@ fn replay(input: Input<'_>, ticks: u32) -> Outcome {
if let Some((name, _)) = watched.iter().find(|(_, e)| *e == published) {
if outcome.tricks.last() != Some(name) {
outcome.tricks.push(name.clone());
outcome.rung_ticks.push((tick, name.clone()));
}
}
}
+15
View File
@@ -149,6 +149,17 @@ pub struct TrainerTuning {
pub turn_power: f32,
pub manual_drag: f32,
pub hold_fakie: bool,
/// Endless Tricks. **Not retail**: lets a held flip keep cycling past the authored quad
/// while the pop has the air for it. False leaves the shipped four-rung ladder alone.
pub endless_flips: bool,
/// Upper bound on the extra rungs past the authored quad, so a stuck stick cannot run the
/// counter away. 12 reaches rung 16, which is the last one the extension table names.
pub endless_flip_max: u32,
/// Require enough air for another whole rung before starting one. False -- the default --
/// keeps flipping for as long as the input is held and the skater is off the ground, which is
/// what makes this feel like holding a trick rather than clearing a height bar. True restores
/// the conservative budget, which will not begin a rung it cannot finish.
pub endless_air_check: bool,
}
impl Default for TrainerTuning {
fn default() -> Self {
@@ -165,6 +176,9 @@ impl Default for TrainerTuning {
turn_power: 1.,
manual_drag: 1.,
hold_fakie: false,
endless_flips: false,
endless_flip_max: 12,
endless_air_check: false,
}
}
}
@@ -186,5 +200,6 @@ impl TrainerTuning {
.all(|v| v.is_finite() && (0.25..=4.).contains(&v))
&& self.wobble.is_finite()
&& (0. ..=2.).contains(&self.wobble)
&& (1..=12).contains(&self.endless_flip_max)
}
}
+1
View File
@@ -155,6 +155,7 @@ fn examples_load_and_run() {
assert_eq!(
ids,
vec![
"community.endless-tricks",
"community.freestyle-mx",
"community.mario-kart",
"community.native-trainer"
+484
View File
@@ -0,0 +1,484 @@
# Endless Tricks: the flip ladder past retail's quad
**This is a deliberate extension beyond Skate 3, not a port fix.** Everything else in this engine
is a faithful reimplementation of retail TU3; this is new work built on top of it, shipped as a
mod, and it is off unless that mod turns it on. With it off, the ladder, the names, the points and
the animations are exactly what the game shipped with — see *Proving it stays retail-exact* below.
Built 2026-09-24. First entry in the Endless Tricks mod; the mechanism generalises to other trick
families.
## What retail actually does, and why nothing in code could be uncapped
The flip ladder is **authored, not computed**. There is no flip-count variable and no clamp to 4
anywhere in the Rust. `T_Kickflip.xml` authors `TrickInto -> Cyc1 -> Cyc2 -> Cyc3 -> Out4` and
simply has no `Cyc4`.
Two facts settle the shape of the problem, both read straight out of the asset tree:
* `T_Kickflip.xml:247-251` — `Cyc3`'s only outgoing transition is a bare
`WillExpire InTime="0.05"` to `End.Out.Out4`, with no precondition.
* `End.Out.Out4`'s state definition carries **no `<expression>` at all**, unlike `Out1`/`Out2`/
`Out3`, whose `not HasIntent <Trick>Hold` / `TimeToLand` / `IsBodyFlipping` gates end the ladder
early. Rung 4 is unconditional.
So the quad is not a gate that can be relaxed. The extra rung has to be authored.
That was worth proving rather than assuming. Suppressing `WillExpire` on `_CYC3` behind a
throwaway env var made `B_KICKFLIP_OUT4`, `B_AIR_CYC` and `BLEND_LAND` all disappear from the
animation list and the reward drop to zero — the skater never left the state and never landed,
which is exactly what "the only exit" predicts. It also confirmed the clip does not loop of its own
accord: `clip_clock.rs:88-93` sets `crossed_end` on a non-looping clip and lets time run past
`length`, and `sample_time()` clamps the sample, so holding the state parks the deck on the last
frame.
## How the loop is made
`crates/skate-game/src/graph_host/endless_flip.rs` appends one transition per flip cycle state,
pointing the state **at itself**, guarded by a new `EndlessFlipLoop` condition. Installed between
`StateGraph::load` and `Binding::from_graph` in `graph_runtime.rs`, on the MotionGraph only.
Appending is safe because `Binding::from_graph` requires only `child > parent` and unique
ownership — **not contiguity** (`binding.rs:124`) — so new elements go on the end of the arena and
nothing renumbers. Binding, name resolution, compilation and condition instantiation then all run
through the ordinary authored path; there is no parallel-array surgery.
Three details that are easy to get wrong:
* **The self-target resolves for free.** `find_state(start = Cyc3, name = "Cyc3", ascend = true)`
misses in the child loop and then matches the state itself on its first ancestor step.
* **Ordering needs one fixup.** `Binding::from_graph` walks elements in *index* order, so an
appended transition lands last in its owner's list and `search_transitions` takes the first
activatable — which would let the authored exit win every time. `reorder` swaps the final pair,
putting the loop immediately ahead of the authored exit. Underflip and dark catch are earlier in
the list and keep their authored priority, which is why an endless flip still cannot be
underflipped past the fourth rung.
* **Re-entering a cycle state is authored retail behaviour, not an invention.**
`T_Kickflip_Unique.xml` — the Mike Carroll and Gonzo signature kickflips — runs
`TrickCyc1`/`TrickCyc2`/`TrickCyc3` over the *same* clip, differing only in the scoring name.
### The six install sites
`install` asserts it finds exactly six and fails graph load otherwise, because quietly installing
onto a changed asset tree would be worse than refusing.
| clip | quad `ScoringTrick` | authored exit |
|---|---|---|
| `B_KICKFLIP_CYC3` | `Kickflip4` | `End.Out.Out4` |
| `B_HEELFLIP_CYC3` | `Heelflip4` | `End.Out.Out4` |
| `B_N_KICKFLIP_CYC3` | `N_Kickflip4` | `End.Out.Out4` |
| `B_N_HEELFLIP_CYC3` | `N_Heelflip4` | `End.Out.Out4` |
| `B_KICKFLIP_MCARROLL_CYC` | `Kickflip4` | `TrickOut` |
| `B_KICKFLIP_GONZ_CYC` | `Kickflip4` | `TrickOut` |
Nollie comes free: it is the same authored file, included a third and fourth time.
## No height bar, by decision — 2026-09-25
The first build gated every rung on having the air to finish it: `time_to_land >= cycle * 2 +
blend`. That reads as a height bar, and for the single-clip families it is a brutal one, because
their "cycle" is the whole trick animation and so wants roughly twice the air a kickflip does.
That is why endless 360 flips were effectively unreachable in play while laserflips occasionally
landed.
**The default is now permissive**: hold the input, stay off the ground, keep flipping. The only
requirement left is room to cross-fade. A flat pop reaches a `720 FLIP`.
The cost is real and worth stating: the ladder will start a rotation it cannot land out of, so
bails go up. `TrainerTuning::endless_air_check` (mod setting *Require air for each flip*, default
off) restores the conservative budget for anyone who would rather land than rotate.
Retail's own air-time gates on the first four kickflip rungs are untouched in both modes. What
changed is only whether *extra* rungs demand air up front.
## Repeats play the authored cycle clip
Reported from play, twice: holding a 360 flip made the skater re-pop the trick on every rotation,
and then made the back foot look glued to a jittery board.
Both came from the same mistake, and the mistake was a **research failure, not a design one**. I
searched the state graph for cycle content, found none, and concluded retail had authored none for
this family -- then spent two rounds patching around that: entering the clip 45% in to skip its
windup, and freezing the body range to stop it re-performing. The second patch is what glued the
foot to the deck, because the reparented helper bones (32-35) are expressed in the board's *moving*
space, so freezing them makes them ride the board.
**The cycle clips existed the whole time, in the animation bank, referenced by no authored state:**
| clip | loop-closure error |
|---|---|
| `T_360FLIP_H_CYC` | 0.21 |
| `T_360FLIP_L_CYC` | 0.20 |
| `T_LASERFLIP_H_CYC` | 0.23 |
| `360FLIP_D_HIGH_A` (the air clip, looped) | **10.56** |
The lesson worth keeping: *the graph not naming a clip does not mean the clip does not exist* --
search the bank, not only the authored states.
### ...but playing them was wrong, and the closure figure is why that was missed
> **Superseded.** This section used to end by saying a repeat swaps in `T_<trick>_H_CYC`, that
> `N_360Flip` is dropped, and that rotations are therefore slower. All three are false. They are kept
> here because the reason the closure table misled is worth more than the table.
Those clips are hold **poses**, not rotations. Measured, `T_360FLIP_H_CYC` moves the board a tenth as
much as the kickflip's cycle does -- which is exactly *why* it closes its own loop so tightly. The
0.21 against the air clip's 10.56 was measuring **stillness, not loopability**, and playing one on a
repeat parked the deck, so only the first rotation was ever visible.
So a rung keeps playing the trick's own air clip, which is the thing that turns the board, and
`animation_pose`'s body/board split replaces the body alone -- parked in `T_HI_KICK_CYC2`, the
kickflip's cycle, which retail authored for legs held clear of a rotating board. The shape is:
```
B_360FLIP_G -> B_360FLIP_A -> B_360FLIP_A (xN, body replaced, deck spun) -> BLEND_LAND
```
Consequences, corrected:
* **`N_360Flip` is not dropped.** It ladders. `scoring::extension` gives `n_360flip` (id 106) a full
rung table, `has_ladder` asks that table rather than a hand-written list, and `gesture_input`
includes `N_360Flip` among the gestures the mod lets you hold. `install` logs all twelve:
`360FLIP 360HARDFLIP 360INWARDHEELFLIP 360POPSHUVIT FS360POPSHUVIT LASERFLIP` plus every `N_` form.
* **Rotations are not slower.** The air clip is what replays, so the pacing is the trick's own. Since
the deck is now spun at its measured plateau rate rather than one turn per clip length, a held
rotation is in fact *faster* than it was: measured 0.54 against 0.28 of board movement per tick.
### The held deck is spun, and every number in the spin is measured
The air clip turns the board, but replaying it replays its **acceleration**: a ramp off the flick, a
plateau, then the catch slowing the deck. So each rung visibly re-accelerated. Four attempts at
retiming the authored frames each traded that for something else -- entering at an "at speed" frame
only moved where the ramp sat, evening out the rate surged at the seam because the clip holds no whole
number of revolutions, and trimming to a window that closes chopped off visible motion.
The deck does not need resampling. Every bone of the board rotates by the same amount through the
clip -- `TRUCK_BACK` 494.3 degrees, the wheels and `SKATEBOARD_ROOT` the same to a tenth of a degree
-- so it is one rigid body, and one pose of it turned at a steady rate is all a hold needs.
`cargo run -p skate-data --example spin_measure -- <assets>` prints the numbers, in seconds, without
building the game. For `360FLIP_D_HIGH_A`:
| what | value | why it matters |
|---|---|---|
| path `sum abs r` | 494.3 deg | total angular travel through the clip |
| net `abs sum r` | 360.6 deg about `[-0.44, 0.72, -0.53]` | one clean revolution about an axis tilted **43.6 deg** from vertical |
| ratio | 0.729 | near `1/sqrt(2)`: the instantaneous axis wanders, as a flip-plus-shuvit does |
| endpoint axis vs accumulated | `dot = -0.60` | the old axis was **inverted** |
| plateau | 20.85 deg/frame | against a whole-clip mean of 15.45 |
Three things follow, and each was a bug:
* **Direction.** The axis came from `to_axis_angle` of the net first-to-last-frame rotation. A
494-degree path is not recoverable from its endpoints, and the measurement shows that axis points
backwards: `dot` is -0.60 on `_HIGH_A`, -0.76 on `_LOW_A`, -0.89 on `GONZ_HIGH_A`. That was "the
board goes in the wrong direction", as a number. Summing the per-frame rotation *vectors* cannot
invert, because a signed sum has no axis sign to guess.
* **Speed.** One turn per clip length is 654 deg/s where the plateau is 1251 -- nearly half. That is
why every rung read as sluggish. The rate is now the mean of the fastest third of frames, which
lands on the plateau and is immune to a single noisy frame.
* **Where the deck sits.** The rigid reference was frame 0, the deck still at the feet pre-flick, so
a spinning deck was parked inside a body whose legs are held clear. It is now the **last** authored
frame, which makes the hand-off out of the real trick continuous in orientation and position both,
with the first synthesised rung starting at angle zero.
The 0.73 ratio means a fixed axis is an **idealisation**, chosen deliberately. Replaying the authored
directions instead carries 15-49 degrees per frame of direction wobble plus a 20-101 degree kink
where the sequence repeats; a fitted yaw-plus-flip model reconstructs the clip no better than 65
degrees. "Floaty" and "smooth" is what was asked for, and a steady turn about an axis tilted 44
degrees is still a kickflip combined with a shuvit -- that tilt *is* the trick's geometry.
The angle accumulates across rungs, so the step over a junction equals the step inside a rung and the
spin need not complete a whole number of turns per rung. There is no loop to close, which is what all
four resampling attempts broke on. Decay is a rate multiplier per rung, so it composes with that for
free.
### The knobs, none of which need a rebuild
Whether a held leg clears a rotating board is a judgement only play can make, so the pieces that are
judged by eye are switchable from the environment:
| variable | default | what it does |
|---|---|---|
| `SKATE_ENDLESS_RIGID_SPIN` | on | `0` goes back to replaying the authored board motion |
| `SKATE_ENDLESS_SPIN_RATE` | 1.0 | multiplier on the **measured** plateau rate |
| `SKATE_ENDLESS_SPIN_DECAY` | 0.97 | per-rung slowing; `0.97^11` takes the twelfth rung to 0.72 |
| `SKATE_ENDLESS_HOLD_CLIP` | `T_HI_KICK_CYC2` | the body-hold clip; a bare name or `<TRICK>=<clip>;...` |
| `SKATE_ENDLESS_HOLD_SWEEP` | 0.12 | how much of the hold clip a rung traverses; `0` freezes the pose |
| `SKATE_CLIP_TRACE` | off | `SKATE_CLIP` and `SKATE_SPIN` lines: the bank names, and the axis |
### The rung has to survive its own landing
Found by the test that came with this change, and it predates it: the ladder was cleared on the
first grounded tick, while the authored `ScoringTrick` leaf was still publishing. A five-rotation
360 flip therefore collapsed to `#ID_TRICK_FLIP_360_FLIP` and banked as a plain one, at the moment
it landed.
A grace period was the obvious fix and the wrong one -- it repaired the 360 flip and not the
laserflip, because how long that publishing tail runs depends on the clip. The ladder is now
cleared at **takeoff**: exact, because it always outlasts the landing it was earned on and the next
trick always starts clean.
## The air budget, and why it is not a constant
The loop is gated on the hold intent and on not body-flipping — the same terms the authored rungs
use — plus an air-time term. **The air-time term is measured off the clip, not picked**:
```
time_to_land >= current_clip_length * 2
```
one more cycle plus an out clip of comparable length. A fixed threshold does not work, and that was
found the hard way: an earlier version reused `End.Out.Out2`'s authored 0.8 s and the heelflip
started a cycle it could not land out of, ending in `B_WIPEOUT_FLAIL_GESTURES` with a zero reward.
The heelflip's clips are longer than the kickflip's, so the budget has to scale with them. **A rung
the skater cannot land out of is worse than no rung.**
The authored `Out1`/`Out2`/`Out3` preconditions are **not touched** in either mode. Note that the
budget described here is now the opt-in `endless_air_check` path; the default is the permissive one
described above.
## The loop junction has to be blended, and the blend is measured
Re-entering the cycle state cuts. `_CYC3` is authored as the **third clip of a sequence**: its end
pose is shaped to run into `_OUT4`, not back into its own first frame. So the first playable build
flailed -- arms snapping around once per extra flip, which is what a pose discontinuity looks like.
That was measurable rather than a matter of taste. Sampling every skeleton part relative to the
root each tick and taking the worst single-tick movement:
| | worst in-flip jolt | where |
|---|---|---|
| stock quad | 0.238 m | `B_KICKFLIP_IN_A`, the takeoff |
| endless, cut | **0.412 m** | `B_KICKFLIP_CYC3`, once per cycle |
The spikes landed at ticks 197, 223 and 249 -- 26 apart, exactly the cycle clip's length, so one
per loop.
The fix uses retail's own mechanism. The loop transition carries an
`OverideNextAnimTransitionHook` (`MotionHook::Override`, which sets `playback_context
.transition_override` and is consumed by `PlayAnimation::begin`) with `transType="blend"` and
`blendWithCurrentFrame="true"`, so the re-entered clip cross-fades out of the pose actually on
screen instead of cutting to frame zero.
**The blend length was swept, not chosen:**
| blend | worst in-flip jolt |
|---|---|
| 0.00 s | 0.334 m |
| 0.05 s | 0.238 m, but small cycle-clip spikes remain |
| **0.08 s** | **0.238 m, no cycle-clip spike at all** |
| 0.15 s | same as 0.08, and costs more air |
0.08 s is the shortest window where the roughest moment of an endless flip is an authored junction
again. `the_loop_junction_is_no_rougher_than_an_authored_one` pins both halves of that: the endless
worst must not exceed the stock worst, and no `_CYC` tick may be the roughest moment of the flip.
The blend is part of the cost of another rung, so it is part of the air budget too:
`time_to_land >= cycle * 2 + LOOP_BLEND_SECONDS`.
> A caution recorded because it nearly became a wrong fix: after adding the blend, a boost that had
> previously landed started wiping out, and the obvious reading was that the blend had eaten the
> landing margin. It had not. Running the **stock** ladder at the same boosts shows it banks zero
> there too -- past about 15 m/s the flat canonical course simply runs out. Landing success at that
> margin is chaotic and needs the mod-off control to interpret at all.
## Naming and scoring, without touching the retail tables
`catalog::IDENTIFIERS`, `conversions::LINKS` and `SCORABLE_COUNT` are recovered data and are
**unchanged**. No 333rd scorable id exists.
Instead `crates/skate-core/src/scoring/extension.rs` gives each extra rung a mod-only identifier
(`kickflip5` …) that **borrows the retail quad's ledger identity** — 135 for the kickflip, 134
heelflip, 137 nollie kickflip, 136 nollie heelflip — and overrides only the published name, the
label and the points.
That works because the ladder *converts* rather than accumulates (`Carrier::convert_to`): the only
things that ever reach `ScoreHolder` are the surviving carrier's `Scorable` and its reward, and the
quad's `Scorable` is already the right ledger identity for a fifth flip — same class 3, same score
type 2, same repetition bucket, same conversion source. So `Scorable::valid()` passes, `LINKS[135]`
is in range, and `by_id(135)` resolves. Minting a high id instead would have hit all three: an id
past 332 panics at the unchecked `LINKS[*new]`, and `ScoreHolder::end_trick` silently drops it, so
the rung would have been named correctly and scored nothing.
One thing had to change to make that work. `Runtime::carrier` keys the "is this a new trick" test
on the scorable id alone, so borrowing 135 for every rung would create no new carrier. A rung
ordinal now rides alongside (`Runtime::extension`), and it is **constant zero on the retail path**:
nothing publishes an extension name, so the added disjunct is permanently false and `points` and
`label` reduce to the definition's own.
**Named counts stop at the octuple.** Rungs 5 to 8 read `QUINTUPLE`, `SEXTUPLE`, `SEPTUPLE` and
`OCTUPLE`; from the ninth flip on the label is simply `ENDLESS KICKFLIP` (or `ENDLESS HEELFLIP`,
`ENDLESS NOLLIE KICKFLIP`). Past eight the exact count is not something a player is tracking in
the air, and a bare `12X` reads worse than the trick's own name. The rungs still differ in
identifier and points; only the displayed label repeats.
**Points continue retail's own step.** The authored ladder is 100 / 150 / 200 / 250 — a flat +50 a
rung — so a quintuple is 300 and the sixteenth flip is 850. Linear is deliberate: only the final rung banks,
and a superlinear curve would let one lucky drop dwarf a whole line.
**Labels are plain words, not `ID_TRICK_*` keys.** `apt_text::localize` splits a `#`-prefixed name
on whitespace and echoes any token it cannot resolve — that is how a spin's bare `360` survives —
so `QUINTUPLE KICKFLIP` renders as written and needs no language-table entry. An
`ID_TRICK_FLIP_QUINTUPLE_KICKFLIP`-style key would have reached the HUD as that raw identifier.
## Measured
`crates/skate-game/src/tests/flip_playback.rs`, flat course at 8 m/s, takeoff velocity boosted on
one frame. Flip counts are structural; the reward pair is at boost 14.
| takeoff boost | flips reached |
|---|---|
| 8 (the stock quad boost) | 4 — unchanged |
| 11 | 5 |
| 12 | 6 |
| 14 | 7 |
| 16 | 8 |
| 18+ | 9 — but the skater overshoots the flat course and never lands |
The ninth rung is where the counted names stop: at boost 18 the harness reaches
`Kickflip9` and the composed name is `#ENDLESS KICKFLIP`. It is pinned by
`the_ninth_flip_and_beyond_is_named_endless`, which asserts the *name* and the rung count
rather than a banked reward, because nine rungs needs more pop than the flat canonical
course has room to come back down from. The landing is covered at boost 14 and 16 instead.
At boost 14: the stock ladder banks **969.25** and names `#ID_TRICK_FLIP_QUADRUPLE_KICKFLIP`; the
same pop with the mod on banks **1119.22** and names `#SEPTUPLE KICKFLIP`. The difference is
exactly 150 — three rungs at the authored +50 — which also pins that the ladder still *converts*
rather than summing every rung beneath it.
## The 360 flip family, which is a different problem
Added 2026-09-25 for `360Flip`, `Laserflip` and their nollie forms. It works, and it is honestly
worse than the kickflip ladder, for reasons that are structural rather than fixable.
**Retail authors nothing to build on here.** `T_TrickWithDarkCatch.xml` has no cycle states at
all -- one `ScoringTrick`, one air clip -- and `360flip` (85) / `laserflip` (99) have no numbered
scorables. Where the kickflip ladder reuses purpose-built `_CYC` clips, every extra rotation here
repeats the **whole trick animation**.
**And the hold mechanic does not exist for it.** Only `Kickflip`, `Heelflip`, `N_Kickflip` and
`N_Heelflip` are hold-capable, and only `T_Kickflip.xml` and `T_Kickflip_Unique.xml` consume a
`<Trick>Hold` intent -- checked across the whole authored tree. So these gestures cannot be held
at all until the mod widens the set, which it does **behind the trainer flag**: `HoldPattern` is
read by the authored ActionGraph (`trick.xml`), so widening it ungated would change stock
behaviour for those gestures. The loop condition gates on `HoldPattern` for these families rather
than a `<Trick>Hold` intent that has no authored existence.
**Naming counts rotations, not repeats.** A doubled 360 flip is a 720, so the rungs read
`720 FLIP`, `1080 FLIP`, `1440 FLIP` … and `ENDLESS 360 FLIP` from the ninth. Laserflips read
`720 LASERFLIP` and so on. Points continue the same +50 step, counted from each family's own
authored rung rather than a number invented here -- which is why `Rung` carries `base_rung` and
points are computed by `points_over(retail_points, rung)`.
### Measured
| | stock worst jolt | endless worst jolt |
|---|---|---|
| kickflip | 0.238 m | 0.238 m — no rougher at all |
| 360 flip | 0.161–0.227 m | 0.312 m |
| laserflip | 0.202–0.268 m | 0.370 m |
About 1.4x rougher at the worst moment. Rewards at 14 m/s: 360 flip 995 -> 1303 (`#1800 FLIP`),
laserflip 903 -> 1205 (`#1800 LASERFLIP`).
> **A cliff sits next to the chosen blend.** Sweeping the junction cross-fade for this family:
> 0.02 s gives 0.861 m, 0.04 gives 0.474, 0.06 gives 0.457, **0.08 gives 0.312**, and then 0.10
> gives **12.7 m** and 0.16 gives **7.8 m**. The optimum is sharp and its neighbour is a blow-up.
> That instability is not understood. It means this family is fragile in a way the kickflip
> ladder is not, and a clip whose timing differs could land in the bad zone -- worth suspecting
> first if a 360 flip ever does something violent rather than merely choppy.
### Scope
`360Hardflip` and `360InwardHeelflip` are **in**, along with every other `_360`-family stem and all
their nollie forms — twelve in total, which `install` logs on load. There is no list of family names
anywhere: `has_ladder` asks `scoring::extension::by_family`, so a family earns a ladder exactly by
having rungs in the table and the two cannot drift apart. That is also what makes "a 180 trick can
never be endless" structural rather than remembered — `fspopshuvit` (90) and `n_fspopshuvit` (111) are
absent from the table on purpose, because a pop shuvit turns the board 180 and counting its rungs in
720s would be a lie.
(This section previously said those two were left out and pointed at a `SINGLE_CLIP_FAMILIES`
constant. Neither the exclusion nor the constant exists.)
Note also that each include carries a **second** `LeftGround` inside `GrindOutAssist` that plays
the same clip. Only the real air state sequences into it; the assist blends. Without that
distinction the site count comes out at fourteen rather than ten, which is how the strict
`EXPECTED_CYCLE_SITES` check earns its keep — it counts the six *cycle* sites, while the single-clip
families are discovered and logged rather than counted.
## Played, 2026-09-25 — University
Launched through `PLAY-ENDLESS-TRICKS.bat -Trace` against install
`84bb8943f4a0439a80a7cc5e2e4489ca`. **A different install from the one the tests use**, which is a
real check on the site discovery: `install` refuses to load the graph unless it finds exactly six
flip cycle states, so the game reaching University proves the predicate is not overfitted.
Over the session: **41 sequences published, 10 of them on an extension rung**, and rungs 5 to 8
all reached. The banked `points` land exactly on the authored +50 step -- 300, 350, 400 and 450
for rungs 5, 6, 7 and 8 -- alongside ordinary 100/150/200/250 kickflips from the same session,
so the stock ladder and the extension run side by side. Three of the publishes:
```
SCORE_PUBLISH reward=6224 mult=3.00 line=6841 bail=false name="#OCTUPLE KICKFLIP 180"
SCORE_PUBLISH reward=333 mult=1.50 line=333 bail=false name="#QUINTUPLE KICKFLIP"
SCORE_PUBLISH reward=0 mult=1.00 line=0 bail=true name="#SEXTUPLE KICKFLIP"
```
Four things this confirms that the headless tests could not:
* **The label composes with a spin.** `#OCTUPLE KICKFLIP 180` — the extension rungs inherit the
retail quad's `trick_type`, so `decorates_spin` treats them exactly like a quad and the degree
count lands on the end of a mod-only label. That was a deliberate choice and it holds.
* **The points are the rung.** Banks of `points=300`, `350` and `450` are rungs 5, 6 and 8 on the
authored +50 step, read straight off `SCORE_TRICK air ... id=135`.
* **The repetition penalty applies for free.** A repeated quintuple banked `points=300 reward=60.0`
— factor 0.2, decaying down the authored curve exactly as a repeated quad does. This is the
split-identity design paying off: borrowing id 135 means the rungs share the quad's repetition
bucket, which a parallel scoring channel would have had to reimplement.
* **Lines and combos integrate.** 6224 at a 3x multiplier into a 6841 line, through the stock
`session.rs` path with nothing special-cased.
No `Invalid endless flip install`, no `Missing native scorable`, no `Unsupported MotionGraph hook`,
no panics.
The one bail is ordinary skating, not a diagnosis — an octuple landed clean in the same session.
Whether the air budget ever starts a rung the skater cannot finish is still worth watching; the
headless wipeout that motivated the clip-derived budget is the shape to look for.
## Proving it stays retail-exact
The four pre-existing flip tests run unchanged **with the loop transitions compiled into the
graph**, and they are the guard: `held_flips_cycle_through_the_authored_double_triple_and_quad`,
`landed_quads_bank_and_name_the_quad_scorable`, `a_single_flip_is_the_ladder_baseline` and
`air_scoops_reach_the_authored_late_flips`. Every reward is identical to the pre-change baseline —
451.6692 kickflip quad, 563.5781 heelflip quad, 305.7779 single, 477.43457 / 477.59387 late flips.
```
SKATE3_ASSET_ROOT=C:/s3/installations/<install>/assets RUST_MIN_STACK=134217728 \
cargo test -p skate-game --bin skate3rust flip_tests -- --ignored --nocapture
```
`RUST_MIN_STACK` is not optional; without it rustc dies with `STATUS_STACK_BUFFER_OVERRUN`
building the harness.
`endless_flips_cycle_past_the_authored_quad` replays the *same pop* with the feature off and on, so
the difference it asserts is the feature and not the boost.
> Note: `docs/flip-ladder.md` records the kickflip quad at 497 and the heelflip at 564, measured
> 2026-09-21. The heelflip still matches; the kickflip now measures 451.67 at the same boost. That
> drift predates this work and has not been investigated here.
## Known limits
* **Single-owner tuning.** Native trainer tuning allows one owning mod, so Endless Tricks and the
Native Trainer cannot both be enabled.
* **No underflip past rung 4**, by construction — see the ordering note above.
* **`max_flips` above 16** has no named rung; the cap is enforced at 12 extra rungs in
`TrainerTuning::valid`.
* **Replays** recorded with the mod on carry rungs a stock build has no names for.
* The extension rungs have **no authored audio**. `f.descriptor` also feeds
`physics/audio_observation.rs`, whose trick table is keyed by `IDENTIFIERS`. The University
session above raised no error from that path, so a minted name does fall through harmlessly;
whether anything is audibly missing on an extension rung has not been listened for.
+104
View File
@@ -1178,3 +1178,107 @@ for a missing 7 dB. **But note the call structure does not obviously support it*
`sub_824BA630`, `sub_824B8D48` and `sub_82486EF0` each appear exactly once, so any extra voices are
not started by a loop there. Read the function properly before acting; do not infer voice counts
from call counts.
## 12. The flip ladder stops at four — **working as authored; extended on purpose by a mod**
**Symptom.** A held kickflip or heelflip never reaches a fifth flip, however much air the pop has.
**Evidence.** Not a defect. The ladder is authored, not computed: there is no flip-count variable
and no clamp to 4 anywhere in the Rust. `T_Kickflip.xml` authors `Cyc1`/`Cyc2`/`Cyc3` and no
`Cyc4`, `Cyc3`'s only exit is a bare `WillExpire InTime="0.05"` to `End.Out.Out4`, and `Out4`
carries no precondition at all — unlike `Out1`/`Out2`/`Out3` and their hold / `TimeToLand` /
`IsBodyFlipping` gates. Confirmed by suppressing `WillExpire` on `_CYC3` behind a throwaway env
var: `B_KICKFLIP_OUT4`, `B_AIR_CYC` and `BLEND_LAND` all vanished and the reward went to zero.
### Extended by the Endless Tricks mod — a deliberate deviation
Since 2026-09-24 an opt-in mod appends a self-transition to each of the six authored flip cycle
states, adding rungs 5..16. It is **off by default** and the retail path is unchanged: the four
pre-existing flip tests pass with identical rewards while the new transitions are compiled into the
graph. `crates/skate-core/src/scoring/{catalog,conversions}.rs` and `SCORABLE_COUNT` are untouched —
the extra rungs borrow the retail quad's ledger identity and override only name, label and points.
Full account, including the measured flip counts and the air budget: `docs/endless-tricks.md`.
**Do not** read this as licence to extend other authored ladders. The late flips were asked about
and settled separately (`docs/flip-ladder.md`), and retail-exactness still governs everything that
claims to *be* the port.
**What would disprove the extension being inert:** any of the four stock flip tests changing
reward, trick sequence or landed name while `endless_flips` is false.
### Open, mod-only: a held rung steps the skeleton where the authored trick does not
Measured on the flat course at a 14 m/s boost, with `SKATE3_ASSET_ROOT` set, by
`probe_what_a_held_rung_animates` in `crates/skate-game/src/tests/flip_playback.rs`:
| run | worst single-tick skeleton step | part |
|---|---|---|
| stock 360 flip, mod off | 0.227 m | 25 (`SKATEBOARD_ROOT`, at the pop — authored) |
| held 360 flip, 7 rungs | **0.606 m** | 3 (a body bone) |
| held laserflip, 8 rungs | **0.474 m** | 3 |
**It is not the synthesised deck spin.** The figure is identical with `SKATE_ENDLESS_RIGID_SPIN=0`
(0.665 against 0.665 before the hold ease landed), and the probe reports the part index, which is
below the board subtree at 25..31 every time. Turning the spin on moves board movement per tick from
0.283 to 0.544 and leaves the step where it was.
Two mechanisms, from the tick pattern:
* **The hold arriving.** One larger step as the body goes from mid-flick into the held pose on the
first held rung. Easing it over 0.08 s of clip time in `animation_pose::sample_endless_hold` took
0.665 to 0.606 on the 360 flip and 0.664 to 0.474 on the laserflip.
* **The air clip restarting from frame 0 on every rung**, which shows as a 0.27-0.43 m step recurring
on the clip's own 28-tick period (ticks 237, 265, 293 and so on). The single-clip families have no
cycle clip to loop, so a rung re-enters the whole air clip; the authored cross-fade
`endless_flip::install` adds covers it imperfectly.
**The fix for the second is known and not done.** The loop condition fires at
`crossed_end || remaining_before_wrap <= in_time`, so a rung is cut `in_time` before its last frame,
while `hold_body` bakes the rung as though every frame plays. Plumbing that `InTime` — already read at
`endless_flip.rs`'s `authored_exit` — through `Site` to `set_endless_hold` would let a rung be baked
knowing where it will actually be cut, which closes the gap rather than fading over it.
**What would disprove this being pre-existing:** the step changing when
`SKATE_ENDLESS_RIGID_SPIN=0`, or the worst part index landing in 25..35.
### Open, mod-only: a cycle-ladder rung past the quad is 13% shorter than an authored one
Reported from play as "in kickflip endless the board speeds up once it goes past quad". It does, and it
is the **cycle** ladder, which never touches the synthesised spin. Measured by
`probe_rung_spacing_across_the_quad`, with `SKATE_ENDLESS_TRACE=1` for the install values:
```
rung ticks: 112, 132, 154, 179, 195, 218, 241, 264, 287
gaps: 20, 22, 25, 16, 23, 23, 23, 23 <- 16 at the quad, then 23
authored B_KICKFLIP_CYC2 25 ticks, 0.2664 board/tick CYC3 26 ticks, 0.2562
extension B_KICKFLIP_CYC2 23 ticks, 0.2876 CYC3 23 ticks, 0.2879
clock_length for both = 0.433 s = 26.0 ticks
SKATE_ENDLESS_SITE Kickflip state=Cyc3 base=4 chosen=0.0500s own_exit=Some(0.05) sibling_exit=None
```
**The cause is arithmetic, not mysterious.** The loop fires on the lead-in of the bare `WillExpire` it
sits ahead of, which on the kickflip's `Cyc3` is 0.05 s -- 3 ticks of a 26-tick clip. So an extension
rung runs 23 ticks where an authored one runs 25 or 26, the cycle repeats 13% more often, and the deck
completes a revolution faster. The 8% rise in mean board movement per tick is the same fact measured
differently: the frames being cut are the clip's slowest.
**Two fixes were tried and both failed, with the measurement to show it:**
* Waiting for `crossed_end` instead of the lead-in: the ladder **stopped at the authored quad** and ran
no extension rung at all. The loop is racing that exit for the state and must fire on its lead-in or
lose it.
* Taking the authored *inter-cycle* lead-in instead of the anchor's own: a **no-op**. The `Cyc3`
anchor's sibling `Cyc2` has no bare `WillExpire` as its last transition (`sibling_exit=None`), and
the one site where a sibling is readable (`TrickCyc3`) carries the same 0.02 s.
**What would actually close it, and why it is not done here.** The rung has to be allowed to run its
clip out, which means holding off the authored `Out4` exit while the ladder is live -- appending a
gating condition to an authored transition rather than appending a transition of our own. `Out4` is the
path a *stock* quad lands through, so getting that guard wrong breaks retail behaviour, and
retail-exactness governs everything that claims to be the port. It wants its own change, gated so the
condition is inert with the mod off, and the four stock flip tests as the falsifier.
**What would disprove this diagnosis:** extension rungs measuring the same tick count as authored ones
while the lead-in is still 0.05 s on a 26-tick clip.
+14
View File
@@ -115,3 +115,17 @@ authors exactly six patterns, `L_F_Kickflip`, `L_B_Kickflip`, `L_F_Heelflip`, `L
Adding a late ladder is therefore not a port fix; it would be inventing a trick Skate 3 does not
have, and it would need scorable ids beyond the retail 332, new `LINKS` rows and new clips.
## And the ordinary ladder has no fifth rung either -- but a mod now adds one
Everything above still describes the port. Retail stops at the quad, and that has not changed.
What is new, as of 2026-09-24, is an **opt-in mod** that extends the ordinary flip ladder past it:
`docs/endless-tricks.md`. Keep the two claims apart. "Retail has no fifth rung" is still true and
still what this engine does by default; the Endless Tricks mod adds rungs 5..16 on top, off unless
enabled, and the four flip tests above pass unchanged with its transitions compiled into the graph.
One correction to the measured table above, from re-running it: the kickflip quad now banks 451.67
at the 8 m/s boost rather than the 497 recorded here on 2026-09-21. The heelflip's 564 still holds.
That drift predates the mod work and has not been chased down.
+6
View File
@@ -231,12 +231,18 @@ can own it at a time. Numeric fields default to 1 on each call; `hold_fakie` def
Supported multipliers are pop, grind_pop, push_speed, push_power, braking, steering,
offboard_jump, grip, turn_power and manual_drag (0.25..4), and wobble (0..2).
`hold_fakie=true` suppresses the automatic stance switch. Unknown/nonfinite values fail.
`endless_flips=true` (default false) is **not retail**: it lets a held kickflip or heelflip keep
cycling past Skate 3's authored quad while the pop has the air for another rung, and
`endless_flip_max` (1..12, default 12) caps the extra rungs, so 12 reaches a 16x. Flat ground
still stops at a double -- the authored air-time gates are untouched. See docs/endless-tricks.md.
Disable/reload/fault restores defaults; native contact/state conditions still apply.
These are parameter multipliers, not guarantees of measured speed or height.
`sdk/examples/native-trainer` demonstrates tuning, HUD text, checkpoints, timers,
breadcrumbs, beacons and configurable shortcuts. Defaults: F5 save checkpoint, F6 return,
F7 stopwatch, F8 clear transient data, F9 beacon. Its text data stays in the package.
`sdk/examples/endless-tricks` is the smallest tuning example: it owns nothing but the two
endless-flip fields and a status line.
`sdk/examples/mario-kart` demonstrates vehicles, controller binds, driving HUD, fitted
rider animations, steering, ramp collision tuning and engine audio. Both complete
example ZIPs are committed in top-level mods/, including the prepared kart model and rider clips.
+1 -1
View File
@@ -4,7 +4,7 @@ Native Trainer and Mario Kart are included here as committed, ready-to-install Z
Mario Kart includes the prepared model and fitted rider animations. Both remain
disabled by default for new users; enable them through Escape > Mods.
Drop community `.zip` packages here. PLAY-MARIO-KART.bat loads this project-root folder;
Drop community `.zip` packages here. PLAY-ENDLESS-TRICKS.bat loads this project-root folder;
Escape → Mods lets you enable, disable, reload and configure them. Open mods folder
returns here. ZIPs must have mod.json at their root; the game extracts them automatically.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
# Launch against the install that actually has maps built.
#
# There are two partial installs: the audio-work one has a complete audio set but
# an empty maps/ folder, and this one has all ten stock maps. There is no stock-map
# CLI flag -- --map takes custom .skate files only and crashes on a stock map path
# -- so the map comes from <install>/settings/default-map.json.
param(
[string]$Install = 'C:\s3\installations\84bb8943f4a0439a80a7cc5e2e4489ca',
[switch]$Trace
)
$ProjectRoot = Split-Path $PSScriptRoot -Parent
$ErrorActionPreference = 'Stop'
Push-Location $ProjectRoot
try {
$executable = Join-Path $ProjectRoot 'bin/skate3rust.exe'
if (-not (Test-Path -LiteralPath $executable)) { throw 'Game is not built. Run BUILD.bat first.' }
$assets = Join-Path $Install 'assets'
if (-not (Test-Path -LiteralPath $assets)) { throw "No assets at $assets" }
$mod = Join-Path $ProjectRoot 'mods/endless-tricks.zip'
if (-not (Test-Path -LiteralPath $mod)) {
Write-Warning 'mods/endless-tricks.zip is missing; run tools/package_mod.py first.'
}
$env:SKATE3_MODS = Join-Path $ProjectRoot 'mods'
New-Item -ItemType Directory -Path (Join-Path $ProjectRoot 'logs') -Force | Out-Null
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$log = Join-Path $ProjectRoot "logs/endless-tricks-$stamp.log"
$err = Join-Path $ProjectRoot "logs/endless-tricks-$stamp.err"
if ($Trace) {
# Without this a playtest log contains no scoring lines at all, which reads as a scoring
# failure and is not one.
$env:SKATE_SCORING_TRACE = '1'
}
Write-Host ''
Write-Host 'ENDLESS TRICKS' -ForegroundColor Green
Write-Host ' 1. Escape -> Mods -> enable "Endless Tricks", then resume.'
Write-Host ' 2. Find some height, then hold the kickflip or heelflip flick through the air.'
Write-Host ''
Write-Host ' Flat ground still stops at a double. That is correct, not a bug: the authored'
Write-Host ' air-time gates are untouched, so the ladder needs the pop to have time left.'
Write-Host ' A fifth flip wants a real drop; the higher you get, the further it runs.'
Write-Host ''
Write-Host ' Disable the mod to get the stock four-rung ladder back exactly.'
Write-Host ' Run with -Trace to write SCORE_TRICK lines into the .err log.'
Write-Host ''
Write-Host " University loads in ~40 s and settles around 1.5 GB." -ForegroundColor DarkGray
Write-Host " Most output goes to stderr: $err" -ForegroundColor DarkGray
Write-Host ''
$game = Start-Process -FilePath $executable -WorkingDirectory $ProjectRoot `
-ArgumentList @('--assets', ('"' + $assets + '"')) -NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $log -RedirectStandardError $err
if ($game.ExitCode -ne 0) {
Get-Content -LiteralPath $err -Tail 30
throw "Game exited with code $($game.ExitCode). Log: $err"
}
} finally { Pop-Location }
+1 -1
View File
@@ -5,7 +5,7 @@ it to make a mod. It links the package format, complete API, examples and valida
Editable sources: `examples/`. Player-installable output: project-root `mods/*.zip`.
Use `tools/package_mod.py` to validate and package. The current integrated game launcher
is `PLAY-MARIO-KART.bat`; older feature-specific launchers are historical builds.
is `PLAY-ENDLESS-TRICKS.bat`; older feature-specific launchers are historical builds.
## Multiplayer
+68
View File
@@ -0,0 +1,68 @@
# Endless Tricks
Hold a kickflip, heelflip, 360 flip or laserflip and keep going for as long as the pop lasts.
**This is not a Skate 3 trick.** Retail's ladder is four rungs and stops there — `T_Kickflip.xml`
authors `Cyc1`, `Cyc2`, `Cyc3` and nothing after it. This mod adds the fifth rung and beyond, and
the rest of the engine stays retail-exact: with the mod off, the ladder, the names and the points
are exactly what the game shipped with.
## Using it
1. `PLAY-ENDLESS-TRICKS.bat`
2. Escape → Mods → enable **Endless Tricks** → resume.
3. Find some height. Hold the flip flick through the whole air.
**There is no height bar.** Hold the input and you keep flipping for as long as you are off the
ground — more air simply means more rotations, not a threshold to clear. A flat pop still gets you
past what retail does.
The trade is that the ladder will happily start a rotation it cannot land out of, so you will bail
more than stock. **Require air for each flip** turns that around: it only begins a flip it has the
room to finish, which lands more and flips less. Retail's own air-time gates on the first four
kickflip rungs are untouched either way.
## Settings
| Setting | Meaning |
|---|---|
| Endless flips | Off restores the stock four-rung ladder exactly |
| Maximum flips | Most flips one pop may reach, 5 to 16 |
| Require air for each flip | Off by default. On, only start a flip you can land out of |
| Show status | Small on-screen reminder of the current limit |
## Scoring
The extra rungs are named and scored. They continue retail's own step — the authored ladder is
100 / 150 / 200 / 250, a flat +50 a rung, so a quintuple is 300 and the sixteenth flip is 850.
They also **convert** the way retail's rungs do rather than accumulating: a septuple banks the
septuple, not the sum of every flip under it. That is how the stock quad works too, and the extra
rungs are not allowed to behave differently.
Two families, named the way each is actually counted.
**Kickflips and heelflips** count by flip: `QUINTUPLE KICKFLIP`, `SEXTUPLE`, `SEPTUPLE`,
`OCTUPLE` — and from the ninth on, simply `ENDLESS KICKFLIP`. Past eight nobody is counting in
the air, so the named counts stop there and the trick takes the mod's own name.
**360 flips and laserflips** count by rotation, because a doubled 360 flip is a 720:
`720 FLIP`, `1080 FLIP`, `1440 FLIP` … and from the ninth on, `ENDLESS 360 FLIP`. Laserflips read
`720 LASERFLIP` and so on.
Nollie kickflips, heelflips and laserflips are included. **The nollie 360 flip is not** — it is
the one trick here with no authored cycle clip of its own, and the alternative looked bad enough
to be worth leaving out.
Every repeated rotation plays a purpose-built cycle clip that retail shipped and never wired up,
so a hold looks like a hold rather than the skater performing the trick over and over. One
consequence: rotations run at the cycle clip's own pacing, which is slower than the pop, so a given
drop buys fewer rotations than it would at trick speed.
## Limitations
- **Cannot run alongside the Native Trainer.** Native tuning is single-owner, and both mods want
it. Enable one at a time.
- An endless flip cannot be underflipped past the fourth rung. The underflip and dark-catch
transitions keep their authored priority, which is deliberate.
- Replays recorded with the mod on carry rungs a stock build has no names for.
+59
View File
@@ -0,0 +1,59 @@
-- Endless Tricks: the flip ladder past retail's quad.
--
-- The mod itself is thin on purpose. Lua cannot reach the MotionGraph or the scorer, so the loop
-- lives in the host and this only owns the switch, through the same trainer tuning the Native
-- Trainer uses. Only one mod may own that tuning at a time.
--
-- Settings are read on load and whenever they change, not every tick: the tuning is owned and
-- reversible, so re-applying an unchanged table each frame would be pure waste.
local HUD_KEY = 'status'
local function tuning()
local enabled = sdk.settings.enabled
if enabled == nil then
enabled = true
end
-- The host counts rungs *past* the authored quad, so a 16-flip limit is 12 extra rungs.
local flips = tonumber(sdk.settings.max_flips) or 16
local extra = math.floor(flips) - 4
if extra < 1 then
extra = 1
elseif extra > 12 then
extra = 12
end
return {
endless_flips = enabled and true or false,
endless_flip_max = extra,
endless_air_check = sdk.settings.require_air == true,
}
end
local function refresh()
local t = tuning()
sdk.trainer.apply(t)
if sdk.settings.hud == false then
sdk.scene.remove(HUD_KEY)
elseif t.endless_flips then
sdk.ui.text(HUD_KEY, string.format('ENDLESS TRICKS up to %dx (needs air)', t.endless_flip_max + 4))
else
sdk.ui.text(HUD_KEY, 'ENDLESS TRICKS off')
end
end
return {
on_load = function()
refresh()
sdk.log('Endless Tricks ready. Hold a kickflip, heelflip, 360 flip or laserflip off something tall.')
end,
on_settings = refresh,
on_event = function(event)
-- A map change drops owned visuals, so the status line is placed again.
if event.name == 'world_changed' then
refresh()
end
end,
on_unload = function()
sdk.scene.remove(HUD_KEY)
end,
}
+38
View File
@@ -0,0 +1,38 @@
{
"id": "community.endless-tricks",
"api": 1,
"name": "Endless Tricks",
"version": "1.0.0",
"author": "Skate Rust Engine",
"description": "Hold a kickflip, heelflip, 360 flip or laserflip and keep going for as long as the air lasts, up to sixteen. Not a Skate 3 trick: retail stops at four. Needs real height off a ledge or a ramp; a flat pop still stops at a double. Owns native trainer tuning, so it cannot run alongside the Native Trainer.",
"entry": "main.lua",
"settings": {
"enabled": {
"type": "boolean",
"label": "Endless flips",
"description": "Off restores the stock four-rung ladder exactly.",
"default": true
},
"max_flips": {
"type": "number",
"label": "Maximum flips",
"description": "Most flips one pop may reach. 4 is the stock quad; from the 9th on it is just called ENDLESS.",
"default": 16,
"min": 5,
"max": 16,
"step": 1
},
"require_air": {
"type": "boolean",
"label": "Require air for each flip",
"description": "Off (default) keeps flipping while you hold and stay off the ground, with no height needed. On only starts a flip it has the air to land out of, so you bail less and flip less.",
"default": false
},
"hud": {
"type": "boolean",
"label": "Show status",
"description": "Small on-screen reminder of the current limit.",
"default": true
}
}
}
+3
View File
@@ -107,6 +107,9 @@ function sdk.animation.replace(path) end
---@field turn_power? number 0.25..4, native heading turn strength.
---@field hold_fakie? boolean Defaults false; suppress automatic fakie stance switching.
---@field manual_drag? number 0.25..4, manual balance linear drag.
---@field endless_flips? boolean Defaults false. Not retail: a held kickflip or heelflip keeps cycling past the authored quad while the pop has air for another rung. Needs real height; a flat pop still stops at a double.
---@field endless_flip_max? integer 1..12, default 12; extra flips allowed past the quad, so 12 reaches a 16x.
---@field endless_air_check? boolean Defaults false: keep flipping while held and airborne, with no height requirement. True only starts a rung it has the air to finish.
sdk.trainer = {}
---@param tuning TrainerTuning Owned, reversible native tuning. Conflicting owner is rejected.
function sdk.trainer.apply(tuning) end