mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Merge gameplay crash fixes and native board adjustment
This commit is contained in:
commit
2611f9ccc4
19 files changed
+700
-21
No files matched your search
@@ -86,6 +86,20 @@ fn constructor_uses_clip_metrics_and_original_initial_fields() {
|
||||
assert_eq!(s.motion.target_scale_784, -1.0);
|
||||
assert_eq!(s.cadence.phase.duration, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_velocity_delta_keeps_ground_publication_finite() {
|
||||
let mut controller = Controller::new(settings(), metrics());
|
||||
controller.state.motion.velocity_480 = [0.0, 1.0e-21, 1.0, 0.0];
|
||||
controller.state.motion.speed_704 = 1.0;
|
||||
let result = controller.step_ground(&job());
|
||||
assert!(result.position.into_iter().all(f32::is_finite));
|
||||
assert!(result.velocity.into_iter().all(f32::is_finite));
|
||||
assert!(result.physical_frame.into_iter().flatten().all(f32::is_finite));
|
||||
assert!(result.animation_frame.into_iter().flatten().all(f32::is_finite));
|
||||
assert!(controller.state.surface.lean.into_iter().all(f32::is_finite));
|
||||
assert!(controller.state.cadence.phase.phase.is_finite());
|
||||
}
|
||||
#[test]
|
||||
fn absent_clip_queries_have_original_zero_speed() {
|
||||
let s = State::new([None; 3]);
|
||||
|
||||
@@ -10,13 +10,22 @@ pub(super) fn scale(a: Vector, s: f32) -> Vector { a.map(|v| v * s) }
|
||||
pub(super) fn sub(a: Vector, b: Vector) -> Vector { std::array::from_fn(|i| a[i] - b[i]) }
|
||||
pub(super) fn madd(a: Vector, s: f32, b: Vector) -> Vector { std::array::from_fn(|i| a[i].mul_add(s, b[i])) }
|
||||
fn inverse_squared(value: f32) -> f32 {
|
||||
// The portable estimate preserves subnormals. For a tiny positive squared
|
||||
// length, r*r can overflow even though both the length and inverse root
|
||||
// are finite. Normalize its exponent before the recovered refinements;
|
||||
// rsqrt(x * 2^24) * 2^12 = rsqrt(x). Normal inputs keep their old path.
|
||||
let (value, rescale) = if value > 0.0 && value.is_subnormal() {
|
||||
(value * 16_777_216.0, 4096.0)
|
||||
} else {
|
||||
(value, 1.0)
|
||||
};
|
||||
//PC seed is not bit-exact Xenon; retain both original refinement stages.
|
||||
let mut r = crate::physics::reciprocal_sqrt::estimate(value);
|
||||
for _ in 0..2 {
|
||||
let error = (-value).mul_add(r * r, 1.0);
|
||||
r = (r * 0.5).mul_add(error, r);
|
||||
}
|
||||
r
|
||||
r * rescale
|
||||
}
|
||||
pub(super) fn length(v: Vector) -> f32 {
|
||||
let squared = dot(v, v);
|
||||
|
||||
@@ -1,4 +1,49 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tiny_velocity_delta_remains_finite_and_reaches_target() {
|
||||
// Flat-ground convergence can leave a tiny vertical correction while X/Z
|
||||
// already match. Its squared length is positive but subnormal.
|
||||
let current = [-1.6481168, 1.0e-21, 5.7692037, 0.0];
|
||||
let target = [-1.6481168, 0.0, 5.7692037, 0.0];
|
||||
let delta = sub(target, current);
|
||||
assert!(dot(delta, delta).is_subnormal());
|
||||
let result = limit_delta(target, current, 0.2);
|
||||
assert_eq!(result, target);
|
||||
assert!(length(delta).is_finite());
|
||||
assert!((length(delta) / 1.0e-21 - 1.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_velocity_refinement_boundaries_preserve_limits_and_invalid_inputs() {
|
||||
for exponent in -74..=-60 {
|
||||
let component = 2.0f32.powi(exponent);
|
||||
let delta = [component, 0.0, 0.0, 0.0];
|
||||
let measured = length(delta);
|
||||
assert!(measured.is_finite() && measured > 0.0);
|
||||
assert!((measured / component - 1.0).abs() < 1e-6);
|
||||
let maximum = component * 0.5;
|
||||
let limited = limit_delta(delta, [0.0; 4], maximum);
|
||||
assert!(limited.into_iter().all(f32::is_finite));
|
||||
assert!((limited[0] / maximum - 1.0).abs() < 1e-6);
|
||||
}
|
||||
assert_eq!(length([0.0; 4]), 0.0);
|
||||
assert!(length([f32::NAN, 0.0, 0.0, 0.0]).is_nan());
|
||||
assert!(limit_delta([f32::NAN, 0.0, 0.0, 0.0], [0.0; 4], 0.2)[0].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_velocity_correction_does_not_poison_walking_state() {
|
||||
let mut state = State { velocity: [0.0, 1.0e-21, 5.0, 0.0], speed: 5.0, ..State::default() };
|
||||
let mut input = input();
|
||||
input.steering = 0.0;
|
||||
for _ in 0..120 {
|
||||
state.update(&settings(), &input);
|
||||
assert_eq!(state.velocity, [0.0, 0.0, 5.0, 0.0]);
|
||||
assert_eq!(state.speed, 5.0);
|
||||
assert!(state.forward_delta.is_finite() && state.right_delta.is_finite());
|
||||
}
|
||||
}
|
||||
fn curve(value: f32) -> PointGraph<8> { PointGraph { x: std::array::from_fn(|i| i as f32), y: [value; 8] } }
|
||||
fn settings() -> Settings {
|
||||
Settings { slope_speed_scalar: curve(1.0), slope_mode_speed: curve(3.0), turn_vs_speed: curve(90.0), turn_delta_vs_speed: curve(15.0) }
|
||||
|
||||
@@ -12,7 +12,8 @@ use skate_core::graph::{
|
||||
};
|
||||
use skate_core::input::body_flip_signal;
|
||||
use skate_core::input::graph_intents::{CreateMgIntent, IntentMutation};
|
||||
use skate_core::input::graph_intents::apply_filter;
|
||||
#[path = "action_board_adjust.rs"]
|
||||
mod board_adjust;
|
||||
use skate_data::collections::Collections;
|
||||
use super::outputs::{ActionGraphInput, ActionGraphOutput, GraphCapabilityReport, GraphDiagnostics};
|
||||
|
||||
@@ -44,6 +45,7 @@ pub struct ActionHost {
|
||||
created: Vec<bool>,
|
||||
const_handlers: Vec<ConstMgIntent>,
|
||||
time_handlers: Vec<TimeMgIntent>,
|
||||
board_adjust: Vec<board_adjust::State>,
|
||||
body_flip: Vec<body_flip_signal::State>,
|
||||
body_flip_settings: Option<body_flip_signal::Settings>,
|
||||
/// JuiceHook instance+8 starts with the native null intent name. Its
|
||||
@@ -148,6 +150,7 @@ impl ActionHost {
|
||||
created: vec![false; count],
|
||||
const_handlers,
|
||||
time_handlers,
|
||||
board_adjust: vec![board_adjust::State::default(); count],
|
||||
body_flip: vec![body_flip_signal::State::default(); count],
|
||||
body_flip_settings: None,
|
||||
juice_pending: vec![String::new(); count],
|
||||
@@ -201,7 +204,7 @@ impl ActionHost {
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_board_adjust(&mut self, instance: &ActionInstance) {
|
||||
fn publish_board_adjust(&mut self, behavior: BehaviorId, instance: &ActionInstance) {
|
||||
let Some(magnitude_name) = instance.config.mg_intent_mag.as_deref() else {
|
||||
return;
|
||||
};
|
||||
@@ -209,21 +212,16 @@ impl ActionHost {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(value) = self.action_intents.get("BoardAdjustMag").copied() {
|
||||
self.motion_intents.insert(magnitude_name, value);
|
||||
if let Some((magnitude, angle)) = self.board_adjust[behavior].update(
|
||||
self.action_intents.get("BoardAdjustMag").copied(),
|
||||
self.action_intents.get("BoardAdjustAngle").copied(),
|
||||
instance.config.angle_filter, instance.config.negate_on_mirror,
|
||||
self.stance.is_some_and(|(_, mirrored)| mirrored),
|
||||
) {
|
||||
self.motion_intents.insert(magnitude_name, magnitude);
|
||||
self.motion_intents.insert(angle_name, angle);
|
||||
} else {
|
||||
self.motion_intents.remove(magnitude_name);
|
||||
}
|
||||
|
||||
if let Some(mut value) = self.action_intents.get("BoardAdjustAngle").copied() {
|
||||
if instance.config.angle_filter != 0 {
|
||||
value = apply_filter(value, instance.config.angle_filter);
|
||||
}
|
||||
if instance.config.negate_on_mirror && self.stance.is_some_and(|(_, mirrored)| mirrored) {
|
||||
value = -value;
|
||||
}
|
||||
self.motion_intents.insert(angle_name, value);
|
||||
} else {
|
||||
self.motion_intents.remove(angle_name);
|
||||
}
|
||||
}
|
||||
@@ -317,7 +315,7 @@ impl Host for ActionHost {
|
||||
return;
|
||||
}
|
||||
if let ActionOperation::BoardAdjust = instance.operation {
|
||||
self.publish_board_adjust(&instance);
|
||||
self.board_adjust[behavior].begin();
|
||||
return;
|
||||
}
|
||||
if let ActionOperation::CreateConstMgIntent = instance.operation {
|
||||
@@ -391,7 +389,7 @@ impl Host for ActionHost {
|
||||
return;
|
||||
}
|
||||
if let ActionOperation::BoardAdjust = instance.operation {
|
||||
self.publish_board_adjust(&instance);
|
||||
self.publish_board_adjust(behavior, &instance);
|
||||
return;
|
||||
}
|
||||
if let ActionOperation::CreateConstMgIntent = instance.operation {
|
||||
@@ -429,6 +427,14 @@ impl Host for ActionHost {
|
||||
let Some(instance) = self.operation(behavior).cloned() else {
|
||||
return;
|
||||
};
|
||||
if matches!(instance.operation, ActionOperation::BoardAdjust) {
|
||||
//82BA2EB8: remove both authored names; End does not reset state.
|
||||
for name in [instance.config.mg_intent_mag.as_deref(),
|
||||
instance.config.mg_intent_angle.as_deref()].into_iter().flatten() {
|
||||
self.motion_intents.remove(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if matches!(instance.operation, ActionOperation::CreateTrickIntentFromGesture { .. }) {
|
||||
self.trick_handlers[behavior].end(&mut self.motion_intents);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! TU3 BoardAdjust instance+8/+12, Begin82BA2BA8, Update82BA2BC8.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct State {
|
||||
wrap: u32,
|
||||
previous_angle: f32,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn begin(&mut self) { *self = Self::default(); }
|
||||
|
||||
pub fn update(&mut self, magnitude: Option<f32>, angle: Option<f32>, filter: u32,
|
||||
negate_on_mirror: bool, mirrored: bool) -> Option<(f32, f32)> {
|
||||
let (Some(magnitude), Some(mut angle)) = (magnitude, angle) else {
|
||||
//82BA2E88: remove both outputs and reset both instance fields.
|
||||
self.begin();
|
||||
return None;
|
||||
};
|
||||
if filter <= 7 { angle = skate_core::input::graph_intents::apply_filter(angle, filter); }
|
||||
if negate_on_mirror && mirrored { angle *= -1.0; }
|
||||
//82BA2D70: crossing the +/-pi seam retains the previous side until
|
||||
//the raw angle crosses back. Test the previous RAW angle, not output.
|
||||
let crossed = self.previous_angle * angle < 0.0
|
||||
&& self.previous_angle.abs() > f32::from_bits(0x3fc90fdb);
|
||||
match self.wrap {
|
||||
0 if crossed && self.previous_angle < 0.0 => self.wrap = 1,
|
||||
0 if crossed && self.previous_angle > 0.0 => self.wrap = 2,
|
||||
1 if crossed && self.previous_angle > 0.0 => self.wrap = 0,
|
||||
2 if crossed && self.previous_angle < 0.0 => self.wrap = 0,
|
||||
_ => {}
|
||||
}
|
||||
self.previous_angle = angle;
|
||||
let output = match self.wrap {
|
||||
1 => f32::from_bits(0xc0490fdb), //822F8908
|
||||
2 => f32::from_bits(0x40490fdb), //82060C44
|
||||
_ => angle,
|
||||
};
|
||||
Some((magnitude, output))
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,8 @@ impl Parameter {
|
||||
filter(attributes.text("mirrorFilter")),
|
||||
],
|
||||
angle_filter: filter(attributes.text("angleFilter")),
|
||||
negate_on_mirror: attributes.boolean_byte("negateOnMirror", 0) != 0,
|
||||
//BoardAdjust82BA2A90 passes true as the constructor default.
|
||||
negate_on_mirror: attributes.boolean_byte("negateOnMirror", 1) != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) mod motion_ground_slope;
|
||||
pub(crate) mod motion_hand_services;
|
||||
mod motion_hooks;
|
||||
mod motion_hippy_jump;
|
||||
mod motion_finger_flip;
|
||||
pub(crate) mod motion_intent_filter;
|
||||
mod motion_kickturn;
|
||||
pub(crate) mod motion_landing;
|
||||
|
||||
@@ -114,6 +114,7 @@ pub struct MotionHost {
|
||||
pub physical_stance: Option<(bool, bool)>,
|
||||
pub flags: super::motion_landing::Flags,
|
||||
hippy_jump: super::motion_hippy_jump::Settings,
|
||||
finger_flip: super::motion_finger_flip::Settings,
|
||||
pub landing_physical: Option<super::motion_landing::Physical>,
|
||||
pub wipeout_physical: Option<super::motion_wipeout::Physical>,
|
||||
pub wipeout_controls: super::motion_wipeout::Controls,
|
||||
@@ -290,6 +291,7 @@ impl MotionHost {
|
||||
kickturn: super::motion_kickturn::load_settings(data)?,
|
||||
manual: super::motion_manual::settings(data)?,
|
||||
hippy_jump: super::motion_hippy_jump::Settings::load(data)?,
|
||||
finger_flip: super::motion_finger_flip::Settings::load(data)?,
|
||||
next_instance: 1,
|
||||
})
|
||||
}
|
||||
@@ -424,3 +426,11 @@ mod jump_into_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "tests/motion_deck_angles.rs"]
|
||||
mod deck_angles_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/motion_finger_flip.rs"]
|
||||
mod finger_flip_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/motion_board_adjust.rs"]
|
||||
mod board_adjust_tests;
|
||||
@@ -290,6 +290,23 @@ pub(super) fn execute(
|
||||
// matching conditions; the authored node itself must still
|
||||
// advance that lifecycle instead of aborting graph execution.
|
||||
match operation {
|
||||
crate::graph_host::motion_stock_gameplay::Operation::FingerFlipOut { grab_intent } => {
|
||||
let Instance::FingerFlipOut(state) = instance else {
|
||||
return Err("FingerFlipOut operation/instance mismatch".into());
|
||||
};
|
||||
if phase == 0 {
|
||||
state.begin();
|
||||
} else if phase == 1 {
|
||||
let value = state.update(
|
||||
host.animation.motion_intents.contains_key(&grab_intent),
|
||||
frame.dt, &host.finger_flip,
|
||||
);
|
||||
host.animation.set_attribute(SettableAttribute {
|
||||
name: encode(b"Grabbing"), value,
|
||||
normalized: false, sequence_id: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
crate::graph_host::motion_stock_gameplay::Operation::MatchAirTime => {
|
||||
let Instance::MatchAirTime(state) = instance else {
|
||||
return Err("MatchAirTime operation/instance mismatch".into());
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! FingerFlipOut: TU3 Begin82BB3198, Update82BB7C40, empty End82B61BB8.
|
||||
use skate_core::point_graph::PointGraph;
|
||||
use skate_data::collections::Collections;
|
||||
|
||||
pub(super) struct Settings {
|
||||
minimum: f32,
|
||||
maximum: f32,
|
||||
curve: PointGraph<8>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub(super) fn load(data: &Collections) -> Result<Self, String> {
|
||||
// GlobalAttributeCollection164 is selected at8289FD10..30. The
|
||||
// anim_motion schema places this PointNegGraphData8 field at190.
|
||||
// Preserve numeric identities because the retail vault omits names.
|
||||
let words = data.words::<20>(
|
||||
"anim_motion", "Hash_41DB0C4F82003A15", "Hash_E0C1407B688858AD",
|
||||
)?;
|
||||
let minimum = f32::from_bits(words[0]);
|
||||
let maximum = f32::from_bits(words[2]);
|
||||
if !minimum.is_finite() || !maximum.is_finite() || minimum > maximum {
|
||||
return Err("FingerFlipOut has invalid stock timer bounds".into());
|
||||
}
|
||||
Ok(Self {
|
||||
minimum,
|
||||
maximum,
|
||||
curve: PointGraph {
|
||||
x: std::array::from_fn(|i| f32::from_bits(words[4 + i])),
|
||||
y: std::array::from_fn(|i| f32::from_bits(words[12 + i])),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
elapsed: f32,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(super) fn begin(&mut self) {
|
||||
self.elapsed = 0.0;
|
||||
}
|
||||
|
||||
pub(super) fn update(&mut self, grab_present: bool, dt: f32, settings: &Settings) -> f32 {
|
||||
//82454FD8 tests membership, including a zero-valued intent. The time
|
||||
//service supplies dt; this is not the magnitude of the grab input.
|
||||
self.elapsed = if grab_present { self.elapsed - dt } else { self.elapsed + dt };
|
||||
self.elapsed = self.elapsed.clamp(settings.minimum, settings.maximum);
|
||||
settings.curve.evaluate(self.elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finger_flip_release_regrab_bounds_and_reentry() {
|
||||
let settings = Settings {
|
||||
minimum: 0.0,
|
||||
maximum: 0.5,
|
||||
curve: PointGraph {
|
||||
x: std::array::from_fn(|i| i as f32 / 14.0),
|
||||
y: std::array::from_fn(|i| 1.0 - i as f32 / 7.0),
|
||||
},
|
||||
};
|
||||
let mut state = State::default();
|
||||
state.begin();
|
||||
assert_eq!(state.update(true, 0.25, &settings), 1.0);
|
||||
assert!((state.update(false, 0.125, &settings) - 0.75).abs() < 1e-6);
|
||||
assert!((state.update(false, 0.125, &settings) - 0.5).abs() < 1e-6);
|
||||
assert!((state.update(true, 0.125, &settings) - 0.75).abs() < 1e-6);
|
||||
assert_eq!(state.update(false, 1.0, &settings), 0.0);
|
||||
assert_eq!(state.update(false, 1.0, &settings), 0.0);
|
||||
assert_eq!(state.elapsed, 0.5);
|
||||
state.begin();
|
||||
assert_eq!(state.elapsed, 0.0);
|
||||
assert_eq!(state.update(true, 0.25, &settings), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ pub(super) enum Instance {
|
||||
Shove(super::super::motion_shove::ShoveState),
|
||||
SetManualAngle(skate_core::animation::manual::State),
|
||||
HippyJumpAntic(super::super::motion_hippy_jump::State),
|
||||
FingerFlipOut(super::super::motion_finger_flip::State),
|
||||
///82BB5930 initializes byte8 once per allocated behavior instance.
|
||||
JumpInto { first_update: bool },
|
||||
}
|
||||
@@ -95,6 +96,9 @@ impl Instance {
|
||||
MotionOperation::StockGameplay(
|
||||
super::super::motion_stock_gameplay::Operation::HippyJumpAntic,
|
||||
) => Self::HippyJumpAntic(Default::default()),
|
||||
MotionOperation::StockGameplay(
|
||||
super::super::motion_stock_gameplay::Operation::FingerFlipOut { .. },
|
||||
) => Self::FingerFlipOut(Default::default()),
|
||||
MotionOperation::StockGameplay(
|
||||
super::super::motion_stock_gameplay::Operation::JumpInto { .. },
|
||||
) => Self::JumpInto { first_update: true },
|
||||
|
||||
@@ -146,7 +146,7 @@ pub enum Operation {
|
||||
attribute_y: AttributeName,
|
||||
},
|
||||
FootPlantAbsorb,
|
||||
FingerFlipOut,
|
||||
FingerFlipOut { grab_intent: String },
|
||||
EnterSkitchingBehaviour,
|
||||
SkitchingBehaviour,
|
||||
SkitchShimmyingBehaviour,
|
||||
@@ -217,7 +217,10 @@ impl Operation {
|
||||
attribute_x: encode(b"tweak_x"),
|
||||
attribute_y: encode(b"tweak_y"),
|
||||
}, "FootPlantAbsorb" => Self::FootPlantAbsorb,
|
||||
"FingerFlipOut" => Self::FingerFlipOut,
|
||||
"FingerFlipOut" => Self::FingerFlipOut {
|
||||
//Factory82BC7C18 binds authored grabintent and literal Grabbing.
|
||||
grab_intent: required_text(a, "grabintent"),
|
||||
},
|
||||
"EnterSkitchingBehaviour" => Self::EnterSkitchingBehaviour,
|
||||
"SkitchingBehaviour" => Self::SkitchingBehaviour,
|
||||
"SkitchShimmyingBehaviour" => Self::SkitchShimmyingBehaviour,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires private stock graphs and animation banks"]
|
||||
fn board_adjust_stock_release_keeps_the_authored_air_blend() {
|
||||
use crate::graph_runtime::CompiledGraph;
|
||||
use skate_data::state_graph::{StateGraph, binding::Binding};
|
||||
use skate_core::animation::playback_tree::{Evaluation, PoseCommand};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap());
|
||||
let source = StateGraph::load(&root.join("private/stock/data/state/MotionGraph_OnBoard.stategraph")).unwrap();
|
||||
let binding = Binding::from_graph(&source).unwrap();
|
||||
let runtime = CompiledGraph::from_binding(&binding).unwrap();
|
||||
let graph = LoadedGraph { source, binding, runtime };
|
||||
let mut host = MotionHost::from_graph(&graph, &Collections::load(&root).unwrap(),
|
||||
skate_data::animation_banks::AnimationBanks::load(&root).unwrap().metadata().unwrap(),
|
||||
PlaybackContext { is_switch: Some(false), is_mirrored: Some(false),
|
||||
board_available: Some(true), pro_skater: encode(b""), transition_override: None }).unwrap();
|
||||
host.animation.skater_animation_flags = Some(0);
|
||||
let find = |predicate: &dyn Fn(&MotionOperation) -> bool| {
|
||||
host.remap.behaviors.iter().position(|&op| predicate(&host.operations[op])).unwrap()
|
||||
};
|
||||
let adjust = find(&|op| matches!(op, MotionOperation::Play(p) if p.animation == "BLEND_ADJUST_RIGHT"));
|
||||
let air = find(&|op| matches!(op, MotionOperation::Play(p) if p.animation == "B_AIR_CYC" && p.transition.kind == 2 && p.transition.under == 1));
|
||||
let filters = ["RightBoardAdjustMag", "RightBoardAdjustAngle"].map(|name|
|
||||
find(&|op| matches!(op, MotionOperation::IntentFilter(p) if p.intent == name && p.settings.blend_out == Some(0.08))));
|
||||
let frame = Frame { dt: 1.0 / 60.0, current: None, last: None, state_times: vec![] };
|
||||
for id in filters { host.allocate(id, &frame); host.execute(id, &frame, 0).unwrap(); }
|
||||
host.allocate(adjust, &frame);
|
||||
host.execute(adjust, &frame, 0).unwrap();
|
||||
for _ in 0..60 {
|
||||
host.animation.advance(frame.dt, 0.0);
|
||||
host.animation.motion_intents.insert("RightBoardAdjustMag", 0.9);
|
||||
host.animation.motion_intents.insert("RightBoardAdjustAngle", 0.6);
|
||||
for id in filters { host.execute(id, &frame, 1).unwrap(); }
|
||||
host.execute(adjust, &frame, 1).unwrap();
|
||||
host.animation.apply_parameters().unwrap();
|
||||
}
|
||||
host.animation.motion_intents.remove("RightBoardAdjustMag");
|
||||
host.animation.motion_intents.remove("RightBoardAdjustAngle");
|
||||
let mut reached_exit = false;
|
||||
for _ in 0..120 {
|
||||
host.animation.advance(frame.dt, 0.0);
|
||||
for id in filters { host.execute(id, &frame, 1).unwrap(); }
|
||||
host.execute(adjust, &frame, 1).unwrap();
|
||||
host.animation.apply_parameters().unwrap();
|
||||
if ["BoardAdjustMag", "BoardAdjustAngle"].iter().all(|name|
|
||||
host.animation.filtered_intents.get(name).unwrap().abs() < 0.4) {
|
||||
reached_exit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(reached_exit);
|
||||
host.animation.refresh_tree_attributes().unwrap();
|
||||
host.execute(adjust, &frame, 2).unwrap();
|
||||
for id in filters { host.execute(id, &frame, 2).unwrap(); }
|
||||
host.allocate(air, &frame);
|
||||
host.execute(air, &frame, 0).unwrap();
|
||||
host.animation.apply_parameters().unwrap();
|
||||
let evaluation = Evaluation { cull_threshold: 0.0, update_history: false };
|
||||
let commands = host.animation.evaluate_pose(evaluation).unwrap();
|
||||
assert!(matches!(commands.last(), Some(PoseCommand::Blend { weight }) if *weight == 0.0), "new air tree must not replace the outgoing pose: {commands:?}");
|
||||
let mut previous = 0.0;
|
||||
for _ in 0..12 {
|
||||
host.animation.advance(frame.dt, 0.0);
|
||||
let commands = host.animation.evaluate_pose(evaluation).unwrap();
|
||||
let Some(PoseCommand::Blend { weight }) = commands.last() else { panic!("missing authored 0.2s blend") };
|
||||
assert!(*weight >= previous && *weight <= 1.0);
|
||||
previous = *weight;
|
||||
}
|
||||
assert!(previous > 0.999);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires private stock graphs and animation banks"]
|
||||
fn stock_finger_flip_crash_node_3080_lifecycle_and_intent_source() {
|
||||
use crate::graph_runtime::CompiledGraph;
|
||||
use skate_data::state_graph::{StateGraph, binding::Binding};
|
||||
use super::super::motion_stock_gameplay::Operation;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap());
|
||||
let source = StateGraph::load(&root.join(
|
||||
"private/stock/data/state/MotionGraph_OnBoard.stategraph",
|
||||
)).unwrap();
|
||||
let binding = Binding::from_graph(&source).unwrap();
|
||||
let runtime = CompiledGraph::from_binding(&binding).unwrap();
|
||||
let graph = LoadedGraph { source, binding, runtime };
|
||||
let mut host = MotionHost::from_graph(
|
||||
&graph, &Collections::load(&root).unwrap(),
|
||||
skate_data::animation_banks::AnimationBanks::load(&root).unwrap().metadata().unwrap(),
|
||||
PlaybackContext { is_switch: Some(false), is_mirrored: Some(false),
|
||||
board_available: Some(true), pro_skater: encode(b""), transition_override: None },
|
||||
).unwrap();
|
||||
let behavior = 3080;
|
||||
let MotionOperation::StockGameplay(Operation::FingerFlipOut { grab_intent }) =
|
||||
&host.operations[host.remap.behaviors[behavior]] else {
|
||||
panic!("The reported stock behavior must bind FingerFlipOut");
|
||||
};
|
||||
let intent = grab_intent.clone();
|
||||
assert!(!intent.is_empty() && !intent.contains('$'), "authored intent must be resolved");
|
||||
let frame = Frame { dt: 0.125, current: None, last: None, state_times: vec![] };
|
||||
host.allocate(behavior, &frame);
|
||||
host.execute(behavior, &frame, 0).unwrap();
|
||||
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), None);
|
||||
|
||||
// Raw AG input cannot substitute for the MG intent read by virtual16.
|
||||
host.action_intents.insert(&intent, 1.0);
|
||||
host.execute(behavior, &frame, 1).unwrap();
|
||||
let released = host.animation.pending_parameter(encode(b"Grabbing")).unwrap();
|
||||
assert!((0.0..1.0).contains(&released));
|
||||
// Native membership test treats zero as present and reverses the timer.
|
||||
host.animation.motion_intents.insert(&intent, 0.0);
|
||||
host.execute(behavior, &frame, 1).unwrap();
|
||||
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(1.0));
|
||||
host.animation.motion_intents.remove(&intent);
|
||||
for _ in 0..6 { host.execute(behavior, &frame, 1).unwrap(); }
|
||||
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(0.0));
|
||||
host.execute(behavior, &frame, 2).unwrap();
|
||||
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(0.0));
|
||||
// Reentry resets private time, but Begin/End do not publish a parameter.
|
||||
host.execute(behavior, &frame, 0).unwrap();
|
||||
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(0.0));
|
||||
host.execute(behavior, &frame, 1).unwrap();
|
||||
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(released));
|
||||
|
||||
let second = host.remap.behaviors.iter().enumerate().find_map(|(id, &op)| {
|
||||
(id != behavior && matches!(host.operations[op],
|
||||
MotionOperation::StockGameplay(Operation::FingerFlipOut { .. }))).then_some(id)
|
||||
}).expect("stock graph has separate finger-flip exit nodes");
|
||||
host.allocate(second, &frame);
|
||||
host.execute(second, &frame, 0).unwrap();
|
||||
host.execute(second, &frame, 1).unwrap();
|
||||
assert_eq!(host.animation.pending_parameter(encode(b"Grabbing")), Some(released));
|
||||
host.execute(behavior, &frame, 1).unwrap();
|
||||
assert!(host.animation.pending_parameter(encode(b"Grabbing")).unwrap() < released);
|
||||
assert!(host.animation.motion_attributes.is_empty(), "writes a tree parameter, not a physics packet");
|
||||
}
|
||||
@@ -52,6 +52,77 @@ fn gesture_behavior_publishes_and_releases_the_stock_tre_flip_intent() {
|
||||
assert!(host.errors.is_empty(), "{:?}", host.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn board_adjust_native_wrap_lifecycle_and_release() {
|
||||
let mut config = parameter("", 0.0);
|
||||
config.mg_intent = None;
|
||||
config.mg_intent_mag = Some("RightBoardAdjustMag".into());
|
||||
config.mg_intent_angle = Some("RightBoardAdjustAngle".into());
|
||||
config.negate_on_mirror = true;
|
||||
let mut host = ActionHost::new(ActionInstances::new(vec![ActionInstance {
|
||||
operation: ActionOperation::BoardAdjust, config, parameters: vec![],
|
||||
}]), OperationRemap { behaviors: vec![0, 0], conditions: vec![], hooks: vec![] });
|
||||
let frame = Frame { dt: 1.0 / 60.0, current: None, last: None, state_times: vec![] };
|
||||
let pi = f32::from_bits(0x40490fdb);
|
||||
for mirrored in [false, true] {
|
||||
host.stance = Some((false, mirrored));
|
||||
Host::begin(&mut host, 0, [0; 6], &frame);
|
||||
assert!(!host.motion_intents.contains_key("RightBoardAdjustAngle"));
|
||||
host.action_intents.insert("BoardAdjustMag", 0.9);
|
||||
// Expected outputs from82BA2D70..2E80: latch at seam, hold,
|
||||
// unlatch crossing back; then exercise the opposite seam direction.
|
||||
for (raw, expected) in [(3.0, 3.0), (-3.0, pi), (-2.0, pi),
|
||||
(2.0, 2.0), (0.0, 0.0), (-3.0, -3.0), (3.0, -pi),
|
||||
(2.0, -pi), (-2.0, -2.0)] {
|
||||
host.action_intents.insert("BoardAdjustAngle", raw);
|
||||
Host::update(&mut host, 0, [0; 6], &frame);
|
||||
assert_eq!(host.motion_intents.get("RightBoardAdjustAngle"),
|
||||
Some(&(if mirrored { -expected } else { expected })));
|
||||
}
|
||||
// Another instance has its own seam state, even for the same config.
|
||||
Host::begin(&mut host, 1, [0; 6], &frame);
|
||||
host.action_intents.insert("BoardAdjustAngle", 3.0);
|
||||
Host::update(&mut host, 1, [0; 6], &frame);
|
||||
assert_eq!(host.motion_intents.get("RightBoardAdjustAngle"), Some(&(if mirrored { -3.0 } else { 3.0 })));
|
||||
// One missing member removes the PAIR and resets the latch.
|
||||
host.action_intents.remove("BoardAdjustMag");
|
||||
Host::update(&mut host, 0, [0; 6], &frame);
|
||||
assert!(!host.motion_intents.contains_key("RightBoardAdjustMag"));
|
||||
assert!(!host.motion_intents.contains_key("RightBoardAdjustAngle"));
|
||||
host.action_intents.insert("BoardAdjustMag", 0.0); // present zero is valid
|
||||
Host::update(&mut host, 0, [0; 6], &frame);
|
||||
assert_eq!(host.motion_intents.get("RightBoardAdjustAngle"), Some(&(if mirrored { -3.0 } else { 3.0 })));
|
||||
host.motion_intents.insert("Unrelated", 0.75);
|
||||
Host::end(&mut host, 0, [0; 6], &frame);
|
||||
assert!(!host.motion_intents.contains_key("RightBoardAdjustMag"));
|
||||
assert!(!host.motion_intents.contains_key("RightBoardAdjustAngle"));
|
||||
assert_eq!(host.motion_intents.get("Unrelated"), Some(&0.75));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires private stock graph"]
|
||||
fn board_adjust_stock_constructor_defaults_and_overrides() {
|
||||
use crate::graph_runtime::{CompiledGraph, LoadedGraph};
|
||||
use skate_data::state_graph::{StateGraph, binding::Binding};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("SKATE3_ASSET_ROOT").unwrap());
|
||||
let source = StateGraph::load(&root.join("private/stock/data/state/ActionGraph_OnBoard.stategraph")).unwrap();
|
||||
let binding = Binding::from_graph(&source).unwrap();
|
||||
let runtime = CompiledGraph::from_binding(&binding).unwrap();
|
||||
let graph = LoadedGraph { source, binding, runtime };
|
||||
let host = ActionHost::from_graph(&graph, &skate_data::collections::Collections::load(&root).unwrap()).unwrap();
|
||||
let nodes: Vec<_> = host.instances.operations.iter().filter(|op| matches!(op.operation, ActionOperation::BoardAdjust)).collect();
|
||||
assert!(!nodes.is_empty());
|
||||
for direction in ["Left", "Right", "Up", "Down"] {
|
||||
assert!(nodes.iter().any(|op| op.config.mg_intent_mag.as_deref() == Some(format!("{direction}BoardAdjustMag").as_str())));
|
||||
}
|
||||
assert!(nodes.iter().any(|op| op.config.negate_on_mirror));
|
||||
assert!(nodes.iter().any(|op| !op.config.negate_on_mirror));
|
||||
// Right.Regular omits negateOnMirror; Right.Switch explicitly disables it.
|
||||
assert!(nodes.iter().any(|op| op.config.angle_filter == 6 && op.config.mg_intent_mag.as_deref() == Some("RightBoardAdjustMag") && op.config.negate_on_mirror));
|
||||
assert!(nodes.iter().any(|op| op.config.angle_filter == 6 && op.config.mg_intent_mag.as_deref() == Some("LeftBoardAdjustMag") && !op.config.negate_on_mirror));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn const_callbacks_publish_then_remove_motion_intent() {
|
||||
let instance = ActionInstance {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# BoardAdjust native lifecycle and release
|
||||
|
||||
The cleanup-only change `fd280fd` was reverted in `411260a` after a user
|
||||
reported a snap during return to neutral. This replacement ports the complete
|
||||
BoardAdjust lifecycle, rather than changing animation timing to hide the snap.
|
||||
|
||||
## Reference and observed behavior
|
||||
|
||||
Read-only sources under `Documents/Skate3Research/research/reverse-engineering`:
|
||||
|
||||
- `generated/skate3_animation_research_recomp.71.cpp`, SHA-256
|
||||
`a4b47427402cfc4fc6aa0a6e5b08d4fe81b974da669c107e610a960ec2652257`.
|
||||
- `XexDump/default_82000000_011B0000.bin`, SHA-256
|
||||
`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`.
|
||||
- The same guest instructions decoded with IDA 9.3 in a disposable database.
|
||||
- Stock ActionGraph BoardAdjust includes, `MotionGraphIncludes/air.xml`, and
|
||||
`MotionGraphIncludes/BoardAdjusts/T_BoardAdjust.xml` from `MiscloadState`.
|
||||
|
||||
Addresses below are TU3 guest virtual addresses (image base `0x82000000`), not
|
||||
addresses in the Windows reference executable. The reference installation was
|
||||
located at `C:/CLEAN/Skate3CustomEngineLayer-0.1.0-preview.18-Windows`;
|
||||
its `skate3.exe` SHA-256 is
|
||||
`4d5234b2297fa7f1e02808ce2780ad37c960e4da6d26ae5004591cba91fe6904`.
|
||||
It was not launched or modified; no runtime parity claim is made for that build.
|
||||
|
||||
Observed in recompilation and corroborated by IDA:
|
||||
|
||||
| Callback | Guest address | Behavior |
|
||||
|---|---|---|
|
||||
| Constructor | `82BA2A30` | Magnitude name at object+28, angle name at +52, angle filter at +76, mirror-negation default **true** at +80. |
|
||||
| Allocate | `82BA2F48` | Separate 16-byte instance for each behavior. |
|
||||
| Begin | `82BA2BA8` | Clear instance wrap mode at +8 and previous raw angle at +12. No intent publication. |
|
||||
| Update | `82BA2BC8` | Require both source intents; copy magnitude, filter angle, optionally negate for mirrored stance, apply persistent angle seam logic. |
|
||||
| Missing input | `82BA2E88` | If either source is missing, remove both outputs and clear both instance fields. Present zero remains a valid value. |
|
||||
| End | `82BA2EB8` | Remove both authored outputs; no instance reset here. |
|
||||
|
||||
The seam logic at `82BA2D70` detects a sign change when the previous raw angle
|
||||
has absolute value greater than pi/2 (`822538BC`, bits `3FC90FDB`). In mode 0,
|
||||
crossing from negative enters mode 1, crossing from positive enters mode 2.
|
||||
Mode 1 holds -pi (`822F8908`, bits `C0490FDB`), mode 2 holds +pi
|
||||
(`82060C44`, bits `40490FDB`). Crossing back from the opposite side clears
|
||||
the mode. Every successful update stores the **raw filtered angle**, even while
|
||||
the published angle is held at pi. This state was missing from the Rust host.
|
||||
|
||||
## Release and animation handoff
|
||||
|
||||
The stock graph filters missing axes toward zero with blendOut 0.08,
|
||||
clampAcc 0.03, clampVel 0.2. Recompilation `82BB17D0` agrees with the existing
|
||||
filter arithmetic, including the missing-input branch. The board-adjust state
|
||||
exits when the directional intent is absent and both absolute filtered values
|
||||
are below 0.4. The default airborne tree is `B_AIR_CYC` with authored time 0.2
|
||||
and transitionUnder enabled. Blend evaluation/weight functions `82B961F8` and
|
||||
`82B963D8` agree with the existing blend progression. No duration, threshold,
|
||||
filter coefficient, pose offset, or extra smoothing has been fitted or changed.
|
||||
|
||||
The new tests cover the native seam branches, mirror signs, separate instances,
|
||||
entry/update/exit, partial input loss, present zero, stock constructor defaults,
|
||||
and the stock filtered release followed by its 0.2-second animation-tree blend.
|
||||
These are source-level tests, not a replay of the user's controller sequence.
|
||||
The reported visual snap's precise cause remains unconfirmed until gameplay
|
||||
validation; the additional native discrepancies are independently established.
|
||||
|
||||
Validation (Windows MSVC release/static CRT): seven action-host tests passed,
|
||||
including stock graph parsing; the stock release-to-air animation test passed.
|
||||
That test observes a zero-weight incoming tree at handoff and the authored blend
|
||||
progression to full weight. It does not measure rendered bone-pose continuity.
|
||||
@@ -12,6 +12,123 @@ Coverage limits: a Windows unhandled-exception filter attempts a fixed, allocati
|
||||
|
||||
For manual UI validation, run the executable with `--crash-report-preview`. This generates a clearly synthetic report and opens the popup without initializing the game, setup, Steam or assets. Check Copy report, clipboard text, Open folder, saved UTF-8 text and Close. Static tests do not validate desktop presentation.
|
||||
|
||||
## Mixed-worktree dependency in f96be1d (2026-09-09)
|
||||
|
||||
The DownTown report at tick1020 repeats the nonfinite walking result before
|
||||
the swept-line rejection. The full local stderr retains the finite GroundJob
|
||||
and tiny surface components omitted from the bounded report.
|
||||
|
||||
The matching shipped PDB establishes that `movement_velocity/math.rs` was
|
||||
compiled from worktree `8186`, with SHA-256
|
||||
`4652072df4a51a6bf8eb2dd837e61d4fb6e01710d984334af8073deb4c4449ac`.
|
||||
That source lacks the earlier subnormal refinement fix. The intended source
|
||||
in this worktree has SHA-256
|
||||
`76de0780adb5ea015c57fa4118105669f1897ef01eafb611eed14cc829c5eda1`.
|
||||
The game build ID identified the game crate, but did not establish the identity
|
||||
of reused path dependencies. Matching EXE/PDB GUIDs alone did not catch this.
|
||||
|
||||
`tools/build_verified_windows.ps1` now builds into a private
|
||||
`target/verified-windows` under the current worktree. Its verification step
|
||||
reads PDB source checksums and requires both current-worktree paths and matching
|
||||
SHA-256 for referenced project crates and vendored Bevy sources. It fails closed
|
||||
when required game/core/data source records are missing. The old shipped PDB
|
||||
fails this check (619 mismatched path/checksum entries).
|
||||
|
||||
Use this build script for subsequent crash-test builds and retain its
|
||||
`source-checks.json` with the staged EXE/PDB and existing import/hash manifest.
|
||||
Do not share Cargo artifact directories between independently edited worktrees.
|
||||
This repair changes build provenance, not gameplay rules or collision validation.
|
||||
|
||||
## Walking velocity convergence (2026-09-09)
|
||||
|
||||
Observed user failure: clean revision `dff672a`, build timestamp
|
||||
`1788969088174193000`, report timestamp `1788969335`. At tick4059 in
|
||||
BipedGround on University, `OFFBOARD_GROUND_RESULT_NONFINITE` precedes
|
||||
`Invalid offboard swept-line request`. The job has finite spatial inputs;
|
||||
the controller publishes NaN velocity/position, a NaN right delta and a
|
||||
forward delta of approximately `-3.11e-43`. The collision query then rejects
|
||||
NaN XYZ coordinates. Its validation is correct and remains intact.
|
||||
|
||||
The walking velocity limiter's portable reciprocal-square-root refinement
|
||||
formed `r*r` from a positive subnormal squared length. A finite correction
|
||||
of `1e-21` produces squared bits `0x000002CA` and a finite inverse root near
|
||||
`9.997e20`, whose square overflows to infinity. The two refinements then
|
||||
produce NaN. A focused pre-fix regression reproduced an all-NaN limited
|
||||
velocity from otherwise finite vectors. This establishes the numerical
|
||||
failure independently of the collision system; it is not a replay of all
|
||||
4059 input ticks.
|
||||
|
||||
The fix normalizes only positive subnormal squared lengths by the exact
|
||||
power of two `2^24`, retains both refinement stages, and rescales the inverse
|
||||
root by `2^12`. It preserves the ordinary-input path, movement limits and
|
||||
invalid-input propagation. No global floating-point mode changes, collision
|
||||
bypass, arbitrary epsilon, or replacement player state are involved. This
|
||||
is a stability correction for the existing portable PC arithmetic, not a
|
||||
claim of bit-exact Xenon instruction emulation.
|
||||
|
||||
Regressions cover the failing finite vectors, powers-of-two across the
|
||||
subnormal/normal boundary, limiting tiny corrections, steady walking updates,
|
||||
and the complete ground-controller publication (including position, lean and
|
||||
cadence). Zero and NaN checks ensure invalid inputs are not silently repaired.
|
||||
The report, matching stderr and arithmetic evidence are retained privately
|
||||
with the test builds. Gameplay validation remains with the user.
|
||||
|
||||
Validation: the finite-delta regression failed before the arithmetic change
|
||||
with `[NaN, NaN, NaN, NaN]`. Afterward all 11 movement-velocity tests passed,
|
||||
followed by all 202 offboard library tests in the release MSVC configuration
|
||||
(`cargo test --release --locked --target x86_64-pc-windows-msvc -p skate-core
|
||||
--lib player::offboard::`). No game or renderer was launched.
|
||||
|
||||
## FingerFlipOut (2026-09-09)
|
||||
|
||||
Observed user failure: clean revision `1a6e95b`, build timestamp
|
||||
`1788967727931108700`, report timestamp `1788968141`, University fingerprint
|
||||
`c911af6099e02b65`. At tick3467 in KnownAir, MotionGraph behavior3080 returned
|
||||
`MotionGraph stock gameplay producer FingerFlipOut is not implemented`.
|
||||
This was an explicit unsupported-operation exit, with no recorded panic or
|
||||
native exception. The matching launch stderr contains the same error.
|
||||
|
||||
Native recovery from the TU3 image mapped at `0x82000000`:
|
||||
|
||||
- Factory82BC7C18 binds authored `grabintent` and literal `Grabbing`.
|
||||
Vtable8232072C allocates a private float through82BBCB50, calls
|
||||
Begin82BB3198, Update82BB7C40 and empty End82B61BB8.
|
||||
- Begin sets the float to zero. Update reads the MotionGraph intent map
|
||||
through Actor20/virtual16. Membership lookup82454FD8 counts a zero-valued
|
||||
intent as present. Presence subtracts the time-service delta; absence adds
|
||||
it. This does not read raw ActionGraph or filtered tweak input.
|
||||
- Update clamps the retained time to layout190/198, evaluates eight curve
|
||||
keys at1A0 and values at1C0 through PointGraph82481E10, then writes
|
||||
`Grabbing` through ISkaterAnim80 (unnormalized, sequence -1).
|
||||
- Global constructor8289FD10..30 selects collection `41DB0C4F82003A15` at
|
||||
offset164. Helper8289D4E8 identifies class `anim_motion`. The retail schema
|
||||
independently maps layout190 to field `E0C1407B688858AD`, type
|
||||
`Sk8::PointNegGraphData8`. Its stock timer bounds are 0 and 0.5 seconds,
|
||||
with a descending grab blend from 1 to 0. Numeric names remain supported
|
||||
directly; no shared asset edit or invented curve is required.
|
||||
|
||||
The implementation retains one timer per bound behavior and publishes only
|
||||
on Update. Begin resets time; End leaves the last parameter alone. Other
|
||||
unimplemented operations still report errors.
|
||||
|
||||
Evidence identities (SHA-256): TU3 mapped image
|
||||
`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`;
|
||||
tested stock MotionGraph
|
||||
`806b2665e435be4e313d70a06fab4538e0adf60a2dfd3e80e82ae8d0b4a2e7c1`.
|
||||
Bounded decoded instructions, schema lookup and the original report are
|
||||
retained privately with this test build. IDA changes were confined to a
|
||||
disposable database copy. The instruction/data mapping is observed;
|
||||
successful gameplay reproduction after the fix remains user validation.
|
||||
|
||||
Focused regressions cover release/regrab timing, clamping, entry reset,
|
||||
the actual stock behavior3080, zero-valued intent membership, raw-input
|
||||
separation, independent behavior instances and Begin/End publication rules.
|
||||
They do not launch the renderer or replay the user's full input sequence.
|
||||
|
||||
Validation: `cargo test --release --locked --target x86_64-pc-windows-msvc
|
||||
-p skate-game --bin skate3rust --no-default-features finger_flip --
|
||||
--include-ignored` passed both tests against the prepared assets (0.18 seconds).
|
||||
|
||||
## SetDeckPitchAndYaw (2026-09-08)
|
||||
|
||||
The supplied report stopped in KnownAir at tick4813, MotionGraph behavior3373:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
param([Parameter(Mandatory=$true)][string]$OutputDirectory)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sourceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$outputPath = [IO.Path]::GetFullPath($OutputDirectory)
|
||||
if (Test-Path -LiteralPath $outputPath) { throw "Build output already exists: $outputPath" }
|
||||
New-Item -ItemType Directory -Path $outputPath | Out-Null
|
||||
# Never share path-dependency artifacts between concurrently edited worktrees.
|
||||
$targetPath = Join-Path $sourceRoot 'target/verified-windows'
|
||||
$previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS
|
||||
Push-Location $sourceRoot
|
||||
try {
|
||||
$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static'
|
||||
cargo rustc --release --locked --target x86_64-pc-windows-msvc --target-dir $targetPath -p skate-game --bin skate3rust --no-default-features -- -C extra-filename= -o "$outputPath/skate3rust.exe" -C "link-arg=/PDB:$outputPath/skate3rust.pdb" *> "$outputPath/compile.log"
|
||||
if ($LASTEXITCODE -ne 0) { Get-Content "$outputPath/compile.log" -Tail 40; throw 'Game build failed' }
|
||||
& "$PSScriptRoot/verify_windows_build_sources.ps1" -Pdb "$outputPath/skate3rust.pdb" -SourceRoot $sourceRoot -ReportPath "$outputPath/source-checks.json"
|
||||
} finally {
|
||||
$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = $previousFlags
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Pdb,
|
||||
[string]$SourceRoot = (Split-Path -Parent $PSScriptRoot),
|
||||
[string]$ReportPath
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sourcePrefix = [IO.Path]::GetFullPath($SourceRoot).TrimEnd('\', '/') + '\'
|
||||
$pdbutil = Join-Path $env:ProgramFiles 'LLVM/bin/llvm-pdbutil.exe'
|
||||
$dump = & $pdbutil dump --files $Pdb
|
||||
if ($LASTEXITCODE -ne 0) { throw 'PDB source inspection failed' }
|
||||
$checked = @{}
|
||||
$failures = [Collections.Generic.List[string]]::new()
|
||||
foreach ($line in $dump) {
|
||||
if ($line -notmatch '^- \(SHA-256: ([0-9A-Fa-f]{64})\) (.+)$') { continue }
|
||||
$compiledHash = $Matches[1]
|
||||
$compiledPath = $Matches[2].Replace('/', '\')
|
||||
if ($compiledPath -notmatch '\\((?:crates\\skate-[^\\]+|vendor\\bevy_[^\\]+)\\.+)$') { continue }
|
||||
$relative = $Matches[1]
|
||||
$key = "$compiledPath|$compiledHash"
|
||||
if ($checked.ContainsKey($key)) { continue }
|
||||
$expectedPath = Join-Path $sourcePrefix $relative
|
||||
$sourceHash = if (Test-Path -LiteralPath $expectedPath -PathType Leaf) {
|
||||
(Get-FileHash -LiteralPath $expectedPath -Algorithm SHA256).Hash
|
||||
} else { $null }
|
||||
$matchesRoot = $compiledPath.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase)
|
||||
$matchesHash = $sourceHash -eq $compiledHash
|
||||
$checked[$key] = [ordered]@{ file=$relative; compiledPath=$compiledPath; compiledSha256=$compiledHash; sourceSha256=$sourceHash; matchesRoot=$matchesRoot; matchesHash=$matchesHash }
|
||||
if (-not $matchesRoot -or -not $matchesHash) { $failures.Add($relative) }
|
||||
}
|
||||
foreach ($crate in @('skate-core', 'skate-data', 'skate-game')) {
|
||||
if (-not ($checked.Values | Where-Object { $_.file.StartsWith("crates\$crate\") })) {
|
||||
$failures.Add("No source checksums found for $crate")
|
||||
}
|
||||
}
|
||||
if ($ReportPath) {
|
||||
[ordered]@{ pdb=[IO.Path]::GetFullPath($Pdb); sourceRoot=$sourcePrefix; passed=($failures.Count -eq 0); files=@($checked.Values | Sort-Object file,compiledPath) } |
|
||||
ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ReportPath -Encoding utf8
|
||||
}
|
||||
if ($failures.Count) { throw "PDB source provenance failed for $($failures.Count) entries: $(($failures | Select-Object -First 8) -join ', ')" }
|
||||
Write-Output "Verified $($checked.Count) workspace source checksums and paths in $Pdb"
|
||||
Reference in new issue
Block a user