-Double-click **PLAY.bat** to launch. Use an XInput-compatible controller;
-**Esc** opens the pause/settings menu. Select **Easy**, **Normal**, or **Hardcore**
-there; the selection saves for the next launch. The initial default is Easy.
-Difficulty uses the extracted stock `physics_mode` tables and native selectors.
-See [difficulty implementation and remaining parity gaps](docs/DIFFICULTY.md).
+# Skate 3 Imported
-The menu supports window resolution (720p through 4K), internal render scale
-(25–100%), supported MSAA levels (Off/2×/4×/8×), an FPS cap (30–240 or
-Unlimited), and GPU occlusion culling (On/Off). Click a row to cycle it, or use arrow keys and Enter. Changes apply
-immediately and save to `settings/graphics.json`. Escape or Resume returns to
-gameplay; Quit game exits. The menu stays at the window's native resolution.
-Occlusion culling defaults to On and skips meshes hidden behind other geometry.
-It needs no changes to `.skate` files. Its benefit depends on the view; compare
-On/Off where you see dips. It does not reduce visible mesh detail or collision.
+A Rust and Bevy skating project built from Skate 3 reverse-engineering research.
+Includes skating, tricks, grinds, offboard movement, difficulty settings and
+`.skate` map support. Gameplay parity is still a work in progress.
-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).
+## Build and run
-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.
-No-map PLAY.bat still loads the supplied test environment.
+Requires Windows, Rust with the MSVC toolchain, and LLVM installed in its default
+location. Run `BUILD.bat` to build, then `PLAY.bat` to launch the test world.
+Use `PLAY-MAP.bat` to select a map. An XInput controller is required for gameplay;
+Escape opens difficulty and graphics settings.
-New San Vanelona now loads its embedded `RWCM` collision archive, including
-native edge flags and cluster query filtering. The staged build was checked
-with the actual map and rendered the skater on its streets. No separate
-collision sidecar is needed. See [map support notes](docs/SKATE-MAPS.md) for
-verification and remaining sky/grind limitations.
+**Skate 3 assets are not included.** Running the game requires a separately
+prepared local asset set in `assets/private/`, including its `game.json`
+manifest, character, animation banks, graphs and gameplay data. This repository
+does not include an asset extraction pipeline. A source checkout alone cannot
+run the game.
-This folder is a **local Git repository**, on `lol/skate-map-support`, with no
-GitHub remote. Private assets, private maps, binaries, logs and build outputs
-are excluded. See [the map support notes](docs/SKATE-MAPS.md) for supported
-versions and the explicit runtime limitations.
+Custom animations and climbing support remain available, but no custom clips
+are shipped. The included format-demo map is original procedural content.
-This folder is separate from the earlier clone. It contains the contents of
-`crates (1).zip` and `assets.zip`, a reconstructed Cargo workspace, and launch
-scripts. The supplied stock animation banks, decoded frames, graphs, skater
-mesh and textures are used together. No previous-clone animation code was
-needed for setup.
-
-The supplied scene contains a small test environment. Gameplay quality and
-Skate 3 parity remain for the user to assess; compiling and starting the
-project do not establish those claims.
-
-## Observed runtime issue
-
-The later bump crash (tick 4159, behaviors 3623/3624) is repaired: the host now
-supports the stock `B_BUMP` blend space and `SetBumpCoefficients`. See
-[the fix and verification notes](docs/BUMP-CRASH-FIX.md). Visual confirmation
-remains with the user; the separate runout issue below is still open.
-
-The build launches and reports `GAME_CHARACTER_READY bones=35`. All 35 skin
-bones match the supplied stock rig; the decoded frame data contains 487 clips.
-However, the imported motion-graph host exits when it encounters
-`AddRunoutAttribs` (behavior 86). Both startup sessions recorded that exact
-unsupported-behavior error. This is an implementation gap, not a missing
-animation-file error. It has not been bypassed or replaced with guessed behavior.
-See `logs/game-20260906-033940.stderr.log` for the character-ready message
-and subsequent failure. No automated gameplay or screenshot test was run.
-
-## Files
-
-- `crates/`: imported Rust source. The two startup profile selections were
- changed from `normal` to `easy`.
-- `assets/private/`: supplied assets; keep these private.
-- `bin/`: staged executable and its exact Rust/Bevy runtime DLL dependencies.
-- `logs/`: per-launch output and error logs.
-- `Cargo.lock`: resolved dependency versions for reproducible rebuilds.
-- `SETUP-PROVENANCE.json`: original ZIP hashes and setup changes.
-
-**BUILD.bat** rebuilds and stages the executable. It requires Cargo/Rust and
-LLVM `llvm-readobj` at the installed Windows location. The initial build
-reused the earlier project's compilation cache; PLAY.bat uses only this
-folder's staged binary, libraries and assets. Subsequent default builds use
-this folder's own `target/` directory.
-
-If startup fails, the launcher keeps the console open and shows the error
-log path. Do not substitute animation files to silence a missing-data error;
-record the exact error first.
+Implementation notes are in [`docs/`](docs/). Patched Bevy dependencies and
+their licenses are in [`vendor/`](vendor/). This is an unofficial project,
+not affiliated with EA.
diff --git a/crates/skate-core/src/player/offboard/air_launch.rs b/crates/skate-core/src/player/offboard/air_launch.rs
index 9f2dbcb2..ce2bbecd 100644
--- a/crates/skate-core/src/player/offboard/air_launch.rs
+++ b/crates/skate-core/src/player/offboard/air_launch.rs
@@ -158,7 +158,11 @@ fn jump(out: &mut Launch, p: &Input, j: &JumpInput<'_>) {
redirected = scale(unit_unchecked(projected), sqrt(speed2));
} else if angle < 90. * f32::from_bits(0x3c8efa35) {
redirected = scale(
- limit_angle(unit_unchecked(projected), initial, 45.),
+ limit_angle(
+ unit_unchecked(projected),
+ initial,
+ 45. * f32::from_bits(0x3c8efa35),
+ ),
sqrt(speed2),
);
}
diff --git a/crates/skate-core/src/player/offboard/landing_deck.rs b/crates/skate-core/src/player/offboard/landing_deck.rs
new file mode 100644
index 00000000..3fe6aee7
--- /dev/null
+++ b/crates/skate-core/src/player/offboard/landing_deck.rs
@@ -0,0 +1,74 @@
+//! LandingOnDeckManager trajectory preparation,82D78B28/82D78EE8/82D78D30.
+//! The board frame and velocity are observations of the canonical live board.
+use super::air_launch::{V,dot,length,madd,scale,sub};
+use crate::air::trajectory::Trajectory;
+const ZERO:V=[0.;4];
+const GRAVITY:V=[0.,-9.8,0.,0.];
+#[derive(Clone,Copy,Debug)]
+pub struct Settings {pub deck_min_uprightness:f32,pub approximate_com_height:f32}
+#[derive(Clone,Copy,Debug)]
+pub struct Input {
+ pub board_position:V,pub board_velocity:V,pub board_up:V,pub up:V,
+ pub board_contact_count:i32,pub flags_2480:u32,
+}
+#[derive(Clone,Debug)]
+pub struct State {
+ pub trajectory:Trajectory,pub proposed:Trajectory,pub elapsed:f32,pub trajectory_valid:bool,
+ pub horizontal_correction:V,pub remaining:f32,pub proposed_remaining:f32,
+ pub contact_height:f32,pub query_count:i32,pub can_land:bool,pub force:bool,
+ pub blocked:bool,pub first_query:bool,pub hippy_hurdling:bool,pub collision_override:bool,
+ pub collision_normal:V,pub query_pending:bool,
+}
+impl Default for State {
+ fn default()->Self {
+ let trajectory=Trajectory{position:ZERO,velocity:ZERO,acceleration:ZERO,duration:-1.};
+ Self{trajectory,proposed:trajectory,elapsed:0.,trajectory_valid:false,horizontal_correction:ZERO,
+ remaining:0.,proposed_remaining:0.,contact_height:0.,query_count:0,can_land:false,force:false,
+ blocked:false,first_query:false,hippy_hurdling:false,collision_override:false,collision_normal:ZERO,query_pending:false}
+ }
+}
+impl State {
+ ///82D78EE8: solve the descending crossing of the deck's COM-height plane,
+ ///then prepare the velocity matching trajectory. No scene hit is invented.
+ pub fn probe(&mut self,input:Input,settings:Settings,position:V,velocity:V)->bool {
+ let mut board_velocity=input.board_velocity;
+ let acceleration=if input.board_contact_count==0 {board_velocity[1]=0.;scale(GRAVITY,0.5)} else {GRAVITY};
+ let relative=sub(velocity,board_velocity);
+ let plane=madd(input.up,settings.approximate_com_height,input.board_position);
+ let a=dot(scale(acceleration,0.5),input.up);
+ let b=dot(relative,input.up);let c=dot(sub(position,plane),input.up);
+ let discriminant=b*b-4.*a*c;
+ if discriminant<0. {self.can_land=false;return false;}
+ //82D60B98 picks the larger root of82D60C80; the caller requires t>0.
+ let root=discriminant.sqrt();
+ let time=((-b+root)/(2.*a)).max((-b-root)/(2.*a));
+ if !(time>0.) {self.can_land=false;return false;}
+ let distance=length(sub(position,plane));
+ let duration=(2.*distance/9.8).sqrt().max(time*0.75).min(time*1.3);
+ self.proposed=Trajectory{position,
+ velocity:madd(input.board_velocity,1.,sub(scale(sub(plane,position),1./duration),scale(GRAVITY,0.5*duration))),
+ acceleration:GRAVITY,duration:-1.};
+ let relative_arc=Trajectory{position,velocity:relative,acceleration,duration:-1.};
+ self.horizontal_correction=sub(plane,relative_arc.position_at(time));self.horizontal_correction[1]=0.;
+ self.remaining=time;self.proposed_remaining=duration;
+ true
+ }
+ ///82D78D30, before the world's obstruction query82D79948. Some is the
+ ///actual duration to submit to that query; None means no submission this tick.
+ pub fn update_air(&mut self,input:Input,settings:Settings,position:V,velocity:V,maximum_velocity_difference:f32)->Option {
+ self.query_count=0;self.first_query=false;
+ if (!self.force&&input.flags_2480&0x8000!=0)||!(input.board_up[1]>settings.deck_min_uprightness) {return None;}
+ self.probe(input,settings,position,velocity);
+ if self.remaining>0.4 {
+ self.trajectory=self.proposed;self.trajectory_valid=true;self.remaining=self.proposed_remaining;
+ self.can_land=length(sub(velocity,self.proposed.velocity))Trajectory {
+ let mut target=madd(input.board_velocity,duration,input.board_position);target[1]+=f32::from_bits(0x3e4cccce);
+ self.blocked=false;self.query_pending=true;self.contact_height=target[1]-0.1;
+ Trajectory{position,velocity:sub(scale(sub(target,position),1./duration),scale(GRAVITY,duration*0.5)),acceleration:GRAVITY,duration}
+ }
+}
diff --git a/crates/skate-core/src/player/offboard/mod.rs b/crates/skate-core/src/player/offboard/mod.rs
index babfa55a..eb07418f 100644
--- a/crates/skate-core/src/player/offboard/mod.rs
+++ b/crates/skate-core/src/player/offboard/mod.rs
@@ -42,3 +42,4 @@ pub mod air_prediction;
pub mod air_queries;
pub mod air_state;
pub mod feet;
+pub mod landing_deck;
diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs
index c5b43608..38f053cf 100644
--- a/crates/skate-game/src/physics.rs
+++ b/crates/skate-game/src/physics.rs
@@ -5,6 +5,7 @@ mod air_phase;
mod air_trajectory;
pub(crate) mod camera_output;
mod clock;
+mod climbing;
mod colliders;
mod controls;
mod foot_ik;
diff --git a/crates/skate-game/src/physics/climbing/approach.rs b/crates/skate-game/src/physics/climbing/approach.rs
new file mode 100644
index 00000000..30229269
--- /dev/null
+++ b/crates/skate-game/src/physics/climbing/approach.rs
@@ -0,0 +1,208 @@
+//! Airborne reach is a presentation overlay. Stock graphs, gravity and collisions
+//! retain ownership until both arm targets are physically within reach.
+use super::*;
+pub(super) struct Approach {
+ ledge: ledge::Ledge,
+ pub(super) weight: f32,
+}
+pub(crate) fn advance(
+ physics: &GamePhysics,
+ skater: &mut SkaterRuntime,
+ controls: &PlayerControls,
+) {
+ let mut runtime = std::mem::take(&mut skater.climbing);
+ update(&mut runtime, physics, skater, controls);
+ skater.climbing = runtime;
+}
+fn update(
+ r: &mut Runtime,
+ physics: &GamePhysics,
+ skater: &mut SkaterRuntime,
+ controls: &PlayerControls,
+) {
+ let Some(clips) = &r.clips else {
+ return;
+ };
+ let c = &clips.reach;
+ let root = matrix(skater.animated_skeleton.roots.animation_to_world);
+ let globals: Vec<_> = r
+ .indices
+ .iter()
+ .map(|&i| matrix(skater.render_pose[i]))
+ .collect();
+ let mut locals: Vec<_> = globals
+ .iter()
+ .enumerate()
+ .map(|(i, &g)| {
+ Transform::from_matrix(if c.parents[i] < 0 {
+ g
+ } else {
+ globals[c.parents[i] as usize].inverse() * g
+ })
+ })
+ .collect();
+ let state = skater.player_state.current();
+ let ground_speed = Vec3::from_slice(
+ &skater
+ .player_input
+ .physical
+ .reckoning
+ .vector_16
+ .map(f32::from_bits)[..3],
+ )
+ .length();
+ if state == PhysicalStateId::BipedGround && (r.ground_entry.is_empty() || ground_speed < 0.3) {
+ r.ground_entry = locals.clone();
+ // Native walking roots sit on the support plane. Normalize the saved
+ // return pose so a lifted foot in the run cycle cannot lower that root.
+ let height = c.feet(&globals).y;
+ for (i, local) in r.ground_entry.iter_mut().enumerate() {
+ if c.parents[i] < 0 {
+ local.translation.y -= height;
+ }
+ }
+ }
+ let airborne = state == PhysicalStateId::BipedAir;
+ if (!airborne && state != PhysicalStateId::BipedGround) || r.cooldown > 0. {
+ r.approach = None;
+ return;
+ }
+ let feet = root.transform_point3(c.feet(&globals));
+ let velocity = Vec3::from_slice(
+ &skater
+ .player_input
+ .physical
+ .reckoning
+ .vector_16
+ .map(f32::from_bits)[..3],
+ );
+ let direction = controls
+ .offboard_direction
+ .map(|v| Vec3::new(v[0], 0., v[2]))
+ .filter(|v| v.length_squared() > 0.04)
+ .unwrap_or_else(|| {
+ root.z_axis.truncate()
+ * if skater.player_input.processed.flags_2476 & 4 != 0 {
+ -1.
+ } else {
+ 1.
+ }
+ });
+ let moving = airborne
+ || ground_speed > 0.15
+ || controls
+ .offboard_direction
+ .is_some_and(|v| Vec2::new(v[0], v[2]).length_squared() > 0.04);
+ let found = moving
+ .then(|| ledge::find_air(&physics.world, feet, direction))
+ .flatten();
+ // Follow the approach while running. Freeze the two contacts on takeoff,
+ // once the player has committed to this edge.
+ if !airborne {
+ if let (Some(a), Some(ledge)) = (&mut r.approach, found) {
+ if a.ledge.anchor.distance(ledge.anchor) < 0.5 {
+ a.ledge = ledge;
+ }
+ }
+ }
+ if r.approach.is_none() {
+ if let Some(ledge) = found {
+ if velocity.dot(ledge.forward) >= -0.1 {
+ r.approach = Some(Approach { ledge, weight: 0. });
+ }
+ }
+ }
+ let Some(a) = &mut r.approach else {
+ return;
+ };
+ let separation = a.ledge.anchor - feet;
+ let distance = Vec2::new(separation.x, separation.z).length();
+ let valid = moving
+ && separation.y > 0.35
+ && separation.y < 2.75
+ && distance < 2.7
+ && separation.dot(a.ledge.forward) > 0.
+ && direction.normalize_or_zero().dot(a.ledge.forward) > 0.65
+ && velocity.dot(a.ledge.forward) >= -0.3
+ && ledge::clear(&physics.world, a.ledge);
+ let dt = physics.settings.step.simulation.time_step;
+ // Continuous distance gain, starting during the run. Ground anticipation
+ // is gentle; the jump smoothly builds from it into full hand contact.
+ let desired = if valid {
+ reach_gain(distance, airborne)
+ } else {
+ 0.
+ };
+ a.weight += (desired - a.weight) * (1. - (-dt / 0.10).exp());
+ if a.weight < 0.001 && !valid {
+ r.approach = None;
+ return;
+ }
+ let weight = a.weight;
+ // Only shoulders and arms borrow the authored reach. The stock jump keeps
+ // its hips, legs, trajectory and timing, even during a failed attempt.
+ let authored = c.sample(c.duration());
+ for side in ["LEFT", "RIGHT"] {
+ for suffix in ["SHOULDER", "ARM", "FOREARM", "HAND"] {
+ let i = c.index(&format!("{side}{suffix}"));
+ locals[i].rotation = locals[i]
+ .rotation
+ .slerp(authored[i].rotation, weight * 0.75);
+ }
+ }
+ let before_ik = c.globals(&locals);
+ let reachable = ["LEFT", "RIGHT"].into_iter().enumerate().all(|(i, side)| {
+ let shoulder =
+ root.transform_point3(before_ik[c.index(&format!("{side}ARM"))].w_axis.truncate());
+ let elbow = root.transform_point3(
+ before_ik[c.index(&format!("{side}FOREARM"))]
+ .w_axis
+ .truncate(),
+ );
+ let hand =
+ root.transform_point3(before_ik[c.index(&format!("{side}HAND"))].w_axis.truncate());
+ shoulder.distance(contacts::wrist(a.ledge, i).0)
+ <= shoulder.distance(elbow) + elbow.distance(hand) - 0.015
+ });
+ contacts::hands(c, &mut locals, root, a.ledge, weight);
+ let output = c.globals(&locals);
+ for (&i, &g) in r.indices.iter().zip(&output) {
+ skater.render_pose[i] = native(g);
+ }
+ if airborne && valid && reachable && a.weight >= 0.95 && !r.ground_entry.is_empty() {
+ let deck = matrix(super::super::solve::deck_frame(&physics.board));
+ r.active = Some(Attached {
+ phase: Phase::Catch,
+ time: 0.,
+ ledge: a.ledge,
+ start_root: Transform::from_matrix(root),
+ entry: locals,
+ fallback: skater.render_pose.clone(),
+ board_world: root * output[c.index("SKATEBOARD_ROOT")],
+ physical_board_world: deck,
+ carry_board: skater.skateboard_controller.fields.state_448 == 1,
+ });
+ r.approach = None;
+ skater.offboard.air_prediction = None;
+ skater.offboard.feet.reset();
+ skater.foot_ik.state.enable_feet(false);
+ bevy::log::info!("Climbing: caught ledge from stock jump");
+ }
+}
+
+fn reach_gain(distance: f32, airborne: bool) -> f32 {
+ let proximity = smooth((2.6 - distance) / 1.8);
+ proximity * if airborne { 1. } else { 0.45 }
+}
+#[test]
+fn reach_builds_continuously_before_takeoff() {
+ assert_eq!(reach_gain(2.6, false), 0.);
+ assert!(reach_gain(2., false) > 0.);
+ assert!(reach_gain(1.5, false) > reach_gain(2., false));
+ assert!(reach_gain(0.8, false) <= 0.45);
+ assert_eq!(reach_gain(0.8, true), 1.);
+ for i in 1..260 {
+ let x = i as f32 * 0.01;
+ assert!((reach_gain(x, true) - reach_gain(x + 0.01, true)).abs() < 0.009);
+ }
+}
diff --git a/crates/skate-game/src/physics/climbing/clip.rs b/crates/skate-game/src/physics/climbing/clip.rs
new file mode 100644
index 00000000..0724d35b
--- /dev/null
+++ b/crates/skate-game/src/physics/climbing/clip.rs
@@ -0,0 +1,160 @@
+//! Optional authored clips, separate from the SHA-bound stock animation banks.
+use bevy::prelude::*;
+use serde::Deserialize;
+use std::path::Path;
+
+#[derive(Deserialize)]
+struct File {
+ version: u32,
+ clips: Vec,
+}
+
+#[derive(Deserialize)]
+pub(super) struct Clip {
+ pub name: String,
+ pub fps: f32,
+ pub names: Vec,
+ pub parents: Vec,
+ pub frames: Vec>,
+}
+
+pub(super) struct Clips {
+ pub reach: Clip,
+ pub mantle: Clip,
+}
+impl Clips {
+ pub fn load(root: &Path) -> Result