Wire stock gesture recognition and 360 flip graph path

This commit is contained in:
chazmm committed 2026-09-06 05:31:12 +01:00
1 parent b4f5b67d5d
commit b033d59b1d
24 files changed
+1698 -11

No files matched your search

+4
View File
@@ -3,6 +3,10 @@
Double-click **PLAY.bat** to launch. Use an XInput-compatible controller;
**Esc** exits. Startup selects the supplied **Easy** profile.
The standing/rolling **360 flip** now uses the supplied gesture files and
ActionGraph/MotionGraph route, including right-stick crouch preparation.
See [360-flip integration and visual checks](docs/360-FLIP.md).
Double-click **PLAY-MAP.bat** to choose a `.skate` map, or drag a map onto
either launcher. `maps/private/` contains local copies of the transition park
and parity grid. `maps/format-demo.skate` is a small original textured floor.
@@ -0,0 +1,48 @@
//! Right-stick anticipation publication, Fill825999F0:
//! angle/magnitude 82599AD0..9C4C, emissions 8259AD0C..AD40.
use super::{
angle::left_stick_angle,
controller::{DerivedControllerInput, magnitude},
riding_intentions::RidingIntent,
};
pub fn produce(controller: &DerivedControllerInput) -> Vec<RidingIntent> {
let words = controller.words();
let x = f32::from_bits(words[9]);
let y = f32::from_bits(words[10]);
if x == 0.0 && y == 0.0 {
return Vec::new();
}
// Unlike steering, these two records have no actor1908 bit gate.
vec![
RidingIntent {
name: "AnticMag",
value: magnitude(x.mul_add(x, y * y)),
},
RidingIntent {
name: "AnticAngle",
value: left_stick_angle(x, y),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neutral_removes_anticipation_and_tail_hold_selects_ollie_range() {
assert!(produce(&DerivedControllerInput::from_words([0; 26])).is_empty());
let mut words = [0; 26];
words[10] = (-1.0f32).to_bits();
let intents = produce(&DerivedControllerInput::from_words(words));
assert!((intents[0].value - 1.0).abs() < 0.000001);
assert!(intents[1].value.abs() <= 0.52);
words[10] = 1.0f32.to_bits();
assert!(
produce(&DerivedControllerInput::from_words(words))[1]
.value
.abs()
>= 2.62
);
}
}
+248
View File
@@ -0,0 +1,248 @@
//! TU3 pattern recognition, 82697168/826972B8/826974A8.
//! Points are supplied in PAT authoring order. Native 82696B18 pushes them
//! to the front of a ring buffer, then the matcher walks that buffer backwards.
#[derive(Clone, Debug, PartialEq)]
pub struct Pattern {
pub name: String,
pub points: Vec<[f32; 2]>,
pub tolerance_squared: f32,
}
#[derive(Clone, Copy, Debug)]
pub struct Settings {
/// Recognizer config byte 33. Supplied by the owner, never inferred here.
pub maximum_misses: u8,
pub difficulty: u32,
}
#[derive(Clone, Copy, Debug, Default)]
struct Node {
active: bool,
complete: bool,
next: usize,
elapsed: u16,
misses: u8,
distance: f32,
}
impl Node {
fn tick(&mut self, pattern: &Pattern, sample: [f32; 2], maximum_misses: u8) {
if !self.active {
let distance = distance_squared(pattern.points[0], sample);
if distance <= pattern.tolerance_squared {
*self = Self {
active: true,
next: 1,
elapsed: 1,
distance,
..Self::default()
};
}
} else if !self.complete {
let distance = distance_squared(pattern.points[self.next], sample);
if distance <= pattern.tolerance_squared {
self.distance += distance;
self.elapsed = (self.elapsed + 1) & 0x3ff;
if self.next + 1 == pattern.points.len() {
self.complete = true;
} else {
self.next += 1;
self.misses = 0;
}
} else {
// Holding the first point does not age a pending gesture.
if self.next != 1
|| distance_squared(pattern.points[0], sample) > pattern.tolerance_squared
{
self.elapsed = (self.elapsed + 1) & 0x3ff;
self.misses = (self.misses + 1) & 0x3f;
}
if self.misses > maximum_misses {
*self = Self::default();
}
}
}
}
fn score(self, count: usize) -> f32 {
let count = count as f32;
// The unusual two-sided 0.15 operation is present in 826972B8.
let mean = (self.distance.max(0.15) / count).min(0.15);
(count * count * count * count) / (mean * self.elapsed as f32)
}
}
fn distance_squared(a: [f32; 2], b: [f32; 2]) -> f32 {
let x = a[0] - b[0];
let y = a[1] - b[1];
// VMX multiplies then adds; do not contract to FMA.
x * x + y * y
}
#[derive(Clone, Debug, PartialEq)]
pub struct Recognition {
pub pattern: usize,
pub strength: f32,
pub distance: f32,
pub elapsed: f32,
}
#[derive(Clone, Debug)]
pub struct Recognizer {
patterns: Vec<Pattern>,
nodes: Vec<Node>,
has_previous_sample: bool,
refractory: bool,
held: Option<usize>,
}
impl Recognizer {
pub fn new(patterns: Vec<Pattern>) -> Result<Self, String> {
for pattern in &patterns {
if !(2..=15).contains(&pattern.points.len())
|| !pattern.tolerance_squared.is_finite()
|| pattern.tolerance_squared < 0.0
|| pattern.points.iter().flatten().any(|v| !v.is_finite())
{
return Err(format!("invalid native gesture pattern {}", pattern.name));
}
}
Ok(Self {
nodes: vec![Node::default(); patterns.len()],
patterns,
has_previous_sample: false,
refractory: false,
held: None,
})
}
pub fn patterns(&self) -> &[Pattern] {
&self.patterns
}
/// Held messages are checked before recognition in 826962D8. Native point
/// zero is the final authored coordinate (82699738 push-front).
pub fn held(&mut self, sample: [f32; 2]) -> Option<usize> {
if let Some(index) = self.held {
let pattern = &self.patterns[index];
if distance_squared(*pattern.points.last().unwrap(), sample) > pattern.tolerance_squared
{
self.held = None;
}
}
self.held
}
pub fn sample(&mut self, sample: [f32; 2], settings: Settings) -> Option<Recognition> {
if self.refractory {
self.nodes.fill(Node::default());
self.refractory = false;
return None;
}
if !self.has_previous_sample {
self.has_previous_sample = true;
return None;
}
let mut best: Option<usize> = None;
for (node, pattern) in self.nodes.iter_mut().zip(&self.patterns) {
node.tick(pattern, sample, settings.maximum_misses);
}
for (index, node) in self.nodes.iter().enumerate() {
if node.complete
&& best.is_none_or(|old| {
node.score(self.patterns[index].points.len())
> self.nodes[old].score(self.patterns[old].points.len())
})
{
best = Some(index);
}
}
let pattern = best?;
let node = self.nodes[pattern];
let ratio = node.elapsed as f32 / self.patterns[pattern].points.len() as f32;
// 822249B4/82063B08 and 821E63E8/821E63EC, checked against TU3.
let (low, high) = if settings.difficulty == 2 {
(1.5, 3.0)
} else {
(1.75, 4.4)
};
let strength = if ratio <= low {
1.0
} else if ratio >= high {
0.0
} else {
let slope = 1.0 / (low - high);
slope.mul_add(ratio, -(slope * high))
};
self.refractory = true;
self.held = Some(pattern);
Some(Recognition {
pattern,
strength,
distance: node.distance,
elapsed: node.elapsed as f32,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn recognizer() -> Recognizer {
Recognizer::new(vec![Pattern {
name: "360Flip".into(),
points: vec![
[-0.965714, 0.268571],
[-0.497143, 0.84],
[0.211429, 0.988571],
[0.908571, -0.405714],
],
tolerance_squared: 0.4 * 0.4,
}])
.unwrap()
}
const SETTINGS: Settings = Settings {
maximum_misses: 3,
difficulty: 0,
};
#[test]
fn anticipation_hold_does_not_weaken_authored_scoop() {
let mut r = recognizer();
let points = r.patterns[0].points.clone();
r.sample([0.0; 2], SETTINGS);
for _ in 0..120 {
assert!(r.sample(points[0], SETTINGS).is_none());
}
assert!(r.sample(points[1], SETTINGS).is_none());
assert!(r.sample(points[2], SETTINGS).is_none());
let result = r.sample(points[3], SETTINGS).unwrap();
assert_eq!(
(result.pattern, result.strength, result.elapsed),
(0, 1.0, 4.0)
);
assert_eq!(r.held(points[3]), Some(0));
assert!(r.sample(points[3], SETTINGS).is_none());
assert_eq!(r.held([0.0; 2]), None);
}
#[test]
fn disconnected_scoop_and_reverse_path_do_not_trigger() {
let mut r = recognizer();
let points = r.patterns[0].points.clone();
r.sample([0.0; 2], SETTINGS);
r.sample(points[0], SETTINGS);
for _ in 0..4 {
assert!(r.sample([0.0; 2], SETTINGS).is_none());
}
for &point in &points[1..] {
assert!(r.sample(point, SETTINGS).is_none());
}
let mut r = recognizer();
r.sample([0.0; 2], SETTINGS);
for &point in points.iter().rev() {
assert!(r.sample(point, SETTINGS).is_none());
}
}
}
+2
View File
@@ -1,5 +1,7 @@
//! Native input publication. Controller/graph producers remain separate stages.
pub mod angle;
pub mod gesture;
pub mod anticipation_intentions;
pub mod animation_packet;
pub mod controller;
pub mod gameplay_map;
+83
View File
@@ -0,0 +1,83 @@
//! Authored joystick PAT data consumed by TU3 82696B18.
use skate_core::input::gesture::Pattern;
use std::path::Path;
pub fn load(path: &Path) -> Result<Vec<Pattern>, String> {
parse(&std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?)
.map_err(|e| format!("{}: {e}", path.display()))
}
pub fn parse(source: &str) -> Result<Vec<Pattern>, String> {
let mut patterns: Vec<Pattern> = Vec::new();
let mut tolerance = None;
for (line, source) in source.lines().enumerate() {
let words: Vec<_> = source
.split("//")
.next()
.unwrap_or("")
.split_whitespace()
.collect();
if words.is_empty() {
continue;
}
let fail = || format!("invalid PAT directive at line {}: {source}", line + 1);
let number = |index: usize| -> Result<f32, String> {
let value: f32 = words
.get(index)
.ok_or_else(fail)?
.parse()
.map_err(|_| fail())?;
if !value.is_finite() {
return Err(fail());
}
Ok(value)
};
match words[0] {
"global_tolerance_dist" if words.len() == 2 => {
let v = number(1)?;
tolerance = Some(v * v);
}
// The native loader reads but does not store these values.
"global_tolerance_time"
| "global_tolerance_speed"
| "global_anticipation_delay"
| "tolerance_time"
if words.len() == 2 =>
{
number(1)?;
}
"pattern" if words.len() == 2 => patterns.push(Pattern {
name: words[1].into(),
points: Vec::new(),
tolerance_squared: tolerance
.ok_or_else(|| "PAT lacks global_tolerance_dist".to_owned())?,
}),
"tolerance_dist" if words.len() == 2 => {
let v = number(1)?;
patterns.last_mut().ok_or_else(fail)?.tolerance_squared = v * v;
}
"coord" if words.len() == 3 => {
let point = [number(1)?, number(2)?];
patterns.last_mut().ok_or_else(fail)?.points.push(point);
}
// Fail explicitly on polar or modified directives until their
// transcendental implementation is provided. Stock skater.pat uses coord.
_ => return Err(fail()),
}
}
skate_core::input::gesture::Recognizer::new(patterns.clone())?;
Ok(patterns)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authored_order_and_per_pattern_tolerances_are_preserved() {
let p = parse("global_tolerance_dist 0.4\nglobal_anticipation_delay 15\npattern Ollie\ntolerance_dist 0.55\ncoord 0 1\ncoord 0 -1\npattern Test\ncoord 1 0\ncoord -1 0").unwrap();
assert_eq!(p[0].points, vec![[0.0, 1.0], [0.0, -1.0]]);
assert_eq!(p[0].tolerance_squared, 0.55 * 0.55);
assert_eq!(p[1].tolerance_squared, 0.4 * 0.4);
assert!(parse("global_tolerance_dist 0.4\npattern Broken\ncoord 0 1").is_err());
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod animation_frames;
pub mod animation_metadata;
pub mod collections;
pub mod input_config;
pub mod gesture_patterns;
pub mod physics_skeleton;
mod sha256;
pub mod state_graph;
@@ -45,6 +45,7 @@ pub struct ActionHost {
/// JuiceHook instance+8 starts with the native null intent name. Its
/// update removes that pending key and resets it,82BA39A8.
juice_pending: Vec<String>,
trick_handlers: Vec<crate::input::gesture_mapping::State>,
}
impl ActionHost {
@@ -109,6 +110,7 @@ impl ActionHost {
body_flip: vec![body_flip_signal::State::default(); count],
body_flip_settings: None,
juice_pending: vec![String::new(); count],
trick_handlers: vec![crate::input::gesture_mapping::State::default(); count],
}
}
@@ -194,6 +196,7 @@ impl Host for ActionHost {
self.time_handlers[behavior] = TimeMgIntent::default();
self.body_flip[behavior] = body_flip_signal::State::default();
self.juice_pending[behavior].clear();
self.trick_handlers[behavior] = crate::input::gesture_mapping::State::default();
let result = self.next_instance;
self.next_instance = self.next_instance.wrapping_add(1).max(1);
result
@@ -202,6 +205,14 @@ impl Host for ActionHost {
let Some(instance) = self.operation(behavior).cloned() else {
return;
};
if let ActionOperation::CreateTrickIntentFromGesture { group, override_name } = &instance.operation {
if let Some((_, mirrored)) = self.stance {
self.trick_handlers[behavior].begin(*group, override_name.as_deref(), &self.action_intents, &mut self.motion_intents, mirrored);
} else {
self.errors.push("CreateTrickIntentFromGesture requires published skater stance".into());
}
return;
}
if instance.unsupported().is_some() {
self.unsupported(behavior, &instance);
return;
@@ -250,6 +261,10 @@ impl Host for ActionHost {
let Some(instance) = self.operation(behavior).cloned() else {
return;
};
if matches!(instance.operation, ActionOperation::CreateTrickIntentFromGesture { .. }) {
self.trick_handlers[behavior].update(&mut self.motion_intents);
return;
}
if instance.unsupported().is_some() {
self.unsupported(behavior, &instance);
return;
@@ -320,6 +335,10 @@ impl Host for ActionHost {
let Some(instance) = self.operation(behavior).cloned() else {
return;
};
if matches!(instance.operation, ActionOperation::CreateTrickIntentFromGesture { .. }) {
self.trick_handlers[behavior].end(&mut self.motion_intents);
return;
}
if let Some(name) = instance.config.mg_intent.as_deref() {
let mutation = match instance.operation {
ActionOperation::CreateConstMgIntent => self.const_handlers[behavior].end(),
@@ -73,7 +73,7 @@ pub enum ActionOperation {
CreateMgTimeIntent,
CreateConstMgIntent,
BoardAdjust,
CreateTrickIntentFromGesture,
CreateTrickIntentFromGesture { group: crate::input::gesture_catalog::Group, override_name: Option<String> },
JuiceHook,
BodyFlippingSignal,
/// Diagnostic presentation is owned by this host and has no intent effect.
@@ -120,9 +120,6 @@ impl ActionFactory {
ActionOperation::CreateConstMgIntent
}
(OperationKind::Behavior, "BoardAdjust") => ActionOperation::BoardAdjust,
(OperationKind::Behavior, "CreateTrickIntentFromGesture") => {
ActionOperation::CreateTrickIntentFromGesture
}
(OperationKind::Behavior, "JuiceHook") => ActionOperation::JuiceHook,
(OperationKind::Behavior, "BodyFlippingSignal") => ActionOperation::BodyFlippingSignal,
_ => ActionOperation::Unsupported {
@@ -160,7 +157,12 @@ impl OperationFactory for ActionFactory {
.unwrap_or_else(|| Self::operation(kind, name))
}
} else {
if kind == OperationKind::Behavior && name == "PrintText2D" {
if kind == OperationKind::Behavior && name == "CreateTrickIntentFromGesture" {
ActionOperation::CreateTrickIntentFromGesture {
group: crate::input::gesture_catalog::Group::parse(attributes.text("group").unwrap_or("Square")).map_err(ActionFactoryError)?,
override_name: attributes.text("override").map(str::to_owned),
}
} else if kind == OperationKind::Behavior && name == "PrintText2D" {
ActionOperation::PrintText(attributes.text("text").unwrap_or("").to_owned())
} else {
Self::operation(kind, name)
@@ -200,10 +202,6 @@ impl ActionInstance {
kind: OperationKind::Behavior,
name: "BoardAdjust".into(),
}),
ActionOperation::CreateTrickIntentFromGesture => Some(UnsupportedOperation {
kind: OperationKind::Behavior,
name: "CreateTrickIntentFromGesture".into(),
}),
_ => None,
}
}
+1
View File
@@ -20,6 +20,7 @@ pub(crate) mod motion_landing;
mod motion_landing_execute;
pub(crate) mod motion_native;
mod motion_nodes;
mod motion_tricks;
#[path = "motion_offboard/push_off.rs"]
pub(crate) mod motion_push_off;
#[path = "motion_offboard/reset.rs"]
@@ -44,6 +44,7 @@ pub struct MotionPhysical {
pub foot_frame: Option<PushFootFrame>,
}
enum Instance {
Trick(i32),
Stateless,
IntentFilter(skate_core::animation::intent_filter::State),
Landing(super::motion_landing::State),
@@ -69,6 +70,7 @@ enum Instance {
impl Instance {
fn new(operation: &MotionOperation) -> Self {
match operation {
MotionOperation::Trick(_) => Self::Trick(0),
MotionOperation::IntentFilter(_) => Self::IntentFilter(Default::default()),
MotionOperation::Landing(_) => Self::Landing(Default::default()),
MotionOperation::Wipeout(_) => Self::Wipeout(Default::default()),
@@ -134,6 +136,8 @@ pub struct MotionHost {
///SpecificCA4 bit26, getter8258F900/setter8258F910; reset clears it.
pub is_power_sliding: bool,
pub score_packet: super::motion_native::ScorePacket,
pub(super) trick_requests: super::motion_tricks::Requests,
pub(super) trick_height_settings: (bool, bool),
pub action_intents: IntentMap,
pub time_tags: Option<BTreeMap<String, f32>>,
pub physical: Option<MotionPhysical>,
@@ -223,6 +227,8 @@ impl MotionHost {
slide_latch: set_turning::SlideLatch::default(),
is_power_sliding: false,
score_packet: super::motion_native::ScorePacket::default(),
trick_requests: Default::default(),
trick_height_settings: (data.boolean("anim_motion", "jumping", "use_gesture_speed")?, data.boolean("anim_motion", "jumping", "clamp_gesture_to_antic")?),
action_intents: IntentMap::new(),
time_tags: None,
physical: None,
@@ -372,3 +378,7 @@ mod slide_tests;
#[cfg(test)]
#[path = "tests/motion_bump.rs"]
mod bump_tests;
#[cfg(test)]
#[path = "tests/motion_tricks.rs"]
mod trick_tests;
@@ -15,6 +15,14 @@ impl MotionHost {
.get(behavior)
.ok_or("Unbound MotionGraph behavior")?;
let operation = self.operations[id].clone();
if let MotionOperation::Trick(operation) = operation {
let Some(&Instance::Trick(mut updates)) = self.instances.get(behavior) else {
return Err("Trick operation/instance mismatch".into());
};
let result = super::super::motion_tricks::execute(self, operation, &mut updates, phase);
self.instances[behavior] = Instance::Trick(updates);
return result;
}
if let MotionOperation::Slide(operation) = operation {
return self.execute_slide(behavior, operation, frame, phase);
}
@@ -40,6 +40,8 @@ pub enum GameplayCondition {
CanEnterSlide { right: bool },
GrabbingObject,
Dark,
UnderflipRequested,
DarkCatchRequested,
HandPlanting { state: u8, direction: u8 },
EnteringSkitch,
Skitching,
@@ -60,6 +62,8 @@ impl GameplayCondition {
| "CanEnterSlide"
| "IsGrabbingObject"
| "IsDark"
| "IsUnderflipRequested"
| "IsDarkCatchRequested"
| "IsHandPlanting"
| "IsEnteringSkitch"
| "IsSkitching"
@@ -81,6 +85,8 @@ impl GameplayCondition {
},
"IsGrabbingObject" => Self::GrabbingObject,
"IsDark" => Self::Dark,
"IsUnderflipRequested" => Self::UnderflipRequested,
"IsDarkCatchRequested" => Self::DarkCatchRequested,
"IsEnteringSkitch" => Self::EnteringSkitch,
"IsSkitching" => Self::Skitching,
"IsHandPlanting" => Self::HandPlanting {
@@ -132,7 +138,7 @@ impl GameplayCondition {
_ => false,
}
}
Self::DroppingBoard | Self::Dark | Self::CanEnterSlide { .. } => return None,
Self::DroppingBoard | Self::Dark | Self::UnderflipRequested | Self::DarkCatchRequested | Self::CanEnterSlide { .. } => return None,
})
}
@@ -149,6 +155,8 @@ impl GameplayCondition {
Self::DroppingBoard => p.dropping_board || host.animation.channels.has("RetrieveBoard"),
//82BA79A0 calls the specific MotionGraph getter8258FB68.
Self::Dark => host.riding.dark,
Self::UnderflipRequested => host.trick_requests.underflip,
Self::DarkCatchRequested => host.trick_requests.dark_catch,
Self::CanEnterSlide { right } => {
//82BA6FE0 rejects RevertGround102, reverses the side for fakie,
//then reads its start bit in the retained C84 slide packet.
@@ -79,6 +79,7 @@ impl Operation {
/// this game.82595D08 sets a name and ORs a bit in the graph's score packet.
#[derive(Default)]
pub struct ScorePacket {
pub trick: Option<skate_core::animation::output::attributes::AttributeName>,
pub name: Option<u32>,
pub flags: u32,
}
@@ -14,6 +14,7 @@ use skate_data::state_graph::{
#[derive(Clone, Debug, PartialEq)]
pub enum MotionOperation {
Trick(super::motion_tricks::Operation),
Play(PlayAnimation),
///TU3 vtable82309664: Begin/Update/End all point to the empty82B61BB8.
PrintText2D,
@@ -123,6 +124,7 @@ impl OperationFactory for MotionFactory {
super::motion_air_leg::Operation::parse(a),
)),
"ClearTrickAttr" => Some(MotionOperation::ClearTrickAttr),
"SetTrickHeight" | "SetTrickAttr" | "ScoringTrick" | "MonitorUnderflip" | "SetDark" | "UpdateIsWeightOnNose" => super::motion_tricks::Operation::parse(a).map(MotionOperation::Trick),
"CharacterGesture" => Some(MotionOperation::CharacterGesture),
"DisallowPumping" => Some(MotionOperation::DisallowPumping),
"FakieHeadChannel" => Some(MotionOperation::FakieHeadChannel),
@@ -0,0 +1,188 @@
//! Original trick leaves. Registration strings, not community symbol guesses,
//! identify SetTrickAttr82BC7848 and ScoringTrick82BCC030.
use super::motion::MotionHost;
use skate_core::animation::{
output::attributes::AttributeName,
playback_parameters::{AttributeSink, ParameterInputs, SettableAttribute},
skeleton_input::name::encode,
};
use skate_data::state_graph::attributes::Attributes;
#[derive(Clone, Debug, PartialEq)]
pub enum Operation {
Height {
from: AttributeName,
rename: AttributeName,
value: Option<f32>,
manual: bool,
grind: bool,
},
Attribute(AttributeName),
Scoring(AttributeName),
MonitorUnderflip,
Dark,
WeightOnNose,
}
impl Operation {
pub fn parse(a: &Attributes<'_>) -> Option<Self> {
Some(match a.text("name")? {
"SetTrickHeight" => Self::Height {
from: encode(a.text("from").unwrap_or("").as_bytes()),
rename: encode(a.text("rename").unwrap_or("").as_bytes()),
value: a.get("setToValue").map(|v| f32::from_bits(v.float_bits)),
manual: a.get("trickFromManual").is_some(),
grind: a.get("trickFromGrind").is_some(),
},
"SetTrickAttr" => Self::Attribute(encode(a.text("trick").unwrap_or("").as_bytes())),
"ScoringTrick" => Self::Scoring(encode(a.text("trick").unwrap_or("").as_bytes())),
"MonitorUnderflip" => Self::MonitorUnderflip,
"SetDark" => Self::Dark,
"UpdateIsWeightOnNose" => Self::WeightOnNose,
_ => return None,
})
}
}
#[derive(Default)]
pub struct Requests {
pub underflip: bool,
pub dark_catch: bool,
}
pub fn execute(
host: &mut MotionHost,
operation: Operation,
updates: &mut i32,
phase: u8,
) -> Result<(), String> {
match operation {
Operation::Height {
from,
rename,
value,
manual,
grind,
} if phase == 0 => {
// 82BAEE90: graph intents override animation only on the authored paths.
let gesture = host
.animation
.motion_intents
.get("GestureSpeed")
.copied()
.unwrap_or(0.0);
let height = if let Some(value) = value {
value
} else if let Some(value) = host.animation.motion_intents.get("TrickHeight") {
*value
} else {
let mut height = host
.animation
.last_attribute(from)?
.and_then(|a| a.payload.0[0])
.map(f32::from_bits)
.unwrap_or(0.0);
if host.trick_height_settings.0 {
height = if host.trick_height_settings.1 {
height.min(gesture.max(0.0))
} else {
gesture
};
}
height
};
// Independent packet output carries gesture speed, not selected height.
host.animation.emit_packet(
encode(b"JumpHeightOverride"),
gesture
* if manual {
0.27
} else if grind {
1.0
} else {
0.2
},
);
host.animation.set_attribute(SettableAttribute {
name: rename,
value: height,
normalized: true,
sequence_id: -1,
});
}
Operation::Attribute(value) => {
let name = encode(b"Trick");
if phase == 0 {
if let Some(entry) = host
.animation
.construction_values
.iter_mut()
.find(|e| e.0 == name)
{
entry.1 = value;
} else {
host.animation.construction_values.push((name, value));
}
} else if phase == 2
&& host
.animation
.last_attribute(encode(b"defaultcyc"))?
.is_some()
{
host.animation.construction_values.retain(|e| e.0 != name);
}
}
Operation::Scoring(name) if phase == 1 => {
// ISkaterMotionGraph236 ->8258FA20 stores both equal names and bit24.
host.score_packet.trick = Some(name);
host.score_packet.flags |= 0x0100_0000;
}
Operation::MonitorUnderflip => {
if phase == 0 {
*updates = 0;
} else if phase == 2 {
host.trick_requests = Requests::default();
} else {
*updates = updates.wrapping_add(1);
if *updates > 1 {
let intents = &host.animation.motion_intents;
if [
"U_L_F_Kickflip",
"U_L_F_Heelflip",
"U_L_B_Kickflip",
"U_L_B_Heelflip",
"U_Nollie",
"U_Fingerflip",
"U_Kickflip",
"U_Heelflip",
"U_N_Kickflip",
"U_N_Heelflip",
]
.iter()
.any(|n| intents.contains_key(n))
{
host.trick_requests.underflip = true;
} else if intents.contains_key("DarkCatch") {
host.trick_requests.dark_catch = true;
}
}
}
}
Operation::Dark => {
host.riding.dark = phase != 2;
if phase == 1 {
host.animation.emit_packet(encode(b"IsDark"), 1.0);
}
}
Operation::WeightOnNose if phase != 1 => {
//82BB2670/26D0 -> ISkaterAnim20 ->82B97110, bit28.
let flags = host
.animation
.skater_animation_flags
.as_mut()
.ok_or("UpdateIsWeightOnNose requires live ISkaterAnim")?;
*flags = (*flags & !0x1000_0000) | if phase == 0 { 0x1000_0000 } else { 0 };
}
_ => {}
}
Ok(())
}
@@ -0,0 +1,194 @@
use super::*;
use skate_core::animation::playback_tree::Evaluation;
#[test]
#[ignore = "requires private stock graphs and animation banks; no gameplay simulation"]
fn stock_360_crouch_takeoff_and_air_animation_pipeline() {
use crate::{animation_pose::PoseEvaluator, graph_runtime::CompiledGraph};
use skate_data::{
animation_banks::AnimationBanks,
state_graph::{StateGraph, binding::Binding},
};
let root = std::path::PathBuf::from(
std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"),
);
let banks = AnimationBanks::load(&root).unwrap();
let evaluator = PoseEvaluator::from_banks(&banks).unwrap();
let source =
StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph"))
.unwrap();
let binding = Binding::from_graph(&source).unwrap();
let runtime = CompiledGraph::from_binding(&binding).unwrap();
let graph = LoadedGraph {
source,
binding,
runtime,
};
let collections = Collections::load(&root).unwrap();
let mut host = MotionHost::from_graph(
&graph,
&collections,
banks.metadata().unwrap(),
PlaybackContext {
is_switch: Some(false),
is_mirrored: Some(false),
board_available: Some(true),
pro_skater: encode(b""),
transition_override: None,
},
)
.unwrap();
host.animation
.set_hierarchy(
&evaluator.frames.bone_names,
&evaluator.frames.mirror_indices,
)
.unwrap();
let frame = Frame {
dt: 1.0 / 60.0,
current: None,
last: None,
state_times: Vec::new(),
};
let evaluation = Evaluation {
cull_threshold: 0.01,
update_history: true,
};
let find = |suffix: &str, operation: &str| -> usize {
use skate_data::state_graph::binding::Node;
graph
.binding
.operations
.iter()
.enumerate()
.find_map(|(id, source)| {
let Node::State(mut state) = source.parent else {
return None;
};
let mut names = Vec::new();
loop {
names.push(graph.binding.states[state].name.clone());
let Some(parent) = graph.binding.states[state].parent else {
break;
};
state = parent;
}
names.reverse();
if !names.join(".").ends_with(suffix) {
return None;
}
let runtime = host.remap.behaviors.iter().position(|&op| op == id)?;
let matches = match (&host.operations[id], operation) {
(MotionOperation::Play(_), "PlayAnimation") => true,
(
MotionOperation::Trick(super::super::motion_tricks::Operation::Height {
..
}),
"SetTrickHeight",
) => true,
(
MotionOperation::Trick(super::super::motion_tricks::Operation::Attribute(
_,
)),
"SetTrickAttr",
) => true,
(
MotionOperation::Trick(super::super::motion_tricks::Operation::Scoring(_)),
"ScoringTrick",
) => true,
(
MotionOperation::Trick(
super::super::motion_tricks::Operation::MonitorUnderflip,
),
"MonitorUnderflip",
) => true,
_ => false,
};
matches.then_some(runtime)
})
.unwrap_or_else(|| panic!("Missing {suffix}/{operation}"))
};
let crouch = find("Anticipation.AnticInto.AnticTail", "PlayAnimation");
let scoop = find(
"Anticipation.AnticCyc.360PopShuvitAnticCycle",
"PlayAnimation",
);
let ground = find("TailTrick.360Flip.Takeoff.FromAntic", "PlayAnimation");
let height = find("TailTrick.360Flip.Takeoff.FromAntic", "SetTrickHeight");
let air = find("TailTrick.360Flip.LeftGround", "PlayAnimation");
let attr = find("TailTrick.360Flip", "SetTrickAttr");
let score = find("TailTrick.360Flip", "ScoringTrick");
let monitor = find("TailTrick.360Flip", "MonitorUnderflip");
for mirrored in [false, true] {
host.animation.reset_from_stock();
host.animation.skater_animation_flags = Some(if mirrored { 0x40000000 } else { 0 });
host.playback_context.is_mirrored = Some(mirrored);
host.animation.filtered_intents.insert("Compression", 1.0);
for (behavior, duration) in [(crouch, 0.25), (scoop, 0.5)] {
host.execute(behavior, &frame, 0).unwrap();
host.animation.apply_parameters().unwrap();
host.animation.advance(duration, 0.0);
host.animation.refresh_tree_attributes().unwrap();
let pose = evaluator
.evaluate(&host.animation.evaluate_pose(evaluation).unwrap())
.unwrap();
assert!(
pose.iter()
.flat_map(|b| b.rotation.iter().chain(&b.translation))
.all(|v| v.is_finite())
);
}
let antic = host
.animation
.last_attribute(encode(b"AnticStrength"))
.unwrap()
.expect("stock crouch must supply AnticStrength");
assert!(f32::from_bits(antic.payload.0[0].unwrap()) > 0.0);
host.animation.motion_intents.insert("GestureSpeed", 1.0);
for behavior in [attr, score, monitor, ground, height] {
host.execute(behavior, &frame, 0).unwrap();
}
host.execute(score, &frame, 1).unwrap();
assert_eq!(host.score_packet.trick, Some(encode(b"360Flip")));
assert!(
host.animation
.construction_values
.contains(&(encode(b"Trick"), encode(b"360Flip")))
);
host.animation.apply_parameters().unwrap();
host.animation.advance(0.1, 0.0);
host.animation.refresh_tree_attributes().unwrap();
assert!(
host.animation
.last_attribute(encode(b"TrickHeight"))
.unwrap()
.is_some()
);
let pose = evaluator
.evaluate(&host.animation.evaluate_pose(evaluation).unwrap())
.unwrap();
assert!(
pose.iter()
.flat_map(|b| b.rotation.iter().chain(&b.translation))
.all(|v| v.is_finite())
);
host.execute(air, &frame, 0).unwrap();
host.animation.apply_parameters().unwrap();
host.animation.advance(0.1, 0.0);
host.animation.refresh_tree_attributes().unwrap();
let pose = evaluator
.evaluate(&host.animation.evaluate_pose(evaluation).unwrap())
.unwrap();
assert!(
pose.iter()
.flat_map(|b| b.rotation.iter().chain(&b.translation))
.all(|v| v.is_finite())
);
host.execute(monitor, &frame, 1).unwrap();
assert!(!host.trick_requests.dark_catch);
host.execute(monitor, &frame, 2).unwrap();
}
println!(
"Stock crouch, scoop, B_360FLIP_G -> B_360FLIP_A, attributes and finite poses checked in both stances"
);
}
+3
View File
@@ -4,6 +4,9 @@ use bevy::prelude::*;
mod controllers;
pub(crate) mod gesture_catalog;
mod gesture_mapping_data;
pub(crate) mod gesture_mapping;
pub(crate) mod gesture_input;
mod platform;
pub(crate) use controllers::{ControllerInput, ControllerStatus};
@@ -0,0 +1,203 @@
//! GameInputManager82696030/826962D8 -> listener8259B878/8259B9D0 ->
//! Fill8259B1F0..B7D8. Runs on the existing 60 Hz gameplay input boundary.
use skate_core::{
graph::intents::IntentMap,
input::gesture::{Recognizer, Settings},
};
use skate_data::collections::Collections;
use std::path::Path;
pub(crate) struct GestureInput {
recognizers: Vec<(usize, Recognizer)>,
maximum_misses: [u8; 2],
held_pattern: Option<String>,
}
impl GestureInput {
pub fn load(root: &Path) -> Result<Self, String> {
let data = Collections::load(root)?;
let misses = |key| -> Result<u8, String> {
let field = data.field("recognizer", key, "NumTicksPatternNotInRangeBeforeCulling")?;
if field.type_name != "EA::Reflection::UInt8" {
return Err("Recognizer culling field must be UInt8".into());
}
u8::from_str_radix(&field.data, 16).map_err(|e| e.to_string())
};
let mut recognizers = Vec::new();
// Original constructor82695A68 insertion order; each file competes
// internally, and all winners are delivered to the listener in order.
for (stick, file) in [
(1, "skater.pat"),
(1, "skater90.pat"),
(1, "skaterN90.pat"),
(1, "skater_air.pat"),
(1, "skater_fingerflip.pat"),
(0, "skaterls.pat"),
(0, "skaterstep.pat"),
] {
recognizers.push((
stick,
Recognizer::new(skate_data::gesture_patterns::load(
&root.join("private/stock/data/joystick").join(file),
)?)?,
));
}
Ok(Self {
recognizers,
maximum_misses: [misses("left_stick")?, misses("right_stick")?],
held_pattern: None,
})
}
pub fn publish(
&mut self,
axes: [[f32; 2]; 2],
difficulty: u32,
flags: u32,
physical_state: u32,
ag: &mut IntentMap,
) {
// Native manager negates mapped Y. Component deadzone0.1 is initialized
// by82F75F60 from820641A8, separately from cInputMap's own deadzones.
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;
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) {
if let Some(index) = recognizer.held(samples[stick]) {
if self.held_pattern.as_deref().is_some_and(|name| {
name.eq_ignore_ascii_case(&recognizer.patterns()[index].name)
}) {
held = true;
}
}
}
for (_, recognizer) in self.recognizers.iter_mut().filter(|r| r.0 == stick) {
if let Some(result) = recognizer.sample(
samples[stick],
Settings {
maximum_misses: self.maximum_misses[stick],
difficulty,
},
) {
let name = recognizer.patterns()[result.pattern].name.clone();
// Listener retains only these four hold-capable events.
if ["Kickflip", "Heelflip", "N_Kickflip", "N_Heelflip"].contains(&name.as_str())
{
self.held_pattern = Some(name.clone());
}
events.push((name, result.strength));
}
}
}
if held {
ag.insert("HoldPattern", 1.0);
}
for (name, strength) in events {
if permitted(&name, flags, physical_state, ag) {
ag.insert("Trick", 1.0);
ag.insert(&name, 1.0);
ag.insert("GestureSpeed", strength);
}
}
}
}
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));
if bit(5) && member(&["FingerFlip", "FS_Varial", "BS_Varial"]) {
return false;
}
if bit(4)
&& (matches!(state, 200 | 201)
|| member(&[
"L_F_Kickflip",
"L_B_Kickflip",
"L_F_Heelflip",
"L_B_Heelflip",
"L_FS_Shuvit",
"L_BS_Shuvit",
]))
{
return false;
}
if member(&["FrontFlip", "BackFlip"])
&& (!(ag.contains_key("RightAirGrab") || ag.contains_key("LeftAirGrab"))
|| ag.contains_key("LeftPush")
|| ag.contains_key("RightPush")
|| bit(3))
{
return false;
}
if bit(8) && member(&["SlideFs180", "SlideBs180"]) {
return false;
}
if bit(12)
&& !member(&[
"Ollie",
"Kickflip",
"Heelflip",
"Hardflip",
"InwardHeelflip",
"VarialKickflip",
"VarialHeelflip",
"PopShuvit",
"FSPopShuvit",
"360PopShuvit",
"FS360PopShuvit",
])
{
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires private stock joystick and collection data"]
fn stock_360_gesture_reaches_native_square_mapping() {
let root = std::path::PathBuf::from(
std::env::var_os("SKATE3_ASSET_ROOT").expect("SKATE3_ASSET_ROOT"),
);
let mut input = GestureInput::load(&root).unwrap();
let mut ag = IntentMap::new();
let points = input.recognizers[0]
.1
.patterns()
.iter()
.find(|p| p.name == "360Flip")
.unwrap()
.points
.clone();
input.publish([[0.0; 2]; 2], 0, 0, 100, &mut ag);
for _ in 0..30 {
ag.clear();
input.publish(
[[0.0; 2], [points[0][0], -points[0][1]]],
0,
0,
100,
&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);
}
assert!(ag.contains_key("360Flip"), "{ag:?}");
assert_eq!(
super::super::gesture_mapping::select(
super::super::gesture_catalog::Group::Square,
&ag,
false
),
Some("360Flip")
);
assert_eq!(ag.get("GestureSpeed"), Some(&1.0));
println!("Authored scoop through all seven PAT recognizers: {ag:?}");
}
}
@@ -0,0 +1,121 @@
//! GestureTrickMapping82BA07F0 and CreateTrickIntentFromGesture82BA1C30..2228.
use super::{gesture_catalog::Group, gesture_mapping_data::TABLES};
use skate_core::{animation::playback_parameters::intent_key, graph::intents::IntentMap};
pub(crate) fn select(group: Group, intents: &IntentMap, mirrored: bool) -> Option<&'static str> {
let rows = TABLES[group as usize];
// 82BC0D90 uses greatest prime <=46, then maximum load10000.
// 82BC1BE8 prepends entries; 82BA07F0 visits ascending buckets.
let mut selected = None;
let mut bucket = usize::MAX;
for row in rows.iter().rev() {
let current = intent_key(row.0)
.iter()
.fold(0u32, |sum, word| sum.wrapping_add(*word)) as usize
% 43;
if current < bucket && intents.contains_key(row.0) {
selected = Some(row);
bucket = current;
}
}
let row = selected?;
let mirrored = mirrored && !intents.contains_key("DontMirrorTrick");
Some(if intents.contains_key("DarkCatch") && row.3 != 0 {
match (row.3, mirrored) {
(2, false) | (1, true) => "Heelflip",
(3, true) | (4, false) => "N_Heelflip",
(3, false) | (4, true) => "N_Kickflip",
_ => "Kickflip",
}
} else if mirrored {
row.2
} else {
row.1
})
}
#[derive(Clone, Debug)]
pub(crate) struct State {
first_update: bool,
selected: Option<String>,
successor: Option<&'static str>,
}
impl Default for State {
fn default() -> Self {
Self {
first_update: true,
selected: None,
successor: None,
}
}
}
impl State {
pub fn begin(
&mut self,
group: Group,
override_name: Option<&str>,
ag: &IntentMap,
mg: &mut IntentMap,
mirrored: bool,
) {
let Some(name) = select(group, ag, mirrored) else {
return;
};
let name = override_name.unwrap_or(name);
self.selected = Some(name.to_owned());
self.successor = match name {
"Kickflip" => Some("KickflipHold"),
"Heelflip" => Some("HeelflipHold"),
"N_Kickflip" => Some("N_KickflipHold"),
"N_Heelflip" => Some("N_HeelflipHold"),
_ => None,
};
mg.insert(name, 0.0);
}
pub fn update(&mut self, mg: &mut IntentMap) {
if !self.first_update {
if let (Some(name), Some(successor)) = (&self.selected, self.successor) {
mg.remove(name);
mg.insert(successor, 0.0);
}
}
self.first_update = false;
}
pub fn end(&mut self, mg: &mut IntentMap) {
if let Some(name) = &self.selected {
if let Some(successor) = self.successor {
mg.remove(successor);
}
mg.remove(name);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tre_flip_uses_original_mirror_mapping_and_presence_not_strength() {
let mut ag = IntentMap::new();
ag.insert("360Flip", 0.0);
assert_eq!(select(Group::Square, &ag, false), Some("360Flip"));
assert_eq!(select(Group::Square, &ag, true), Some("Laserflip"));
ag.insert("DontMirrorTrick", 0.0);
assert_eq!(select(Group::Square, &ag, true), Some("360Flip"));
}
#[test]
fn first_update_retains_launch_intent_then_successor_is_cleaned_on_exit() {
let mut ag = IntentMap::new();
ag.insert("Kickflip", 1.0);
let mut mg = IntentMap::new();
let mut state = State::default();
state.begin(Group::Square, None, &ag, &mut mg, false);
state.update(&mut mg);
assert!(mg.contains_key("Kickflip"));
state.update(&mut mg);
assert!(!mg.contains_key("Kickflip"));
assert!(mg.contains_key("KickflipHold"));
state.end(&mut mg);
assert!(mg.is_empty());
}
}
@@ -0,0 +1,413 @@
//! TU3 82B98F70 records, re-extracted from original bytes and all 270 map inserts.
// Each tuple is (gesture key, normal intent, mirrored intent, dark-catch category).
pub(super) const TABLES: [&[(&str, &str, &str, u32)]; 7] = [
&[
("Ollie", "Ollie", "Ollie", 1),
("PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("VarialKickflip", "VarialKickflip", "VarialHeelflip", 1),
("VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2),
("Hardflip", "Hardflip", "InwardHeelflip", 1),
("InwardHeelflip", "InwardHeelflip", "Hardflip", 2),
("360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1),
("FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2),
("360Flip", "360Flip", "Laserflip", 0),
("Laserflip", "Laserflip", "360Flip", 0),
("360Hardflip", "360Hardflip", "360InwardHeelflip", 0),
("360InwardHeelflip", "360InwardHeelflip", "360Hardflip", 0),
("Kickflip", "Kickflip", "Heelflip", 0),
("Heelflip", "Heelflip", "Kickflip", 0),
("Nollie", "Nollie", "Nollie", 3),
("N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
(
"N_VarialKickflip",
"N_VarialKickflip",
"N_VarialHeelflip",
3,
),
(
"N_VarialHeelflip",
"N_VarialHeelflip",
"N_VarialKickflip",
4,
),
("N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3),
("N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4),
("N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3),
("N_FS360PopShuvit", "N_FS360PopShuvit", "N_360PopShuvit", 4),
("N_360Flip", "N_360Flip", "N_Laserflip", 0),
("N_Laserflip", "N_Laserflip", "N_360Flip", 0),
("N_360Hardflip", "N_360Hardflip", "N_360InwardHeelflip", 0),
(
"N_360InwardHeelflip",
"N_360InwardHeelflip",
"N_360Hardflip",
0,
),
("N_Kickflip", "N_Kickflip", "N_Heelflip", 0),
("N_Heelflip", "N_Heelflip", "N_Kickflip", 0),
],
&[
("Nollie", "Nollie", "Nollie", 3),
("N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
(
"N_VarialKickflip",
"N_VarialKickflip",
"N_VarialHeelflip",
3,
),
(
"N_VarialHeelflip",
"N_VarialHeelflip",
"N_VarialKickflip",
4,
),
("N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3),
("N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4),
("N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3),
("N_FS360PopShuvit", "N_FS360PopShuvit", "N_360PopShuvit", 4),
("N_360Flip", "N_360Flip", "N_Laserflip", 0),
("N_Laserflip", "N_Laserflip", "N_360Flip", 0),
("N_360Hardflip", "N_360Hardflip", "N_360InwardHeelflip", 0),
(
"N_360InwardHeelflip",
"N_360InwardHeelflip",
"N_360Hardflip",
0,
),
("N_Kickflip", "N_Kickflip", "N_Heelflip", 0),
("N_Heelflip", "N_Heelflip", "N_Kickflip", 0),
("Ollie", "Nollie", "Nollie", 3),
("PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
("VarialKickflip", "Nollie", "Nollie", 3),
("VarialHeelflip", "Nollie", "Nollie", 4),
("Hardflip", "Nollie", "Nollie", 3),
("InwardHeelflip", "Nollie", "Nollie", 4),
("360PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("FS360PopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
("360Flip", "Nollie", "Nollie", 3),
("Laserflip", "Nollie", "Nollie", 4),
("360Hardflip", "Nollie", "Nollie", 3),
("360InwardHeelflip", "Nollie", "Nollie", 4),
("Kickflip", "Nollie", "Nollie", 3),
("Heelflip", "Nollie", "Nollie", 4),
],
&[
("Ollie", "Ollie", "Ollie", 1),
("PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("VarialKickflip", "VarialKickflip", "VarialHeelflip", 1),
("VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2),
("Hardflip", "Hardflip", "InwardHeelflip", 1),
("InwardHeelflip", "InwardHeelflip", "Hardflip", 2),
("360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1),
("FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2),
("360Flip", "360Flip", "Laserflip", 0),
("Laserflip", "Laserflip", "360Flip", 0),
("360Hardflip", "360Hardflip", "360InwardHeelflip", 0),
("360InwardHeelflip", "360InwardHeelflip", "360Hardflip", 0),
("Kickflip", "Kickflip", "Heelflip", 0),
("Heelflip", "Heelflip", "Kickflip", 0),
("Nollie", "Ollie", "Ollie", 1),
("N_PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("N_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("N_VarialKickflip", "Ollie", "Ollie", 1),
("N_VarialHeelflip", "Ollie", "Ollie", 2),
("N_Hardflip", "Ollie", "Ollie", 1),
("N_InwardHeelflip", "Ollie", "Ollie", 2),
("N_360PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("N_FS360PopShuvit", "FSPopShuvit", "PopShuvit", 2),
("N_360Flip", "Ollie", "Ollie", 1),
("N_Laserflip", "Ollie", "Ollie", 2),
("N_360Hardflip", "Ollie", "Ollie", 1),
("N_360InwardHeelflip", "Ollie", "Ollie", 2),
("N_Kickflip", "Ollie", "Ollie", 1),
("N_Heelflip", "Ollie", "Ollie", 2),
],
&[
("90_Nollie", "Nollie", "Nollie", 3),
("90_N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("90_N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
(
"90_N_VarialKickflip",
"N_VarialKickflip",
"N_VarialHeelflip",
3,
),
(
"90_N_VarialHeelflip",
"N_VarialHeelflip",
"N_VarialKickflip",
4,
),
("90_N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3),
("90_N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4),
("90_N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3),
(
"90_N_FS360PopShuvit",
"N_FS360PopShuvit",
"N_360PopShuvit",
4,
),
("90_N_360Flip", "N_360Flip", "N_Laserflip", 0),
("90_N_Laserflip", "N_Laserflip", "N_360Flip", 0),
(
"90_N_360Hardflip",
"N_360Hardflip",
"N_360InwardHeelflip",
0,
),
(
"90_N_360InwardHeelflip",
"N_360InwardHeelflip",
"N_360Hardflip",
0,
),
("90_N_Kickflip", "N_Kickflip", "N_Heelflip", 0),
("90_N_Heelflip", "N_Heelflip", "N_Kickflip", 0),
("Nollie", "Nollie", "Nollie", 3),
("N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
(
"N_VarialKickflip",
"N_VarialKickflip",
"N_VarialHeelflip",
3,
),
(
"N_VarialHeelflip",
"N_VarialHeelflip",
"N_VarialKickflip",
4,
),
("N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3),
("N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4),
("N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3),
("N_FS360PopShuvit", "N_FS360PopShuvit", "N_360PopShuvit", 4),
("N_360Flip", "N_360Flip", "N_Laserflip", 0),
("N_Laserflip", "N_Laserflip", "N_360Flip", 0),
("N_360Hardflip", "N_360Hardflip", "N_360InwardHeelflip", 0),
(
"N_360InwardHeelflip",
"N_360InwardHeelflip",
"N_360Hardflip",
0,
),
("N_Kickflip", "N_Kickflip", "N_Heelflip", 0),
("N_Heelflip", "N_Heelflip", "N_Kickflip", 0),
("Ollie", "Nollie", "Nollie", 3),
("PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
("VarialKickflip", "Nollie", "Nollie", 3),
("VarialHeelflip", "Nollie", "Nollie", 4),
("Hardflip", "Nollie", "Nollie", 3),
("InwardHeelflip", "Nollie", "Nollie", 4),
("360PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("FS360PopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
("360Flip", "Nollie", "Nollie", 3),
("Laserflip", "Nollie", "Nollie", 4),
("360Hardflip", "Nollie", "Nollie", 3),
("360InwardHeelflip", "Nollie", "Nollie", 4),
("Kickflip", "Nollie", "Nollie", 3),
("Heelflip", "Nollie", "Nollie", 4),
],
&[
("90_Ollie", "Ollie", "Ollie", 1),
("90_PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("90_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("90_VarialKickflip", "VarialKickflip", "VarialHeelflip", 1),
("90_VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2),
("90_Hardflip", "Hardflip", "InwardHeelflip", 1),
("90_InwardHeelflip", "InwardHeelflip", "Hardflip", 2),
("90_360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1),
("90_FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2),
("90_360Flip", "360Flip", "Laserflip", 0),
("90_Laserflip", "Laserflip", "360Flip", 0),
("90_360Hardflip", "360Hardflip", "360InwardHeelflip", 0),
(
"90_360InwardHeelflip",
"360InwardHeelflip",
"360Hardflip",
0,
),
("90_Kickflip", "Kickflip", "Heelflip", 0),
("90_Heelflip", "Heelflip", "Kickflip", 0),
("Ollie", "Ollie", "Ollie", 1),
("PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("VarialKickflip", "VarialKickflip", "VarialHeelflip", 1),
("VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2),
("Hardflip", "Hardflip", "InwardHeelflip", 1),
("InwardHeelflip", "InwardHeelflip", "Hardflip", 2),
("360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1),
("FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2),
("360Flip", "360Flip", "Laserflip", 0),
("Laserflip", "Laserflip", "360Flip", 0),
("360Hardflip", "360Hardflip", "360InwardHeelflip", 0),
("360InwardHeelflip", "360InwardHeelflip", "360Hardflip", 0),
("Kickflip", "Kickflip", "Heelflip", 0),
("Heelflip", "Heelflip", "Kickflip", 0),
("Nollie", "Ollie", "Ollie", 1),
("N_PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("N_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("N_VarialKickflip", "Ollie", "Ollie", 1),
("N_VarialHeelflip", "Ollie", "Ollie", 2),
("N_Hardflip", "Ollie", "Ollie", 1),
("N_InwardHeelflip", "Ollie", "Ollie", 2),
("N_360PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("N_FS360PopShuvit", "FSPopShuvit", "PopShuvit", 2),
("N_360Flip", "Ollie", "Ollie", 1),
("N_Laserflip", "Ollie", "Ollie", 2),
("N_360Hardflip", "Ollie", "Ollie", 1),
("N_360InwardHeelflip", "Ollie", "Ollie", 2),
("N_Kickflip", "Ollie", "Ollie", 1),
("N_Heelflip", "Ollie", "Ollie", 2),
],
&[
("N90_Nollie", "Nollie", "Nollie", 3),
("N90_N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("N90_N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
(
"N90_N_VarialKickflip",
"N_VarialKickflip",
"N_VarialHeelflip",
3,
),
(
"N90_N_VarialHeelflip",
"N_VarialHeelflip",
"N_VarialKickflip",
4,
),
("N90_N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3),
("N90_N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4),
(
"N90_N_360PopShuvit",
"N_360PopShuvit",
"N_FS360PopShuvit",
3,
),
(
"N90_N_FS360PopShuvit",
"N_FS360PopShuvit",
"N_360PopShuvit",
4,
),
("N90_N_360Flip", "N_360Flip", "N_Laserflip", 0),
("N90_N_Laserflip", "N_Laserflip", "N_360Flip", 0),
(
"N90_N_360Hardflip",
"N_360Hardflip",
"N_360InwardHeelflip",
0,
),
(
"N90_N_360InwardHeelflip",
"N_360InwardHeelflip",
"N_360Hardflip",
0,
),
("N90_N_Kickflip", "N_Kickflip", "N_Heelflip", 0),
("N90_N_Heelflip", "N_Heelflip", "N_Kickflip", 0),
("Nollie", "Nollie", "Nollie", 3),
("N_PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("N_FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
(
"N_VarialKickflip",
"N_VarialKickflip",
"N_VarialHeelflip",
3,
),
(
"N_VarialHeelflip",
"N_VarialHeelflip",
"N_VarialKickflip",
4,
),
("N_Hardflip", "N_Hardflip", "N_InwardHeelflip", 3),
("N_InwardHeelflip", "N_InwardHeelflip", "N_Hardflip", 4),
("N_360PopShuvit", "N_360PopShuvit", "N_FS360PopShuvit", 3),
("N_FS360PopShuvit", "N_FS360PopShuvit", "N_360PopShuvit", 4),
("N_360Flip", "N_360Flip", "N_Laserflip", 0),
("N_Laserflip", "N_Laserflip", "N_360Flip", 0),
("N_360Hardflip", "N_360Hardflip", "N_360InwardHeelflip", 0),
(
"N_360InwardHeelflip",
"N_360InwardHeelflip",
"N_360Hardflip",
0,
),
("N_Kickflip", "N_Kickflip", "N_Heelflip", 0),
("N_Heelflip", "N_Heelflip", "N_Kickflip", 0),
("Ollie", "Nollie", "Nollie", 3),
("PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("FSPopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
("VarialKickflip", "Nollie", "Nollie", 3),
("VarialHeelflip", "Nollie", "Nollie", 4),
("Hardflip", "Nollie", "Nollie", 3),
("InwardHeelflip", "Nollie", "Nollie", 4),
("360PopShuvit", "N_PopShuvit", "N_FSPopShuvit", 3),
("FS360PopShuvit", "N_FSPopShuvit", "N_PopShuvit", 4),
("360Flip", "Nollie", "Nollie", 3),
("Laserflip", "Nollie", "Nollie", 4),
("360Hardflip", "Nollie", "Nollie", 3),
("360InwardHeelflip", "Nollie", "Nollie", 4),
("Kickflip", "Nollie", "Nollie", 3),
("Heelflip", "Nollie", "Nollie", 4),
],
&[
("N90_Ollie", "Ollie", "Ollie", 1),
("N90_PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("N90_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("N90_VarialKickflip", "VarialKickflip", "VarialHeelflip", 1),
("N90_VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2),
("N90_Hardflip", "Hardflip", "InwardHeelflip", 1),
("N90_InwardHeelflip", "InwardHeelflip", "Hardflip", 2),
("N90_360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1),
("N90_FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2),
("N90_360Flip", "360Flip", "Laserflip", 0),
("N90_Laserflip", "Laserflip", "360Flip", 0),
("N90_360Hardflip", "360Hardflip", "360InwardHeelflip", 0),
(
"N90_360InwardHeelflip",
"360InwardHeelflip",
"360Hardflip",
0,
),
("N90_Kickflip", "Kickflip", "Heelflip", 0),
("N90_Heelflip", "Heelflip", "Kickflip", 0),
("Ollie", "Ollie", "Ollie", 1),
("PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("VarialKickflip", "VarialKickflip", "VarialHeelflip", 1),
("VarialHeelflip", "VarialHeelflip", "VarialKickflip", 2),
("Hardflip", "Hardflip", "InwardHeelflip", 1),
("InwardHeelflip", "InwardHeelflip", "Hardflip", 2),
("360PopShuvit", "360PopShuvit", "FS360PopShuvit", 1),
("FS360PopShuvit", "FS360PopShuvit", "360PopShuvit", 2),
("360Flip", "360Flip", "Laserflip", 0),
("Laserflip", "Laserflip", "360Flip", 0),
("360Hardflip", "360Hardflip", "360InwardHeelflip", 0),
("360InwardHeelflip", "360InwardHeelflip", "360Hardflip", 0),
("Kickflip", "Kickflip", "Heelflip", 0),
("Heelflip", "Heelflip", "Kickflip", 0),
("Nollie", "Ollie", "Ollie", 1),
("N_PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("N_FSPopShuvit", "FSPopShuvit", "PopShuvit", 2),
("N_VarialKickflip", "Ollie", "Ollie", 1),
("N_VarialHeelflip", "Ollie", "Ollie", 2),
("N_Hardflip", "Ollie", "Ollie", 1),
("N_InwardHeelflip", "Ollie", "Ollie", 2),
("N_360PopShuvit", "PopShuvit", "FSPopShuvit", 1),
("N_FS360PopShuvit", "FSPopShuvit", "PopShuvit", 2),
("N_360Flip", "Ollie", "Ollie", 1),
("N_Laserflip", "Ollie", "Ollie", 2),
("N_360Hardflip", "Ollie", "Ollie", 1),
("N_360InwardHeelflip", "Ollie", "Ollie", 2),
("N_Kickflip", "Ollie", "Ollie", 1),
("N_Heelflip", "Ollie", "Ollie", 2),
],
];
+7 -1
View File
@@ -59,7 +59,13 @@ fn main() -> bevy::app::AppExit {
return bevy::app::AppExit::error();
}
};
app::build(config, manifest, graphs, physics, skater).run()
let controls = match physics::PlayerControls::load(&config.asset_root) {
Ok(controls) => controls,
Err(error) => { eprintln!("{error}"); return bevy::app::AppExit::error(); }
};
let mut app = app::build(config, manifest, graphs, physics, skater);
app.insert_resource(controls);
app.run()
}
#[cfg(test)]
+16
View File
@@ -19,6 +19,7 @@ pub(crate) struct PlayerControls {
pub bumper_state_502: bool,
pub bumper_state_104: bool,
pub preferences: PushPreferences,
gestures: Option<crate::input::gesture_input::GestureInput>,
}
impl Default for PlayerControls {
fn default() -> Self {
@@ -33,6 +34,7 @@ impl Default for PlayerControls {
bumper_state_502: false,
bumper_state_104: false,
preferences: PushPreferences::default(),
gestures: None,
}
}
}
@@ -50,9 +52,22 @@ pub(super) fn sample(
physics.settings.input_magnitude_threshold,
skater.player_input.physical.scoring.capabilities_204,
);
player.publish_gestures(physics.animation_profile.physics_mode, skater.player_input.physical.state.state_16);
}
impl PlayerControls {
pub fn load(root: &std::path::Path) -> Result<Self, String> {
Ok(Self { gestures: Some(crate::input::gesture_input::GestureInput::load(root)?), ..Self::default() })
}
pub fn publish_gestures(&mut self, difficulty: u32, physical_state: u32) {
if let Some(gestures) = &mut self.gestures {
let words = self.controller.words();
let axes = [[words[7],words[8]], [words[9],words[10]]].map(|p|p.map(f32::from_bits));
gestures.publish(axes,difficulty,self.actor_flags,physical_state,&mut self.action_intents);
}
}
pub fn update(&mut self, map: &mut impl ActionMap, dt: f32, magnitude_threshold: f32, physical_capabilities: u32) {
self.controller.update(
map,
@@ -70,6 +85,7 @@ impl PlayerControls {
self.intents.extend(wipeout_intentions::produce(
&self.controller, self.actor_flags, physical_capabilities,
));
self.intents.extend(skate_core::input::anticipation_intentions::produce(&self.controller));
//GenerateActionGraphIntents82594310 clears the AG map through82BC1B68
//before Listener::Fill. MG lifecycle intents use a different persistent map.
self.action_intents.clear();
@@ -19,6 +19,35 @@ fn parameter(mg_intent: &str, value: f32) -> Parameter {
}
}
#[test]
fn gesture_behavior_publishes_and_releases_the_stock_tre_flip_intent() {
let mut config = parameter("", 0.0);
config.mg_intent = None;
let instance = ActionInstance {
operation: ActionOperation::CreateTrickIntentFromGesture {
group: crate::input::gesture_catalog::Group::Square,
override_name: None,
},
config,
parameters: Vec::new(),
};
let mut host = ActionHost::new(ActionInstances::new(vec![instance]), OperationRemap {
behaviors: vec![0], conditions: Vec::new(), hooks: Vec::new(),
});
let frame = Frame { dt: 1.0 / 60.0, current: None, last: None, state_times: Vec::new() };
for (mirrored, expected) in [(false, "360Flip"), (true, "Laserflip")] {
host.stance = Some((false, mirrored));
host.action_intents.insert("360Flip", 1.0);
Host::allocate(&mut host, 0, &frame);
Host::begin(&mut host, 0, [0; 6], &frame);
Host::update(&mut host, 0, [0; 6], &frame);
assert_eq!(host.motion_intents.get(expected), Some(&0.0));
Host::end(&mut host, 0, [0; 6], &frame);
assert!(host.motion_intents.is_empty());
}
assert!(host.errors.is_empty(), "{:?}", host.errors);
}
#[test]
fn const_callbacks_publish_then_remove_motion_intent() {
let instance = ActionInstance {
+81
View File
@@ -0,0 +1,81 @@
# 360 flip through the stock graphs
The ordinary on-board 360-flip route is wired for user visual testing.
PLAY.bat and PLAY-MAP.bat use the staged build. The supplied graphs and
animation banks remain the source of animation selection, transitions,
postures, timing, and physical animation attributes.
## Input and playback
- Right-stick magnitude and angle publish `AnticMag` and `AnticAngle`.
The existing anticipation states choose and blend the crouch/scoop poses.
- The seven supplied joystick `.pat` files now run through the recovered
recognizer. There is no trick hotkey, synthetic launch impulse, or new clip
selected outside the graphs.
- Recognized events pass through the original listener gates, then publish
the gesture name, `Trick`, and `GestureSpeed` into the ActionGraph.
- `CreateTrickIntentFromGesture` uses all 270 recovered mapping records,
including native bucket order, mirrored mappings, overrides, dark-catch
categories, and the four hold successors. A non-mirrored `360Flip` gesture
maps to `360Flip`; when mirrored, that gesture maps to `Laserflip`, and the
opposite gesture maps to `360Flip`, as in the original mapping.
- The stock ground path selects `B_360FLIP_G`, then sequences into
`B_360FLIP_A`. `SetTrickHeight` consumes the preceding animation's
`AnticStrength`, gesture speed, and the stock jumping settings. Its
`JumpHeightOverride` packet enters the existing skeleton-input handler.
- Trick construction identity, scoring metadata, underflip/dark-catch
requests, and nose-weight anticipation now have their original handlers.
The stock scoop coordinates for the non-mirrored 360-flip gesture correspond
to left/slightly down, down-left, down/slightly right, then an up-right flick
on the physical right stick. The recognizer permits holding the initial
point without weakening the eventual gesture. Direction changes with the
stock mirrored mapping; this is not an extra control setting.
## Evidence
TU3 instruction bytes were checked in a disposable IDA database against
the recompiled PPC instruction listings. Relevant boundaries:
| Behavior | Native source |
| --- | --- |
| PAT loading and push-front coordinate storage | 82696B18, 82699738 |
| Recognition, competition, strength, refractory frame | 82697168, 826972B8, 826974A8 |
| Axis sign and component deadzone | 82696030, 826962D8, 82F75F60 |
| Anticipation inputs | 82599AD0–82599C4C, 8259AD0C–8259AD40 |
| Pattern/held listeners and intent publication | 8259B878, 8259B9D0, 8259B1F0–8259B7D8 |
| Gesture mappings and graph lifecycle | 82B98F70, 82BA07F0, 82BA1C30–82BA2228 |
| Trick height | 82BAED78, 82BAEE90 |
| Trick construction identity | 82BB5C20, 82BB5CA0 |
| Scoring metadata | 82BBFA88, 8258FA20 |
| Underflip requests | 82BBE098, 82BBE0A8, 82BBE358 |
| Nose-weight anticipation | 82BB2670, 82BB26D0, 82B97110 |
Factory names were verified from registration strings. In particular,
the community label on 82BC7848 incorrectly calls it `ScoringTrick`; the
actual registration is `SetTrickAttr`. Scoring's factory is 82BCC030.
Local research outputs and the re-extracted mapping records are under
`logs/`; original private binaries and animation data are not committed.
## Verification and user checks
Nonvisual checks pass for 36 core input tests, the PAT parser, the gesture
mapping/ActionHost lifecycle, and two private-stock fixtures. Those fixtures
recognize the authored scoop through all seven files and exercise the
stock crouch, scoop, takeoff, and air-animation handlers in both stances,
including finite decoded poses and required attributes. These are data/code
checks, not a claim of visually verified gameplay parity. No gameplay or
visual test was automated. The existing game instance was closed to stage
the new build; restarting it for the user is separate from these checks.
Please check:
1. Hold the right stick to crouch while stationary and while rolling.
2. Perform a 360 flip; check preparation, pop, board flip/spin, and foot motion.
3. Land and push away, then repeat in the opposite stance.
This work validates the ordinary ground route, not every trick in the newly
connected catalog. Advanced manual/grind-out branches still contain the
known unsupported `JumpInto` behavior, and the existing separate
`AddRunoutAttribs` gap remains. Those handlers were not bypassed. The current
test does not certify dark catches or the rest of the trick catalog visually.