From 792056ca8e88a97406251dac19aa86a4e6cda132 Mon Sep 17 00:00:00 2001 From: chazmm Date: Sun, 6 Sep 2026 04:11:25 +0100 Subject: [PATCH] Add SKATE map reader, static world adapter, and map launcher --- .gitignore | 1 + Cargo.lock | 31 + Launch.ps1 | 10 +- PLAY-MAP.bat | 8 + PLAY.bat | 6 +- README.md | 10 + Select-Map.ps1 | 13 + crates/skate-data/Cargo.toml | 2 + crates/skate-data/examples/inspect_skate.rs | 29 + crates/skate-data/src/lib.rs | 1 + crates/skate-data/src/skate_map.rs | 625 ++++++++++++++++++++ crates/skate-data/tests/skate_map.rs | 225 +++++++ crates/skate-game/Cargo.toml | 1 + crates/skate-game/src/config.rs | 8 +- crates/skate-game/src/main.rs | 9 +- crates/skate-game/src/physics.rs | 17 +- crates/skate-game/src/skate_world.rs | 503 ++++++++++++++++ crates/skate-game/src/world.rs | 6 + docs/SKATE-MAPS.md | 81 +++ maps/format-demo.skate | Bin 0 -> 737 bytes tools/make_skate_demo.py | 50 ++ 21 files changed, 1630 insertions(+), 6 deletions(-) create mode 100644 PLAY-MAP.bat create mode 100644 Select-Map.ps1 create mode 100644 crates/skate-data/examples/inspect_skate.rs create mode 100644 crates/skate-data/src/skate_map.rs create mode 100644 crates/skate-data/tests/skate_map.rs create mode 100644 crates/skate-game/src/skate_world.rs create mode 100644 docs/SKATE-MAPS.md create mode 100644 maps/format-demo.skate create mode 100644 tools/make_skate_demo.py diff --git a/.gitignore b/.gitignore index 96310228..a859881f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /bin/ /logs/ /assets/private/ +/maps/private/ diff --git a/Cargo.lock b/Cargo.lock index 680934d4..e3c5696e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4370,9 +4370,11 @@ version = "0.1.0" name = "skate-data" version = "0.1.0" dependencies = [ + "flate2", "serde", "serde_json", "skate-core", + "zstd", ] [[package]] @@ -4380,6 +4382,7 @@ name = "skate-game" version = "0.1.0" dependencies = [ "bevy", + "half", "skate-core", "skate-data", ] @@ -5935,3 +5938,31 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Launch.ps1 b/Launch.ps1 index b2fdda15..2d4592e1 100644 --- a/Launch.ps1 +++ b/Launch.ps1 @@ -1,3 +1,4 @@ +param([string]$Map) $ErrorActionPreference = 'Stop' Push-Location $PSScriptRoot try { @@ -9,8 +10,15 @@ try { Write-Host "Log: $log" $errorLog = [System.IO.Path]::ChangeExtension($log, 'stderr.log') $assetArgument = '"' + (Join-Path $PSScriptRoot 'assets') + '"' + $gameArguments = @('--assets', $assetArgument) + if ($Map) { + $mapPath = (Resolve-Path -LiteralPath $Map).Path + if ([System.IO.Path]::GetExtension($mapPath) -ine '.skate') { throw 'Select a .skate map file.' } + $gameArguments += @('--map', ('"' + $mapPath + '"')) + Write-Host "Map: $mapPath" + } $game = Start-Process -FilePath $executable -WorkingDirectory $PSScriptRoot ` - -ArgumentList @('--assets', $assetArgument) -NoNewWindow -Wait -PassThru ` + -ArgumentList $gameArguments -NoNewWindow -Wait -PassThru ` -RedirectStandardOutput $log -RedirectStandardError $errorLog if ($game.ExitCode -ne 0) { Get-Content -LiteralPath $errorLog -Tail 30 diff --git a/PLAY-MAP.bat b/PLAY-MAP.bat new file mode 100644 index 00000000..bfa6774b --- /dev/null +++ b/PLAY-MAP.bat @@ -0,0 +1,8 @@ +@echo off +cd /d "%~dp0" +if "%~1"=="" ( + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Select-Map.ps1" +) else ( + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Select-Map.ps1" -Map "%~1" +) +if errorlevel 1 pause diff --git a/PLAY.bat b/PLAY.bat index 371a973d..d1990da6 100644 --- a/PLAY.bat +++ b/PLAY.bat @@ -1,4 +1,8 @@ @echo off cd /d "%~dp0" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Launch.ps1" +if "%~1"=="" ( + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Launch.ps1" +) else ( + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Launch.ps1" -Map "%~1" +) if errorlevel 1 pause diff --git a/README.md b/README.md index 56e9ec3f..d5e19534 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,16 @@ Double-click **PLAY.bat** to launch. Use an XInput-compatible controller; **Esc** exits. Startup selects the supplied **Easy** profile. +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. + +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. + 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 diff --git a/Select-Map.ps1 b/Select-Map.ps1 new file mode 100644 index 00000000..62b5615d --- /dev/null +++ b/Select-Map.ps1 @@ -0,0 +1,13 @@ +param([string]$Map) +$ErrorActionPreference = 'Stop' +if (-not $Map) { + $maps = @(Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot 'maps') -Filter '*.skate' -File -Recurse | Sort-Object FullName) + if ($maps.Count -eq 0) { throw 'Put a .skate file in maps/private, or drag one onto PLAY-MAP.bat.' } + for ($i = 0; $i -lt $maps.Count; $i++) { Write-Host "$($i + 1). $($maps[$i].Name)" } + $choice = Read-Host 'Map number (Enter cancels)' + if (-not $choice) { exit 0 } + $selected = 0 + if (-not [int]::TryParse($choice, [ref]$selected) -or $selected -lt 1 -or $selected -gt $maps.Count) { throw 'Invalid map number.' } + $Map = $maps[$selected - 1].FullName +} +& (Join-Path $PSScriptRoot 'Launch.ps1') -Map $Map diff --git a/crates/skate-data/Cargo.toml b/crates/skate-data/Cargo.toml index c811da07..4e24f914 100644 --- a/crates/skate-data/Cargo.toml +++ b/crates/skate-data/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true publish.workspace = true [dependencies] +flate2 = "=1.1.10" +zstd = "=0.13.3" skate-core = { path = "../skate-core" } serde.workspace = true serde_json.workspace = true diff --git a/crates/skate-data/examples/inspect_skate.rs b/crates/skate-data/examples/inspect_skate.rs new file mode 100644 index 00000000..22a56ddd --- /dev/null +++ b/crates/skate-data/examples/inspect_skate.rs @@ -0,0 +1,29 @@ +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 + ), + Err(e) => { + eprintln!("{e}"); + failed = true; + } + } + } + if failed { + std::process::exit(1); + } +} diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs index 562e5065..3e8ab2cc 100644 --- a/crates/skate-data/src/lib.rs +++ b/crates/skate-data/src/lib.rs @@ -11,3 +11,4 @@ pub mod input_config; pub mod physics_skeleton; mod sha256; pub mod state_graph; +pub mod skate_map; diff --git a/crates/skate-data/src/skate_map.rs b/crates/skate-data/src/skate_map.rs new file mode 100644 index 00000000..24facbd6 --- /dev/null +++ b/crates/skate-data/src/skate_map.rs @@ -0,0 +1,625 @@ +//! Little-endian SKATE01..14 reader. Layout follows the supplied +//! SK8R15/Source/tools/blender_owned_map/SKATE_FORMAT.md and +//! owned/world/src/owned_map_package.cpp. No geometry or physics is inferred. +use std::{io::Read, path::Path}; + +#[derive(Debug)] +pub struct SkateMap { + pub version: u8, + pub name: String, + pub spawn: [f32; 3], + pub heading: f32, + /// Authored environment fields in wire order (12, 14, or 45 floats). + pub environment: Vec, + pub materials: Vec, + pub textures: Vec, + pub geometry: Geometry, + pub rails: Vec, + pub doors: Vec, + pub lights: Vec, + pub routes: Vec, + pub extensions: Vec, +} +#[derive(Debug)] +pub struct Material { + pub name: String, + pub flags: u32, + pub friction: f32, + pub restitution: f32, + pub color: [f32; 3], + pub roughness: f32, + pub emissive: f32, + /// Albedo, indirect, normal, ORM, emissive. One-based, zero absent. + pub textures: [u32; 5], + pub indirect_strength: f32, + pub alpha_mode: u32, + pub alpha_cutoff: f32, + pub audio: u32, + pub physics: u32, + pub pattern: u32, + pub depth_layer: Option, + /// Complete v12+ retail definition bytes; distinct from portable PBR fields. + pub retail_definition: Option>, +} +#[derive(Debug)] +pub struct Texture { + pub name: String, + pub width: u32, + pub height: u32, + pub color_space: u32, + pub rgba: Vec, +} +#[derive(Debug, Clone)] +pub struct Vertex { + pub position: [f32; 3], + pub normal: [f32; 3], + pub uv: [f32; 2], + pub lightmap_uv: [f32; 2], + pub material: u32, + pub decal_uv: Option<[f32; 2]>, + pub tangent_frame: Option<[u8; 4]>, +} +#[derive(Debug)] +pub struct Collision { + pub points: [[f32; 3]; 3], + pub surface: u32, + pub material: u32, + pub native_edges: Option<[u8; 3]>, +} +#[derive(Debug)] +pub struct Geometry { + pub vertices: Vec, + pub indices: Vec, + pub collision: Vec, +} +#[derive(Debug)] +pub struct Rail { + pub name: String, + pub closed: bool, + pub points: Vec<[f32; 3]>, + pub native: Option>, +} +#[derive(Debug)] +pub struct Door { + pub name: String, + /// Hinge position/axis, closed width/depth axes, local min/max. + pub frame: [[f32; 3]; 6], + /// Minimum/maximum/initial angle, mass, damping. + pub motion: [f32; 5], + /// Spring, max angular speed, contact impulse scale (absent in v4). + pub response: Option<[f32; 3]>, + pub friction: f32, + pub restitution: f32, + pub surface: u32, + pub geometry: Geometry, +} +#[derive(Debug)] +pub struct Light { + pub name: String, + pub kind: u32, + pub position: [f32; 3], + pub direction: [f32; 3], + pub color: [f32; 3], + pub intensity: f32, + pub range: f32, + pub radius: f32, + pub inner_cos: f32, + pub outer_cos: f32, +} +#[derive(Debug)] +pub struct Route { + pub rail: Rail, + pub skaters: u32, + pub speed: f32, + pub spacing: f32, +} +#[derive(Debug)] +pub struct Extension { + pub tag: [u8; 4], + pub schema: u32, + pub payload: Vec, +} + +struct Reader<'a> { + bytes: &'a [u8], + at: usize, +} +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8], String> { + let end = self.at.checked_add(n).ok_or("SKATE offset overflow")?; + let result = self + .bytes + .get(self.at..end) + .ok_or_else(|| format!("Truncated SKATE at byte {} (need {n})", self.at))?; + self.at = end; + Ok(result) + } + fn u(&mut self) -> Result { + Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) + } + fn f(&mut self) -> Result { + let value = f32::from_bits(self.u()?); + if !value.is_finite() { + return Err(format!("Non-finite SKATE float at {}", self.at - 4)); + } + Ok(value) + } + fn floats(&mut self) -> Result<[f32; N], String> { + let mut result = [0.; N]; + for v in &mut result { + *v = self.f()?; + } + Ok(result) + } + fn string(&mut self) -> Result { + let n = self.u()? as usize; + String::from_utf8(self.take(n)?.to_vec()).map_err(|_| "Invalid SKATE UTF-8".into()) + } + fn check_count(&self, count: u32, minimum_bytes: usize) -> Result<(), String> { + if count as usize > (self.bytes.len() - self.at) / minimum_bytes { + return Err("SKATE count exceeds remaining data".into()); + } + Ok(()) + } + fn points(&mut self) -> Result, String> { + let n = self.u()?; + self.check_count(n, 12)?; + (0..n).map(|_| self.floats()).collect() + } + fn stored(&mut self, expected: usize) -> Result, String> { + if expected > 2_147_483_648 { + return Err("SKATE decoded block exceeds 2 GiB reader limit".into()); + } + let method = self.u()?; + let n = self.u()? as usize; + let bytes = self.take(n)?; + let decoded = match method { + 0 => bytes.to_vec(), + 1 => { + let mut result = Vec::new(); + let mut decoder = flate2::read::ZlibDecoder::new(bytes); + decoder + .by_ref() + .take(expected as u64 + 1) + .read_to_end(&mut result) + .map_err(|e| format!("SKATE DEFLATE: {e}"))?; + if decoder.total_in() as usize != bytes.len() { + return Err("SKATE compressed block has unused input".into()); + } + result + } + 2 => { + let mut result = Vec::new(); + zstd::stream::read::Decoder::new(bytes) + .map_err(|e| e.to_string())? + .take(expected as u64 + 1) + .read_to_end(&mut result) + .map_err(|e| format!("SKATE Zstandard: {e}"))?; + result + } + _ => return Err(format!("Unsupported SKATE storage method {method}")), + }; + if decoded.len() != expected { + return Err("SKATE decoded block size mismatch".into()); + } + Ok(decoded) + } + fn geometry( + &mut self, + counts: [u32; 3], + materials: usize, + version: u8, + ) -> Result { + let [nv, ni, nc] = counts; + self.check_count(nv, if version >= 12 { 56 } else { 44 })?; + let vertices: Vec = (0..nv) + .map(|_| { + Ok(Vertex { + position: self.floats()?, + normal: self.floats()?, + uv: self.floats()?, + lightmap_uv: self.floats()?, + material: self.u()?, + decal_uv: if version >= 12 { + Some(self.floats()?) + } else { + None + }, + tangent_frame: if version >= 12 { + Some(self.take(4)?.try_into().unwrap()) + } else { + None + }, + }) + }) + .collect::>()?; + self.check_count(ni, 4)?; + let indices: Vec = (0..ni).map(|_| self.u()).collect::>()?; + self.check_count(nc, if version >= 11 { 48 } else { 44 })?; + let collision: Vec = (0..nc) + .map(|_| { + let points = [self.floats()?, self.floats()?, self.floats()?]; + let surface = self.u()?; + let material = self.u()?; + let native_edges = if version >= 11 { + let bytes = self.take(4)?; + if bytes[3] != 0 { + Some(bytes[..3].try_into().unwrap()) + } else { + None + } + } else { + None + }; + Ok(Collision { + points, + surface, + material, + native_edges, + }) + }) + .collect::>()?; + let valid_material = |id: u32| id > 0 && id as usize <= materials; + if vertices.iter().any(|v| !valid_material(v.material)) + || collision + .iter() + .any(|v| !valid_material(v.material) || v.surface == 0) + { + return Err("SKATE invalid material/surface reference".into()); + } + if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= vertices.len()) { + return Err("SKATE invalid triangle indices".into()); + } + for tri in indices.chunks_exact(3) { + if tri + .iter() + .any(|&i| vertices[i as usize].material != vertices[tri[0] as usize].material) + { + return Err("SKATE triangle mixes materials".into()); + } + } + for tri in &collision { + let [a, b, c] = tri.points; + let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let cross = [ + u[1] * v[2] - u[2] * v[1], + u[2] * v[0] - u[0] * v[2], + u[0] * v[1] - u[1] * v[0], + ]; + let area = cross.iter().map(|x| x * x).sum::(); + if !area.is_finite() || area <= 0. { + return Err("SKATE degenerate collision triangle".into()); + } + } + Ok(Geometry { + vertices, + indices, + collision, + }) + } +} +impl SkateMap { + pub fn load(path: &Path) -> Result { + let data = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + Self::parse(&data).map_err(|e| format!("{}: {e}", path.display())) + } + pub fn parse(data: &[u8]) -> Result { + let mut r = Reader { bytes: data, at: 0 }; + let magic = r.take(8)?; + if &magic[..5] != b"SKATE" + || magic[7] != 0 + || !magic[5].is_ascii_digit() + || !magic[6].is_ascii_digit() + { + return Err("Invalid SKATE magic/version".into()); + } + let version = (magic[5] - b'0') * 10 + magic[6] - b'0'; + if !(1..=14).contains(&version) { + return Err("Unsupported SKATE version: reader supports 01 through 14; newer packages require an update".into()); + } + if r.u()? != 0x12345678 { + return Err("Invalid SKATE endian marker".into()); + } + let name = r.string()?; + if name.is_empty() { + return Err("Empty SKATE map name".into()); + } + let spawn = r.floats()?; + let heading = r.f()?; + let ne = if version >= 6 { + 45 + } else if version >= 3 { + 14 + } else { + 12 + }; + let environment = (0..ne).map(|_| r.f()).collect::>()?; + let mut counts = [0; 9]; + let n = if version >= 8 { + 9 + } else if version >= 7 { + 8 + } else if version >= 4 { + 7 + } else { + 6 + }; + for c in &mut counts[..n] { + *c = r.u()?; + } + r.check_count(counts[0], if version >= 2 { 80 } else { 48 })?; + let mut materials = Vec::new(); + for _ in 0..counts[0] { + let mut m = Material { + name: r.string()?, + flags: r.u()?, + friction: r.f()?, + restitution: r.f()?, + color: r.floats()?, + roughness: r.f()?, + emissive: r.f()?, + textures: [r.u()?, r.u()?, 0, 0, 0], + indirect_strength: r.f()?, + alpha_mode: 0, + alpha_cutoff: 0.5, + audio: 3, + physics: 1, + pattern: 0, + depth_layer: None, + retail_definition: None, + }; + if version >= 2 { + for id in &mut m.textures[2..] { + *id = r.u()?; + } + m.alpha_mode = r.u()?; + m.alpha_cutoff = r.f()?; + m.audio = r.u()?; + m.physics = r.u()?; + m.pattern = r.u()?; + } + if version >= 13 { + let layer = r.u()?; + if layer > 3 { + return Err("Invalid SKATE depth layer".into()); + } + m.depth_layer = Some(layer); + } + if version >= 12 && r.u()? != 0 { + let start = r.at; + r.take(16)?; + r.string()?; + r.take(8)?; + let bindings = r.u()?; + r.check_count(bindings, 20)?; + for _ in 0..bindings { + r.string()?; + let id = r.u()?; + let uv = r.u()?; + let au = r.u()?; + let av = r.u()?; + if id > counts[1] || uv > 2 || au > 1 || av > 1 { + return Err("Invalid SKATE retail texture binding".into()); + } + } + let parameters = r.u()?; + r.check_count(parameters, 8)?; + for _ in 0..parameters { + r.string()?; + let n = r.u()?; + r.check_count(n, 4)?; + for _ in 0..n { + r.string()?; + } + } + r.string()?; + m.retail_definition = Some(r.bytes[start..r.at].to_vec()); + } + if m.name.is_empty() + || m.textures.iter().any(|&id| id > counts[1]) + || m.alpha_mode > 2 + || m.audio > 127 + || m.physics > 13 + || m.pattern > 15 + || m.friction < 0. + || m.restitution < 0. + || !(0.0..=1.0).contains(&m.roughness) + || !(0.0..=1.0).contains(&m.alpha_cutoff) + || m.indirect_strength < 0. + { + return Err("Invalid SKATE material".into()); + } + materials.push(m); + } + r.check_count(counts[1], 20)?; + let mut textures = Vec::new(); + for _ in 0..counts[1] { + let name = r.string()?; + let width = r.u()?; + let height = r.u()?; + let color_space = r.u()?; + if (width == 0) != (height == 0) || width > 16384 || height > 16384 || color_space > 1 { + return Err("Invalid SKATE embedded texture".into()); + } + let expected = width as usize * height as usize * 4; + let rgba = if version >= 9 { + r.stored(expected)? + } else { + let bytes = r.u()? as usize; + if bytes != expected { + return Err("Invalid SKATE embedded texture size".into()); + } + r.take(bytes)?.to_vec() + }; + textures.push(Texture { + name, + width, + height, + color_space, + rgba, + }); + } + let geometry_counts = [counts[2], counts[3], counts[4]]; + let geometry = if version >= 9 { + let mut bytes = r.stored(counts[2] as usize * if version >= 12 { 56 } else { 44 })?; + bytes.extend(r.stored(counts[3] as usize * 4)?); + bytes.extend(r.stored(counts[4] as usize * if version >= 11 { 48 } else { 44 })?); + Reader { + bytes: &bytes, + at: 0, + } + .geometry(geometry_counts, materials.len(), version)? + } else { + r.geometry(geometry_counts, materials.len(), version)? + }; + if materials.is_empty() + || geometry.vertices.is_empty() + || geometry.indices.is_empty() + || geometry.collision.is_empty() + { + return Err("SKATE requires materials, render geometry, and collision".into()); + } + r.check_count(counts[5], 12)?; + let mut rails = Vec::new(); + for _ in 0..counts[5] { + let name = r.string()?; + let closed = r.u()? != 0; + let representation = if version >= 10 { r.u()? } else { 0 }; + let (points, native) = match representation { + 0 => (r.points()?, None), + 1 => { + let start = r.at; + r.take(24)?; + let n = r.u()?; + r.check_count(n, 120)?; + r.take(n as usize * 120)?; + (vec![], Some(r.bytes[start..r.at].to_vec())) + } + _ => return Err("Unsupported SKATE rail representation".into()), + }; + rails.push(Rail { + name, + closed, + points, + native, + }); + } + r.check_count(counts[6], 120)?; + let mut doors = Vec::new(); + for _ in 0..counts[6] { + let name = r.string()?; + let frame = [ + r.floats()?, + r.floats()?, + r.floats()?, + r.floats()?, + r.floats()?, + r.floats()?, + ]; + let motion = r.floats()?; + let response = if version >= 5 { + Some(r.floats()?) + } else { + None + }; + let friction = r.f()?; + let restitution = r.f()?; + let surface = r.u()?; + let counts = [r.u()?, r.u()?, r.u()?]; + doors.push(Door { + name, + frame, + motion, + response, + friction, + restitution, + surface, + geometry: r.geometry(counts, materials.len(), version)?, + }); + } + r.check_count(counts[7], 64)?; + let mut lights = Vec::new(); + for _ in 0..counts[7] { + let light = Light { + name: r.string()?, + kind: r.u()?, + position: r.floats()?, + direction: r.floats()?, + color: r.floats()?, + intensity: r.f()?, + range: r.f()?, + radius: r.f()?, + inner_cos: r.f()?, + outer_cos: r.f()?, + }; + if light.kind > 2 + || light.range <= 0. + || light.intensity < 0. + || light.radius < 0. + || !(-1.0..=1.0).contains(&light.inner_cos) + || !(-1.0..=1.0).contains(&light.outer_cos) + { + return Err("Invalid SKATE light".into()); + } + lights.push(light); + } + r.check_count(counts[8], 24)?; + let mut routes = Vec::new(); + for _ in 0..counts[8] { + let name = r.string()?; + let closed = r.u()? != 0; + let skaters = r.u()?; + let speed = r.f()?; + let spacing = r.f()?; + let points = r.points()?; + routes.push(Route { + rail: Rail { + name, + closed, + points, + native: None, + }, + skaters, + speed, + spacing, + }); + } + let mut extensions = Vec::new(); + if version >= 12 { + let n = r.u()?; + r.check_count(n, 20)?; + for _ in 0..n { + let tag = r.take(4)?.try_into().unwrap(); + let schema = r.u()?; + let bytes = r.u()? as usize; + extensions.push(Extension { + tag, + schema, + payload: r.stored(bytes)?, + }); + } + } + if r.at != data.len() { + return Err(format!( + "SKATE contains {} unexplained trailing bytes", + data.len() - r.at + )); + } + Ok(Self { + version, + name, + spawn, + heading, + environment, + materials, + textures, + geometry, + rails, + doors, + lights, + routes, + extensions, + }) + } +} diff --git a/crates/skate-data/tests/skate_map.rs b/crates/skate-data/tests/skate_map.rs new file mode 100644 index 00000000..d48ca217 --- /dev/null +++ b/crates/skate-data/tests/skate_map.rs @@ -0,0 +1,225 @@ +use skate_data::skate_map::SkateMap; + +fn u(b: &mut Vec, value: u32) { + b.extend(value.to_le_bytes()); +} +fn f(b: &mut Vec, value: f32) { + u(b, value.to_bits()); +} +fn s(b: &mut Vec, value: &str) { + u(b, value.len() as u32); + b.extend(value.as_bytes()); +} +fn fs(b: &mut Vec, values: &[f32]) { + for &v in values { + f(b, v); + } +} +fn fixture(version: u8) -> Vec { + let mut b = format!("SKATE{version:02}\0").into_bytes(); + u(&mut b, 0x12345678); + s(&mut b, "Fixture"); + fs(&mut b, &[2., 0., 3., 1.]); + fs(&mut b, &[0.; 9]); + fs(&mut b, &[0., 12., 0.]); + if version >= 3 { + fs(&mut b, &[17., 0.]); + } + if version >= 6 { + fs(&mut b, &[0.; 31]); + } + for n in [1, 1, 3, 3, 1, 1] { + u(&mut b, n); + } + if version >= 4 { + u(&mut b, 0); + } + if version >= 7 { + u(&mut b, 1); + } + if version >= 8 { + u(&mut b, 1); + } + s(&mut b, "Surface"); + u(&mut b, 1); + fs(&mut b, &[0.6, 0.1, 1., 0.5, 0.25, 0.8, 0.]); + u(&mut b, 1); + u(&mut b, 0); + f(&mut b, 1.); + if version >= 2 { + for n in [0, 0, 0, 1] { + u(&mut b, n); + } + f(&mut b, 0.4); + for n in [42, 4, 7] { + u(&mut b, n); + } + } + if version >= 13 { + u(&mut b, 1); + } + if version >= 12 { + u(&mut b, 0); + } + s(&mut b, "Pixel"); + for n in [1, 1, 1] { + u(&mut b, n); + } + if version >= 9 { + stored(&mut b, &[255, 128, 64, 255], version % 3); + } else { + u(&mut b, 4); + b.extend([255, 128, 64, 255]); + } + let points = [[0., 0., 0.], [0., 0., 1.], [1., 0., 0.]]; + let mut vertices = Vec::new(); + for p in points { + fs(&mut vertices, &p); + fs(&mut vertices, &[0., 1., 0., 0., 0., 0., 0.]); + u(&mut vertices, 1); + if version >= 12 { + fs(&mut vertices, &[0., 0.]); + vertices.extend([0, 0, 127, 127]); + } + } + let mut indices = Vec::new(); + for n in [0, 1, 2] { + u(&mut indices, n); + } + let mut collision = Vec::new(); + for p in points { + fs(&mut collision, &p); + } + u(&mut collision, 99); + u(&mut collision, 1); + if version >= 11 { + collision.extend([26, 26, 26, 0]); + } + for bytes in [vertices, indices, collision] { + if version >= 9 { + stored(&mut b, &bytes, version % 3); + } else { + b.extend(bytes); + } + } + s(&mut b, "Rail"); + u(&mut b, 0); + if version >= 10 { + u(&mut b, 0); + } + u(&mut b, 2); + fs(&mut b, &[0., 1., 0., 2., 1., 0.]); + if version >= 7 { + s(&mut b, "Spot"); + u(&mut b, 1); + fs( + &mut b, + &[ + 0., 2., 0., 0., -1., 0., 1., 0.8, 0.5, 100., 10., 0.1, 0.95, 0.8, + ], + ); + } + if version >= 8 { + s(&mut b, "Route"); + u(&mut b, 1); + u(&mut b, 2); + fs(&mut b, &[4., 3.]); + u(&mut b, 2); + fs(&mut b, &[0., 0., 0., 3., 0., 0.]); + } + if version >= 12 { + u(&mut b, 1); + b.extend(b"WMET"); + u(&mut b, 1); + u(&mut b, 2); + stored(&mut b, b"{}", version % 3); + } + b +} +fn stored(b: &mut Vec, data: &[u8], method: u8) { + use std::io::Write; + let bytes = match method { + 0 => data.to_vec(), + 1 => { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(data).unwrap(); + encoder.finish().unwrap() + } + 2 => zstd::stream::encode_all(data, 1).unwrap(), + _ => unreachable!(), + }; + u(b, method as u32); + u(b, bytes.len() as u32); + b.extend(bytes); +} +#[test] +fn reads_all_documented_versions_without_moving_geometry() { + for version in 1..=14 { + let map = SkateMap::parse(&fixture(version)).unwrap(); + assert_eq!(map.version, version); + assert_eq!(map.spawn, [2., 0., 3.]); + assert_eq!(map.heading, 1.); + assert_eq!(map.geometry.vertices[2].position, [1., 0., 0.]); + assert_eq!(map.geometry.collision[0].surface, 99); + assert_eq!(map.textures[0].rgba, [255, 128, 64, 255]); + assert_eq!(map.materials[0].audio, if version == 1 { 3 } else { 42 }); + assert_eq!(map.materials[0].physics, if version == 1 { 1 } else { 4 }); + assert_eq!(map.rails[0].points.len(), 2); + assert_eq!(map.lights.len(), usize::from(version >= 7)); + assert_eq!(map.routes.len(), usize::from(version >= 8)); + if version >= 12 { + assert_eq!(map.extensions[0].payload, b"{}"); + assert_eq!( + map.geometry.vertices[0].tangent_frame, + Some([0, 0, 127, 127]) + ); + } + } +} +#[test] +fn rejects_every_truncated_prefix_and_trailing_data() { + let data = fixture(8); + for end in 0..data.len() { + assert!( + SkateMap::parse(&data[..end]).is_err(), + "accepted truncation at {end}" + ); + } + let mut data = data; + data.push(0); + assert!(SkateMap::parse(&data).is_err()); +} +#[test] +fn rejects_future_versions_endianness_nonfinite_and_huge_counts() { + let data = fixture(8); + let mut bad = data.clone(); + bad[5] = b'9'; + bad[6] = b'9'; + assert!(SkateMap::parse(&bad).unwrap_err().contains("version")); + let mut bad = data.clone(); + bad[8] = 0; + assert!(SkateMap::parse(&bad).unwrap_err().contains("endian")); + let mut bad = data.clone(); + bad[23..27].copy_from_slice(&f32::NAN.to_le_bytes()); + assert!(SkateMap::parse(&bad).unwrap_err().contains("Non-finite")); + let mut bad = data; + bad[219..223].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(SkateMap::parse(&bad).is_err()); +} +#[test] +fn rejects_bad_vertex_and_material_references() { + let data = fixture(1); + // Locate the known three-index sequence immediately before collision data. + let mut needle = Vec::new(); + for v in [0u32, 1, 2] { + u(&mut needle, v); + } + let offset = data.windows(12).position(|w| w == needle).unwrap(); + let mut bad = data.clone(); + bad[offset..offset + 4].copy_from_slice(&99u32.to_le_bytes()); + assert!(SkateMap::parse(&bad).unwrap_err().contains("indices")); + let mut bad = data; + bad[offset - 4..offset].copy_from_slice(&0u32.to_le_bytes()); + assert!(SkateMap::parse(&bad).unwrap_err().contains("reference")); +} diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index 59ff6260..709c3d7e 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -8,3 +8,4 @@ publish.workspace = true bevy.workspace = true skate-core.workspace = true skate-data.workspace = true +half = "=2.7.1" diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs index 05903dc4..fbcef4e1 100644 --- a/crates/skate-game/src/config.rs +++ b/crates/skate-game/src/config.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; pub(crate) struct Config { pub asset_root: PathBuf, pub verification_capture: Option, + pub map: Option, } impl Config { @@ -12,6 +13,7 @@ impl Config { let mut config = Self { asset_root: PathBuf::from("assets"), verification_capture: None, + map: None, }; let mut args = std::env::args_os().skip(1); while let Some(arg) = args.next() { @@ -19,6 +21,10 @@ impl Config { Some("--assets") => { config.asset_root = args.next().ok_or("--assets requires a directory")?.into() } + Some("--map") => { + let path = PathBuf::from(args.next().ok_or("--map requires a .skate file")?); + config.map = Some(skate_data::skate_map::SkateMap::load(&path)?); + } Some("--verify") => { config.verification_capture = Some( args.next() @@ -28,7 +34,7 @@ impl Config { } _ => { return Err(format!( - "Unknown argument {arg:?}. Usage: skate-game [--assets DIRECTORY] [--verify CAPTURE.png]" + "Unknown argument {arg:?}. Usage: skate-game [--assets DIRECTORY] [--map MAP.skate] [--verify CAPTURE.png]" )); } } diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index ab2b46f1..20c1e1c7 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -11,6 +11,7 @@ mod physics; mod skater_animation; mod verification; mod world; +mod skate_world; fn main() -> bevy::app::AppExit { let config = match config::Config::from_env() { @@ -38,7 +39,13 @@ fn main() -> bevy::app::AppExit { return bevy::app::AppExit::error(); } }; - let physics = match physics::GamePhysics::load(&config.asset_root) { + if let Some(map) = &config.map { + if let Err(error) = skate_world::validate_runtime(map) { + eprintln!("{error}"); + return bevy::app::AppExit::error(); + } + } + let physics = match physics::GamePhysics::load_with_map(&config.asset_root, config.map.as_ref()) { Ok(physics) => physics, Err(error) => { eprintln!("{error}"); diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 764558f5..ec6a6b28 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -95,10 +95,14 @@ impl GamePhysics { } pub fn load(asset_root: &std::path::Path) -> Result { + Self::load_with_map(asset_root, None) + } + + pub fn load_with_map(asset_root: &std::path::Path, map: Option<&skate_data::skate_map::SkateMap>) -> Result { let data = Collections::load(asset_root)?; let settings = PhysicsSettings::load(&data)?; let animation_profile = animation_phase::AnimationProfile::load(&data, "easy")?; - let spawn = RetailAffineTransform { + let mut spawn = RetailAffineTransform { translation: Vector3::new( 0.0, ground::HEIGHT + settings.wheel_radius - settings.authored[0].translation.y, @@ -106,6 +110,12 @@ impl GamePhysics { ), ..RetailAffineTransform::IDENTITY }; + if let Some(map) = map { + // Native collision placement aligns the package spawn with the + // wheel-ground point (skate3_native_collision.cpp), not skeleton COM. + spawn.translation = Vector3::new(map.spawn[0], map.spawn[1] + settings.wheel_radius - settings.authored[0].translation.y, map.spawn[2]); + spawn.basis = skate_core::math::Basis3 { columns: Mat3::from_rotation_y(map.heading).to_cols_array_2d() }; + } let board = BoardRuntime::new( settings.masses, settings.authored, @@ -113,7 +123,10 @@ impl GamePhysics { settings.step.simulation, BoardMotion::Active, ); - let world = ground::world(settings.floor_material); + let world = match map { + Some(map) => crate::skate_world::collision_world(map, settings.floor_material)?, + None => ground::world(settings.floor_material), + }; let processed_flags_2468 = 0x2000; let riding = RidingOutputs::load(&data, &board, processed_flags_2468)?; let (query, retention) = ground::query_settings(); diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs new file mode 100644 index 00000000..bbe0349a --- /dev/null +++ b/crates/skate-game/src/skate_world.rs @@ -0,0 +1,503 @@ +//! Adapter for authored .skate world geometry. Does not replace controllers. +use bevy::{ + asset::RenderAssetUsages, + image::ImageSampler, + prelude::*, + render::render_resource::{Extent3d, TextureDimension, TextureFormat}, +}; +use skate_core::{ + math::Vector3, + physics::{ + board_world::{ + BoardWorld, WorldTriangle, + query_metadata::{Bounds, QueryMesh, QueryMetadata, QueryPool}, + }, + collision::TriangleFeature, + contact::RetailContactMaterial, + drive_frames::RetailAffineTransform, + }, +}; +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() { + 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()); + } + if !map.doors.is_empty() { + return Err(format!( + "Map '{}' contains {} hinged doors. This imported game has no door body/controller adapter yet; refusing to drop their geometry or turn them into static walls.", + map.name, + map.doors.len() + )); + } + for extension in &map.extensions { + let tag = String::from_utf8_lossy(&extension.tag); + 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.", + extension.schema + )); + } + eprintln!( + "SKATE LIMITATION: {tag} extension retained; its runtime behavior is not connected." + ); + } + if map.materials.iter().any(|m| m.retail_definition.is_some()) { + eprintln!( + "SKATE LIMITATION: retail shader definitions retained; rendering uses the package's portable PBR material fields." + ); + } + if map.textures.iter().any(|t| t.width == 0) { + return Err( + "SKATE contains external texture placeholders; supply a package with embedded textures" + .into(), + ); + } + if !map.rails.is_empty() { + eprintln!( + "SKATE LIMITATION: {} rail records parsed, but supplied game has no nonempty grind-edge controller. Rails will not engage grinds.", + map.rails.len() + ); + } + if !map.routes.is_empty() { + eprintln!( + "SKATE LIMITATION: {} NPC routes parsed; supplied game has no NPC controller.", + map.routes.len() + ); + } + if map.lights.iter().any(|l| l.kind == 2) { + eprintln!( + "SKATE LIMITATION: area-light records retained; Bevy adapter currently renders point and spot lights only." + ); + } + eprintln!( + "SKATE LIMITATION: using existing game directional/ambient lighting with map horizon color; authored sky/day-night controller is not yet connected." + ); + eprintln!( + "SKATE_MAP_LOADED name={:?} version={} render_triangles={} collision_triangles={} textures={} spawn={:?}", + map.name, + map.version, + map.geometry.indices.len() / 3, + map.geometry.collision.len(), + map.textures.len(), + map.spawn + ); + Ok(()) +} + +pub(crate) fn collision_world( + map: &SkateMap, + material: RetailContactMaterial, +) -> Result { + // 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(); + let mut positions = Vec::::new(); + let mut vertices = Vec::new(); + let mut normals = Vec::new(); + for tri in &map.geometry.collision { + let ids = tri.points.map(|p| { + let inverse = 1.0 / f64::from(0.001_f32); + let key = p.map(|v| (f64::from(v) * inverse).round() as i64); + *welded.entry(key).or_insert_with(|| { + let id = positions.len(); + positions.push(Vec3::from_array(p)); + id + }) + }); + let [a, b, c] = tri.points.map(Vec3::from_array); + let normal = (b - a) + .cross(c - a) + .try_normalize() + .ok_or("Invalid SKATE collision triangle normal")?; + vertices.push(ids); + normals.push(normal); + } + 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); + } + } + } 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); + } + } + 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; + } + } + } + } + } + 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() { + 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); + let points = vertices[i].map(|id| { + let p = positions[id]; + Vector3::new(p.x, p.y, p.z) + }); + // Keep the supplied game's original static-world contact combine values. + // The native map bridge supplies packed surfaces, not a guessed split of + // the package's single friction scalar into static/dynamic coefficients. + triangles.push( + WorldTriangle::from_vertices( + points, + material, + source.surface, + flags[i], + cosines[i], + 0., + ) + .ok_or("Invalid SKATE collision volume")?, + ); + } + let bounds = Bounds::from_points(triangles.iter().flat_map(|t| t.triangle.vertices)) + .ok_or("SKATE collision bounds empty")?; + let metadata = QueryMetadata { + packed_surfaces, + meshes: vec![QueryMesh { + triangle_range: 0..triangles.len(), + local_to_world: RetailAffineTransform::IDENTITY, + world_to_local: RetailAffineTransform::IDENTITY, + local_bounds: bounds, + matching_group: -1, + pool: QueryPool::Ground, + }], + static_edges: vec![], + island_flags: 0, + }; + BoardWorld::with_query_metadata(triangles, metadata).map_err(str::to_owned) +} + +pub(crate) fn spawn( + map: &SkateMap, + commands: &mut Commands, + meshes: &mut Assets, + materials: &mut Assets, + images: &mut Assets, +) { + // Texture roles have different transfer functions even when sharing a record. + let mut cache = HashMap::<(u32, u8), Handle>::new(); + let mut texture = |id: u32, role: u8| -> Option> { + if id == 0 { + return None; + } + Some( + cache + .entry((id, role)) + .or_insert_with(|| { + let source = &map.textures[id as usize - 1]; + let (format, bytes) = if role == 1 { + let mut bytes = Vec::with_capacity(source.rgba.len() * 2); + for pixel in source.rgba.chunks_exact(4) { + for (i, &byte) in pixel.iter().enumerate() { + let v = f32::from(byte) / 255.; + let linear = if i == 3 { 1. } else { v * v * 4. }; + bytes.extend_from_slice(&half::f16::from_f32(linear).to_le_bytes()); + } + } + (TextureFormat::Rgba16Float, bytes) + } else { + let srgb = + role == 0 && source.color_space == 1 && !(2..=3).contains(&map.version); + ( + if srgb { + TextureFormat::Rgba8UnormSrgb + } else { + TextureFormat::Rgba8Unorm + }, + source.rgba.clone(), + ) + }; + let mut image = Image::new( + Extent3d { + width: source.width, + height: source.height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + bytes, + format, + RenderAssetUsages::default(), + ); + let mut sampler = bevy::image::ImageSamplerDescriptor::linear(); + if role != 1 { + sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat; + sampler.address_mode_v = bevy::image::ImageAddressMode::Repeat; + } + image.sampler = ImageSampler::Descriptor(sampler); + images.add(image) + }) + .clone(), + ) + }; + let mut groups = vec![Vec::::new(); map.materials.len()]; + for tri in map.geometry.indices.chunks_exact(3) { + groups[map.geometry.vertices[tri[0] as usize].material as usize - 1].extend_from_slice(tri); + } + for (m, indices) in map.materials.iter().zip(groups) { + if indices.is_empty() { + continue; + } + // Reindex each material once, preserving authored normals and both UV sets. + let mut remap = HashMap::new(); + let mut vertices = Vec::new(); + let local: Vec = indices + .into_iter() + .map(|index| { + *remap.entry(index).or_insert_with(|| { + let id = vertices.len() as u32; + vertices.push(&map.geometry.vertices[index as usize]); + id + }) + }) + .collect(); + let mut mesh = Mesh::new( + bevy::mesh::PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ) + .with_inserted_attribute( + Mesh::ATTRIBUTE_POSITION, + vertices.iter().map(|v| v.position).collect::>(), + ) + .with_inserted_attribute( + Mesh::ATTRIBUTE_NORMAL, + vertices.iter().map(|v| v.normal).collect::>(), + ) + .with_inserted_attribute( + Mesh::ATTRIBUTE_UV_0, + vertices.iter().map(|v| v.uv).collect::>(), + ) + .with_inserted_attribute( + Mesh::ATTRIBUTE_UV_1, + vertices.iter().map(|v| v.lightmap_uv).collect::>(), + ) + .with_inserted_indices(bevy::mesh::Indices::U32(local)); + if vertices.iter().all(|v| v.tangent_frame.is_some()) { + let tangents: Vec<[f32; 4]> = vertices + .iter() + .map(|v| { + let frame = v + .tangent_frame + .unwrap() + .map(|b| (b as i8 as f32 / 127.).max(-1.)); + let binormal = Vec3::new(frame[0], frame[1], frame[2]); + let tangent = binormal.cross(Vec3::from_array(v.normal)) * frame[3]; + [tangent.x, tangent.y, tangent.z, frame[3]] + }) + .collect(); + mesh.insert_attribute(Mesh::ATTRIBUTE_TANGENT, tangents); + } else if m.textures[2] != 0 { + if let Err(error) = mesh.generate_tangents() { + warn!("SKATE material {} tangent generation: {error}", m.name); + } + } + let orm = texture(m.textures[3], 2); + let material = materials.add(StandardMaterial { + base_color: Color::linear_rgb(m.color[0], m.color[1], m.color[2]), + base_color_texture: texture(m.textures[0], 0), + normal_map_texture: texture(m.textures[2], 2), + metallic_roughness_texture: orm.clone(), + occlusion_texture: orm, + metallic: if m.textures[3] != 0 { 1. } else { 0. }, + perceptual_roughness: m.roughness, + emissive: LinearRgba::rgb( + m.color[0] * m.emissive, + m.color[1] * m.emissive, + m.color[2] * m.emissive, + ), + emissive_texture: texture(m.textures[4], 0), + alpha_mode: match m.alpha_mode { + 1 => AlphaMode::Mask(m.alpha_cutoff), + 2 => AlphaMode::Blend, + _ => AlphaMode::Opaque, + }, + lightmap_exposure: m.indirect_strength, + ..default() + }); + let mut entity = commands.spawn(( + Name::new(m.name.clone()), + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(material), + Transform::default(), + )); + if let Some(image) = texture(m.textures[1], 1) { + entity.insert(bevy::pbr::Lightmap { + image, + uv_rect: Rect::new(0., 0., 1., 1.), + bicubic_sampling: false, + }); + } + } + for light in &map.lights { + let color = Color::linear_rgb(light.color[0], light.color[1], light.color[2]); + let transform = Transform::from_translation(Vec3::from_array(light.position)); + match light.kind { + 0 => { + commands.spawn(( + Name::new(light.name.clone()), + PointLight { + color, + intensity: light.intensity, + range: light.range, + radius: light.radius, + ..default() + }, + transform, + )); + } + 1 => { + commands.spawn(( + Name::new(light.name.clone()), + SpotLight { + color, + intensity: light.intensity, + range: light.range, + radius: light.radius, + inner_angle: light.inner_cos.acos(), + outer_angle: light.outer_cos.acos(), + ..default() + }, + transform.looking_to(Vec3::from_array(light.direction), Vec3::Y), + )); + } + _ => {} + } + } + commands.insert_resource(ClearColor(Color::linear_rgb( + map.environment[3], + map.environment[4], + map.environment[5], + ))); + commands.spawn(( + DirectionalLight { + illuminance: 11000., + shadows_enabled: true, + ..default() + }, + Transform::from_xyz(4., 7., 4.).looking_at(Vec3::ZERO, Vec3::Y), + )); + info!( + "SKATE_WORLD_READY name={:?} render_triangles={} collision_triangles={}", + map.name, + map.geometry.indices.len() / 3, + map.geometry.collision.len() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + fn demo() -> SkateMap { + SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap() + } + fn material() -> RetailContactMaterial { + RetailContactMaterial { + static_friction: 0., + dynamic_friction: 0., + restitution: 1., + } + } + #[test] + fn map_collision_uses_separate_geometry_and_surface_metadata() { + let mut map = demo(); + map.geometry.vertices[0].position = [1000.; 3]; + map.materials[0].audio = 42; + map.materials[0].physics = 4; + map.materials[0].pattern = 7; + let world = collision_world(&map, material()).unwrap(); + assert_eq!(world.triangles().len(), 2); + assert_eq!( + world.triangles()[0].triangle.vertices[0], + Vector3::new(-30., 0., -30.) + ); + assert!(world.triangles()[0].triangle.feature.normal.y > 0.999); + assert_eq!( + world.query_metadata().unwrap().packed_surfaces, + vec![42 | (4 << 7) | (7 << 12); 2] + ); + assert!(world.query_metadata().unwrap().static_edges.is_empty()); + } + #[test] + fn shared_flat_diagonal_is_not_a_convex_contact_edge() { + let world = collision_world(&demo(), material()).unwrap(); + let a = world.triangles()[0].triangle.feature; + let b = world.triangles()[1].triangle.feature; + assert!(!a.edge_convex(2)); + assert!(!b.edge_convex(0)); + assert!(a.edge_convex(0)); + assert_eq!(a.edge_cosines[2], 1.); + assert!(a.vertex_disabled(0)); + } + #[test] + fn render_adapter_creates_mesh_and_decoded_lightmap_without_a_window() { + let map = demo(); + let mut world = World::new(); + let mut queue = bevy::ecs::world::CommandQueue::default(); + let mut meshes = Assets::::default(); + let mut materials = Assets::::default(); + let mut images = Assets::::default(); + spawn( + &map, + &mut Commands::new(&mut queue, &world), + &mut meshes, + &mut materials, + &mut images, + ); + queue.apply(&mut world); + assert_eq!(meshes.len(), 1); + assert_eq!(materials.len(), 1); + assert_eq!(images.len(), 2); + assert_eq!(world.query::<&Mesh3d>().iter(&world).count(), 1); + let lightmap = world + .query::<&bevy::pbr::Lightmap>() + .single(&world) + .unwrap(); + let image = images.get(&lightmap.image).unwrap(); + assert_eq!(image.texture_descriptor.format, TextureFormat::Rgba16Float); + let data = image.data.as_ref().unwrap(); + let value = half::f16::from_le_bytes([data[0], data[1]]).to_f32(); + assert!((value - (64. / 255_f32).powi(2) * 4.).abs() < 0.0002); + } +} diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs index 99b774f2..ec4a1b4e 100644 --- a/crates/skate-game/src/world.rs +++ b/crates/skate-game/src/world.rs @@ -23,6 +23,8 @@ fn spawn( manifest: Res, mut meshes: ResMut>, mut materials: ResMut>, + mut images: ResMut>, + config: Res, ) { commands .spawn((PlayerRoot, Transform::default(), Visibility::default())) @@ -31,6 +33,10 @@ fn spawn( GltfAssetLabel::Scene(0).from_asset(manifest.0.character_scene.clone()), ))); }); + if let Some(map) = &config.map { + crate::skate_world::spawn(map, &mut commands, &mut meshes, &mut materials, &mut images); + return; + } let colors = [ Color::srgb(0.16, 0.19, 0.21), Color::srgb(0.48, 0.35, 0.22), diff --git a/docs/SKATE-MAPS.md b/docs/SKATE-MAPS.md new file mode 100644 index 00000000..831c23ed --- /dev/null +++ b/docs/SKATE-MAPS.md @@ -0,0 +1,81 @@ +# .skate maps + +Run `PLAY-MAP.bat` and enter the map number. Alternatively drag a `.skate` +file onto `PLAY.bat` or `PLAY-MAP.bat`. Command-line equivalent: + +```powershell +.\Launch.ps1 -Map 'C:\path with spaces\park.skate' +``` + +The original test environment remains available through PLAY.bat without a map. +Maps are parsed before initializing graphics; invalid files produce a readable +error and do not silently load the default level instead. + +## Reader and adapter are different capabilities + +| Data | Current support | +| --- | --- | +| SKATE01–14 records | Reader supports the documented layouts and raw, zlib and Zstandard blocks. | +| SKATE15+ | Rejected with a version error. V15's additional storage transforms are not implemented. | +| Visual geometry | Authored positions, triangle winding, normals, UV0/UV1 and material groups. Packed v12+ tangent frames are reconstructed. | +| Embedded materials | Albedo, normal, ORM, emissive, alpha mask/blend and indirect lightmaps. Lightmap RGB is decoded as `encoded² * 4` into half-float textures. | +| Collision | Separate authored collision mesh, reference 1 mm vertex welding, adjacent-edge and coplanar-vertex metadata. No collision inferred from visible triangles. | +| Surface types | Exact audio/physics/pattern packing from `EncodeRwSurfaceId`. Authored friction is retained; the original imported static-world contact combine remains in charge. | +| Spawn | Package position interpreted as the wheel-ground anchor, with the supplied wheel/body offset. Heading rotates about Y; coordinates stay Y-up metres. | +| Point/spot lights | Position, direction, color, intensity, range and cone settings are passed to Bevy. Renderer brightness parity is not established. | +| Sky/day-night | Metadata retained. Currently uses the package horizon as the clear color and existing game directional/ambient lighting. No full sky/celestial adapter yet. | +| Grind rails | Authored polylines and native spline words retained. Supplied game's `NoGrindEdges` path does not support grind engagement. A startup warning reports this. | +| 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. | +| Empty texture placeholders | Parsed, but launch requires embedded textures. | + +This is a map-loading integration, not a claim of full SK8 Engine renderer or +Skate 3 gameplay parity. In particular, maps can be readable without all their +runtime features being supported. Nothing bypasses the imported game's existing +`AddRunoutAttribs` failure, documented in the main README. + +## Maps supplied locally + +- `format-demo.skate`: original 60 m square checker floor with an embedded + albedo and indirect lightmap; reproducible with `python tools/make_skate_demo.py`. +- `private/transition_grind_park.skate`: existing v8 transition map, copied from + the user's research folder. Contains 510 visual / 398 collision triangles. +- `private/skate_parity_grid.skate`: existing v8 textured floor, copied from + the same folder. Contains 2 visual / 2 collision triangles. + +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. + +## Source evidence + +Layout, versions and compression were checked against the user's local: + +- `Skate3Research/Skate3CustomEngineLayer/tools/blender_owned_map/SKATE_FORMAT.md` +- `Skate3Research/Skate3CustomEngineLayer/owned/world/src/owned_map_package.cpp` +- `SK8R15/Source/tools/blender_owned_map/SKATE_FORMAT.md` +- `SK8R15/Source/owned/world/src/owned_map_package.cpp` + +Collision surface packing, welding and adjacency reference: +`owned/world/include/skate/world/rw_collision_mesh.h` and +`owned/world/src/rw_collision_mesh.cpp`. Spawn ground-anchor reference: +`src/skate3_native_collision.cpp`, native collision placement block. +All reference files remain outside this repository and were read only. + +## Nonvisual checks + +```text +cargo test -p skate-data --test skate_map --locked +cargo test -p skate-game skate_world::tests --locked +cargo run -p skate-data --example inspect_skate -- path/to/map.skate +``` + +Reader tests cover versions 1–14 and all three supported compression methods, +every truncated prefix of the v8 fixture, invalid references, non-finite input, +oversized counts, wrong byte order and unknown versions. Adapter tests check +separate collision ownership, packed surfaces, flat seam handling and embedded +lightmap decoding without creating a window. Gameplay and visual checks belong +to the user. diff --git a/maps/format-demo.skate b/maps/format-demo.skate new file mode 100644 index 0000000000000000000000000000000000000000..a55149c66b625d76c18e46d29edc440e9952e398 GIT binary patch literal 737 zcmZ`$Jx>Bb5M9JrkoZMtZ17qeV~hof>@9~MXtY6$iN*pv&S?0!n^ z|J1>I-BZ4Cz0BXf9(g+dy-Pj`EkL~po4OqhC)0T=NuHy2y{@IZjXn_#jKS!7+EKFl^0b3-tcW0R;%h7Jjw(X#(*;o@8QZ<5aHZG|tW2&i_!%4tI zm?$)`C4y1Dv5K~4dUs$P0E!jDU@(|$U%g8p#=khO7VjO)bT!kiKDdRd3$bd6x1>_3 z31j09g=EU(%>e_KJNVU%4Nu|M$}_)qs`;x0|5rPmMfFv+%-7eyI&TSBm2Wocq24mP V3wa|%U%;1#){sZi&*F|+?hk$_lcN9t literal 0 HcmV?d00001 diff --git a/tools/make_skate_demo.py b/tools/make_skate_demo.py new file mode 100644 index 00000000..bf9a0f82 --- /dev/null +++ b/tools/make_skate_demo.py @@ -0,0 +1,50 @@ +"""Write a small, original v8 map for validating the adapter (no private assets).""" +import pathlib +import struct + +out = bytearray(b"SKATE08\0") +def u(*values): + out.extend(struct.pack("<" + "I" * len(values), *values)) +def f(*values): + out.extend(struct.pack("<" + "f" * len(values), *values)) +def s(value): + data = value.encode("utf-8") + u(len(data)) + out.extend(data) + +u(0x12345678) +s("SKATE Format Demo") +f(0, 0, 0, 0) +f(.09,.34,.72, .58,.78,.98, .18,.25,.34, 0,12,.62, 17,0) +f(.045,.10,.26, 1,.32,.10, .05,.035,.06) +f(.007,.015,.045, .045,.085,.17, .008,.014,.032) +f(1,.92,.78, .42,.56,.92, 1.25,.18,.32,.11, 1,1,1) +u(1, 2, 4, 6, 2, 0, 0, 0, 0) +s("Checker floor") +u(1) +f(.6, .1, 1,1,1, .85,0) +u(1,2) +f(1) +u(0,0,0,0) +f(.5) +u(3,1,0) +s("Albedo checker") +u(2,2,1,16) +out.extend(bytes([220,220,220,255, 50,90,150,255, 50,90,150,255, 220,220,220,255])) +s("Indirect constant") +u(1,1,0,4) +out.extend(bytes([64,64,64,255])) +points = [(-30,0,-30),(-30,0,30),(30,0,30),(30,0,-30)] +for i, p in enumerate(points): + f(*p, 0,1,0, (0 if i<2 else 30), (30 if i in (1,2) else 0), .5,.5) + u(1) +indices = [0,1,2,0,2,3] +u(*indices) +for i in range(0,6,3): + for index in indices[i:i+3]: + f(*points[index]) + u(i//3+1,1) +target = pathlib.Path(__file__).resolve().parents[1] / "maps" / "format-demo.skate" +target.parent.mkdir(exist_ok=True) +target.write_bytes(out) +print(f"Wrote {target} ({len(out)} bytes)")