Load embedded retail collision and decode unsigned vertex offsets

This commit is contained in:
chazmm committed 2026-09-06 14:56:18 +01:00
1 parent 63d9b7d269
commit 01ebd215de
15 files changed
+894 -74

No files matched your search

+96 -5
View File
@@ -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<QueryMetadata>,
contacts: Vec<BoardCollision>,
buffer: ContactBuffer,
maximum_fatness: f32,
}
impl BoardWorld {
pub fn new(triangles: Vec<WorldTriangle>) -> 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<Bounds>) -> Vec<std::ops::Range<usize>> {
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<Item = (usize, &WorldTriangle)> {
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<WorldLineHit> = 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<Vec<_>> = 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<Bounds> {
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::<f32>())
.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.
@@ -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<Item = Vector3>) -> Option<Self> {
let mut points = points.into_iter();
let first = points.next()?;
@@ -90,6 +90,121 @@ fn close(a: f32, b: f32) {
assert!((a - b).abs() < 2e-6, "{a} != {b}");
}
fn clustered(triangles: Vec<WorldTriangle>) -> 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"
);
}
+36 -15
View File
@@ -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;
+1
View File
@@ -13,3 +13,4 @@ pub mod physics_skeleton;
mod sha256;
pub mod state_graph;
pub mod skate_map;
pub mod retail_collision;
+203
View File
@@ -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<u16, String> {
Ok(u16::from_be_bytes(span(data, at, 2)?.try_into().unwrap()))
}
fn be32(data: &[u8], at: usize) -> Result<u32, String> {
Ok(u32::from_be_bytes(span(data, at, 4)?.try_into().unwrap()))
}
fn le32(data: &[u8], at: &mut usize) -> Result<usize, String> {
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<u16, String> {
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<usize, String> {
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<usize, String> {
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<Vec<RetailTriangle>, 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)
}
Binary file not shown.
@@ -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}");
}
+1 -1
View File
@@ -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.
@@ -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);
@@ -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,
+241 -40
View File
@@ -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<Option<&[u8]>, 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<BoardWorld, String> {
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<BoardWorld, String> {
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];