Files
Syplheed-Reborn/crates/sylpheed-formats/src/mesh.rs
MechaCat02 2053f31d17 feat(formats,cli): decode XBG7 stage containers + fix weapon layout
Stage containers (hidden/resource3d/Stage_S*.xpr) are collections of up to
~450 enemy/prop sub-models, not single meshes. Xbg7Model::stage_models decodes
them: each resource is a [12-byte header][index buffer][vertex buffer] block
(index count = descriptor marker, vertex count = u32 32 bytes before it) whose
on-disc offset is NOT stored, so it is located by content — one O(file) pass per
stride finds vertex-buffer starts (unit NORMAL at +12 whose previous slot isn't)
and each resource is pinned to the candidate whose indices validate and produce
non-degenerate, well-connected triangles. A connectivity gate (mean triangle
edge <= 0.28x the bbox diagonal) rejects spiky mis-anchors. ~4993 sub-models
decode across the 22 stages.

The same insight fixes the weapon single-model layout: it is [12-byte header]
[index][vertex] too, not [index][12-byte gap][vertex]. Reading indices from the
block start turned the 12 header bytes into 6 junk indices (2 leading degenerate
triangles — the recurring stray-triangle artifact) and dropped the last 6 real
indices. Skipping the header leaves vertex offsets identical (coverage unchanged
at 36/166) and corrects the triangle list. Verified on wep_00/03/04.

Adds `sylpheed-cli mesh {info,render}` — a headless software rasterizer that
writes a shaded PNG (orthographic, z-buffered, two-sided), so recovered geometry
can be verified without the GUI. Stages render as a normalised thumbnail grid;
--only filters sub-models.

Tests: stage_models_{decode,sweep,quality_audit}; docs/re updated (xbg7-mesh.md
evidence log + INDEX.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:35 +02:00

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