From 01ebd215dea945c7bb89c616aef60ed4af0e79d6 Mon Sep 17 00:00:00 2001 From: chazmm Date: Sun, 6 Sep 2026 14:56:18 +0100 Subject: [PATCH] Load embedded retail collision and decode unsigned vertex offsets --- .gitignore | 2 + README.md | 6 + crates/skate-core/src/physics/board_world.rs | 101 ++++++- .../src/physics/board_world/query_metadata.rs | 22 ++ .../src/physics/tests/board_world.rs | 143 ++++++++- crates/skate-data/examples/inspect_skate.rs | 51 +++- crates/skate-data/src/lib.rs | 1 + crates/skate-data/src/retail_collision.rs | 203 +++++++++++++ .../tests/fixtures/retail-collision.rwcmset | Bin 0 -> 843 bytes crates/skate-data/tests/retail_collision.rs | 88 ++++++ crates/skate-game/src/camera/world_query.rs | 2 +- .../src/physics/air_trajectory/world.rs | 4 +- .../skate-game/src/physics/foot_ik_queries.rs | 2 +- crates/skate-game/src/skate_world.rs | 281 +++++++++++++++--- docs/SKATE-MAPS.md | 62 +++- 15 files changed, 894 insertions(+), 74 deletions(-) create mode 100644 crates/skate-data/src/retail_collision.rs create mode 100644 crates/skate-data/tests/fixtures/retail-collision.rwcmset create mode 100644 crates/skate-data/tests/retail_collision.rs diff --git a/.gitignore b/.gitignore index a859881f..d7a32326 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /logs/ /assets/private/ /maps/private/ +/maps/*.skate +!/maps/format-demo.skate diff --git a/README.md b/README.md index d79991a7..d97b57a0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ 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. +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. + 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 diff --git a/crates/skate-core/src/physics/board_world.rs b/crates/skate-core/src/physics/board_world.rs index 7aba963b..0fd0618a 100644 --- a/crates/skate-core/src/physics/board_world.rs +++ b/crates/skate-core/src/physics/board_world.rs @@ -4,7 +4,7 @@ //! determine the physical result, in world-triangle then moving-volume order. pub mod query_metadata; use crate::math::Vector3; -use query_metadata::QueryMetadata; +use query_metadata::{Bounds, QueryMetadata}; use super::{ board::BodyId, @@ -105,11 +105,17 @@ pub struct BoardWorld { query_metadata: Option, contacts: Vec, buffer: ContactBuffer, + maximum_fatness: f32, } impl BoardWorld { pub fn new(triangles: Vec) -> Self { + let maximum_fatness = triangles + .iter() + .map(|t| t.triangle.fatness) + .fold(0., f32::max); Self { + maximum_fatness, triangles, query_metadata: None, contacts: Vec::new(), @@ -147,6 +153,44 @@ impl BoardWorld { &self.triangles } + /// Conservative cluster culling, retaining canonical traversal order and + /// every triangle in intersecting clusters. Narrow-phase remains unchanged. + pub fn candidate_ranges(&self, bounds: Option) -> Vec> { + let (Some(metadata), Some(bounds)) = (&self.query_metadata, bounds) else { + return vec![0..self.triangles.len()]; + }; + let scale = [ + bounds.min.x, + bounds.min.y, + bounds.min.z, + bounds.max.x, + bounds.max.y, + bounds.max.z, + ] + .into_iter() + .map(f32::abs) + .fold(1., f32::max); + let bounds = bounds.expanded(self.maximum_fatness + scale * (8. * f32::EPSILON)); + metadata + .meshes + .iter() + .filter(|m| m.local_bounds.overlaps(bounds)) + .map(|m| m.triangle_range.clone()) + .collect() + } + + pub fn line_candidates( + &self, + start: Vector3, + end: Vector3, + radius: f32, + ) -> impl Iterator { + self.candidate_ranges(Bounds::from_points([start, end]).map(|b| b.expanded(radius))) + .into_iter() + .flatten() + .map(|i| (i, &self.triangles[i])) + } + /// Board wheel segments use zero query radius; rounded world triangles /// still select the native swept branch through their own fatness. pub fn query_thin_line( @@ -170,7 +214,7 @@ impl BoardWorld { } let direction = Vector3::new(end.x - start.x, end.y - start.y, end.z - start.z); let mut nearest: Option = None; - for entry in &self.triangles { + for (_, entry) in self.line_candidates(start, end, radius) { let mut geometry = TriangleLineHit { position: Vector3::ZERO, normal: Vector3::ZERO, @@ -226,8 +270,8 @@ impl BoardWorld { } /// World-space shapes come from the live part poses and authored children. - /// The host's small level visits every pair, preserving source query order - /// without a broad-phase bound that could drop valid predictive contacts. + /// Cluster bounds include shape radii and the maximum predictive padding. + /// Candidate pairs retain source query order. pub fn query_primitives( &mut self, volumes: &[BoardWorldVolume], @@ -242,11 +286,21 @@ impl BoardWorld { self.buffer.capacity = retention.capacity; self.buffer.distance_squared_threshold = retention.duplicate_distance_squared; self.buffer.deferred_reduction = u8::from(retention.deferred_reduction); + let padding = query.volume_padding.max(0.) + query.maximum_separating_distance.max(0.); + let bounds: Option> = volumes + .iter() + .map(|v| primitive_bounds(v.primitive)) + .collect(); + let bounds = bounds + .and_then(|b| Bounds::from_points(b.iter().flat_map(|b| [b.min, b.max]))) + .map(|b| b.expanded(padding)); + let ranges = self.candidate_ranges(bounds); let output = &mut self.contacts; let mut publish = |records: &[ContactRecord]| { output.extend(records.iter().map(collision_from_record)); }; - for entry in &self.triangles { + for index in ranges.into_iter().flatten() { + let entry = &self.triangles[index]; for volume in volumes { let Some(manifold) = primitive_triangle_world_contacts( volume.primitive, @@ -292,6 +346,43 @@ impl BoardWorld { } } +fn primitive_bounds(primitive: ContactPrimitive) -> Option { + let (center, radius) = match primitive { + ContactPrimitive::Sphere(s) => (s.center, s.radius), + ContactPrimitive::Capsule { + center, + axis, + half_length, + radius, + } => ( + center, + radius + half_length.abs() * (axis.x.abs() + axis.y.abs() + axis.z.abs()), + ), + ContactPrimitive::RoundedBox { + center, + basis, + half_extents, + radius, + } => { + let half = [half_extents.x, half_extents.y, half_extents.z]; + let extent: f32 = basis + .columns + .iter() + .zip(half) + .map(|(axis, h)| h.abs() * axis.iter().map(|v| v.abs()).sum::()) + .sum(); + (center, radius + extent) + } + ContactPrimitive::Triangle(t) => { + return Bounds::from_points(t.vertices).map(|b| b.expanded(t.fatness)); + } + }; + if !radius.is_finite() { + return None; + } + Bounds::from_points([center]).map(|b| b.expanded(radius.abs())) +} + /// Retention reads only contact header geometry, body IDs and material/tag. /// BoardStep builds the actual body workspaces after this frame's force queue /// has been applied, preventing stale acceleration copies in solver contacts. diff --git a/crates/skate-core/src/physics/board_world/query_metadata.rs b/crates/skate-core/src/physics/board_world/query_metadata.rs index e307f432..af0f5c8e 100644 --- a/crates/skate-core/src/physics/board_world/query_metadata.rs +++ b/crates/skate-core/src/physics/board_world/query_metadata.rs @@ -16,6 +16,28 @@ pub struct Bounds { pub max: Vector3, } impl Bounds { + pub fn expanded(self, padding: f32) -> Self { + Self { + min: Vector3::new( + self.min.x - padding, + self.min.y - padding, + self.min.z - padding, + ), + max: Vector3::new( + self.max.x + padding, + self.max.y + padding, + self.max.z + padding, + ), + } + } + pub fn overlaps(self, other: Self) -> bool { + !(self.max.x < other.min.x + || self.min.x > other.max.x + || self.max.y < other.min.y + || self.min.y > other.max.y + || self.max.z < other.min.z + || self.min.z > other.max.z) + } pub fn from_points(points: impl IntoIterator) -> Option { let mut points = points.into_iter(); let first = points.next()?; diff --git a/crates/skate-core/src/physics/tests/board_world.rs b/crates/skate-core/src/physics/tests/board_world.rs index b8ac4ea1..9957407c 100644 --- a/crates/skate-core/src/physics/tests/board_world.rs +++ b/crates/skate-core/src/physics/tests/board_world.rs @@ -90,6 +90,121 @@ fn close(a: f32, b: f32) { assert!((a - b).abs() < 2e-6, "{a} != {b}"); } +fn clustered(triangles: Vec) -> BoardWorld { + use crate::physics::board_world::query_metadata::{ + Bounds, QueryMesh, QueryMetadata, QueryPool, + }; + let metadata = QueryMetadata { + packed_surfaces: vec![0; triangles.len()], + meshes: triangles + .iter() + .enumerate() + .map(|(i, t)| QueryMesh { + triangle_range: i..i + 1, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: Bounds::from_points(t.triangle.vertices).unwrap(), + matching_group: -1, + pool: QueryPool::Ground, + }) + .collect(), + static_edges: vec![], + island_flags: 0, + }; + BoardWorld::with_query_metadata(triangles, metadata).unwrap() +} + +#[test] +fn cluster_culling_preserves_swept_hits_and_equal_hit_order() { + let mut rounded = triangle(); + rounded.triangle.fatness = 0.5; + let mut later = rounded; + later.tag = 999; + let indexed = clustered(vec![rounded, later]); + let full = BoardWorld::new(vec![rounded, later]); + for x in [-100., -10., -1., 0., 9., 100.] { + for radius in [0., 0.2, 1.] { + let start = Vector3::new(x, 2., 0.); + let end = Vector3::new(x, -2., 0.); + assert_eq!( + format!("{:?}", indexed.query_swept_line(start, end, radius)), + format!("{:?}", full.query_swept_line(start, end, radius)) + ); + } + } + assert_eq!( + indexed + .query_thin_line(Vector3::new(0., 2., 0.), Vector3::new(0., -2., 0.)) + .unwrap() + .unwrap() + .tag, + 42 + ); + assert_eq!( + indexed + .line_candidates(Vector3::new(100., 2., 0.), Vector3::new(100., -2., 0.), 0.) + .count(), + 0 + ); +} + +#[test] +fn cluster_culling_preserves_predictive_contacts_for_every_shape() { + let mut floor = triangle(); + floor.triangle.fatness = 0.1; + let mut indexed = clustered(vec![floor]); + let mut full = BoardWorld::new(vec![floor]); + let mut settings = config(); + settings.query.volume_padding = 0.05; + settings.query.maximum_separating_distance = 0.8; + for y in [0.1, 0.4, 0.9, 2.] { + let center = Vector3::new(0., y, 0.); + let basis = Basis3 { + columns: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]], + }; + let mut moving_triangle = triangle().triangle; + for p in &mut moving_triangle.vertices { + p.y += y; + } + for primitive in [ + ContactPrimitive::Sphere(Sphere { + center, + radius: 0.2, + }), + ContactPrimitive::Capsule { + center, + axis: Vector3::new(1., 0., 0.), + half_length: 0.4, + radius: 0.2, + }, + ContactPrimitive::RoundedBox { + center, + basis, + half_extents: Vector3::new(0.4, 0.2, 0.3), + radius: 0.05, + }, + ContactPrimitive::Triangle(moving_triangle), + ] { + let volumes = [BoardWorldVolume { + body: CollisionBody::Board(BodyId::ORDER[0]), + primitive, + linear_velocity: Vector3::new(0., -60., 0.), + material: settings.material, + }]; + assert_eq!( + format!( + "{:?}", + indexed.query_primitives(&volumes, settings.query, settings.retention) + ), + format!( + "{:?}", + full.query_primitives(&volumes, settings.query, settings.retention) + ) + ); + } + } +} + #[test] fn finite_world_geometry_produces_four_ordered_wheel_contacts_with_combined_materials() { let mut world = BoardWorld::new(vec![triangle()]); @@ -241,7 +356,11 @@ fn attached_volume_contact_reaches_its_actual_solver_body() { let mut world = BoardWorld::new(vec![triangle(), triangle()]); let config = config(); let contacts = world.query_primitives(&[volume], config.query, config.retention); - assert_eq!(contacts.len(), 1, "retention must preserve attached body identity"); + assert_eq!( + contacts.len(), + 1, + "retention must preserve attached body identity" + ); assert_eq!(contacts[0].body_a, CollisionBody::Attached(0)); let settings = BoardStepSettings { simulation: RetailSimulationStep::fixed_60_hz(30, 0.001, Vector3::ZERO), @@ -250,12 +369,20 @@ fn attached_volume_contact_reaches_its_actual_solver_body() { truck_dynamics: retail_truck_drive_dynamics(RetailTruckDriveSettings::STOCK), force_point_y_offset: 0.0, }; - board.advance_attached(contacts, [0.0; 2], settings, AttachedStep { - bodies: vec![&mut attached], - contacts: &mut [], - joints: &mut [], - drives: &mut [], - }); + board.advance_attached( + contacts, + [0.0; 2], + settings, + AttachedStep { + bodies: vec![&mut attached], + contacts: &mut [], + joints: &mut [], + drives: &mut [], + }, + ); assert!(attached.rates.linear_velocity.y > before + 0.5); - assert!(board.contact_reports().is_empty(), "skater contacts are not board observations"); + assert!( + board.contact_reports().is_empty(), + "skater contacts are not board observations" + ); } diff --git a/crates/skate-data/examples/inspect_skate.rs b/crates/skate-data/examples/inspect_skate.rs index 22a56ddd..56cd0792 100644 --- a/crates/skate-data/examples/inspect_skate.rs +++ b/crates/skate-data/examples/inspect_skate.rs @@ -2,21 +2,42 @@ fn main() { let mut failed = false; for path in std::env::args_os().skip(1) { match skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)) { - Ok(m) => println!( - "{} v{}: vertices={} triangles={} collision={} textures={} rails={} doors={} lights={} routes={} spawn={:?} heading={}", - m.name, - m.version, - m.geometry.vertices.len(), - m.geometry.indices.len() / 3, - m.geometry.collision.len(), - m.textures.len(), - m.rails.len(), - m.doors.len(), - m.lights.len(), - m.routes.len(), - m.spawn, - m.heading - ), + Ok(m) => { + println!( + "{} v{}: vertices={} triangles={} collision={} textures={} rails={} doors={} lights={} routes={} spawn={:?} heading={}", + m.name, + m.version, + m.geometry.vertices.len(), + m.geometry.indices.len() / 3, + m.geometry.collision.len(), + m.textures.len(), + m.rails.len(), + m.doors.len(), + m.lights.len(), + m.routes.len(), + m.spawn, + m.heading + ); + for e in &m.extensions { + println!( + "extension {} schema={} decoded_bytes={}", + String::from_utf8_lossy(&e.tag), + e.schema, + e.payload.len() + ); + } + let mut angles = [0_usize; 32]; + let mut native = 0; + for t in &m.geometry.collision { + if let Some(edges) = t.native_edges { + native += 1; + for e in edges { + angles[usize::from(e & 31)] += 1; + } + } + } + println!("native_collision_triangles={native} angle_histogram={angles:?}"); + } Err(e) => { eprintln!("{e}"); failed = true; diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs index 6f812395..dfa9de94 100644 --- a/crates/skate-data/src/lib.rs +++ b/crates/skate-data/src/lib.rs @@ -13,3 +13,4 @@ pub mod physics_skeleton; mod sha256; pub mod state_graph; pub mod skate_map; +pub mod retail_collision; diff --git a/crates/skate-data/src/retail_collision.rs b/crates/skate-data/src/retail_collision.rs new file mode 100644 index 00000000..9fd651da --- /dev/null +++ b/crates/skate-data/src/retail_collision.rs @@ -0,0 +1,203 @@ +//! Embedded RWCM v1 / RWCMSET1 archives. Cluster coordinates are already in +//! world space. Never weld them or replace their feature bytes with adjacency. +//! Layout reference: SK8R15 tools/vanilla_map_extraction/tools/retail_collision_mesh.py. +//! Its signed-16 interpretation is incorrect: native GetVertex82AC79D0 +//! zero-extends the offsets before signed saturating addition to the base. + +#[derive(Clone, Copy, Debug)] +pub struct RetailTriangle { + pub points: [[f32; 3]; 3], + pub edges: Option<[u8; 3]>, + pub surface: u16, + pub group: u16, + pub one_sided: bool, +} + +fn span(data: &[u8], at: usize, n: usize) -> Result<&[u8], String> { + data.get(at..at.checked_add(n).ok_or("RWCM offset overflow")?) + .ok_or_else(|| format!("Truncated RWCM at {at} (need {n})")) +} +fn be16(data: &[u8], at: usize) -> Result { + Ok(u16::from_be_bytes(span(data, at, 2)?.try_into().unwrap())) +} +fn be32(data: &[u8], at: usize) -> Result { + Ok(u32::from_be_bytes(span(data, at, 4)?.try_into().unwrap())) +} +fn le32(data: &[u8], at: &mut usize) -> Result { + let value = u32::from_le_bytes(span(data, *at, 4)?.try_into().unwrap()); + *at += 4; + Ok(value as usize) +} +fn id(data: &[u8], at: &mut usize, width: u8) -> Result { + let value = match width { + 1 => u16::from(span(data, *at, 1)?[0]), + 2 => u16::from_le_bytes(span(data, *at, 2)?.try_into().unwrap()), + _ => return Err(format!("Unsupported RWCM ID width {width}")), + }; + *at += usize::from(width); + Ok(value) +} + +/// Visits complete clusters in archive order, retaining only one decoded +/// cluster at a time. The callback can build its own spatial query index. +pub fn visit_clusters( + data: &[u8], + mut visit: impl FnMut(&str, &[RetailTriangle]) -> Result<(), String>, +) -> Result { + if span(data, 0, 8)? != b"RWCMSET1" { + return Err("Invalid RWCMSET1 magic".into()); + } + let mut at = 8; + let count = le32(data, &mut at)?; + if count == 0 || count > data.len() / 105 { + return Err("Invalid RWCM mesh count".into()); + } + let mut total = 0; + for _ in 0..count { + let size = le32(data, &mut at)?; + if size == 0 || size > 4096 { + return Err("Invalid RWCM mesh name length".into()); + } + let name = + std::str::from_utf8(span(data, at, size)?).map_err(|_| "Invalid RWCM mesh name")?; + at += size; + let size = le32(data, &mut at)?; + let mesh = span(data, at, size)?; + at += size; + total += mesh_clusters(mesh, &mut |triangles| visit(name, triangles)) + .map_err(|e| format!("RWCM mesh {name:?}: {e}"))?; + } + if at != data.len() { + return Err("RWCM archive has trailing bytes".into()); + } + Ok(total) +} + +fn mesh_clusters( + data: &[u8], + visit: &mut impl FnMut(&[RetailTriangle]) -> Result<(), String>, +) -> Result { + span(data, 0, 96)?; + let size = be32(data, 80)? as usize; + if size < 96 || size > data.len() { + return Err("Invalid RWCM mesh size".into()); + } + let data = &data[..size]; + let table = be32(data, 52)? as usize; + let count = be32(data, 64)? as usize; + let granularity = f32::from_bits(be32(data, 56)?); + if !granularity.is_finite() || granularity <= 0. { + return Err("Invalid RWCM granularity".into()); + } + if table < 96 || count == 0 || count > data.len() / 4 { + return Err("Invalid RWCM cluster table".into()); + } + span(data, table, count * 4)?; + let one_sided = be16(data, 60)? & 0x10 != 0; + let mut previous_end = table + count * 4; + let mut total = 0; + for i in 0..count { + let offset = be32(data, table + i * 4)? as usize; + if offset < previous_end { + return Err("Overlapping RWCM clusters".into()); + } + let size = be16(data, offset + 8)? as usize; + if size < 16 { + return Err("Invalid RWCM cluster size".into()); + } + let cluster = span(data, offset, size)?; + previous_end = offset + size; + let triangles = decode_cluster(cluster, granularity, data[62], data[63], one_sided)?; + total += triangles.len(); + visit(&triangles)?; + } + if total != be32(data, 40)? as usize { + return Err("RWCM triangle count mismatch".into()); + } + Ok(total) +} + +fn decode_cluster( + data: &[u8], + granularity: f32, + group_width: u8, + surface_width: u8, + one_sided: bool, +) -> Result, String> { + let unit_count = usize::from(be16(data, 0)?); + let unit_start = (usize::from(be16(data, 4)?) + 1) * 16; + let units = span(data, unit_start, usize::from(be16(data, 2)?))?; + let vertex_data = span(data, 0, unit_start)?; + let mut vertices = Vec::with_capacity(usize::from(data[10])); + for i in 0..usize::from(data[10]) { + let mut p = [0.; 3]; + for axis in 0..3 { + p[axis] = match data[12] { + 0 => f32::from_bits(be32(vertex_data, 16 + i * 16 + axis * 4)?), + 1 => { + let base = be32(vertex_data, 16 + axis * 4)? as i32; + // vmrghh with a zero vector produces unsigned halfwords; + // vaddsws then adds these to the signed base with saturation. + let delta = be16(vertex_data, 28 + i * 6 + axis * 2)?; + let integer = base.saturating_add(i32::from(delta)); + // Float conversion precedes multiplication in GetVertex. + integer as f32 * granularity + } + 2 => (be32(vertex_data, 16 + i * 12 + axis * 4)? as i32) as f32 * granularity, + mode => return Err(format!("Unsupported RWCM vertex compression {mode}")), + }; + if !p[axis].is_finite() { + return Err("Non-finite RWCM vertex".into()); + } + } + vertices.push(p); + } + let mut at = 0; + let mut triangles = Vec::new(); + for _ in 0..unit_count { + let flags = span(units, at, 1)?[0]; + at += 1; + if flags & 15 != 1 { + return Err(format!( + "Unsupported RWCM unit type {}; triangle units are required", + flags & 15 + )); + } + let indices = span(units, at, 3)?; + at += 3; + let mut points = [[0.; 3]; 3]; + for i in 0..3 { + points[i] = *vertices + .get(usize::from(indices[i])) + .ok_or("RWCM missing vertex")?; + } + let edges = if flags & 0x20 != 0 { + let e = span(units, at, 3)?.try_into().unwrap(); + at += 3; + Some(e) + } else { + None + }; + let group = if flags & 0x40 != 0 { + id(units, &mut at, group_width)? + } else { + 0 + }; + let surface = if flags & 0x80 != 0 { + id(units, &mut at, surface_width)? + } else { + 0 + }; + triangles.push(RetailTriangle { + points, + edges, + surface, + group, + one_sided, + }); + } + if at != units.len() { + return Err("RWCM unit stream has trailing bytes".into()); + } + Ok(triangles) +} diff --git a/crates/skate-data/tests/fixtures/retail-collision.rwcmset b/crates/skate-data/tests/fixtures/retail-collision.rwcmset new file mode 100644 index 0000000000000000000000000000000000000000..cabf1b68671bc7a3b1f5076298674a818ef39fb8 GIT binary patch literal 843 zcmWFvclHf-4KZY9U|`?{;^h3?f}+&o;>`R!T>}P2pa>eUZ-5BEXe4Qv>I9(J1iJSJ1U@RcQkoJ{mU0RYl>DjNU* literal 0 HcmV?d00001 diff --git a/crates/skate-data/tests/retail_collision.rs b/crates/skate-data/tests/retail_collision.rs new file mode 100644 index 00000000..f98192c1 --- /dev/null +++ b/crates/skate-data/tests/retail_collision.rs @@ -0,0 +1,88 @@ +use skate_data::retail_collision::visit_clusters; + +const FIXTURE: &[u8] = include_bytes!("fixtures/retail-collision.rwcmset"); + +#[test] +fn compressed_offsets_zero_extend_across_the_sign_boundary() { + let mut bytes = FIXTURE.to_vec(); + // Locate the second fixture mesh, which uses 16-bit offsets. + let mut at = 12; + let mut mesh = 0; + for _ in 0..2 { + let name_len = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + at += 4 + name_len; + let size = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + mesh = at + 4; + at = mesh + size; + } + let cluster = mesh + 160; + let base = [-40000_i32, -32768, i32::MAX - 2]; + for axis in 0..3 { + bytes[cluster + 16 + axis * 4..cluster + 20 + axis * 4] + .copy_from_slice(&base[axis].to_be_bytes()); + } + for (i, raw) in [0x7fff_u16, 0x8000, 0xffff].into_iter().enumerate() { + for axis in 0..3 { + bytes[cluster + 28 + i * 6 + axis * 2..cluster + 30 + i * 6 + axis * 2] + .copy_from_slice(&raw.to_be_bytes()); + } + } + visit_clusters(&bytes, |name, triangles| { + if name == "compression-1" { + assert_eq!( + triangles[0].points, + [ + [-1808.25, -0.25, 536870912.], + [-1808., 0., 536870912.], + [6383.75, 8191.75, 536870912.], + ] + ); + } + Ok(()) + }) + .unwrap(); +} + +#[test] +fn preserves_all_vertex_encodings_and_little_endian_unit_ids() { + let mut clusters = 0; + let total = visit_clusters(FIXTURE, |name, triangles| { + assert_eq!(name, format!("compression-{clusters}")); + assert_eq!(triangles.len(), 1); + let t = triangles[0]; + let x = clusters as f32 * 10.; + assert_eq!(t.points, [[x, 0., 0.], [x, 0., 1.], [x + 1., 0., 0.]]); + assert_eq!(t.edges, Some([0x20, 0x42, 0x9a])); + assert_eq!(t.surface, 0x4321); + assert_eq!(t.group, 0x1234); + assert!(t.one_sided); + clusters += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(total, 3); + assert_eq!(clusters, 3); +} + +#[test] +fn rejects_every_truncated_prefix_and_trailing_bytes() { + for end in 0..FIXTURE.len() { + assert!( + visit_clusters(&FIXTURE[..end], |_, _| Ok(())).is_err(), + "prefix {end}" + ); + } + let mut bytes = FIXTURE.to_vec(); + bytes.push(0); + assert!(visit_clusters(&bytes, |_, _| Ok(())).is_err()); +} + +#[test] +#[ignore = "requires SKATE_RWCM_TEST_PATH pointing to a private archive"] +fn private_archive_decodes_all_clusters() { + let path = std::env::var("SKATE_RWCM_TEST_PATH").unwrap(); + let bytes = std::fs::read(path).unwrap(); + let count = visit_clusters(&bytes, |_, _| Ok(())).unwrap(); + assert!(count > 0); + eprintln!("RWCM triangles decoded: {count}"); +} diff --git a/crates/skate-game/src/camera/world_query.rs b/crates/skate-game/src/camera/world_query.rs index d6e3b460..3243de44 100644 --- a/crates/skate-game/src/camera/world_query.rs +++ b/crates/skate-game/src/camera/world_query.rs @@ -12,7 +12,7 @@ pub(super) fn line(world: &BoardWorld, start: [f32; 4], end: [f32; 4], radius: f let delta = Vector3::new(end[0] - start.x, end[1] - start.y, end[2] - start.z); let mut fraction = f32::MAX; let mut result = no_hit(); - for entry in world.triangles() { + for (_, entry) in world.line_candidates(start, vector(end), radius) { let mut hit = TriangleLineHit { position: Vector3::ZERO, normal: Vector3::ZERO, fraction: 0.0, volume_parameter: [0.0; 3] }; // Zero triangle fatness and zero material exclusion mask are native. diff --git a/crates/skate-game/src/physics/air_trajectory/world.rs b/crates/skate-game/src/physics/air_trajectory/world.rs index be1ce091..d24c694f 100644 --- a/crates/skate-game/src/physics/air_trajectory/world.rs +++ b/crates/skate-game/src/physics/air_trajectory/world.rs @@ -37,7 +37,7 @@ pub(super) fn line( let delta = Vector3::new(end[0] - start.x, end[1] - start.y, end[2] - start.z); let mut nearest = f32::MAX; let mut result = None; - for (index, entry) in world.triangles().iter().enumerate() { + for (index, entry) in world.line_candidates(start, vec3(end), radius) { let mut hit = TriangleLineHit { position: Vector3::ZERO, normal: Vector3::ZERO, @@ -76,7 +76,7 @@ pub(super) fn nearby( let minimum = std::array::from_fn(|i| center[i] - radius); let maximum = std::array::from_fn(|i| center[i] + radius); let mut triangles = Vec::with_capacity(64); - for entry in world.triangles() { + for (_, entry) in world.line_candidates(vec3(center), vec3(center), radius) { let vertices = entry.triangle.vertices.map(lanes); if triangle_box(vertices, minimum, maximum) { triangles.push(vertices); diff --git a/crates/skate-game/src/physics/foot_ik_queries.rs b/crates/skate-game/src/physics/foot_ik_queries.rs index 7edbf147..487571c8 100644 --- a/crates/skate-game/src/physics/foot_ik_queries.rs +++ b/crates/skate-game/src/physics/foot_ik_queries.rs @@ -105,7 +105,7 @@ fn query_trajectory( let direction = Vector3::new(end[0] - start[0], end[1] - start[1], end[2] - start[2]); let mut nearest = f32::MAX; let mut position = None; - for triangle in world.triangles() { + for (_, triangle) in world.line_candidates(origin, vector(end), radius) { let mut geometry = TriangleLineHit { position: Vector3::ZERO, normal: Vector3::ZERO, diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index bbe0349a..b5322d6f 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -21,16 +21,14 @@ use skate_data::skate_map::SkateMap; use std::collections::HashMap; pub(crate) fn validate_runtime(map: &SkateMap) -> Result<(), String> { - if map.geometry.collision.is_empty() { + let archive = retail_archive(map)?; + if map.geometry.collision.is_empty() && archive.is_none() { return Err("SKATE map has no collision geometry".into()); } - if map - .geometry - .collision - .iter() - .any(|t| t.native_edges.is_some()) - { - return Err("SKATE native collision edge codes are preserved by the reader, but the compressed retail-edge decoder is not yet connected to this game's triangle adapter. Refusing to replace them with regenerated edges.".into()); + for triangle in map.geometry.collision.iter().filter(|_| archive.is_none()) { + if let Some(edges) = triangle.native_edges { + decode_native_edges(edges)?; + } } if !map.doors.is_empty() { return Err(format!( @@ -41,6 +39,15 @@ pub(crate) fn validate_runtime(map: &SkateMap) -> Result<(), String> { } for extension in &map.extensions { let tag = String::from_utf8_lossy(&extension.tag); + if extension.tag == *b"RWCM" { + continue; + } + if extension.tag == *b"SKYB" && extension.schema == 1 { + eprintln!( + "SKATE LIMITATION: SKYB retail sky retained; using the map horizon until its shader adapter is available." + ); + continue; + } if extension.tag != *b"WMET" && extension.tag != *b"WCFG" && extension.tag != *b"BMAT" { return Err(format!( "SKATE extension {tag} schema {} is decoded but its runtime adapter is not implemented. Refusing to silently omit potentially required world geometry.", @@ -94,10 +101,122 @@ pub(crate) fn validate_runtime(map: &SkateMap) -> Result<(), String> { Ok(()) } +/// TU3 ClusteredMesh::GetUnitVolumes (82AC8A68): fdivs then fsubs, +/// using the pi-squared word at 822F88D0. This is not acos/angle decoding. +/// Bit 7 denotes an unmatched compiler edge and is not a triangle flag. +fn decode_native_edges(edges: [u8; 3]) -> Result<(u32, [f32; 3]), String> { + let mut flags = 1 | TriangleFeature::ONE_SIDED | TriangleFeature::USE_EDGE_COSINES; + let mut cosines = [0.; 3]; + for (i, code) in edges.into_iter().enumerate() { + let exponent = code & 0x1f; + // The native signed 32-bit shift becomes negative at 28 and zero + // above it. Reject those malformed codes instead of producing NaNs. + if exponent >= 28 { + return Err(format!( + "Invalid SKATE native edge angle code {exponent} at corner {i}" + )); + } + cosines[i] = 1.0 - f32::from_bits(0x411d_e9e7) / ((8_u32 << exponent) as f32); + flags |= u32::from(code & 0x20) << i; + flags |= u32::from(code & 0x40) << (i + 3); + } + Ok((flags, cosines)) +} + +fn retail_archive(map: &SkateMap) -> Result, String> { + let mut archives = map.extensions.iter().filter(|e| e.tag == *b"RWCM"); + let Some(archive) = archives.next() else { + return Ok(None); + }; + if archive.schema != 1 || archives.next().is_some() { + return Err("SKATE requires one RWCM extension with schema 1".into()); + } + Ok(Some(&archive.payload)) +} + +fn retail_collision_world( + archive: &[u8], + material: RetailContactMaterial, +) -> Result { + let mut triangles = Vec::new(); + let mut packed_surfaces = Vec::new(); + let mut meshes = Vec::new(); + let count = skate_data::retail_collision::visit_clusters(archive, |_, cluster| { + // Preserve cluster order and partition further only for group filters. + let mut cursor = 0; + while cursor < cluster.len() { + let group = cluster[cursor].group; + let start = triangles.len(); + while cursor < cluster.len() && cluster[cursor].group == group { + let source = cluster[cursor]; + let (flags, cosines) = match source.edges { + Some(edges) => { + let (mut flags, cosines) = decode_native_edges(edges)?; + flags &= !TriangleFeature::ONE_SIDED; + if source.one_sided { + flags |= TriangleFeature::ONE_SIDED; + } + (flags, cosines) + } + // TriangleVolume ctor82AC7770 retains these defaults when + // the unit has no edge data; the mesh sidedness is not read. + None => (0x1e1, [-1.; 3]), + }; + triangles.push( + WorldTriangle::from_vertices( + source.points.map(|p| Vector3::new(p[0], p[1], p[2])), + material, + u32::from(source.surface), + flags, + cosines, + 0., + ) + .ok_or("Invalid RWCM collision triangle")?, + ); + packed_surfaces.push(source.surface); + cursor += 1; + } + let range = start..triangles.len(); + let bounds = Bounds::from_points( + triangles[range.clone()] + .iter() + .flat_map(|t| t.triangle.vertices), + ) + .ok_or("Invalid RWCM cluster bounds")?; + meshes.push(QueryMesh { + triangle_range: range, + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: bounds, + matching_group: i32::from(group), + pool: QueryPool::Ground, + }); + } + Ok(()) + })?; + eprintln!( + "SKATE_RWCM_READY triangles={count} query_clusters={} source=embedded", + meshes.len() + ); + BoardWorld::with_query_metadata( + triangles, + QueryMetadata { + packed_surfaces, + meshes, + static_edges: vec![], + island_flags: 0, + }, + ) + .map_err(str::to_owned) +} + pub(crate) fn collision_world( map: &SkateMap, material: RetailContactMaterial, ) -> Result { + if let Some(archive) = retail_archive(map)? { + return retail_collision_world(archive, material); + } // Match the reference RW mesh compiler's 1 mm vertex welding and reversed // edge pairing. Triangle diagonals are adjacency, never authored ledges. let mut welded = HashMap::<[i64; 3], usize>::new(); @@ -125,43 +244,52 @@ pub(crate) fn collision_world( let mut cosines = vec![[1.; 3]; vertices.len()]; let mut flags = vec![TriangleFeature::ONE_SIDED | TriangleFeature::USE_EDGE_COSINES | 0xe0; vertices.len()]; - let mut open = HashMap::<(usize, usize), (usize, usize)>::new(); - for (i, ids) in vertices.iter().enumerate() { - for edge in 0..3 { - let (a, b) = (ids[edge], ids[(edge + 1) % 3]); - if let Some((other, oe)) = open.remove(&(b, a)) { - let cosine = normals[i].dot(normals[other]).clamp(-1., 1.); - let orientation = - (positions[b] - positions[a]).dot(normals[i].cross(normals[other])); - // ExtendedEdgeCosine / MakeEdgeCode in rw_collision_mesh.cpp: - // orientation >= -1e-6 is convex; flat edges have no convex bit. - for (ti, e) in [(i, edge), (other, oe)] { - cosines[ti][e] = cosine; - if orientation <= -1.0e-6 || cosine >= 1. { - flags[ti] &= !(0x20 << e); + // Fully native maps need no reconstructed adjacency. Mixed maps still + // include every face when finding neighbors for their authored geometry. + if map + .geometry + .collision + .iter() + .any(|t| t.native_edges.is_none()) + { + let mut open = HashMap::<(usize, usize), (usize, usize)>::new(); + for (i, ids) in vertices.iter().enumerate() { + for edge in 0..3 { + let (a, b) = (ids[edge], ids[(edge + 1) % 3]); + if let Some((other, oe)) = open.remove(&(b, a)) { + let cosine = normals[i].dot(normals[other]).clamp(-1., 1.); + let orientation = + (positions[b] - positions[a]).dot(normals[i].cross(normals[other])); + // ExtendedEdgeCosine / MakeEdgeCode in rw_collision_mesh.cpp: + // orientation >= -1e-6 is convex; flat edges have no convex bit. + for (ti, e) in [(i, edge), (other, oe)] { + cosines[ti][e] = cosine; + if orientation <= -1.0e-6 || cosine >= 1. { + flags[ti] &= !(0x20 << e); + } } + } else { + open.entry((a, b)).or_insert((i, edge)); } - } else { - open.entry((a, b)).or_insert((i, edge)); } } - } - let mut adjacent = vec![Vec::new(); positions.len()]; - for (i, ids) in vertices.iter().enumerate() { - for &v in ids { - adjacent[v].push(i); + let mut adjacent = vec![Vec::new(); positions.len()]; + for (i, ids) in vertices.iter().enumerate() { + for &v in ids { + adjacent[v].push(i); + } } - } - for (v, faces) in adjacent.iter().enumerate() { - let reference = normals[faces[0]]; - if faces - .iter() - .all(|&i| (reference.dot(normals[i]) - 1.).abs() <= 0.01) - { - for &i in faces { - for corner in 0..3 { - if vertices[i][corner] == v { - flags[i] |= 0x200 << corner; + for (v, faces) in adjacent.iter().enumerate() { + let reference = normals[faces[0]]; + if faces + .iter() + .all(|&i| (reference.dot(normals[i]) - 1.).abs() <= 0.01) + { + for &i in faces { + for corner in 0..3 { + if vertices[i][corner] == v { + flags[i] |= 0x200 << corner; + } } } } @@ -170,6 +298,9 @@ pub(crate) fn collision_world( let mut triangles = Vec::with_capacity(vertices.len()); let mut packed_surfaces = Vec::with_capacity(vertices.len()); for (i, source) in map.geometry.collision.iter().enumerate() { + if let Some(edges) = source.native_edges { + (flags[i], cosines[i]) = decode_native_edges(edges)?; + } let m = &map.materials[source.material as usize - 1]; // Exact EncodeRwSurfaceId mapping from the reference native adapter. packed_surfaces.push((m.audio | (m.physics << 7) | (m.pattern << 12)) as u16); @@ -440,6 +571,76 @@ mod tests { } } #[test] + fn native_edges_override_generated_adjacency() { + let mut map = demo(); + map.geometry.collision[0].native_edges = Some([0x20, 0x42, 0x9a]); + validate_runtime(&map).unwrap(); + let world = collision_world(&map, material()).unwrap(); + let f = world.triangles()[0].triangle.feature; + assert!(f.edge_convex(0)); + assert!(!f.edge_convex(1)); + assert!(!f.edge_convex(2)); + assert!(!f.vertex_disabled(0)); + assert!(f.vertex_disabled(1)); + assert!(!f.vertex_disabled(2)); + assert_eq!(f.edge_cosines[0].to_bits(), 0xbe6f4f38); + assert_eq!(f.edge_cosines[1].to_bits(), 0x3f310b0c); + assert_eq!(f.edge_cosines[2], 1.); + assert!(decode_native_edges([31, 0, 0]).is_err()); + assert_eq!(decode_native_edges([0, 2, 26]).unwrap().1, f.edge_cosines); + } + #[test] + fn embedded_archive_is_authoritative_and_preserves_cluster_metadata() { + let mut map = demo(); + map.geometry.collision.clear(); + map.extensions.push(skate_data::skate_map::Extension { + tag: *b"RWCM", + schema: 1, + payload: include_bytes!("../../skate-data/tests/fixtures/retail-collision.rwcmset") + .to_vec(), + }); + validate_runtime(&map).unwrap(); + let world = collision_world(&map, material()).unwrap(); + assert_eq!(world.triangles().len(), 3); + let metadata = world.query_metadata().unwrap(); + assert_eq!(metadata.meshes.len(), 3); + assert_eq!(metadata.meshes[1].matching_group, 0x1234); + assert_eq!(metadata.packed_surfaces, vec![0x4321; 3]); + assert_eq!(world.triangles()[1].triangle.vertices[0].x, 10.); + } + #[test] + fn native_unit_without_edge_data_keeps_constructor_defaults() { + let mut archive = + include_bytes!("../../skate-data/tests/fixtures/retail-collision.rwcmset").to_vec(); + let name_len = u32::from_le_bytes(archive[12..16].try_into().unwrap()) as usize; + let cluster = 16 + name_len + 4 + 160; + archive[cluster + 2..cluster + 4].copy_from_slice(&8_u16.to_be_bytes()); + archive[cluster + 80] = 0xc1; + archive[cluster + 84..cluster + 88].copy_from_slice(&[0x34, 0x12, 0x21, 0x43]); + let world = retail_collision_world(&archive, material()).unwrap(); + let f = world.triangles()[0].triangle.feature; + assert_eq!(f.flags, 0x1e1); + assert_eq!(f.edge_cosines, [-1.; 3]); + } + #[test] + #[ignore = "requires SKATE_MAP_TEST_PATH pointing to a private map"] + fn private_map_builds_collision_world() { + let path = std::env::var("SKATE_MAP_TEST_PATH").unwrap(); + let map = SkateMap::load(std::path::Path::new(&path)).unwrap(); + validate_runtime(&map).unwrap(); + let world = collision_world(&map, material()).unwrap(); + let [x, y, z] = map.spawn; + let hit = world + .query_thin_line(Vector3::new(x, y + 1., z), Vector3::new(x, y - 10., z)) + .unwrap(); + assert!(hit.is_some(), "spawn has no supporting collision"); + eprintln!( + "Private map world: {} triangles, spawn hit {:?}", + world.triangles().len(), + hit + ); + } + #[test] fn map_collision_uses_separate_geometry_and_surface_metadata() { let mut map = demo(); map.geometry.vertices[0].position = [1000.; 3]; diff --git a/docs/SKATE-MAPS.md b/docs/SKATE-MAPS.md index 831c23ed..67d68614 100644 --- a/docs/SKATE-MAPS.md +++ b/docs/SKATE-MAPS.md @@ -28,8 +28,9 @@ error and do not silently load the default level instead. | Doors | Full records and geometry parsed. Launch rejected if present: no hinged rigid-body controller exists in the imported game. | | NPC routes / area lights | Parsed and retained, but no runtime adapter. Startup warnings report them. | | Retail shader definitions | Retained as original definition records. Portable material fields render; original shader families/decal channels are not recreated. | -| Native collision edge codes | Retained. Launch rejected rather than replacing these with generated edges; the retail packed-edge decoder still needs connecting. | -| Extensions | Decoded and retained. WMET/WCFG/BMAT currently report their limitations. Other extensions reject launch because they may contain required world geometry/controllers. | +| Native collision edge codes | Decoded with the recovered TU3 formula and convex-edge/disabled-corner flags. Native codes take precedence over generated adjacency. | +| Embedded `RWCM` collision | Schema 1 `RWCMSET1` archives take precedence over portable collision. Original triangle coordinates, surface/group IDs, sidedness and cluster order are retained, without welding. Float, unsigned 16-bit offsets with signed bases, and signed 32-bit vertices are supported. Currently supports triangle units; quad/list units report an explicit error. | +| Extensions | Decoded and retained. WMET/WCFG/BMAT report their limitations. SKYB schema 1 uses the map horizon with an explicit warning; the retail sky shader is not implemented. Other extensions reject launch because they may contain required world geometry/controllers. | | Empty texture placeholders | Parsed, but launch requires embedded textures. | This is a map-loading integration, not a claim of full SK8 Engine renderer or @@ -50,6 +51,61 @@ The user's v14 `blender_bake_showcase.skate` also parses (15,072 visual / 1,028 collision triangles), but contains a hinged door and is not a supported playable map yet. It has not been modified to remove that feature. +### New San Vanelona embedded collision fix + +`Skate_2_New_San_Vanelona.skate` is v12 and includes `WMET`, `SKYB`, and +`RWCM`. Its former `.spawn-collision.rwcmset` sidecar is already embedded: +101,642,678 decoded bytes, 792 meshes, 21,503 clusters and 4,628,459 triangles. +The portable block has 4,615,663 triangles and is not substituted for that archive. +No separate sidecar is needed; external sidecar discovery is not implemented. + +The adapter builds query ranges from the native clusters. Conservative bounds +filter wheel, camera, foot, trajectory and primitive-contact candidates while +preserving triangle order, narrow-phase tests and solver behavior. The native +serialized KD-tree is not executed; host cluster bounds provide the acceleration. + +Validation on 2026-09-06: the actual package builds its complete collision +world and a spawn query finds supporting ground. The rebuilt staged executable +rendered the map and skater and completed 175 physics ticks with 25 contacts. +Local evidence: `logs/san-van-collision-test.log`, `logs/san-van-startup.png` +and `logs/san-van-startup.input.txt`. This is a startup check, not a complete +gameplay/performance or physics-parity verdict. Retail sky and grind engagement +remain the separate limitations listed above. + +Edge decoding was checked against `ClusteredMesh::GetUnitVolumes` at +`82AC8A68` and its PPC instruction listing. Each low-five-bit exponent decodes +as `1 - f32::from_bits(0x411de9e7) / (8 << exponent)` with separate f32 divide +and subtract. Bits 5 and 6 map to convex edges and disabled vertices; +bit 7 is not published as a triangle feature flag. Evidence is retained in +`logs/edge-decode.json` and `logs/edge-evidence.json`; the latter records the +reference input hash. Reader layout also follows the extraction tool's +`retail_collision_mesh.py` and the runtime's `LoadRetailCollisionArchive`. + +#### Correction: compressed vertex signedness + +The first integration copied an incorrect signed-halfword interpretation from +`retail_collision_mesh.py`. Native `GetVertex` at `82AC79D0` actually uses +`vmrghh` with a zero vector (`82AC7A2C`), then signed saturating addition to +the base (`vaddsws`, `82AC7A30`). The offset is therefore **unsigned u16**; +values `0x8000..0xffff` must not become negative. Rust now follows those +operations, including conversion to f32 before multiplication by granularity. + +An audit of this map found 131,616 affected compressed vertices used by +211,699 triangles. The incorrect decode put 38,311 vertices outside their +source mesh bounds; the corrected decode puts none outside (0.01 m audit +tolerance). At this map's 1 mm granularity, sign extension displaced an +affected coordinate by approximately 65.536 m, producing stretched or +misplaced collision faces. The embedded archive does not need re-extraction +for this defect. The separate Python extractor still has the signed-offset +bug and should be corrected before regenerating portable geometry. + +Evidence: `logs/collision-vertex-native.json` records original instruction +bytes and the reference binary hash. `logs/collision-signedness-audit.json` +records counts and before/after coordinates. Regression coverage includes +offsets `0x7fff`, `0x8000`, `0xffff`, negative bases, and signed saturation. +The earlier startup capture only established that the map loaded; it did not +detect this geometry defect. + ## Source evidence Layout, versions and compression were checked against the user's local: @@ -70,6 +126,8 @@ All reference files remain outside this repository and were read only. ```text cargo test -p skate-data --test skate_map --locked cargo test -p skate-game skate_world::tests --locked +cargo test -p skate-data --test retail_collision --locked +cargo test -p skate-core --lib board_world --locked cargo run -p skate-data --example inspect_skate -- path/to/map.skate ```