Files
Syplheed-Reborn/crates/sylpheed-formats/src/mesh.rs
MechaCat02 578c71a1b9 [formats,viewer] Decode grouped-pool XBG7 models; fix mesh routing + albedo
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>
2026-07-18 19:54:39 +02:00

1247 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.51.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.050.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)
));
}
}