Revert the mis-guided "triangle STRIP" reading (a937779): XBG7 index
buffers are triangle LISTS (prim=4 in the GPU capture; stored-normal
agreement 1.000 as a list vs ~0.49 as a strip). Reinstate the
winding-consistency gate.
Crack the grouped-pool layout used by the hero ship and ~150 detailed
models. A resource's sub-meshes share one index pool (buffers 4-byte
aligned, in descriptor-marker order) followed by one vertex pool (each
vtx_count*stride, same order); the vertex pool is 4-byte aligned after
the index pool. The whole resource is derived from one anchored pivot:
ib0 = vb0 - span - pad (pad in 0..=3, alignment)
ib[i] = align4(ib[i-1] + idx_count[i-1]*2)
vb[i] = vb[i-1] + vtx_count[i-1]*stride
vb0 is found via the unit-normal vertex-run scan; the alignment is
confirmed by validating the LARGEST sub-mesh (most reliable), after
which the rest are read/validated. A single marker reduces this to the
existing adjacency anchor, so grouped generalises it.
Results (cross-checked against a Canary GPU draw-log capture of
DeltaSaber_T.xpr):
- DeltaSaber_T f001 = body + 7 detail parts = 8650 tris, every sub-mesh
0-degenerate / full-coverage / winding-agreement 1.000.
- All 19 previously-declined weapon models now decode (they hit pad 2
and/or lead with a tiny bracket that broke a markers[0] pivot). Corpus
audit: 0/146 geometry files fail (was 19 -> flat-texture in the viewer).
- Stages unchanged (single-marker path is byte-identical; grouped falls
back to the old anchor on failure). 7/7 disc tests green incl. the
strict stage quality audit; new hero_ship_grouped_pool_decodes test.
Viewer: route by count_xbg7; --only matches an exact model name (so the
neutral pose renders without the mnv*/turn180 animation poses). Fix the
albedo matcher: match a sub-model to its _col map by entity stem
(e007_bdy_01 -> e007_col) instead of a full-name prefix, lifting stage
sub-model texturing from ~25% to ~95% (the rest were flat grey). Colour
correctness (channel order/sRGB) remains a separate dynamic-RE item.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1247 lines
52 KiB
Rust
1247 lines
52 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
|
||
//! [ 12-byte vertex-buffer header (contents undecoded) ]
|
||
//! [ index buffer : idx_count × u16 big-endian ] triangle LIST
|
||
//! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven)
|
||
//! (pad to 16 bytes → next sub-mesh)
|
||
//! ```
|
||
//!
|
||
//! The index buffer is a triangle **list**: `idx_count` is a multiple of 3 and
|
||
//! each consecutive triple is one triangle. This is confirmed two ways — the
|
||
//! Canary GPU draw capture logs `prim=4` (triangle list), and the objective
|
||
//! `XVERIFY` diagnostic shows stored-normal agreement of **1.000** under the list
|
||
//! reading (every face normal points the way its vertices' normals do — only
|
||
//! possible with correct topology + winding) versus **~0.49** (random) under a
|
||
//! strip reading. A brief 2026-07 attempt to read these as triangle strips was a
|
||
//! mistake: it over-generated ~2.5× the triangles, filling holes with an
|
||
//! overlapping garbage soup that *looked* solid but had random normals.
|
||
//!
|
||
//! Correctness is enforced by a **winding-consistency gate**: a correctly-carved
|
||
//! sub-mesh agrees with its stored normals either ≈always (≈1.0) or ≈never (≈0.0,
|
||
//! inverted winding — still a real single-sided mesh); a mis-carve wires arbitrary
|
||
//! vertices and scatters to the ≈0.5 middle. Sub-meshes with `max(na, 1-na) <
|
||
//! 0.90` are declined rather than emitted as a spike-mess.
|
||
//!
|
||
//! 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.
|
||
//!
|
||
//! ## The grouped-pool layout (CONFIRMED — hero ships)
|
||
//!
|
||
//! Detailed models (`DeltaSaber_*.xpr` and ~100 others) don't interleave each
|
||
//! sub-mesh's index buffer with its vertex buffer. Instead a resource's *several*
|
||
//! sub-meshes share **two grouped pools**:
|
||
//!
|
||
//! ```text
|
||
//! index pool : [ ib0 | ib1 | … ] each buffer 4-byte aligned, in marker order
|
||
//! vertex pool : [ vb0 | vb1 | … ] each pool `vtx_count × stride`, same order
|
||
//! ```
|
||
//!
|
||
//! with the index pool ending exactly where the vertex pool begins. This is the
|
||
//! **same vertex format** as weapons (stride 24, triangle list) — it was declined
|
||
//! only for *location*, not format. [`Xbg7Model::anchor_models`] decodes it: it
|
||
//! pivots on the one unknown, `vb0` (found by the unit-normal vertex-run scan),
|
||
//! and derives every other index/vertex offset (see [`anchor_grouped_meshes`]).
|
||
//! Reversed statically and cross-checked against a Canary GPU draw-log capture of
|
||
//! `DeltaSaber_T.xpr` (`f001` = body + 7 detail parts = 8650 tris, every sub-mesh
|
||
//! at 0 degenerate / full coverage). See `docs/re/structures/xbg7-mesh.md`.
|
||
//!
|
||
//! Resources whose grouped pivot cannot be validated (a few multi-stream /
|
||
//! quantized bodies remain) are still cleanly declined rather than emitted as
|
||
//! garbage.
|
||
|
||
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>,
|
||
}
|
||
|
||
/// Count the `XBG7` geometry resources in an XPR2 container. Single-model files
|
||
/// (weapons / props) have exactly one; the multi-resource `Stage_*.xpr`
|
||
/// collections have many. Used to route decoding: one → the validated
|
||
/// records-based list decode ([`Xbg7Model::from_xpr2`]); many → the
|
||
/// content-anchored [`Xbg7Model::stage_models`]. (Content-anchoring a
|
||
/// single-model file fabricates phantom / duplicate blocks — see `decode`.)
|
||
pub fn count_xbg7(bytes: &[u8]) -> usize {
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return 0;
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let header = match Xpr2Header::read(&mut cur) {
|
||
Ok(h) => h,
|
||
Err(_) => return 0,
|
||
};
|
||
let mut n = 0usize;
|
||
for _ in 0..header.num_resources {
|
||
match Xpr2ResourceEntry::read(&mut cur) {
|
||
Ok(e) if &e.type_tag == b"XBG7" => n += 1,
|
||
Ok(_) => {}
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
if std::env::var("XMESHDBG").is_ok() {
|
||
let d = &bytes[desc..desc_end];
|
||
eprintln!("[{name}] stride={} records={records:?}", decl.stride);
|
||
let mut rel = 0usize;
|
||
while rel + 8 <= d.len() {
|
||
let (a, c) = (be32(d, rel), be32(d, rel + 4));
|
||
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
|
||
eprintln!(" idx-marker @0x{rel:03x}: idx_count={c}");
|
||
}
|
||
rel += 4;
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// The index buffer is a triangle LIST (u16 BE): `idx_count` is a
|
||
// multiple of 3, and each consecutive triple is one triangle. This is
|
||
// the format the game actually draws (Canary GPU capture: prim=4 =
|
||
// triangle list), and the objective proof is `XVERIFY`: reading it as
|
||
// a list gives stored-normal agreement 1.000 (every face normal points
|
||
// the way its vertices' normals do — only possible with correct
|
||
// topology + winding), whereas a strip reading gives ~0.49 (random).
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ── Decisive list-vs-strip diagnostic (env XVERIFY) ──────────────
|
||
// For each sub-mesh compute, under BOTH interpretations, two
|
||
// topology-correctness metrics that do NOT depend on which is "nicer
|
||
// looking": stored-normal agreement (a triangle's cross-product face
|
||
// normal should point the same way as its vertices' stored normals —
|
||
// correct topology ⇒ ~all agree; wrong topology wires arbitrary
|
||
// vertices ⇒ ~50%) and edge-manifoldness (a closed surface shares
|
||
// almost every edge between exactly 2 triangles).
|
||
if std::env::var("XVERIFY").is_ok() {
|
||
let (lt, ld, ln, le) = topology_report(&indices, &positions, &normals);
|
||
let stripped = expand_triangle_strip(&indices);
|
||
let (st, sd, sn, se) = topology_report(&stripped, &positions, &normals);
|
||
eprintln!(
|
||
" submesh v={vtx_count} idx={idx_count}\n\
|
||
\x20 LIST : {lt:>5} tris, degen {ld:>4}, normal-agree {ln:.3}, edge2 {le:.3}\n\
|
||
\x20 STRIP: {st:>5} tris, degen {sd:>4}, normal-agree {sn:.3}, edge2 {se:.3}"
|
||
);
|
||
}
|
||
|
||
// Objective quality gate on winding CONSISTENCY. Each triangle's
|
||
// cross-product face normal is compared to its vertices' stored
|
||
// normals; `na` is the fraction that agree. A correctly-carved,
|
||
// consistently-wound mesh sits at either ≈1.0 (winding matches the
|
||
// normals) OR ≈0.0 (winding is inverted relative to them — still a
|
||
// real, single-sided mesh, just authored the other way). A mis-carve
|
||
// wires arbitrary vertices, so agreement collapses to the ≈0.5 middle.
|
||
// Gate on `max(na, 1-na)` so both consistent windings pass and only
|
||
// the random middle is declined (e.g. `rou_f001_wep_23` na=0.398 →
|
||
// consistency 0.602 → declined; the clean weapons are all 1.000).
|
||
if !normals.is_empty() {
|
||
let (_, _, na, _) = topology_report(&indices, &positions, &normals);
|
||
if na.max(1.0 - na) < 0.90 {
|
||
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).
|
||
///
|
||
/// Multi-resource stage files use `min_consistency = 0.0` (no winding gate):
|
||
/// the stage corpus is large and its enemy meshes span a continuous
|
||
/// consistency range (0.5–1.0), so a hard gate would drop many legitimate
|
||
/// blocks. The single-model **weapon fallback** ([`decode` in the CLI]) calls
|
||
/// [`Xbg7Model::anchor_models`] with a strict `0.85`, where a mis-anchor is an
|
||
/// obvious phantom / spike-mess that must be declined.
|
||
pub fn stage_models(bytes: &[u8]) -> Vec<Xbg7Model> {
|
||
Self::anchor_models(bytes, 0.0)
|
||
}
|
||
|
||
/// Content-anchor every XBG7 resource, rejecting any block whose winding
|
||
/// consistency (`max(na, 1-na)`) is below `min_consistency`. See
|
||
/// [`Xbg7Model::stage_models`].
|
||
pub fn anchor_models(bytes: &[u8], min_consistency: f32) -> 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,
|
||
/// `(vtx_count, idx_count)` for every sub-mesh, in file order. One
|
||
/// entry → the simple adjacency layout ([`anchor_pool_mesh`]); several
|
||
/// → the grouped-pool layout ([`anchor_grouped_meshes`]).
|
||
markers: Vec<(usize, 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 markers = all_index_markers(d);
|
||
if markers.is_empty() {
|
||
continue;
|
||
}
|
||
let decl = match parse_vertex_decl(d) {
|
||
Some(v) => v,
|
||
None => 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,
|
||
markers,
|
||
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];
|
||
let meshes = if r.markers.len() == 1 {
|
||
// Single sub-mesh → the proven per-block adjacency anchor
|
||
// (index buffer immediately before its vertex buffer). Stages and
|
||
// simple props take this path; `min_consistency` behaviour is
|
||
// exactly as before.
|
||
let (vtx_count, index_count) = r.markers[0];
|
||
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
|
||
.into_iter()
|
||
.collect()
|
||
} else {
|
||
// Several sub-meshes sharing grouped index/vertex pools → the
|
||
// deterministic grouped-pool decode (hero ships et al.).
|
||
let grouped = anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl);
|
||
if !grouped.is_empty() {
|
||
grouped
|
||
} else {
|
||
// The grouped pivot didn't validate (the extra markers were a
|
||
// coincidence, or this isn't a grouped-pool model). Fall back
|
||
// to the original single-block adjacency anchor on the first
|
||
// marker so coverage is never *below* the pre-grouped decode.
|
||
let (vtx_count, index_count) = r.markers[0];
|
||
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
|
||
.into_iter()
|
||
.collect()
|
||
}
|
||
};
|
||
if !meshes.is_empty() {
|
||
out.push(Xbg7Model {
|
||
name: r.name.clone(),
|
||
meshes,
|
||
});
|
||
}
|
||
}
|
||
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,
|
||
min_consistency: f32,
|
||
) -> Option<GameMesh> {
|
||
let idx_bytes = index_count * 2;
|
||
for &vb in starts {
|
||
// The index buffer sits just before the vertex buffer, which is 4-byte
|
||
// aligned — so 0..=3 bytes of padding may separate them (`ib = vb −
|
||
// idx_bytes − pad`). pad 0 is the immediate-adjacency case (all stages so
|
||
// far); some weapons need pad 2. A shifted pad reads garbled indices, so
|
||
// pad>0 is gated at a strict 0.85 consistency to avoid a false anchor in
|
||
// the ungated (`min_consistency == 0`) stage path — pad 0 keeps its exact
|
||
// prior behaviour.
|
||
for pad in 0..=3usize {
|
||
if vb < idx_bytes + pad {
|
||
continue;
|
||
}
|
||
let ib = vb - idx_bytes - pad;
|
||
let mc = if pad == 0 {
|
||
min_consistency
|
||
} else {
|
||
min_consistency.max(0.85)
|
||
};
|
||
if validate_block(bytes, ib, vb, vtx_count, index_count, decl, mc, true) {
|
||
// ── Accepted: read the full mesh. ──
|
||
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Check whether the `index_count` big-endian `u16` indices at `ib`, read against
|
||
/// the `vtx_count`-vertex pool at `vb`, form a coherent triangle-list sub-mesh:
|
||
/// every index in range, ~all vertices referenced, non-degenerate triangles with
|
||
/// a real spatial extent, connected edges, and (when `min_consistency > 0`) a
|
||
/// winding consistent with the stored normals. This is the shared acceptance test
|
||
/// for both the adjacency anchor ([`anchor_pool_mesh`]) and the grouped-pool
|
||
/// anchor ([`anchor_grouped_meshes`]); the two differ only in how they *place*
|
||
/// `ib`/`vb`, not in how they validate a placement.
|
||
fn validate_block(
|
||
bytes: &[u8],
|
||
ib: usize,
|
||
vb: usize,
|
||
vtx_count: usize,
|
||
index_count: usize,
|
||
decl: &VertexDecl,
|
||
min_consistency: f32,
|
||
strict_connectivity: bool,
|
||
) -> bool {
|
||
let stride = decl.stride;
|
||
let idx_bytes = index_count * 2;
|
||
if ib + idx_bytes > bytes.len() {
|
||
return false;
|
||
}
|
||
let vtx_bytes = match vtx_count.checked_mul(stride) {
|
||
Some(v) => v,
|
||
None => return false,
|
||
};
|
||
if vb + vtx_bytes > bytes.len() {
|
||
return false;
|
||
}
|
||
|
||
// ── Full index validation: every index in range, uses ~all vertices. ──
|
||
let mut max_idx = 0u32;
|
||
for k in 0..index_count {
|
||
let i = be16(bytes, ib + k * 2) as u32;
|
||
if i >= vtx_count as u32 {
|
||
return false;
|
||
}
|
||
max_idx = max_idx.max(i);
|
||
}
|
||
if (max_idx as usize) + 4 < vtx_count {
|
||
return false;
|
||
}
|
||
|
||
// ── 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 mut nrm_agree = 0usize;
|
||
let mut nrm_counted = 0usize;
|
||
let tris = index_count / 3;
|
||
let tstep = (tris / 96).max(1);
|
||
let mut t = 0;
|
||
while t < tris {
|
||
let mut p = [[0.0f32; 3]; 3];
|
||
let mut tri_idx = [0usize; 3];
|
||
for (c, pc) in p.iter_mut().enumerate() {
|
||
let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
|
||
tri_idx[c] = vi;
|
||
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 {
|
||
return false;
|
||
}
|
||
*slot = x;
|
||
lo[a] = lo[a].min(x);
|
||
hi[a] = hi[a].max(x);
|
||
}
|
||
}
|
||
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;
|
||
} else if let Some(no) = decl.normal_offset {
|
||
// Stored-normal agreement: the face normal should point the way
|
||
// the three vertices' stored normals do. A wrong anchor wires
|
||
// arbitrary vertices → this collapses toward 50%.
|
||
let mut sn = [0.0f32; 3];
|
||
for &vi in &tri_idx {
|
||
let nb = vb + vi * stride + no;
|
||
sn[0] += half(bytes, nb);
|
||
sn[1] += half(bytes, nb + 2);
|
||
sn[2] += half(bytes, nb + 4);
|
||
}
|
||
if cx[0] * sn[0] + cx[1] * sn[1] + cx[2] * sn[2] > 0.0 {
|
||
nrm_agree += 1;
|
||
}
|
||
nrm_counted += 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;
|
||
}
|
||
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 {
|
||
return false; // 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. Only applied when the placement was *searched*
|
||
// ([`anchor_pool_mesh`] / grouped-pool pivot): a small flat sub-mesh (a fin,
|
||
// an antenna) legitimately has large edges relative to its own diagonal, so
|
||
// the check is skipped for *derived* grouped-pool parts, whose in-range +
|
||
// consistency signature already pins them unambiguously.
|
||
if strict_connectivity {
|
||
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 {
|
||
return false;
|
||
}
|
||
}
|
||
// Winding-consistency gate (same as `from_xpr2`): a correctly-anchored
|
||
// block is internally consistent — face normals agree with stored normals
|
||
// either almost always (≈1.0) or almost never (≈0.0, inverted winding);
|
||
// a false anchor / cross-wired buffer scatters to the ≈0.5 middle. Gate on
|
||
// `max(na,1-na)` so both real windings pass (stage meshes use both) and
|
||
// only the random middle is rejected. 0.85 tolerates small-block sampling.
|
||
if nrm_counted > 0 && min_consistency > 0.0 {
|
||
let na = nrm_agree as f32 / nrm_counted as f32;
|
||
if na.max(1.0 - na) < min_consistency {
|
||
return false;
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Decode a **grouped-pool** XBG7 resource: one whose descriptor holds *several*
|
||
/// index markers (sub-meshes), all sharing one contiguous index pool and one
|
||
/// contiguous vertex pool. This is the hero-ship / detailed-model layout
|
||
/// (`DeltaSaber_*.xpr` and ~100 others) that the per-block adjacency anchor
|
||
/// ([`anchor_pool_mesh`]) cannot decode, because a sub-mesh's index buffer is
|
||
/// **not** immediately before its vertex buffer.
|
||
///
|
||
/// The layout, reversed from the retail disc + cross-checked against a Canary GPU
|
||
/// draw-log capture of `DeltaSaber_T.xpr` (see `docs/re/structures/xbg7-mesh.md`):
|
||
///
|
||
/// ```text
|
||
/// index pool : [ ib0 | ib1 | … ] each buffer 4-byte aligned, in marker order
|
||
/// vertex pool : [ vb0 | vb1 | … ] each pool `vtx_count × stride` bytes, same order
|
||
/// ```
|
||
///
|
||
/// and crucially **the index pool ends exactly where the vertex pool begins**.
|
||
/// So the whole resource pivots on a single unknown — the first vertex pool start
|
||
/// `vb0` (= index-pool end). Everything else is derived:
|
||
/// `ib0 = vb0 − span`, `ib[i] = align4(ib[i-1] + idx_count[i-1]·2)`,
|
||
/// `vb[i] = vb[i-1] + vtx_count[i-1]·stride`. `vb0` is found by scanning the
|
||
/// unit-normal vertex-run `starts` and accepting the first for which sub-mesh 0
|
||
/// validates ([`validate_block`], strict 0.85 consistency — the pivot must be
|
||
/// unambiguous). Each derived sub-mesh is validated too; the chain stops at the
|
||
/// first that fails (emit what validated, never unvalidated geometry).
|
||
fn anchor_grouped_meshes(
|
||
bytes: &[u8],
|
||
data_base: usize,
|
||
starts: &[usize],
|
||
markers: &[(usize, usize)], // (vtx_count, idx_count) in descriptor/file order
|
||
decl: &VertexDecl,
|
||
) -> Vec<GameMesh> {
|
||
let n = markers.len();
|
||
if n == 0 {
|
||
return Vec::new();
|
||
}
|
||
let stride = decl.stride;
|
||
|
||
// Relative index-buffer offsets (ib0 = 0), 4-byte aligned between buffers,
|
||
// and cumulative vertex offsets (vb0 = 0) — both derived from the marker list.
|
||
let mut rel_ib = Vec::with_capacity(n);
|
||
let mut off_v = Vec::with_capacity(n);
|
||
let (mut acc_i, mut acc_v) = (0usize, 0usize);
|
||
for &(vc, ic) in markers {
|
||
rel_ib.push(acc_i);
|
||
off_v.push(acc_v);
|
||
acc_i = align4(acc_i + ic * 2);
|
||
acc_v += vc * stride;
|
||
}
|
||
// The index pool spans ib0 .. end-of-last-buffer. The vertex pool that
|
||
// follows is **4-byte aligned**, so up to 3 bytes of padding can sit between
|
||
// the last index buffer and vb0: `vb0 = align4(ib0 + span)`, i.e.
|
||
// `ib0 = vb0 − span − pad` with `pad ∈ 0..=3`. (DeltaSaber's index pool ended
|
||
// already-aligned → pad 0; many weapons need pad 2.) We try each pad and let
|
||
// `validate_block` pick the one that yields a coherent sub-mesh.
|
||
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
|
||
|
||
// Pivot on the **largest** sub-mesh, not `markers[0]`: it's the one whose
|
||
// triangle-quality + connectivity signature most reliably confirms the
|
||
// (ib0, vb0) alignment. Some weapons lead with a tiny, elongated bracket that
|
||
// fails the connectivity gate even when perfectly placed, which would reject
|
||
// an otherwise-correct pivot.
|
||
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
|
||
let (vck, ick) = markers[kmax];
|
||
|
||
for &vb0 in starts {
|
||
for pad in 0..=3usize {
|
||
if vb0 < span + pad {
|
||
continue;
|
||
}
|
||
let ib0 = vb0 - span - pad;
|
||
if ib0 < data_base {
|
||
continue;
|
||
}
|
||
// Strict anchor on the pivot sub-mesh: require high winding
|
||
// consistency AND connectivity here regardless of the stage-wide
|
||
// `min_consistency`. A wrong pad reads shifted indices → agreement
|
||
// collapses well below 0.85, so only the true pad/pivot passes.
|
||
let ib_k = ib0 + rel_ib[kmax];
|
||
let vb_k = vb0 + off_v[kmax];
|
||
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, 0.85, true) {
|
||
continue;
|
||
}
|
||
|
||
// Pivot confirmed the exact alignment ⇒ every marker up to the pivot
|
||
// is correctly placed; read those unconditionally (a legitimately
|
||
// tiny/flat lead part may fail the quality gates yet still be real).
|
||
// Markers after the pivot are validated so a stray trailing marker
|
||
// ends the chain instead of appending garbage.
|
||
let mut meshes = Vec::with_capacity(n);
|
||
let mut vb = vb0;
|
||
for i in 0..n {
|
||
let (vc, ic) = markers[i];
|
||
let ib = ib0 + rel_ib[i];
|
||
if ib + ic * 2 > bytes.len()
|
||
|| vc.checked_mul(stride).map_or(true, |b| vb + b > bytes.len())
|
||
{
|
||
break;
|
||
}
|
||
// Parts are placed deterministically; in-range + consistency pins
|
||
// them, so the connectivity heuristic (which mis-rejects small
|
||
// flat fins) is relaxed here.
|
||
let ok = validate_block(bytes, ib, vb, vc, ic, decl, 0.85, false);
|
||
if !ok && i > kmax {
|
||
break; // chain diverged — emit the validated prefix, no garbage
|
||
}
|
||
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
|
||
vb += vc * stride;
|
||
}
|
||
return meshes;
|
||
}
|
||
}
|
||
Vec::new()
|
||
}
|
||
|
||
/// 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;
|
||
// Triangle LIST (see `from_xpr2`): each consecutive index triple is a
|
||
// triangle. `anchor_pool_mesh` already validated this block as a list.
|
||
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
|
||
}
|
||
|
||
/// Collect **all** `(vtx_count, idx_count)` sub-mesh records in a descriptor, in
|
||
/// file order, for the grouped-pool layout (see [`anchor_grouped_meshes`]).
|
||
///
|
||
/// Each record is an `(index_bytes, index_count)` marker (as in
|
||
/// [`find_index_marker`]) whose vertex count sits 32 bytes earlier — the same
|
||
/// `(mk_rel − 32)` convention the single-marker anchor uses. Records with an
|
||
/// implausible vertex count are skipped so a stray `a == c·2` coincidence cannot
|
||
/// corrupt the derived index/vertex chain.
|
||
fn all_index_markers(desc: &[u8]) -> Vec<(usize, usize)> {
|
||
let mut out = Vec::new();
|
||
let mut rel = 0usize;
|
||
while rel + 8 <= 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 && rel >= 32 {
|
||
let vc = be32(desc, rel - 32) as usize;
|
||
if (3..=200_000).contains(&vc) {
|
||
out.push((vc, c as usize));
|
||
rel += 8;
|
||
continue;
|
||
}
|
||
}
|
||
rel += 4;
|
||
}
|
||
out
|
||
}
|
||
|
||
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 align4(x: usize) -> usize {
|
||
(x + 3) & !3
|
||
}
|
||
|
||
/// Expand a triangle-**strip** index buffer into a triangle list (alternating
|
||
/// winding, degenerates dropped). **Diagnostic only** — XBG7 index buffers are
|
||
/// triangle LISTS, not strips (proven by `XVERIFY`: normal-agree 1.000 as a list
|
||
/// vs ~0.49 as a strip, plus the GPU capture's `prim=4`). Retained so `XVERIFY`
|
||
/// can keep demonstrating that the strip reading is wrong; NOT used in decode.
|
||
fn expand_triangle_strip(strip: &[u32]) -> Vec<u32> {
|
||
let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3);
|
||
for w in 0..strip.len().saturating_sub(2) {
|
||
let (a, b, c) = if w % 2 == 0 {
|
||
(strip[w], strip[w + 1], strip[w + 2])
|
||
} else {
|
||
(strip[w + 1], strip[w], strip[w + 2])
|
||
};
|
||
if a != b && b != c && a != c {
|
||
out.extend_from_slice(&[a, b, c]);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
/// Topology-correctness metrics for a flat triangle-list index buffer, used to
|
||
/// decide list-vs-strip objectively. Returns `(tris, degenerate, normal_agree,
|
||
/// edge2_frac)`:
|
||
/// - `normal_agree`: fraction of non-degenerate triangles whose geometric face
|
||
/// normal (cross product) dots positively with the sum of its three vertices'
|
||
/// stored normals. Correct topology + consistent winding ⇒ near 1.0; a
|
||
/// mis-interpreted buffer wires arbitrary vertices ⇒ near 0.5.
|
||
/// - `edge2_frac`: fraction of distinct undirected edges shared by exactly 2
|
||
/// triangles. A closed manifold surface ⇒ near 1.0.
|
||
fn topology_report(
|
||
indices: &[u32],
|
||
positions: &[[f32; 3]],
|
||
normals: &[[f32; 3]],
|
||
) -> (usize, usize, f32, f32) {
|
||
use std::collections::HashMap;
|
||
let tris = indices.len() / 3;
|
||
let mut degen = 0usize;
|
||
let mut agree = 0usize;
|
||
let mut counted = 0usize;
|
||
let mut edges: HashMap<(u32, u32), u32> = HashMap::new();
|
||
for t in indices.chunks_exact(3) {
|
||
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
|
||
if a == b || b == c || a == c {
|
||
degen += 1;
|
||
continue;
|
||
}
|
||
for &(i, j) in &[(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||
let e = if i < j { (i, j) } else { (j, i) };
|
||
*edges.entry(e).or_insert(0) += 1;
|
||
}
|
||
let (p0, p1, p2) = (positions[a], positions[b], positions[c]);
|
||
let u = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
|
||
let w = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
|
||
let fx = u[1] * w[2] - u[2] * w[1];
|
||
let fy = u[2] * w[0] - u[0] * w[2];
|
||
let fz = u[0] * w[1] - u[1] * w[0];
|
||
if !normals.is_empty() {
|
||
let sn = [
|
||
normals[a][0] + normals[b][0] + normals[c][0],
|
||
normals[a][1] + normals[b][1] + normals[c][1],
|
||
normals[a][2] + normals[b][2] + normals[c][2],
|
||
];
|
||
let dot = fx * sn[0] + fy * sn[1] + fz * sn[2];
|
||
if dot > 0.0 {
|
||
agree += 1;
|
||
}
|
||
counted += 1;
|
||
}
|
||
}
|
||
let two = edges.values().filter(|&&c| c == 2).count();
|
||
let edge2 = if edges.is_empty() {
|
||
0.0
|
||
} else {
|
||
two as f32 / edges.len() as f32
|
||
};
|
||
let na = if counted == 0 {
|
||
f32::NAN
|
||
} else {
|
||
agree as f32 / counted as f32
|
||
};
|
||
(tris, degen, na, edge2)
|
||
}
|
||
|
||
#[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)
|
||
));
|
||
}
|
||
}
|