mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Collision: skip meshes with no surface IDs (zone/trigger volumes)
Retail collision includes clustered meshes in which no unit carries a surface ID (unit flag 0x80 unset). Across every shipped district these are only 12-triangle boxes: zone, trigger and level-bounds volumes. The loader made them solid, creating invisible walls (SkateSchool spawn area, DownTown's Kube Tower plaza, MegaPark's stadium entrance, Maloof). Authored player positions lie inside some of them, so they are not solid in the original game. visit_clusters now decodes and validates each mesh fully and skips it when no unit has a surface ID. Meshes mixing surfaced and surfaceless units are ordinary geometry and stay whole. RetailTriangle records has_surface. Collision triangles loaded: SkateSchool -84, DownTown -24, Maloof -24, MegaPark -12, all other maps unchanged. Confirmed in play. See docs/hails-additions/05-collision-volumes.md.
This commit is contained in:
4 files changed
+160
-4
No files matched your search
@@ -9,6 +9,8 @@ pub struct RetailTriangle {
|
|||||||
pub points: [[f32; 3]; 3],
|
pub points: [[f32; 3]; 3],
|
||||||
pub edges: Option<[u8; 3]>,
|
pub edges: Option<[u8; 3]>,
|
||||||
pub surface: u16,
|
pub surface: u16,
|
||||||
|
/// Unit flag 0x80: the unit carries a surface ID (`surface` is 0 otherwise).
|
||||||
|
pub has_surface: bool,
|
||||||
pub group: u16,
|
pub group: u16,
|
||||||
pub one_sided: bool,
|
pub one_sided: bool,
|
||||||
}
|
}
|
||||||
@@ -38,8 +40,16 @@ fn id(data: &[u8], at: &mut usize, width: u8) -> Result<u16, String> {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Visits complete clusters in archive order, retaining only one decoded
|
/// Visits complete clusters in archive order, retaining one decoded mesh at a
|
||||||
/// cluster at a time. The callback can build its own spatial query index.
|
/// 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(
|
pub fn visit_clusters(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
mut visit: impl FnMut(&str, &[RetailTriangle]) -> Result<(), String>,
|
mut visit: impl FnMut(&str, &[RetailTriangle]) -> Result<(), String>,
|
||||||
@@ -96,6 +106,7 @@ fn mesh_clusters(
|
|||||||
let one_sided = be16(data, 60)? & 0x10 != 0;
|
let one_sided = be16(data, 60)? & 0x10 != 0;
|
||||||
let mut previous_end = table + count * 4;
|
let mut previous_end = table + count * 4;
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
|
let mut clusters = Vec::with_capacity(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let offset = be32(data, table + i * 4)? as usize;
|
let offset = be32(data, table + i * 4)? as usize;
|
||||||
if offset < previous_end {
|
if offset < previous_end {
|
||||||
@@ -109,11 +120,17 @@ fn mesh_clusters(
|
|||||||
previous_end = offset + size;
|
previous_end = offset + size;
|
||||||
let triangles = decode_cluster(cluster, granularity, data[62], data[63], one_sided)?;
|
let triangles = decode_cluster(cluster, granularity, data[62], data[63], one_sided)?;
|
||||||
total += triangles.len();
|
total += triangles.len();
|
||||||
visit(&triangles)?;
|
clusters.push(triangles);
|
||||||
}
|
}
|
||||||
if total != be32(data, 40)? as usize {
|
if total != be32(data, 40)? as usize {
|
||||||
return Err("RWCM triangle count mismatch".into());
|
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)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +209,7 @@ fn decode_cluster(
|
|||||||
points,
|
points,
|
||||||
edges,
|
edges,
|
||||||
surface,
|
surface,
|
||||||
|
has_surface: flags & 0x80 != 0,
|
||||||
group,
|
group,
|
||||||
one_sided,
|
one_sided,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,44 @@ fn preserves_all_vertex_encodings_and_little_endian_unit_ids() {
|
|||||||
assert_eq!(clusters, 3);
|
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]
|
#[test]
|
||||||
fn rejects_every_truncated_prefix_and_trailing_bytes() {
|
fn rejects_every_truncated_prefix_and_trailing_bytes() {
|
||||||
for end in 0..FIXTURE.len() {
|
for end in 0..FIXTURE.len() {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Environment used for verification
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user