diff --git a/crates/skate-data/src/retail_collision.rs b/crates/skate-data/src/retail_collision.rs index 9fd651da..de5e7bc8 100644 --- a/crates/skate-data/src/retail_collision.rs +++ b/crates/skate-data/src/retail_collision.rs @@ -9,6 +9,8 @@ pub struct RetailTriangle { pub points: [[f32; 3]; 3], pub edges: Option<[u8; 3]>, pub surface: u16, + /// Unit flag 0x80: the unit carries a surface ID (`surface` is 0 otherwise). + pub has_surface: bool, pub group: u16, pub one_sided: bool, } @@ -38,8 +40,16 @@ fn id(data: &[u8], at: &mut usize, width: u8) -> Result { 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. +/// Visits complete clusters in archive order, retaining one decoded mesh at a +/// time. The callback can build its own spatial query index. Returns the number +/// of triangles visited. +/// +/// Meshes in which no unit carries a surface ID are skipped. Across every +/// shipped district these are only 12-triangle boxes (zone/trigger/bounds +/// volumes); authored player positions lie inside some of them, e.g. the +/// SkateSchool area and DownTown's Kube Tower landing, so they are not solid. +/// Meshes that mix surfaced and surfaceless units are ordinary geometry and +/// are kept whole. pub fn visit_clusters( data: &[u8], mut visit: impl FnMut(&str, &[RetailTriangle]) -> Result<(), String>, @@ -96,6 +106,7 @@ fn mesh_clusters( let one_sided = be16(data, 60)? & 0x10 != 0; let mut previous_end = table + count * 4; let mut total = 0; + let mut clusters = Vec::with_capacity(count); for i in 0..count { let offset = be32(data, table + i * 4)? as usize; if offset < previous_end { @@ -109,11 +120,17 @@ fn mesh_clusters( previous_end = offset + size; let triangles = decode_cluster(cluster, granularity, data[62], data[63], one_sided)?; total += triangles.len(); - visit(&triangles)?; + clusters.push(triangles); } if total != be32(data, 40)? as usize { return Err("RWCM triangle count mismatch".into()); } + if !clusters.iter().flatten().any(|t| t.has_surface) { + return Ok(0); // volume, not collision (see visit_clusters) + } + for triangles in &clusters { + visit(triangles)?; + } Ok(total) } @@ -192,6 +209,7 @@ fn decode_cluster( points, edges, surface, + has_surface: flags & 0x80 != 0, group, one_sided, }); diff --git a/crates/skate-data/tests/retail_collision.rs b/crates/skate-data/tests/retail_collision.rs index f98192c1..d7f98954 100644 --- a/crates/skate-data/tests/retail_collision.rs +++ b/crates/skate-data/tests/retail_collision.rs @@ -64,6 +64,44 @@ fn preserves_all_vertex_encodings_and_little_endian_unit_ids() { assert_eq!(clusters, 3); } +/// Removes the surface ID from the first unit of fixture mesh `index`: clears +/// unit flag 0x80 and shortens the unit stream by the 2-byte ID (its last field). +fn strip_surface(bytes: &mut [u8], index: usize) { + let be16 = |b: &[u8], at: usize| u16::from_be_bytes(b[at..at + 2].try_into().unwrap()); + let be32 = |b: &[u8], at: usize| u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as usize; + let mut at = 12; + let mut mesh = 0; + for _ in 0..=index { + 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; + } + assert_eq!(bytes[mesh + 63], 2, "fixture surface IDs are 2 bytes"); + let cluster = mesh + be32(bytes, mesh + be32(bytes, mesh + 52)); + let unit_start = (usize::from(be16(bytes, cluster + 4)) + 1) * 16; + assert_ne!(bytes[cluster + unit_start] & 0x80, 0); + bytes[cluster + unit_start] &= !0x80; + let length = be16(bytes, cluster + 2) - 2; + bytes[cluster + 2..cluster + 4].copy_from_slice(&length.to_be_bytes()); +} + +#[test] +fn skips_meshes_without_any_surface_ids() { + let mut bytes = FIXTURE.to_vec(); + strip_surface(&mut bytes, 1); + let mut names = Vec::new(); + let total = visit_clusters(&bytes, |name, triangles| { + assert!(triangles.iter().all(|t| t.has_surface)); + names.push(name.to_owned()); + Ok(()) + }) + .unwrap(); + assert_eq!(names, ["compression-0", "compression-2"]); + assert_eq!(total, 2); +} + #[test] fn rejects_every_truncated_prefix_and_trailing_bytes() { for end in 0..FIXTURE.len() { diff --git a/docs/hails-additions/05-collision-volumes.md b/docs/hails-additions/05-collision-volumes.md new file mode 100644 index 00000000..0164a86a --- /dev/null +++ b/docs/hails-additions/05-collision-volumes.md @@ -0,0 +1,99 @@ +# 5. Invisible collision volumes + +## Problem + +On SkateSchool the player hit an invisible wall in the middle of the spawn +area and could not skate around. Logs showed normal physics states (no +failures), so the obstruction was in the collision data. + +## Root cause + +Retail collision archives contain a small number of clustered meshes in which +**no unit carries a surface ID** (unit flag `0x80` unset; units read as flags +`0x21` instead of the usual `0xa1`). Every one of them, in every shipped +district, is a 12-triangle axis-aligned box: zone, trigger or level-bounds +volumes. The game's loader (`skate_world::retail_collision_world`, via +`skate_data::retail_collision::visit_clusters`) turned every triangle into +solid collision, so these volumes became invisible walls, floors and ceilings. + +SkateSchool has seven, all in its global stream (`cSim_Global.xsf`): + +| Box (X / Y / Z, metres) | Effect in game | +|---|---| +| −79…16.5 / −1.53…6.65 / −43…52 | covers the whole school area; the authored start (y 0.11) is inside it | +| −66…−62 / −1.53…6.65 / −3.6…12.7 | the invisible wall in the spawn area | +| −11…2.5 / 0…4.16 / −2.6…1.6 (three identical) | invisible block near the origin | +| level bounds, Y −73…79 | floor under the whole map | +| one more box | — | + +Evidence that these are not solid in the original game: every authored +SkateSchool locator (Coach Frank, the hub, all tutorial stations) is at +y 0.0–0.4 on the real floor (surface 258), i.e. inside the big box; and in +DownTown, a surfaceless box (X 259–333, Y 48.9–57.7, Z −152…−71) encloses +the Kube Tower landing zone (`Z_DT_CubeTower`, y 55.9) and four authored +challenge start points. + +Before the spawn change in doc 4, SkateSchool's spawn (y 7.63) stood on top +of the big box, which hid the problem. + +## Survey of all districts + +Classified per mesh by surface-ID presence: + +| District | Fully surfaceless meshes | Notes | +|---|---|---| +| SkateSchool | 7 (all 12-triangle boxes) | global stream | +| MaloofMoneyCup | 2 (12-triangle boxes) | global stream | +| DownTown | 2 (12-triangle boxes) | city cell streams | +| MegaPark | 1 (12-triangle box) | global stream | +| others | 0 | | + +**Mixed** meshes (some units with surface IDs, some without) are common in +real geometry: University cells (up to 31k triangles), Industrial, DownTown +(138 meshes), BlackBoxPark, and the main geometry of several parks. A +per-triangle filter would therefore remove real floors; the rule must be per +mesh. + +## Change + +`crates/skate-data/src/retail_collision.rs`: + +- `RetailTriangle` gains `has_surface` (unit flag `0x80`). +- `mesh_clusters` decodes and validates a whole mesh (including the triangle + count check) before visiting its clusters, and skips the mesh when no unit + has a surface ID. `visit_clusters` returns the number of triangles visited. + Memory: one decoded mesh at a time instead of one cluster. + +The fix is in the game, so existing installations benefit without an asset +refresh. + +## Verification + +- New test `skips_meshes_without_any_surface_ids` (fixture mesh with its + surface ID stripped is skipped; the others are visited intact). All + `skate-data` library and integration tests pass, as do the game's + collision/world tests. +- Collision triangles loaded per map (`SKATE_RWCM_READY`), before → after: + SkateSchool 91,790 → 91,706 (−84 = 7 boxes), DownTown −24, MaloofMoneyCup + −24, MegaPark −12, all other maps unchanged. Every map passes + `--check-assets`. +- In play: SkateSchool (spawn area free to skate) and DownTown tested + thoroughly; the Maloof and MegaPark box locations (below) were visited + afterwards and are also clear. + +Further evidence the volumes are triggers: MegaPark's only box +(X 61.4–73.2, Y −4.1…4.1, Z −140.8…−132.6) surrounds the stadium's arrival +point from the world (`tele_world_to_stadium_dest_locator_01`, 2 m from its +centre), and one of Maloof's two boxes lies 14 m from its arrival point from +DownTown (`tele_dwtn_to_mmcp_dest_locator_01`). The other Maloof box +(2.8 × 3.4 × 2.0 m) is 17 m from a street challenge start. + +## Notes for upstream + +- `cargo test -p skate-data` currently fails to compile three examples + (`apt_data`, `hud_data`, `scoring_flow_data`) that reference game-only + modules; this predates this change. `--lib --tests` runs the test suite. +- The mesh-level rule is empirical (all 12 such meshes in the shipped data are + boxes, and authored player positions lie inside some). If retail code that + consumes these volumes (zones/triggers) is ported later, they should be + routed there instead of dropped. diff --git a/docs/hails-additions/README.md b/docs/hails-additions/README.md index d9bd2a20..643563d2 100644 --- a/docs/hails-additions/README.md +++ b/docs/hails-additions/README.md @@ -12,7 +12,8 @@ Each document stands alone. File paths are relative to the repository root. | 1 | [ISO extraction argument order](01-iso-extraction.md) | Setup (Python) | Done, verified with a real ISO | | 2 | [Windows long paths during setup](02-long-paths.md) | Setup (Python) | Done; UAC path not exercised end to end | | 3 | [SDL3 gamepad input](03-sdl3-gamepad-input.md) | Game (Rust), build scripts, vendored `sdl3-sys` | Done, verified in play with an Xbox Elite Series 2 | -| 4 | [Authored map spawns](04-map-spawns.md) | Asset pipeline (Python) | Done; in-game confirmation in progress | +| 4 | [Authored map spawns](04-map-spawns.md) | Asset pipeline (Python) | Done, all 10 maps confirmed in play | +| 5 | [Invisible collision volumes](05-collision-volumes.md) | Game data loader (Rust) | Done, all four affected maps confirmed in play | ## Environment used for verification