Stage containers (hidden/resource3d/Stage_S*.xpr) are collections of up to
~450 enemy/prop sub-models, not single meshes. Xbg7Model::stage_models decodes
them: each resource is a [12-byte header][index buffer][vertex buffer] block
(index count = descriptor marker, vertex count = u32 32 bytes before it) whose
on-disc offset is NOT stored, so it is located by content — one O(file) pass per
stride finds vertex-buffer starts (unit NORMAL at +12 whose previous slot isn't)
and each resource is pinned to the candidate whose indices validate and produce
non-degenerate, well-connected triangles. A connectivity gate (mean triangle
edge <= 0.28x the bbox diagonal) rejects spiky mis-anchors. ~4993 sub-models
decode across the 22 stages.
The same insight fixes the weapon single-model layout: it is [12-byte header]
[index][vertex] too, not [index][12-byte gap][vertex]. Reading indices from the
block start turned the 12 header bytes into 6 junk indices (2 leading degenerate
triangles — the recurring stray-triangle artifact) and dropped the last 6 real
indices. Skipping the header leaves vertex offsets identical (coverage unchanged
at 36/166) and corrects the triangle list. Verified on wep_00/03/04.
Adds `sylpheed-cli mesh {info,render}` — a headless software rasterizer that
writes a shaded PNG (orthographic, z-buffered, two-sided), so recovered geometry
can be verified without the GUI. Stages render as a normalised thumbnail grid;
--only filters sub-models.
Tests: stage_models_{decode,sweep,quality_audit}; docs/re updated (xbg7-mesh.md
evidence log + INDEX.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
809 lines
31 KiB
Rust
809 lines
31 KiB
Rust
//! XBG7 mesh geometry decoder (geometry resources inside XPR2 containers).
|
||
//!
|
||
//! ## Clean-room note
|
||
//!
|
||
//! This format was reverse-engineered **purely by static observation of the
|
||
//! retail disc's `hidden/resource3d/*.xpr` files** (hex inspection + geometric
|
||
//! validation of the recovered triangles). No game code was decompiled or
|
||
//! copied. See `docs/re/structures/xbg7-mesh.md` for the evidence log.
|
||
//!
|
||
//! ## Where XBG7 lives
|
||
//!
|
||
//! Ship / weapon / prop models are `XPR2` containers (see [`crate::texture`]).
|
||
//! Their resource directory holds `TX2D` texture resources **and** one or more
|
||
//! `XBG7` geometry resources. The `XBG7` *descriptor* (at the resource's
|
||
//! `data_offset`) is a scene/material graph; the actual vertex and index
|
||
//! buffers live in the container's shared data section (from `header_size`).
|
||
//!
|
||
//! ## The "simple" layout decoded here (CONFIRMED)
|
||
//!
|
||
//! For single-stream models (weapons, simple props — 36 of the 166 disc models)
|
||
//! the data section is a straight sequence of sub-meshes, each:
|
||
//!
|
||
//! ```text
|
||
//! [ index buffer : idx_count × u16 big-endian ] triangle list
|
||
//! [ 12-byte vertex-buffer header (contents undecoded) ]
|
||
//! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven)
|
||
//! (pad to 16 bytes → next sub-mesh)
|
||
//! ```
|
||
//!
|
||
//! The vertex layout is **not fixed** — it comes from a **vertex declaration**
|
||
//! in the descriptor: a table of `{offset, format-code, usage}` triples (usage
|
||
//! `0x00` POSITION `f32×3`, `0x03` NORMAL `f16×4`, `0x05` TEXCOORD `f16×2`).
|
||
//! Models omit UV or use fewer elements, so stride varies (20 = pos+normal,
|
||
//! 24 = pos+normal+uv, …). Each element is read in naive big-endian component
|
||
//! order. Correct alignment is pinned by the recovered normals being exactly
|
||
//! unit-length. **Cross-checked against a Canary GPU vertex-fetch capture**,
|
||
//! which confirmed the primitive type (triangle list), formats, and offsets —
|
||
//! see `docs/re/structures/xbg7-mesh.md`.
|
||
//!
|
||
//! `vtx_count` / `idx_count` come from per-sub-mesh records in the descriptor:
|
||
//! a `[vtx_count:u32][0:u32][idx_count:u32][tail:u32]` tuple (big-endian), read
|
||
//! in file order. Every index is validated to be `< vtx_count`; if any
|
||
//! sub-mesh fails to carve cleanly the whole model is rejected
|
||
//! ([`MeshError::UnsupportedLayout`]) rather than emitting garbage.
|
||
//!
|
||
//! ## Not yet decoded
|
||
//!
|
||
//! The hero-ship *body* meshes (`DeltaSaber_*.xpr` `f004`, and ~100 other
|
||
//! models) use a more complex **multi-stream** layout — separate position /
|
||
//! attribute streams at descriptor-addressed offsets, quantized positions —
|
||
//! which is not handled here and is cleanly declined.
|
||
|
||
use crate::texture::{Xpr2Header, Xpr2ResourceEntry};
|
||
use binrw::BinRead;
|
||
use std::io::Cursor;
|
||
use thiserror::Error;
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum MeshError {
|
||
#[error("Not an XPR2 container")]
|
||
NotXpr2,
|
||
#[error("No XBG7 geometry resource in container")]
|
||
NoGeometry,
|
||
#[error("Mesh layout not supported (multi-stream / quantized body mesh)")]
|
||
UnsupportedLayout,
|
||
#[error("Parse error: {0}")]
|
||
Parse(#[from] binrw::Error),
|
||
}
|
||
|
||
/// A decoded 3D mesh ready for Bevy.
|
||
#[derive(Debug, Default, Clone)]
|
||
pub struct GameMesh {
|
||
/// Vertex positions in model space `[x, y, z]`.
|
||
pub positions: Vec<[f32; 3]>,
|
||
/// Vertex normals `[nx, ny, nz]` — empty when not stored (compute smooth
|
||
/// normals from geometry instead).
|
||
pub normals: Vec<[f32; 3]>,
|
||
/// Texture coordinates `[u, v]`. **Best-guess channel** (attr halves 0 & 2)
|
||
/// pending in-game visual confirmation — see the module doc.
|
||
pub uvs: Vec<[f32; 2]>,
|
||
/// Triangle-list indices (3 per triangle).
|
||
pub indices: Vec<u32>,
|
||
/// Sub-mesh / node name from the descriptor, when available.
|
||
pub name: Option<String>,
|
||
}
|
||
|
||
/// A model = the set of sub-meshes recovered from one XPR2 container's first
|
||
/// XBG7 resource, plus the resource's name.
|
||
#[derive(Debug, Default, Clone)]
|
||
pub struct Xbg7Model {
|
||
pub name: String,
|
||
pub meshes: Vec<GameMesh>,
|
||
}
|
||
|
||
impl Xbg7Model {
|
||
/// Total vertex / triangle counts across all sub-meshes.
|
||
pub fn totals(&self) -> (usize, usize) {
|
||
let v = self.meshes.iter().map(|m| m.positions.len()).sum();
|
||
let t = self.meshes.iter().map(|m| m.indices.len() / 3).sum();
|
||
(v, t)
|
||
}
|
||
|
||
/// Decode the geometry of the first XBG7 resource in an XPR2 container.
|
||
///
|
||
/// Returns [`MeshError::UnsupportedLayout`] for models whose data section
|
||
/// does not carve cleanly under the simple single-stream layout (the
|
||
/// complex body meshes) — never partial / garbage geometry.
|
||
pub fn from_xpr2(bytes: &[u8]) -> Result<Self, MeshError> {
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return Err(MeshError::NotXpr2);
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let header = Xpr2Header::read(&mut cur)?;
|
||
let mut xbg: Option<Xpr2ResourceEntry> = None;
|
||
for _ in 0..header.num_resources {
|
||
let e = Xpr2ResourceEntry::read(&mut cur)?;
|
||
if &e.type_tag == b"XBG7" {
|
||
xbg = Some(e);
|
||
break;
|
||
}
|
||
}
|
||
let xbg = xbg.ok_or(MeshError::NoGeometry)?;
|
||
|
||
const DIR_BASE: usize = 0x10;
|
||
let desc = xbg.data_offset as usize + DIR_BASE;
|
||
let desc_end = (desc + xbg.descriptor_size as usize).min(bytes.len());
|
||
if desc >= bytes.len() {
|
||
return Err(MeshError::UnsupportedLayout);
|
||
}
|
||
|
||
// Resource name (for labelling).
|
||
let name = read_cstr(bytes, xbg.name_offset as usize + DIR_BASE)
|
||
.unwrap_or_else(|| "XBG7".to_string());
|
||
|
||
// Parse the vertex declaration (element offsets/formats + stride). The
|
||
// XBG7 layout is NOT fixed-stride — models omit UV or use fewer elements
|
||
// (stride 20 = pos+normal, stride 24 = pos+normal+uv, …). Confirmed
|
||
// against a Canary GPU vertex-fetch capture (see docs/re/xbg7-mesh.md).
|
||
let decl = parse_vertex_decl(&bytes[desc..desc_end])
|
||
.ok_or(MeshError::UnsupportedLayout)?;
|
||
|
||
// Extract the ordered list of sub-mesh (vtx_count, idx_count) records.
|
||
let records = submesh_records(&bytes[desc..desc_end]);
|
||
if records.is_empty() {
|
||
return Err(MeshError::UnsupportedLayout);
|
||
}
|
||
|
||
// Carve the data section sequentially.
|
||
let base = header.header_size as usize;
|
||
let mut off = 0usize; // relative to `base`
|
||
let mut meshes = Vec::new();
|
||
for (vtx_count, idx_count) in records {
|
||
// Each sub-mesh block is `[12-byte header][index buffer][vertex
|
||
// buffer]` — the SAME layout as stage resources (see
|
||
// `stage_models`). The header must be skipped: reading indices from
|
||
// the block start instead treats the 12 header bytes as 6 junk
|
||
// indices (2 leading degenerate triangles — the stray-triangle
|
||
// artifact) and drops the last 6 real indices. Skipping it leaves the
|
||
// vertex buffer at the identical offset (`+12 + idx_bytes`), so
|
||
// coverage is unchanged; only the triangle list is corrected.
|
||
let ib = base + off + VERTEX_BUFFER_GAP;
|
||
let ie = ib + idx_count * 2;
|
||
let vb = ie;
|
||
let ve = vb + vtx_count * decl.stride;
|
||
if ve > bytes.len() {
|
||
return Err(MeshError::UnsupportedLayout);
|
||
}
|
||
|
||
// Indices (u16 BE), validated against the sub-mesh vertex count.
|
||
let mut indices = Vec::with_capacity(idx_count);
|
||
for k in 0..idx_count {
|
||
let i = be16(bytes, ib + k * 2) as u32;
|
||
if i >= vtx_count as u32 {
|
||
return Err(MeshError::UnsupportedLayout);
|
||
}
|
||
indices.push(i);
|
||
}
|
||
|
||
// Vertices per the declaration. The .xpr stores each element in
|
||
// naive big-endian component order (f32 / f16 read at consecutive
|
||
// offsets) — the GPU's `k8in32` fetch endianness applies to the
|
||
// rearranged guest-memory copy, not to these file bytes.
|
||
let mut positions = Vec::with_capacity(vtx_count);
|
||
let mut normals = Vec::with_capacity(vtx_count);
|
||
let mut uvs = Vec::with_capacity(vtx_count);
|
||
let mut normal_len_sum = 0.0f32;
|
||
for v in 0..vtx_count {
|
||
let o = vb + v * decl.stride;
|
||
|
||
// POSITION: f32×3 big-endian.
|
||
let p = o + decl.pos_offset;
|
||
let x = bef(bytes, p);
|
||
let y = bef(bytes, p + 4);
|
||
let z = bef(bytes, p + 8);
|
||
if !(x.is_finite() && y.is_finite() && z.is_finite()) {
|
||
return Err(MeshError::UnsupportedLayout);
|
||
}
|
||
positions.push([x, y, z]);
|
||
|
||
// NORMAL: f16×4 (use xyz).
|
||
if let Some(no) = decl.normal_offset {
|
||
let nb = o + no;
|
||
let nx = half(bytes, nb);
|
||
let ny = half(bytes, nb + 2);
|
||
let nz = half(bytes, nb + 4);
|
||
normal_len_sum += (nx * nx + ny * ny + nz * nz).sqrt();
|
||
normals.push([nx, ny, nz]);
|
||
}
|
||
|
||
// TEXCOORD: f16×2.
|
||
if let Some(uo) = decl.uv_offset {
|
||
let ub = o + uo;
|
||
uvs.push([half(bytes, ub), half(bytes, ub + 2)]);
|
||
}
|
||
}
|
||
|
||
// Sanity gate: when the declaration has a normal element, a correctly
|
||
// aligned vertex buffer yields unit-length normals. A mean far from 1
|
||
// means the layout does not fit (wrong stride / offset) — decline
|
||
// rather than emit garbage.
|
||
if decl.normal_offset.is_some() {
|
||
let mean = normal_len_sum / vtx_count.max(1) as f32;
|
||
if !(0.5..=2.0).contains(&mean) {
|
||
return Err(MeshError::UnsupportedLayout);
|
||
}
|
||
}
|
||
|
||
meshes.push(GameMesh {
|
||
positions,
|
||
normals,
|
||
uvs,
|
||
indices,
|
||
name: None,
|
||
});
|
||
off = align16(ve) - base;
|
||
}
|
||
|
||
Ok(Xbg7Model { name, meshes })
|
||
}
|
||
|
||
/// Decode **every** locatable XBG7 geometry resource in a container.
|
||
///
|
||
/// "Stage" containers (`hidden/resource3d/Stage_*.xpr`) are collections of
|
||
/// many enemy / prop sub-models, each an independent XBG7 resource. Unlike
|
||
/// the single-stream weapon layout (index buffer immediately followed by its
|
||
/// vertex buffer), a stage's index buffers and vertex buffers live in
|
||
/// **separate grouped pools**, and the container stores each resource's
|
||
/// buffer *sizes* (index count via the marker, vertex count 32 bytes before
|
||
/// it) but **not** an explicit data offset — the on-disc block layout is a
|
||
/// separate allocation order we have not reversed.
|
||
///
|
||
/// Rather than guess that order, each resource's `[index buffer][vertex
|
||
/// buffer]` block is located by **content**: the unique offset in the data
|
||
/// section where (a) all `index_count` indices are `< vertex_count`, (b) the
|
||
/// stored normals are unit length, and (c) the resulting triangles are
|
||
/// non-degenerate with a real spatial extent. This signature is strong
|
||
/// enough to pin a block unambiguously in a multi-megabyte file. Resources
|
||
/// that cannot be located and validated this way are **skipped** (never
|
||
/// emitted as garbage) — including the biggest hero bodies, which use the
|
||
/// same quantized/complex layout that [`Xbg7Model::from_xpr2`] declines.
|
||
///
|
||
/// Returns one [`Xbg7Model`] per decoded resource (empty if none decode).
|
||
pub fn stage_models(bytes: &[u8]) -> Vec<Xbg7Model> {
|
||
let mut out = Vec::new();
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return out;
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let header = match Xpr2Header::read(&mut cur) {
|
||
Ok(h) => h,
|
||
Err(_) => return out,
|
||
};
|
||
let data_base = header.header_size as usize;
|
||
if data_base >= bytes.len() {
|
||
return out;
|
||
}
|
||
|
||
// ── Collect every XBG7 resource's parameters up front. ──
|
||
const DIR_BASE: usize = 0x10;
|
||
struct Res {
|
||
name: String,
|
||
index_count: usize,
|
||
vtx_count: usize,
|
||
decl: VertexDecl,
|
||
}
|
||
let mut resources: Vec<Res> = Vec::new();
|
||
for _ in 0..header.num_resources {
|
||
let e = match Xpr2ResourceEntry::read(&mut cur) {
|
||
Ok(e) => e,
|
||
Err(_) => break,
|
||
};
|
||
if &e.type_tag != b"XBG7" {
|
||
continue;
|
||
}
|
||
let desc = e.data_offset as usize + DIR_BASE;
|
||
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
|
||
if desc >= bytes.len() || desc_end <= desc {
|
||
continue;
|
||
}
|
||
let d = &bytes[desc..desc_end];
|
||
let (mk_rel, index_count) = match find_index_marker(d) {
|
||
Some(m) => m,
|
||
None => continue,
|
||
};
|
||
let decl = match parse_vertex_decl(d) {
|
||
Some(v) => v,
|
||
None => continue,
|
||
};
|
||
// Total vertex count is stored 32 bytes before the index marker.
|
||
if mk_rel < 32 {
|
||
continue;
|
||
}
|
||
let vtx_count = be32(d, mk_rel - 32) as usize;
|
||
if !(3..=400_000).contains(&vtx_count) || index_count < 3 {
|
||
continue;
|
||
}
|
||
// Anchoring relies on the unit-normal signature; skip resources with
|
||
// no NORMAL element (too ambiguous to pin safely).
|
||
if decl.normal_offset.is_none() {
|
||
continue;
|
||
}
|
||
let name = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||
.unwrap_or_else(|| "XBG7".to_string());
|
||
resources.push(Res {
|
||
name,
|
||
index_count,
|
||
vtx_count,
|
||
decl,
|
||
});
|
||
}
|
||
if resources.is_empty() {
|
||
return out;
|
||
}
|
||
|
||
// ── One O(file) pass per distinct stride: find vertex-block *starts*. ──
|
||
//
|
||
// Each geometry block is `[12B header][index buffer][vertex buffer]`, and
|
||
// the blocks are scattered among texture data with no stored offset. But
|
||
// a vertex buffer is a run of stride-sized records whose NORMAL (f16×4 at
|
||
// +12) is unit length; a *block start* is the unique offset where that
|
||
// run begins — the previous stride slot is NOT a unit-normal vertex (it's
|
||
// index bytes / header). Collecting those starts turns the per-resource
|
||
// search from O(file) into a scan of a few hundred candidates.
|
||
let mut strides: Vec<usize> = resources.iter().map(|r| r.decl.stride).collect();
|
||
strides.sort_unstable();
|
||
strides.dedup();
|
||
let mut starts_by_stride: std::collections::BTreeMap<usize, Vec<usize>> =
|
||
std::collections::BTreeMap::new();
|
||
for &s in &strides {
|
||
starts_by_stride.insert(s, vertex_run_starts(bytes, data_base, s));
|
||
}
|
||
|
||
for r in &resources {
|
||
let starts = &starts_by_stride[&r.decl.stride];
|
||
if let Some(mesh) = anchor_pool_mesh(
|
||
bytes,
|
||
starts,
|
||
r.index_count,
|
||
r.vtx_count,
|
||
&r.decl,
|
||
) {
|
||
out.push(Xbg7Model {
|
||
name: r.name.clone(),
|
||
meshes: vec![mesh],
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// Scan the data section for offsets that begin a `stride`-sized unit-normal
|
||
/// vertex run (NORMAL is `f16×4` at vertex offset +12). A run *start* is an
|
||
/// offset whose normal is unit while the preceding stride slot's is not — i.e.
|
||
/// the first vertex of a buffer, not a mid-buffer position. Returns the sorted
|
||
/// candidate starts (block vertex-buffer offsets).
|
||
fn vertex_run_starts(bytes: &[u8], data_base: usize, stride: usize) -> Vec<usize> {
|
||
const NRM: usize = 12; // POSITION f32×3 occupies [0,12); NORMAL f16×4 follows
|
||
let mut starts = Vec::new();
|
||
if stride < NRM + 8 {
|
||
return starts;
|
||
}
|
||
let is_unit = |o: usize| -> bool {
|
||
if o + NRM + 6 > bytes.len() {
|
||
return false;
|
||
}
|
||
let nx = half(bytes, o + NRM);
|
||
let ny = half(bytes, o + NRM + 2);
|
||
let nz = half(bytes, o + NRM + 4);
|
||
let l = (nx * nx + ny * ny + nz * nz).sqrt();
|
||
(0.85..=1.15).contains(&l)
|
||
};
|
||
// Vertex buffers begin on 4-byte boundaries in practice; step 4.
|
||
let end = bytes.len().saturating_sub(NRM + 6);
|
||
let mut o = data_base;
|
||
while o <= end {
|
||
if is_unit(o) && (o < data_base + stride || !is_unit(o - stride)) {
|
||
starts.push(o);
|
||
}
|
||
o += 4;
|
||
}
|
||
starts
|
||
}
|
||
|
||
/// Locate a stage resource's `[index buffer][vertex buffer]` block among the
|
||
/// precomputed vertex-run `starts` (see [`vertex_run_starts`]) and decode it, or
|
||
/// return `None` if no candidate validates. A candidate `vb` is accepted when
|
||
/// the `index_count` indices ending just before it are all `< vtx_count`,
|
||
/// reference (nearly) all vertices, and produce non-degenerate triangles with a
|
||
/// real spatial extent — a signature strong enough to pin the block.
|
||
fn anchor_pool_mesh(
|
||
bytes: &[u8],
|
||
starts: &[usize],
|
||
index_count: usize,
|
||
vtx_count: usize,
|
||
decl: &VertexDecl,
|
||
) -> Option<GameMesh> {
|
||
let stride = decl.stride;
|
||
let idx_bytes = index_count * 2;
|
||
let vtx_bytes = vtx_count.checked_mul(stride)?;
|
||
|
||
for &vb in starts {
|
||
// The index buffer sits immediately before the vertex buffer.
|
||
if vb < idx_bytes {
|
||
continue;
|
||
}
|
||
let ib = vb - idx_bytes;
|
||
if vb + vtx_bytes > bytes.len() {
|
||
continue;
|
||
}
|
||
|
||
// ── Full index validation: every index in range, uses ~all vertices. ──
|
||
let mut max_idx = 0u32;
|
||
let mut ok = true;
|
||
for k in 0..index_count {
|
||
let i = be16(bytes, ib + k * 2) as u32;
|
||
if i >= vtx_count as u32 {
|
||
ok = false;
|
||
break;
|
||
}
|
||
max_idx = max_idx.max(i);
|
||
}
|
||
if !ok || (max_idx as usize) + 4 < vtx_count {
|
||
continue;
|
||
}
|
||
|
||
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
|
||
let pos = decl.pos_offset;
|
||
let mut lo = [f32::MAX; 3];
|
||
let mut hi = [f32::MIN; 3];
|
||
let mut degenerate = 0usize;
|
||
let mut sampled = 0usize;
|
||
let mut edge_sum = 0.0f32;
|
||
let tris = index_count / 3;
|
||
let tstep = (tris / 96).max(1);
|
||
let mut t = 0;
|
||
let mut bad = false;
|
||
while t < tris {
|
||
let mut p = [[0.0f32; 3]; 3];
|
||
for (c, pc) in p.iter_mut().enumerate() {
|
||
let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
|
||
let base = vb + vi * stride + pos;
|
||
for (a, slot) in pc.iter_mut().enumerate() {
|
||
let x = bef(bytes, base + a * 4);
|
||
if !x.is_finite() || x.abs() > 1.0e6 {
|
||
bad = true;
|
||
break;
|
||
}
|
||
*slot = x;
|
||
lo[a] = lo[a].min(x);
|
||
hi[a] = hi[a].max(x);
|
||
}
|
||
if bad {
|
||
break;
|
||
}
|
||
}
|
||
if bad {
|
||
break;
|
||
}
|
||
let u = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
|
||
let w = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
|
||
let cx = [
|
||
u[1] * w[2] - u[2] * w[1],
|
||
u[2] * w[0] - u[0] * w[2],
|
||
u[0] * w[1] - u[1] * w[0],
|
||
];
|
||
if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
|
||
degenerate += 1;
|
||
}
|
||
// Sum the triangle's three edge lengths (for the connectivity check).
|
||
let e3 = [p[2][0] - p[1][0], p[2][1] - p[1][1], p[2][2] - p[1][2]];
|
||
edge_sum += (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt()
|
||
+ (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
|
||
+ (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
|
||
sampled += 1;
|
||
t += tstep;
|
||
}
|
||
if bad {
|
||
continue;
|
||
}
|
||
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
|
||
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
|
||
continue; // too flat, or >30% degenerate → not this block
|
||
}
|
||
// Connectivity check: a correctly-anchored mesh has triangle edges that
|
||
// are SMALL relative to its overall size (~0.05–0.15 of the bbox
|
||
// diagonal). A wrong anchor / cross-wired index buffer connects distant
|
||
// vertices, so its mean edge spans a large fraction of the model (a spiky
|
||
// mess). Reject those — try another candidate or decline.
|
||
let diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
|
||
.sqrt()
|
||
.max(1e-6);
|
||
let mean_edge = edge_sum / (sampled as f32 * 3.0);
|
||
if mean_edge / diag > 0.28 {
|
||
continue;
|
||
}
|
||
|
||
// ── Accepted: read the full mesh. ──
|
||
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Read positions / normals / uvs / indices for an anchored stage block.
|
||
fn read_pool_mesh(
|
||
bytes: &[u8],
|
||
ib: usize,
|
||
vb: usize,
|
||
index_count: usize,
|
||
vtx_count: usize,
|
||
decl: &VertexDecl,
|
||
) -> GameMesh {
|
||
let stride = decl.stride;
|
||
let indices: Vec<u32> = (0..index_count)
|
||
.map(|k| be16(bytes, ib + k * 2) as u32)
|
||
.collect();
|
||
|
||
let mut positions = Vec::with_capacity(vtx_count);
|
||
let mut normals = Vec::with_capacity(vtx_count);
|
||
let mut uvs = Vec::with_capacity(vtx_count);
|
||
for v in 0..vtx_count {
|
||
let o = vb + v * stride;
|
||
let p = o + decl.pos_offset;
|
||
positions.push([bef(bytes, p), bef(bytes, p + 4), bef(bytes, p + 8)]);
|
||
if let Some(no) = decl.normal_offset {
|
||
let nb = o + no;
|
||
normals.push([half(bytes, nb), half(bytes, nb + 2), half(bytes, nb + 4)]);
|
||
}
|
||
if let Some(uo) = decl.uv_offset {
|
||
let ub = o + uo;
|
||
uvs.push([half(bytes, ub), half(bytes, ub + 2)]);
|
||
}
|
||
}
|
||
GameMesh {
|
||
positions,
|
||
normals,
|
||
uvs,
|
||
indices,
|
||
name: None,
|
||
}
|
||
}
|
||
|
||
// ── Layout constants ────────────────────────────────────────────────────────
|
||
|
||
/// Size of the header that precedes each sub-mesh block's index buffer
|
||
/// (`[12-byte header][index buffer][vertex buffer]`). Contents not yet decoded.
|
||
const VERTEX_BUFFER_GAP: usize = 12;
|
||
|
||
// ── Vertex declaration ───────────────────────────────────────────────────────
|
||
|
||
/// The vertex layout for one XBG7 resource, parsed from the descriptor's
|
||
/// declaration table (shared by all its sub-meshes).
|
||
struct VertexDecl {
|
||
/// Bytes per vertex.
|
||
stride: usize,
|
||
/// Byte offset of the POSITION element (`f32×3`) within a vertex.
|
||
pos_offset: usize,
|
||
/// Byte offset of the NORMAL element (`f16×4`), if present.
|
||
normal_offset: Option<usize>,
|
||
/// Byte offset of the TEXCOORD element (`f16×2`), if present.
|
||
uv_offset: Option<usize>,
|
||
}
|
||
|
||
/// Known element format codes → element size in bytes (from the GPU capture:
|
||
/// POSITION `f32×3`, NORMAL `f16×4`, TEXCOORD `f16×2`).
|
||
fn decl_code_size(code: u32) -> Option<usize> {
|
||
match code {
|
||
0x2A_23B9 => Some(12), // f32×3 (POSITION)
|
||
0x1A_2360 => Some(8), // f16×4 (NORMAL)
|
||
0x2C_235F => Some(4), // f16×2 (TEXCOORD)
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Parse the XBG7 vertex declaration: a table of `{offset:u32, code:u32,
|
||
/// usage<<16:u32}` big-endian triples that follows the `(index_bytes,
|
||
/// index_count)` marker, terminated by an `offset == 0x00FF0000` /
|
||
/// `code == 0xFFFFFFFF` sentinel. Usage codes: `0` POSITION, `3` NORMAL,
|
||
/// `5` TEXCOORD. Stride is the max element extent; unknown element sizes are
|
||
/// inferred from the next element's offset.
|
||
/// Locate the `(index_bytes, index_count)` marker in a descriptor: the first
|
||
/// big-endian pair where `index_bytes == index_count * 2` and `index_count` is a
|
||
/// positive multiple of 3. Returns `(rel_offset, index_count)`.
|
||
fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> {
|
||
let mut rel = 0usize;
|
||
while rel + 40 <= desc.len() {
|
||
let a = be32(desc, rel);
|
||
let c = be32(desc, rel + 4);
|
||
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
|
||
return Some((rel, c as usize));
|
||
}
|
||
rel += 4;
|
||
}
|
||
None
|
||
}
|
||
|
||
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
|
||
let mk = find_index_marker(desc)?.0;
|
||
|
||
// Read declaration triples.
|
||
let mut elems: Vec<(usize, u32, u32)> = Vec::new(); // (offset, code, usage)
|
||
let mut r = mk + 8;
|
||
for _ in 0..16 {
|
||
if r + 12 > desc.len() {
|
||
break;
|
||
}
|
||
let off = be32(desc, r);
|
||
let code = be32(desc, r + 4);
|
||
let usage = be32(desc, r + 8) >> 16;
|
||
if off == 0x00FF_0000 || code == 0xFFFF_FFFF {
|
||
break;
|
||
}
|
||
if off as usize > 0x1000 {
|
||
break; // out-of-range offset — not a real element
|
||
}
|
||
elems.push((off as usize, code & 0x00FF_FFFF, usage));
|
||
r += 12;
|
||
}
|
||
if elems.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut stride = 0usize;
|
||
for (i, &(off, code, _)) in elems.iter().enumerate() {
|
||
let size = decl_code_size(code).unwrap_or_else(|| {
|
||
if i + 1 < elems.len() {
|
||
elems[i + 1].0.saturating_sub(off)
|
||
} else {
|
||
4
|
||
}
|
||
});
|
||
stride = stride.max(off + size);
|
||
}
|
||
if stride == 0 || stride > 256 {
|
||
return None;
|
||
}
|
||
|
||
let pos_offset = elems
|
||
.iter()
|
||
.find(|&&(_, c, u)| c == 0x2A_23B9 || u == 0)
|
||
.map(|&(o, _, _)| o)
|
||
.unwrap_or(0);
|
||
let normal_offset = elems.iter().find(|&&(_, _, u)| u == 3).map(|&(o, _, _)| o);
|
||
let uv_offset = elems.iter().find(|&&(_, _, u)| u == 5).map(|&(o, _, _)| o);
|
||
|
||
Some(VertexDecl {
|
||
stride,
|
||
pos_offset,
|
||
normal_offset,
|
||
uv_offset,
|
||
})
|
||
}
|
||
|
||
// ── Descriptor sub-mesh record scan ─────────────────────────────────────────
|
||
|
||
/// Scan an XBG7 descriptor for the ordered list of per-sub-mesh
|
||
/// `(vtx_count, idx_count)` records.
|
||
///
|
||
/// The record is a big-endian tuple `[vtx:u32][0:u32][idx:u32][tail:u32]` with
|
||
/// `3 ≤ vtx ≤ 65535`, the second word zero, `idx` a positive multiple of 3, and
|
||
/// a small non-zero `tail`. Found by a sliding 4-byte scan (records are not on
|
||
/// a fixed stride in the scene graph).
|
||
fn submesh_records(desc: &[u8]) -> Vec<(usize, usize)> {
|
||
let mut out = Vec::new();
|
||
if desc.len() < 16 {
|
||
return out;
|
||
}
|
||
let mut rel = 0usize;
|
||
while rel + 16 <= desc.len() {
|
||
let a = be32(desc, rel);
|
||
let z = be32(desc, rel + 4);
|
||
let c = be32(desc, rel + 8);
|
||
let t = be32(desc, rel + 12);
|
||
if (3..=65535).contains(&a)
|
||
&& z == 0
|
||
&& c >= 3
|
||
&& c <= 200_000
|
||
&& c % 3 == 0
|
||
&& (1..=64).contains(&t)
|
||
{
|
||
out.push((a as usize, c as usize));
|
||
rel += 16; // consume the record
|
||
} else {
|
||
rel += 4;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
// ── Little primitive readers ────────────────────────────────────────────────
|
||
|
||
#[inline]
|
||
fn align16(x: usize) -> usize {
|
||
(x + 15) & !15
|
||
}
|
||
#[inline]
|
||
fn be16(b: &[u8], o: usize) -> u16 {
|
||
u16::from_be_bytes([b[o], b[o + 1]])
|
||
}
|
||
#[inline]
|
||
fn be32(b: &[u8], o: usize) -> u32 {
|
||
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||
}
|
||
#[inline]
|
||
fn bef(b: &[u8], o: usize) -> f32 {
|
||
f32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||
}
|
||
/// Big-endian IEEE-754 half → f32.
|
||
#[inline]
|
||
fn half(b: &[u8], o: usize) -> f32 {
|
||
f16_to_f32(be16(b, o))
|
||
}
|
||
|
||
/// Minimal IEEE-754 binary16 → binary32 (no external dep).
|
||
fn f16_to_f32(h: u16) -> f32 {
|
||
let sign = (h >> 15) & 1;
|
||
let exp = (h >> 10) & 0x1F;
|
||
let mant = h & 0x3FF;
|
||
let bits: u32 = match exp {
|
||
0 if mant == 0 => (sign as u32) << 31, // ±0
|
||
0 => {
|
||
// subnormal → normalize
|
||
let mut e: i32 = -1;
|
||
let mut m = mant as u32;
|
||
loop {
|
||
e += 1;
|
||
m <<= 1;
|
||
if m & 0x400 != 0 {
|
||
break;
|
||
}
|
||
}
|
||
let exp32 = (127 - 15 - e) as u32;
|
||
((sign as u32) << 31) | (exp32 << 23) | ((m & 0x3FF) << 13)
|
||
}
|
||
0x1F => ((sign as u32) << 31) | (0xFF << 23) | ((mant as u32) << 13), // Inf/NaN
|
||
_ => {
|
||
let exp32 = (exp as i32 - 15 + 127) as u32;
|
||
((sign as u32) << 31) | (exp32 << 23) | ((mant as u32) << 13)
|
||
}
|
||
};
|
||
f32::from_bits(bits)
|
||
}
|
||
|
||
fn read_cstr(b: &[u8], o: usize) -> Option<String> {
|
||
if o >= b.len() {
|
||
return None;
|
||
}
|
||
let end = b[o..].iter().position(|&c| c == 0).map(|p| o + p)?;
|
||
if end == o {
|
||
return None;
|
||
}
|
||
Some(String::from_utf8_lossy(&b[o..end]).into_owned())
|
||
}
|
||
|
||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn half_roundtrip_known_values() {
|
||
assert_eq!(f16_to_f32(0x3C00), 1.0); // 1.0
|
||
assert_eq!(f16_to_f32(0x0000), 0.0); // +0
|
||
assert_eq!(f16_to_f32(0xBC00), -1.0); // -1.0
|
||
assert_eq!(f16_to_f32(0x4000), 2.0); // 2.0
|
||
assert!((f16_to_f32(0x3800) - 0.5).abs() < 1e-6); // 0.5
|
||
}
|
||
|
||
#[test]
|
||
fn submesh_record_scan_finds_tuple() {
|
||
// [vtx=215][0][idx=1092][tail=4]
|
||
let mut d = vec![0u8; 32];
|
||
d[0..4].copy_from_slice(&215u32.to_be_bytes());
|
||
d[8..12].copy_from_slice(&1092u32.to_be_bytes());
|
||
d[12..16].copy_from_slice(&4u32.to_be_bytes());
|
||
let recs = submesh_records(&d);
|
||
assert_eq!(recs, vec![(215, 1092)]);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_non_xpr2() {
|
||
assert!(matches!(
|
||
Xbg7Model::from_xpr2(b"NOPEnotacontainerXXXXXXXX"),
|
||
Err(MeshError::NotXpr2)
|
||
));
|
||
}
|
||
}
|