With per-sub-mesh declarations enabled, n201_01 decoded as a 2-part fragment 4 bytes off. Both starts validate for the pivot — 0x32BA718 at pad 2 (earlier in file order, so first-match took it) and the capture-proven 0x32BA71C at pad 0 — so the pivot alone cannot separate them; at the early one two of four sub-meshes fall out as out-of-range. anchor_grouped_meshes now builds each accepted candidate and keeps the one that explains the most of the declared pool: it returns immediately when a candidate explains all n sub-meshes, else keeps the best partial, so it can never decode less than first-match did. n201_01 lands on all four capture-proven offsets (0x32BA71C / 0x32BEFF4 / 0x32C416C / 0x32C536C) and its two sibling copies take their own pools, so the twin collapse is gone. XBG7_SUBMESH_DECLS is therefore on by default (=0 reverts): resources that never decode 85 -> 47 resources decoding in no container 63 -> 30 degenerate index runs 1 -> 1 (unchanged) cross-container minority decodes 96 -> 96 (unchanged) captured index runs, stage-02 93/93 (unchanged) captured index runs, stage-05 124/128 -> 128/128 The last line is the point: the buffers the capture could not name are the n201 family, and they now decode and match the GPU's indices byte for byte. Suite green including twin_pairs_do_not_share_a_buffer, apart from the pre-existing known-failing cross-container consistency test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2998 lines
125 KiB
Rust
2998 lines
125 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>,
|
||
/// Byte offset of this sub-mesh's vertex buffer inside the container, when
|
||
/// the decode path knows it. The content-anchored stage path does — and a
|
||
/// runtime capture names the same offset (a draw's `vbase` is this plus the
|
||
/// container's load address), so this is what makes an anchor checkable
|
||
/// against ground truth. See `examples/shared_vbase_check.rs`.
|
||
pub vbuf_offset: Option<usize>,
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// List every XBG7 resource name in an XPR2 container, in directory order.
|
||
///
|
||
/// Cheap: walks only the resource directory (no geometry decode). Used to
|
||
/// discover which container holds a named hull / part model when assembling a
|
||
/// multi-part ship from a [`crate::game_data::Vessel`] recipe.
|
||
pub fn xbg7_resource_names(bytes: &[u8]) -> Vec<String> {
|
||
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,
|
||
};
|
||
const DIR_BASE: usize = 0x10;
|
||
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;
|
||
}
|
||
if let Some(n) = read_cstr(bytes, e.name_offset as usize + DIR_BASE) {
|
||
out.push(n);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
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,
|
||
vbuf_offset: Some(vb),
|
||
});
|
||
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)
|
||
}
|
||
|
||
/// Like [`Xbg7Model::stage_models`] but abortable: `should_cancel` is polled
|
||
/// between resources so a viewer can drop an in-flight decode when the user
|
||
/// selects a different file. Returns whatever decoded before the cancel.
|
||
pub fn stage_models_cancellable(
|
||
bytes: &[u8],
|
||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||
) -> Vec<Xbg7Model> {
|
||
Self::anchor_models_cancellable(bytes, 0.0, should_cancel)
|
||
}
|
||
|
||
/// 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> {
|
||
Self::anchor_models_cancellable(bytes, min_consistency, &|| false)
|
||
}
|
||
|
||
/// Decode only the named XBG7 resources from a container (content-anchored,
|
||
/// abortable). Used to assemble a whole ship from just its part family
|
||
/// (`e106_bdy_*`, `e106_brg_*`, …) instead of decoding the whole ~300-resource
|
||
/// stage. Order follows the container's directory. See [`crate::ship`].
|
||
pub fn models_named(
|
||
bytes: &[u8],
|
||
wanted: &std::collections::HashSet<String>,
|
||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||
) -> Vec<Xbg7Model> {
|
||
Self::anchor_models_filtered(bytes, 0.0, should_cancel, Some(wanted))
|
||
}
|
||
|
||
/// [`Xbg7Model::anchor_models`] with a cancellation poll checked between
|
||
/// resources (a large stage container holds hundreds). See
|
||
/// [`Xbg7Model::stage_models_cancellable`].
|
||
pub fn anchor_models_cancellable(
|
||
bytes: &[u8],
|
||
min_consistency: f32,
|
||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||
) -> Vec<Xbg7Model> {
|
||
Self::anchor_models_filtered(bytes, min_consistency, should_cancel, None)
|
||
}
|
||
|
||
/// Decode the whole container (cached), then hand back the requested subset.
|
||
///
|
||
/// The decode itself must always see every resource — distinct assignment
|
||
/// resolves collisions against the whole population, and pruning first made
|
||
/// the answer depend on the request (see the module history). That makes a
|
||
/// single-resource query as expensive as a full decode, so the full decode is
|
||
/// memoised per container: the viewer asks for one ship's parts at a time and
|
||
/// would otherwise re-anchor 6 000 resources per ship.
|
||
fn anchor_models_filtered(
|
||
bytes: &[u8],
|
||
min_consistency: f32,
|
||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||
wanted: Option<&std::collections::HashSet<String>>,
|
||
) -> Vec<Xbg7Model> {
|
||
let Some(w) = wanted else {
|
||
return Self::anchor_models_uncached(bytes, min_consistency, should_cancel, None);
|
||
};
|
||
let full = full_decode_cached(bytes, min_consistency, should_cancel);
|
||
full.iter().filter(|m| w.contains(&m.name)).cloned().collect()
|
||
}
|
||
|
||
fn anchor_models_uncached(
|
||
bytes: &[u8],
|
||
min_consistency: f32,
|
||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||
wanted: Option<&std::collections::HashSet<String>>,
|
||
) -> 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,
|
||
/// One declaration per marker (same order). A grouped pool can mix
|
||
/// strides, so the grouped path must use these rather than `decl`.
|
||
decls: Vec<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());
|
||
// NOTE: `wanted` is NOT applied here. Distinct assignment resolves
|
||
// collisions against the whole set of resources, so pruning first
|
||
// made a filtered decode depend on *which* subset was asked for —
|
||
// measured 2026-08-12: 27 of 356 resources in `Stage_S02` came out
|
||
// at a different offset when requested alone. The filter is applied
|
||
// to the OUTPUT instead, so a subset is always a subset of the
|
||
// container's own answer.
|
||
let mut decls = if submesh_decls() { all_vertex_decls(d) } else { Vec::new() };
|
||
if decls.len() != markers.len() {
|
||
// Never seen on the disc, but if the two walks disagree fall back
|
||
// to the single declaration rather than mis-pair them.
|
||
decls = vec![decl.clone(); markers.len()];
|
||
}
|
||
resources.push(Res {
|
||
name,
|
||
markers,
|
||
decl,
|
||
decls,
|
||
});
|
||
}
|
||
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));
|
||
}
|
||
|
||
// Anchor each resource. Resources are independent (the shared
|
||
// `starts_by_stride` is read-only from here on), so a big stage's
|
||
// hundreds of sub-models are decoded in parallel on native builds —
|
||
// the dominant cost of loading a stage container. `filter_map(...).collect()`
|
||
// preserves resource order, so the output is identical to the sequential
|
||
// decode. `should_cancel()` is polled per resource so a superseded load
|
||
// stops promptly.
|
||
let empty_taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||
let decode_one = |r: &Res| -> Option<Xbg7Model> {
|
||
if should_cancel() {
|
||
return None;
|
||
}
|
||
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,
|
||
&empty_taken,
|
||
0,
|
||
)
|
||
.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.decls, &empty_taken);
|
||
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,
|
||
&empty_taken,
|
||
0,
|
||
)
|
||
.into_iter()
|
||
.collect()
|
||
}
|
||
};
|
||
(!meshes.is_empty()).then(|| Xbg7Model {
|
||
name: r.name.clone(),
|
||
meshes,
|
||
})
|
||
};
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let decoded: Vec<(usize, Xbg7Model)> = {
|
||
use rayon::prelude::*;
|
||
resources
|
||
.par_iter()
|
||
.enumerate()
|
||
.filter_map(|(i, r)| decode_one(r).map(|m| (i, m)))
|
||
.collect()
|
||
};
|
||
#[cfg(target_arch = "wasm32")]
|
||
let decoded: Vec<(usize, Xbg7Model)> = resources
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, r)| decode_one(r).map(|m| (i, m)))
|
||
.collect();
|
||
|
||
// ── Distinct assignment ──────────────────────────────────────────
|
||
//
|
||
// Selection above is per-resource and greedy: each takes the first
|
||
// candidate that validates, so two resources can claim ONE buffer while
|
||
// a valid buffer sits unused. A runtime capture proves that is wrong for
|
||
// the mirrored `e106_bdy_0{1,2}_l` twins — the container holds both
|
||
// halves (`0x3b3ee8` and its X-mirror `0x3c55d8`) and the engine draws
|
||
// each from its own — and both offsets validate for both names, so the
|
||
// correct block merely lost the first-match race.
|
||
//
|
||
// So: walk the decodes in container order, let the first claimant keep a
|
||
// buffer, and re-anchor any later resource that wanted the same one,
|
||
// skipping everything already claimed. Only single-sub-mesh resources
|
||
// (the adjacency-anchor path) take part; grouped-pool models are left
|
||
// exactly as they were.
|
||
let mut taken: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||
// Opt-in monotone pass: the last offset handed to each (stride, vtx, idx)
|
||
// signature. Resources that share a signature are interchangeable to the
|
||
// validator — many containers hold dozens of identical 24-vertex bound
|
||
// boxes — so file order is the only thing that can pin which is which.
|
||
let monotone = std::env::var("XBG7_MONOTONE").is_ok();
|
||
let mut last_by_sig: std::collections::HashMap<(usize, usize, usize), usize> =
|
||
std::collections::HashMap::new();
|
||
let mut models: Vec<Xbg7Model> = Vec::with_capacity(decoded.len());
|
||
for (i, mut m) in decoded {
|
||
let r = &resources[i];
|
||
let starts = &starts_by_stride[&r.decl.stride];
|
||
if monotone && m.meshes.len() == 1 && r.markers.len() == 1 {
|
||
let (vc, ic) = r.markers[0];
|
||
let sig = (r.decl.stride, vc, ic);
|
||
let floor = last_by_sig.get(&sig).map_or(0, |o| o + 1);
|
||
if m.meshes[0].vbuf_offset.map_or(false, |o| o < floor) {
|
||
if let Some(alt) = anchor_pool_mesh(
|
||
bytes,
|
||
starts,
|
||
ic,
|
||
vc,
|
||
&r.decl,
|
||
min_consistency,
|
||
&taken,
|
||
floor,
|
||
) {
|
||
m.meshes[0] = alt;
|
||
}
|
||
}
|
||
if let Some(o) = m.meshes[0].vbuf_offset {
|
||
last_by_sig.insert(sig, o);
|
||
}
|
||
}
|
||
if let Some(vb) = m.meshes[0].vbuf_offset {
|
||
if taken.contains(&vb) {
|
||
if m.meshes.len() == 1 && r.markers.len() == 1 {
|
||
let (vtx_count, index_count) = r.markers[0];
|
||
if let Some(alt) = anchor_pool_mesh(
|
||
bytes,
|
||
starts,
|
||
index_count,
|
||
vtx_count,
|
||
&r.decl,
|
||
min_consistency,
|
||
&taken,
|
||
0,
|
||
) {
|
||
m.meshes[0] = alt;
|
||
}
|
||
} else {
|
||
// Grouped pool: re-place the WHOLE pool past everything
|
||
// claimed, or keep what we had.
|
||
let alt = anchor_grouped_meshes(
|
||
bytes, data_base, starts, &r.markers, &r.decls, &taken,
|
||
);
|
||
if !alt.is_empty() {
|
||
m.meshes = alt;
|
||
}
|
||
}
|
||
// No free candidate → keep the collided decode rather than
|
||
// drop the resource; coverage never regresses.
|
||
}
|
||
for sub in &m.meshes {
|
||
if let Some(vb2) = sub.vbuf_offset {
|
||
taken.insert(vb2);
|
||
}
|
||
}
|
||
}
|
||
models.push(m);
|
||
}
|
||
if let Some(w) = wanted {
|
||
models.retain(|m| w.contains(&m.name));
|
||
}
|
||
out = models;
|
||
out
|
||
}
|
||
}
|
||
|
||
/// Diagnostic: would the anchor scan accept `vb` as the vertex buffer of the
|
||
/// named resource's first sub-mesh? A runtime capture proves which offset the
|
||
/// engine drew from, so this answers whether the correct block is *acceptable*
|
||
/// and merely lost the first-match race, or is rejected outright by
|
||
/// `validate_block`. Returns `(vtx_count, index_count, accepted_pad)`.
|
||
pub fn debug_try_anchor(
|
||
bytes: &[u8],
|
||
name: &str,
|
||
vb: usize,
|
||
max_pad: usize,
|
||
) -> Option<(usize, usize, usize)> {
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return None;
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let header = Xpr2Header::read(&mut cur).ok()?;
|
||
const DIR_BASE: usize = 0x10;
|
||
for _ in 0..header.num_resources {
|
||
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { 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 rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||
.unwrap_or_else(|| "XBG7".to_string());
|
||
if rname != name {
|
||
continue;
|
||
}
|
||
let d = &bytes[desc..desc_end];
|
||
let markers = all_index_markers(d);
|
||
let decl = parse_vertex_decl(d)?;
|
||
let (vtx_count, index_count) = *markers.first()?;
|
||
let idx_bytes = index_count * 2;
|
||
for pad in 0..=max_pad {
|
||
if vb < idx_bytes + pad {
|
||
continue;
|
||
}
|
||
let mc = if pad == 0 { 0.0 } else { 0.85 };
|
||
if validate_block(
|
||
bytes,
|
||
vb - idx_bytes - pad,
|
||
vb,
|
||
vtx_count,
|
||
index_count,
|
||
&decl,
|
||
mc,
|
||
true,
|
||
) {
|
||
return Some((vtx_count, index_count, pad));
|
||
}
|
||
}
|
||
return None;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// How far did the anchor scan get for a resource it failed to place?
|
||
///
|
||
/// Runs the same candidate loop the decoder runs and keeps the **furthest**
|
||
/// rejection — the candidate that passed the most gates before failing. Over the
|
||
/// resources that never decode, the distribution of these says which gate to
|
||
/// work on, instead of tuning one threshold and re-measuring.
|
||
pub fn debug_best_rejection(bytes: &[u8], name: &str) -> Option<(usize, String)> {
|
||
let (decl, markers) = decl_of(bytes, name)?;
|
||
let starts = debug_vertex_run_starts(bytes, decl.stride);
|
||
#[allow(clippy::type_complexity)]
|
||
let rank = |why: &str| -> usize {
|
||
if why.contains("out of range") {
|
||
1
|
||
} else if why.contains("buffer not covered") {
|
||
2
|
||
} else if why.contains("not finite") || why.contains("degenerate") {
|
||
3
|
||
} else if why.contains("connectivity") {
|
||
4
|
||
} else if why.contains("winding") {
|
||
5
|
||
} else {
|
||
0
|
||
}
|
||
};
|
||
// A grouped-pool resource is placed by its PIVOT sub-mesh, so it needs the
|
||
// grouped candidate loop; running the single-block loop on `markers[0]`
|
||
// would report a gate the decoder never consulted for it.
|
||
if markers.len() > 1 {
|
||
let n = markers.len();
|
||
let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize);
|
||
for &(_, ic) in &markers {
|
||
rel_ib.push(acc_i);
|
||
acc_i = align4(acc_i + ic * 2);
|
||
}
|
||
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
|
||
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
|
||
let (vck, ick) = markers[kmax];
|
||
let off_v: usize = markers.iter().take(kmax).map(|&(vc, _)| vc * decl.stride).sum();
|
||
let mut best = (0usize, String::from("no pool start reached any gate"));
|
||
for &vb0 in &starts {
|
||
for pad in 0..=3usize {
|
||
if vb0 < span + pad {
|
||
continue;
|
||
}
|
||
let ib0 = vb0 - span - pad;
|
||
match validate_block_report(
|
||
bytes,
|
||
ib0 + rel_ib[kmax],
|
||
vb0 + off_v,
|
||
vck,
|
||
ick,
|
||
&decl,
|
||
0.85,
|
||
true,
|
||
) {
|
||
Ok(()) => return None, // the pivot would have anchored
|
||
Err(why) => {
|
||
let r = rank(&why);
|
||
if r > best.0 {
|
||
best = (r, why);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return Some(best);
|
||
}
|
||
let (vtx_count, index_count) = *markers.first()?;
|
||
let idx_bytes = index_count * 2;
|
||
let mut best = (0usize, String::from("no candidate reached any gate"));
|
||
for &vb in &starts {
|
||
for pad in 0..=3usize {
|
||
if vb < idx_bytes + pad {
|
||
continue;
|
||
}
|
||
// Mirror production exactly: the pad-0 path now carries the winding
|
||
// floor too. Reporting at 0.0 would accept blocks the decoder
|
||
// rejects and point at the wrong gate.
|
||
let mc = if pad == 0 { pad0_consistency() } else { 0.85 };
|
||
if let Err(why) = validate_block_report(
|
||
bytes,
|
||
vb - idx_bytes - pad,
|
||
vb,
|
||
vtx_count,
|
||
index_count,
|
||
&decl,
|
||
mc,
|
||
true,
|
||
) {
|
||
let r = rank(&why);
|
||
if r > best.0 {
|
||
best = (r, why);
|
||
}
|
||
} else {
|
||
return None; // it would have decoded — not a miss
|
||
}
|
||
}
|
||
}
|
||
Some(best)
|
||
}
|
||
|
||
/// Diagnostic: the stride of every sub-mesh declaration of a named resource.
|
||
/// A grouped pool may mix them (`n201_01` → 24, 24, 24, 28).
|
||
pub fn debug_decl_strides(bytes: &[u8], name: &str) -> Vec<usize> {
|
||
decls_of(bytes, name).map(|v| v.iter().map(|d| d.stride).collect()).unwrap_or_default()
|
||
}
|
||
|
||
/// Every sub-mesh declaration of a named resource (see [`all_vertex_decls`]).
|
||
fn decls_of(bytes: &[u8], name: &str) -> Option<Vec<VertexDecl>> {
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return None;
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let header = Xpr2Header::read(&mut cur).ok()?;
|
||
const DIR_BASE: usize = 0x10;
|
||
for _ in 0..header.num_resources {
|
||
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { 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 rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||
.unwrap_or_else(|| "XBG7".to_string());
|
||
if rname != name {
|
||
continue;
|
||
}
|
||
return Some(all_vertex_decls(&bytes[desc..desc_end]));
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Diagnostic: why does the decoder refuse a grouped-pool resource at a given
|
||
/// pool start? Recomputes the pool layout exactly as [`anchor_grouped_meshes`]
|
||
/// does and reports the pivot sub-mesh's verdict for each index/vertex pad —
|
||
/// so a capture-proven pool that the decoder rejects names the gate to fix.
|
||
pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec<String> {
|
||
let Some((decl, markers)) = decl_of(bytes, name) else {
|
||
return vec!["no such XBG7 resource".into()];
|
||
};
|
||
let n = markers.len();
|
||
if n == 0 {
|
||
return vec!["no index markers".into()];
|
||
}
|
||
// Per-sub-mesh declarations, like the production path: a pool can mix strides
|
||
// and reporting with one of them blames the wrong gate.
|
||
let decls = match decls_of(bytes, name).filter(|v| v.len() == n && submesh_decls()) {
|
||
Some(v) => v,
|
||
None => vec![decl.clone(); n],
|
||
};
|
||
let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize);
|
||
for &(_, ic) in &markers {
|
||
rel_ib.push(acc_i);
|
||
acc_i = align4(acc_i + ic * 2);
|
||
}
|
||
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
|
||
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
|
||
let (vck, ick) = markers[kmax];
|
||
let mut off_v = 0usize;
|
||
for (i, &(vc, _)) in markers.iter().take(kmax).enumerate() {
|
||
off_v += vc * decls[i].stride;
|
||
}
|
||
let mut out = vec![format!(
|
||
"{name}: {n} sub-meshes, pivot #{kmax} ({vck} verts, {ick} idx), pool span {span}"
|
||
)];
|
||
for pad in 0..=3usize {
|
||
if vb0 < span + pad {
|
||
out.push(format!(" pad {pad}: pool start is before the index pool"));
|
||
continue;
|
||
}
|
||
let ib0 = vb0 - span - pad;
|
||
// Same gates the production pivot test uses: strict winding (0.85)
|
||
// AND connectivity. Reporting at 0.0 would accept blocks the decoder
|
||
// rejects and send the reader chasing the wrong gate.
|
||
let verdict = validate_block_report(
|
||
bytes,
|
||
ib0 + rel_ib[kmax],
|
||
vb0 + off_v,
|
||
vck,
|
||
ick,
|
||
&decls[kmax],
|
||
0.85,
|
||
true,
|
||
);
|
||
out.push(match verdict {
|
||
Ok(()) => format!(" pad {pad}: ACCEPTED"),
|
||
Err(why) => format!(" pad {pad}: {why}"),
|
||
});
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Diagnostic: for a resource whose vertex buffer is *known* (a runtime capture
|
||
/// names it), where could its index buffer be? The anchor scan assumes the index
|
||
/// buffer sits immediately before the vertex buffer; this scans the whole
|
||
/// container instead and returns every offset that validates as this resource's
|
||
/// index buffer. An empty result means the block is unreadable at that `vb` for
|
||
/// another reason; a hit far from `vb` means the adjacency assumption is what
|
||
/// fails. Returns `(ib_offset, signed distance vb - ib)` pairs.
|
||
pub fn debug_find_index_buffer(bytes: &[u8], name: &str, vb: usize) -> Vec<(usize, i64)> {
|
||
let Some(decl) = decl_of(bytes, name) else {
|
||
return Vec::new();
|
||
};
|
||
let markers = decl.1;
|
||
let decl = decl.0;
|
||
let Some(&(vtx_count, index_count)) = markers.first() else {
|
||
return Vec::new();
|
||
};
|
||
let mut out = Vec::new();
|
||
let end = bytes.len().saturating_sub(index_count * 2);
|
||
let mut ib = 0usize;
|
||
while ib < end {
|
||
// Cheap prefilter: the first few indices must be in range, and a real
|
||
// index buffer is not a run of zeros.
|
||
let ok = (0..6).all(|k| (be16(bytes, ib + k * 2) as usize) < vtx_count)
|
||
&& (0..6).any(|k| be16(bytes, ib + k * 2) != 0);
|
||
// `SOFT_IB=1` drops the connectivity requirement, to separate "no index
|
||
// buffer fits" from "our connectivity test is too strict".
|
||
let strict = std::env::var("SOFT_IB").is_err();
|
||
if ok && validate_block(bytes, ib, vb, vtx_count, index_count, &decl, 0.0, strict) {
|
||
out.push((ib, vb as i64 - ib as i64));
|
||
}
|
||
ib += 2;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The connectivity cap: a searched block whose mean triangle edge exceeds this
|
||
/// fraction of its bounding-box diagonal is rejected.
|
||
///
|
||
/// **1.0 since 2026-08-12 — effectively inert.** The winding-consistency gate
|
||
/// ([`pad0_consistency`]) replaced this as the primary structural test: it is an
|
||
/// objective topology signal rather than a shape heuristic, and swapping them
|
||
/// decodes **143 more** resources with **17 fewer** cross-container
|
||
/// inconsistencies while the capture oracle stays at 46/46. The cap is kept as a
|
||
/// knob and a backstop against absurd blocks. History below.
|
||
///
|
||
/// **0.42 from 2026-08-12**, raised from 0.28 on runtime evidence. A capture
|
||
/// names the blocks the engine really draws, and the old cap rejected one of them
|
||
/// outright — `e106_eng_02_l`, a 24-triangle LOD, measures **0.417**, because a
|
||
/// coarse mesh's edges *are* a large fraction of its own size. Swept against the
|
||
/// 46 capture-named `Stage_S02` buffers (with distinct anchor assignment): 0.28
|
||
/// anchors 40 exactly and leaves 4 unclaimed, 0.42 anchors **45 and leaves none**,
|
||
/// and nothing above 0.42 improves further — so this is the least permissive
|
||
/// value that captures the whole measured gain. `XBG7_EDGE_CAP` overrides it (see
|
||
/// docs/re/structures/xbg7-mesh.md).
|
||
fn edge_cap() -> f32 {
|
||
std::env::var("XBG7_EDGE_CAP").ok().and_then(|v| v.parse().ok()).unwrap_or(1.0)
|
||
}
|
||
|
||
/// Winding floor for the grouped-pool **pivot** (default `0.85`). The single-block
|
||
/// path settled at 0.70 on measurement; this is the same question for the pivot,
|
||
/// and `XBG7_GROUPED_CONSISTENCY` sweeps it.
|
||
fn grouped_consistency() -> f32 {
|
||
std::env::var("XBG7_GROUPED_CONSISTENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(0.85)
|
||
}
|
||
|
||
/// Coverage requirement, as `max_index + N >= vtx_count`. **`1` since
|
||
/// 2026-08-12** — i.e. the indices must reach the pool's last vertex exactly.
|
||
///
|
||
/// The old `4` tolerated three unreferenced tail vertices, and that slack was a
|
||
/// mis-anchor tell rather than a real variation: 8 580 of 8 629 decoded
|
||
/// sub-meshes cover their pool exactly, and `e106_bdy_03` in `Stage_S02` was one
|
||
/// of the few that did not — slack 3, decoding to a 600×1600×998 slab visible in
|
||
/// a render, where three other containers give 276×236×941. Requiring exact
|
||
/// coverage moves it onto the block those containers agree on. Costs 3 resources
|
||
/// disc-wide; capture oracle unchanged at 46/46. `XBG7_COVER_SLACK` overrides
|
||
/// (note `0` rejects everything — the comparison is `max_idx + N >= vtx_count`).
|
||
fn cover_slack() -> usize {
|
||
std::env::var("XBG7_COVER_SLACK").ok().and_then(|v| v.parse().ok()).unwrap_or(1)
|
||
}
|
||
|
||
/// Smallest bounding-box extent a block may have (default `0.5`). An absolute
|
||
/// floor on a format with no unit convention is a scale assumption, so it is a
|
||
/// knob: `XBG7_MIN_EXTENT`.
|
||
fn min_extent() -> f32 {
|
||
std::env::var("XBG7_MIN_EXTENT").ok().and_then(|v| v.parse().ok()).unwrap_or(0.5)
|
||
}
|
||
|
||
/// Use a scale-free collinearity test for degeneracy instead of the absolute
|
||
/// triangle-area one (`XBG7_REL_DEGEN=1`). More principled in the abstract — an
|
||
/// absolute area threshold calls a small object's every triangle degenerate —
|
||
/// but measured on this disc it decodes **no more** resources and raises
|
||
/// cross-container inconsistency 39 → 44, so it is **not** the default.
|
||
fn rel_degen() -> bool {
|
||
std::env::var("XBG7_REL_DEGEN").is_ok()
|
||
}
|
||
|
||
/// Winding-consistency floor for the pad-0 single-block anchor.
|
||
///
|
||
/// **0.70 since 2026-08-12.** A triangle's face normal should agree with its
|
||
/// vertices' stored normals almost always (≈1.0) or almost never (≈0.0, inverted
|
||
/// winding); a mis-carve wires arbitrary vertices and lands near 0.5. Gating on
|
||
/// `max(na, 1−na)` is therefore an objective topology test, where the
|
||
/// connectivity cap it replaces is a shape heuristic that provably rejected a
|
||
/// capture-proven block. Measured over the disc, with connectivity inert:
|
||
///
|
||
/// | floor | resources decoded | shared inconsistent |
|
||
/// |---|---|---|
|
||
/// | 0.60 | 6 214 | 53 |
|
||
/// | **0.70** | **6 212** | **39** |
|
||
/// | 0.80 | 5 770 | 1 |
|
||
/// | 0.85 | 5 770 | 0 |
|
||
///
|
||
/// 0.70 dominates the previous connectivity-only default (6 069 / 56) on both
|
||
/// axes with the capture oracle unchanged, so it ships. The cliff at 0.80 buys
|
||
/// perfect cross-container consistency for 442 resources — recorded rather than
|
||
/// taken, since consistency is the weaker witness (see the docs).
|
||
fn pad0_consistency() -> f32 {
|
||
std::env::var("XBG7_PAD0_CONSISTENCY").ok().and_then(|v| v.parse().ok()).unwrap_or(0.70)
|
||
}
|
||
|
||
/// Revert knob for the 2026-08-13 pad scoring: with `XBG7_PAD_FIRST_MATCH=1`,
|
||
/// [`anchor_pool_mesh`] takes the FIRST pad that validates (the pre-fix
|
||
/// behaviour) instead of the pad whose index run is cleanest. Kept so the two
|
||
/// behaviours can be diffed on the disc; see docs/re/structures/xbg7-mesh.md.
|
||
/// Use the **per-sub-mesh** vertex declarations in a grouped pool — **on by
|
||
/// default**; `XBG7_SUBMESH_DECLS=0` restores the single-declaration reading.
|
||
///
|
||
/// Each index marker is followed by its own element triples and they can differ:
|
||
/// `n201_01` (`Stage_S02.xpr`) declares strides 24, 24, 24, **28**, which a runtime
|
||
/// capture confirms draw-for-draw (`stride=28` on the fourth draw, and a distinct
|
||
/// vertex shader per sub-mesh). Reading only the first declaration walked the last
|
||
/// buffer out of phase and declined the whole resource.
|
||
///
|
||
/// With this and the completeness-based candidate choice in
|
||
/// [`anchor_grouped_meshes`], `n201_01` anchors at the capture-proven pool start
|
||
/// with all four sub-meshes at the captured offsets, its two sibling copies take
|
||
/// their own pools, disc-wide misses fall **85 → 47**, and the captured index runs
|
||
/// of the stage-05 mission rise **124 → 128** identical. See
|
||
/// docs/re/structures/xbg7-mesh.md.
|
||
fn submesh_decls() -> bool {
|
||
std::env::var("XBG7_SUBMESH_DECLS").map(|v| v != "0").unwrap_or(true)
|
||
}
|
||
|
||
fn pad_first_match() -> bool {
|
||
std::env::var("XBG7_PAD_FIRST_MATCH").map(|v| v == "1").unwrap_or(false)
|
||
}
|
||
|
||
/// Triangle count below which the looser [`small_cap`] applies. `0` (default)
|
||
/// disables the split, so the flat [`edge_cap`] governs every block.
|
||
fn small_tris() -> usize {
|
||
std::env::var("XBG7_SMALL_TRIS").ok().and_then(|v| v.parse().ok()).unwrap_or(0)
|
||
}
|
||
|
||
/// The connectivity cap for blocks below [`small_tris`] triangles.
|
||
fn small_cap() -> f32 {
|
||
std::env::var("XBG7_EDGE_CAP_SMALL").ok().and_then(|v| v.parse().ok()).unwrap_or(0.45)
|
||
}
|
||
|
||
/// Internal: the descriptor parameters the diagnostics need.
|
||
fn decl_of(bytes: &[u8], name: &str) -> Option<(VertexDecl, Vec<(usize, usize)>)> {
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return None;
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let header = Xpr2Header::read(&mut cur).ok()?;
|
||
const DIR_BASE: usize = 0x10;
|
||
for _ in 0..header.num_resources {
|
||
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { 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 rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||
.unwrap_or_else(|| "XBG7".to_string());
|
||
if rname != name {
|
||
continue;
|
||
}
|
||
let d = &bytes[desc..desc_end];
|
||
return Some((parse_vertex_decl(d)?, all_index_markers(d)));
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Diagnostic: a resource's `(vtx_count, idx_count)` markers and vertex stride,
|
||
/// as the stage anchor scan reads them from the descriptor.
|
||
pub fn debug_resource_params(bytes: &[u8], name: &str) -> Option<(Vec<(usize, usize)>, usize)> {
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return None;
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let header = Xpr2Header::read(&mut cur).ok()?;
|
||
const DIR_BASE: usize = 0x10;
|
||
for _ in 0..header.num_resources {
|
||
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { 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 rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
|
||
.unwrap_or_else(|| "XBG7".to_string());
|
||
if rname != name {
|
||
continue;
|
||
}
|
||
let d = &bytes[desc..desc_end];
|
||
return Some((all_index_markers(d), parse_vertex_decl(d)?.stride));
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Memoised whole-container decode, keyed by a cheap fingerprint of the bytes
|
||
/// plus the consistency setting. Holds the last few containers; a stage decode is
|
||
/// a handful of MB, and the alternative is re-anchoring every resource for every
|
||
/// ship the viewer shows.
|
||
fn full_decode_cached(
|
||
bytes: &[u8],
|
||
min_consistency: f32,
|
||
should_cancel: &(dyn Fn() -> bool + Sync),
|
||
) -> std::sync::Arc<Vec<Xbg7Model>> {
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
// Fingerprint: length plus three sampled 4 KB windows. Two different
|
||
// containers agreeing on all of that is not a case this format produces.
|
||
let mut fp: u64 = 0xcbf2_9ce4_8422_2325 ^ bytes.len() as u64;
|
||
let windows = [0usize, bytes.len() / 2, bytes.len().saturating_sub(4096)];
|
||
for w in windows {
|
||
for b in bytes.iter().skip(w).take(4096) {
|
||
fp = (fp ^ *b as u64).wrapping_mul(0x100_0000_01b3);
|
||
}
|
||
}
|
||
let key = (fp, min_consistency.to_bits());
|
||
#[allow(clippy::type_complexity)]
|
||
static CACHE: OnceLock<Mutex<Vec<((u64, u32), Arc<Vec<Xbg7Model>>)>>> = OnceLock::new();
|
||
let cache = CACHE.get_or_init(|| Mutex::new(Vec::new()));
|
||
if let Ok(c) = cache.lock() {
|
||
if let Some((_, v)) = c.iter().find(|(k, _)| *k == key) {
|
||
return Arc::clone(v);
|
||
}
|
||
}
|
||
let models = Arc::new(Xbg7Model::anchor_models_uncached(
|
||
bytes,
|
||
min_consistency,
|
||
should_cancel,
|
||
None,
|
||
));
|
||
if let Ok(mut c) = cache.lock() {
|
||
c.push((key, Arc::clone(&models)));
|
||
if c.len() > 4 {
|
||
c.remove(0);
|
||
}
|
||
}
|
||
models
|
||
}
|
||
|
||
/// Diagnostic: the candidate vertex-buffer starts the stage anchor scan will
|
||
/// consider for a given `stride`, for one container. A runtime capture names the
|
||
/// offsets the engine really drew from (see `examples/shared_vbase_check.rs`), so
|
||
/// asking whether a proven offset is in this list separates the two possible
|
||
/// root causes of a mis-anchor: **absent** ⇒ the run scan misses it, **present**
|
||
/// ⇒ the scan sees it and the selection picks another.
|
||
pub fn debug_vertex_run_starts(bytes: &[u8], stride: usize) -> Vec<usize> {
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return Vec::new();
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let Ok(header) = Xpr2Header::read(&mut cur) else {
|
||
return Vec::new();
|
||
};
|
||
let data_base = header.header_size as usize;
|
||
if data_base >= bytes.len() {
|
||
return Vec::new();
|
||
}
|
||
vertex_run_starts(bytes, data_base, stride)
|
||
}
|
||
|
||
/// 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,
|
||
taken: &std::collections::HashSet<usize>,
|
||
min_vb: usize,
|
||
) -> Option<GameMesh> {
|
||
let idx_bytes = index_count * 2;
|
||
// First accepted candidate whose index run still has degenerate triangles —
|
||
// used only if no clean candidate exists anywhere (see the end of the loop).
|
||
let mut dirty: Option<(usize, usize)> = None;
|
||
for &vb in starts {
|
||
// Monotone assignment (opt-in): resources of one signature are laid out
|
||
// in descriptor order, so a later one may not take an earlier block.
|
||
if vb < min_vb {
|
||
continue;
|
||
}
|
||
// A buffer another resource already claimed is not a candidate: the
|
||
// engine draws each part from its own buffer (proved for the mirrored
|
||
// `e106_bdy_0{1,2}_l` twins by a runtime capture), so two resources
|
||
// landing on one offset means at least one of them is wrong.
|
||
if taken.contains(&vb) {
|
||
continue;
|
||
}
|
||
// 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.
|
||
// Do NOT take the first pad that validates. A list read one element late
|
||
// still validates — every index is in range, the pool is still covered,
|
||
// and the winding can squeak past 0.70 — but it re-wires every triangle.
|
||
// The runtime capture caught it (17 draw batches whose captured indices
|
||
// equal ours shifted by one, all on pad-2 buffers), and the signature is
|
||
// decidable offline: a shift wires vertices arbitrarily, so triangles come
|
||
// out DEGENERATE (a repeated index). 282 of 283 correctly anchored
|
||
// `Stage_S02` blocks have zero degenerate triangles, against 1–2 156 for
|
||
// their shifted readings. So score every validating pad and keep the
|
||
// cleanest. See docs/re/structures/xbg7-mesh.md.
|
||
let mut best: Option<(usize, f32, usize)> = None; // (degenerate, -winding, pad)
|
||
for pad in 0..=3usize {
|
||
if vb < idx_bytes + pad {
|
||
continue;
|
||
}
|
||
let ib = vb - idx_bytes - pad;
|
||
// `XBG7_PAD0_CONSISTENCY` adds a winding requirement to the pad-0
|
||
// path, which has none by default. Winding agreement is an objective
|
||
// topology signal (≈1.0 or ≈0.0 for a real mesh, ≈0.5 for a
|
||
// mis-carve) where the connectivity cap is a shape heuristic with a
|
||
// capture-proven false positive — so it is the candidate replacement
|
||
// for that cap. Off by default; see docs/re/structures/xbg7-mesh.md.
|
||
let mc = if pad == 0 {
|
||
min_consistency.max(pad0_consistency())
|
||
} else {
|
||
min_consistency.max(0.85)
|
||
};
|
||
if validate_block(bytes, ib, vb, vtx_count, index_count, decl, mc, true) {
|
||
if pad_first_match() {
|
||
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
|
||
}
|
||
let (degen, wind) = index_run_quality(bytes, ib, vb, index_count, decl);
|
||
let cand = (degen, -wind, pad);
|
||
if best.map_or(true, |b| cand < b) {
|
||
best = Some(cand);
|
||
}
|
||
}
|
||
}
|
||
if let Some((degen, _, pad)) = best {
|
||
// A candidate whose triangles are degenerate-free is preferred over an
|
||
// earlier one that is not. This keeps first-match order for every
|
||
// clean hit (the overwhelming majority) and only searches on when the
|
||
// first accepted block is provably mis-fitted — the two resources that
|
||
// survived the pad fix (`e201_bdy_03_m`, `_rou_f402_dead`) each have
|
||
// exactly ONE degenerate-free block in their container, sitting later
|
||
// in file order than the lookalike we were taking.
|
||
if degen == 0 || pad_first_match() {
|
||
return Some(read_pool_mesh(bytes, vb - idx_bytes - pad, vb, index_count, vtx_count, decl));
|
||
}
|
||
if dirty.is_none() {
|
||
dirty = Some((vb, pad));
|
||
}
|
||
}
|
||
}
|
||
// NOT DONE, deliberately: when every validating candidate is degenerate one
|
||
// could re-scan without the pad-0 winding floor, since degeneracy is the
|
||
// stronger witness. Written and measured 2026-08-13 — it fires for **nothing**
|
||
// on the disc. The single remaining dirty resource (`_rou_f402_dead` in
|
||
// `Stage_S09`) does have a degenerate-free block that validates at
|
||
// `XBG7_PAD0_CONSISTENCY=0`, but it is CLAIMED by `e_rou_f003_Near`, so
|
||
// distinct assignment — not the winding floor — is what blocks it. Both are
|
||
// 24-vertex bounding boxes, i.e. the box-identity class that needs
|
||
// descriptor-level data rather than another anchoring heuristic (see the docs).
|
||
// Nothing clean anywhere: keep the first accepted block, so this can never
|
||
// cost coverage relative to first-match.
|
||
if let Some((vb, pad)) = dirty {
|
||
return Some(read_pool_mesh(bytes, vb - idx_bytes - pad, 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 {
|
||
validate_block_report(
|
||
bytes,
|
||
ib,
|
||
vb,
|
||
vtx_count,
|
||
index_count,
|
||
decl,
|
||
min_consistency,
|
||
strict_connectivity,
|
||
)
|
||
.is_ok()
|
||
}
|
||
|
||
/// [`validate_block`], but naming the gate that rejected a block. A runtime
|
||
/// capture can prove a block is real; when the decoder still refuses it, this
|
||
/// says which test is wrong rather than leaving a threshold to be guessed at.
|
||
fn validate_block_report(
|
||
bytes: &[u8],
|
||
ib: usize,
|
||
vb: usize,
|
||
vtx_count: usize,
|
||
index_count: usize,
|
||
decl: &VertexDecl,
|
||
min_consistency: f32,
|
||
strict_connectivity: bool,
|
||
) -> Result<(), String> {
|
||
let stride = decl.stride;
|
||
let idx_bytes = index_count * 2;
|
||
if ib + idx_bytes > bytes.len() {
|
||
return Err("index buffer runs past the container".into());
|
||
}
|
||
let vtx_bytes = match vtx_count.checked_mul(stride) {
|
||
Some(v) => v,
|
||
None => return Err("vertex byte count overflows".into()),
|
||
};
|
||
if vb + vtx_bytes > bytes.len() {
|
||
return Err("vertex buffer runs past the container".into());
|
||
}
|
||
|
||
// ── 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 Err(format!("index {i} out of range (vtx_count {vtx_count})"));
|
||
}
|
||
max_idx = max_idx.max(i);
|
||
}
|
||
if (max_idx as usize) + cover_slack() < vtx_count {
|
||
return Err(format!(
|
||
"indices reach only {max_idx} of {vtx_count} vertices (buffer not covered)"
|
||
));
|
||
}
|
||
|
||
// ── 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 Err(format!("position component {x} is not finite/plausible"));
|
||
}
|
||
*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],
|
||
];
|
||
// Degeneracy = collinear vertices, which is a SCALE-FREE property:
|
||
// compare the cross-product magnitude to the two edge lengths that
|
||
// produced it (i.e. sin of the angle between them). The old absolute
|
||
// `area < 1e-9` test called a small object's every triangle degenerate —
|
||
// `g005` spans 0.346 units and scored 7 of 8 — so it rejected tiny props
|
||
// for being tiny. `XBG7_ABS_DEGEN=1` restores the absolute test.
|
||
let cross = (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt();
|
||
let un = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
|
||
let wn = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt();
|
||
let is_degenerate = if rel_degen() {
|
||
cross < 1.0e-6 * un * wn || un == 0.0 || wn == 0.0
|
||
} else {
|
||
0.5 * cross < 1.0e-9
|
||
};
|
||
if is_degenerate {
|
||
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 < min_extent() || sampled == 0 || degenerate * 10 > sampled * 3 {
|
||
return Err(format!(
|
||
"extent {extent:.3} (min 0.5), {degenerate}/{sampled} degenerate (max 30%)"
|
||
));
|
||
}
|
||
// 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);
|
||
// A COARSE block is coarse by construction: a 24-triangle LOD's edges
|
||
// are a large fraction of its own size, which is why the flat cap has a
|
||
// capture-proven false positive (`e106_eng_02_l`, ratio 0.417). Under
|
||
// `XBG7_SMALL_TRIS` blocks below that triangle count get the looser
|
||
// `XBG7_EDGE_CAP_SMALL` instead — a targeted relaxation, off by default.
|
||
let cap = if tris < small_tris() { small_cap() } else { edge_cap() };
|
||
if mean_edge / diag > cap {
|
||
return Err(format!(
|
||
"connectivity: mean_edge/diag {:.3} > cap {cap:.2}",
|
||
mean_edge / diag
|
||
));
|
||
}
|
||
}
|
||
// 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 Err(format!(
|
||
"winding consistency {:.3} < {min_consistency:.2}",
|
||
na.max(1.0 - na)
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 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
|
||
decls: &[VertexDecl], // one per marker — a pool CAN mix strides
|
||
taken: &std::collections::HashSet<usize>,
|
||
) -> Vec<GameMesh> {
|
||
let n = markers.len();
|
||
if n == 0 || decls.len() < n {
|
||
return Vec::new();
|
||
}
|
||
let decl = &decls[0];
|
||
|
||
// 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 (i, &(vc, ic)) in markers.iter().enumerate() {
|
||
rel_ib.push(acc_i);
|
||
off_v.push(acc_v);
|
||
acc_i = align4(acc_i + ic * 2);
|
||
// Each sub-mesh advances by ITS OWN stride: `n201_01` ends with a
|
||
// stride-28 sub-mesh after three stride-24 ones (capture-confirmed).
|
||
acc_v += vc * decls[i].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];
|
||
|
||
// Build the pool at a candidate (vb0, pad). Sub-meshes that fail the
|
||
// structural requirements (every index inside its own buffer, indices
|
||
// reaching its end) are skipped, so the returned length says how much of the
|
||
// declared pool this candidate actually explains — which is what selects
|
||
// between candidates below.
|
||
let build = |vb0: usize, pad: usize| -> Vec<GameMesh> {
|
||
let ib0 = vb0 - span - pad;
|
||
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(decls[i].stride).map_or(true, |b| vb + b > bytes.len())
|
||
{
|
||
break;
|
||
}
|
||
let ok = validate_block(bytes, ib, vb, vc, ic, &decls[i], 0.85, false);
|
||
if !ok && i > kmax {
|
||
break; // chain diverged — emit the validated prefix, no garbage
|
||
}
|
||
let mut max_idx = 0usize;
|
||
let in_range = (0..ic).all(|k| {
|
||
let i = be16(bytes, ib + k * 2) as usize;
|
||
max_idx = max_idx.max(i);
|
||
i < vc
|
||
});
|
||
if in_range && max_idx + cover_slack() >= vc {
|
||
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, &decls[i]));
|
||
}
|
||
vb += vc * decls[i].stride;
|
||
}
|
||
meshes
|
||
};
|
||
|
||
// A candidate that explains the WHOLE pool beats one that explains part of it,
|
||
// however early it sits in file order. `n201_01` is the case that forced this:
|
||
// a `vb0` **4 bytes before** the capture-proven start also validates for the
|
||
// pivot (at pad 2) and, being earlier in the scan, used to win — then two of
|
||
// the four sub-meshes fell out as out-of-range and the resource decoded as a
|
||
// 2-part fragment 4 bytes off. The proven start explains all four.
|
||
let mut partial: Option<Vec<GameMesh>> = None;
|
||
|
||
for &vb0 in starts {
|
||
// Distinct assignment: a pool another resource already claimed is not a
|
||
// candidate (see the collision resolution in `anchor_models_filtered`).
|
||
if taken.contains(&vb0) {
|
||
continue;
|
||
}
|
||
// Which pad? Not the first that validates — the same trap the searched
|
||
// path had until 2026-08-13: a pool read one index element late still
|
||
// validates but wires every triangle wrongly. Score the pivot's index run
|
||
// (degenerate triangles first, then winding) and keep the cleanest pad.
|
||
// `XBG7_PAD_FIRST_MATCH=1` restores first-match here too.
|
||
let mut best: Option<(usize, f32, usize)> = None;
|
||
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, &decls[kmax], grouped_consistency(), true) {
|
||
continue;
|
||
}
|
||
let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, &decls[kmax]);
|
||
let cand = (degen, -wind, pad);
|
||
if best.map_or(true, |b| cand < b) {
|
||
best = Some(cand);
|
||
}
|
||
if pad_first_match() {
|
||
break;
|
||
}
|
||
}
|
||
if let Some((_, _, pad)) = best {
|
||
// Pivot confirmed the alignment; how much of the pool does it explain?
|
||
let meshes = build(vb0, pad);
|
||
if meshes.len() == n {
|
||
return meshes;
|
||
}
|
||
if partial.as_ref().map_or(true, |p| meshes.len() > p.len()) {
|
||
partial = Some(meshes);
|
||
}
|
||
}
|
||
}
|
||
// No candidate explained the whole pool — keep the best partial one, so this
|
||
// can never decode less than the previous first-match behaviour.
|
||
partial.unwrap_or_default()
|
||
}
|
||
|
||
/// How clean is the triangle list at `ib` against the pool at `vb`?
|
||
///
|
||
/// Returns `(degenerate triangles, winding agreement)` — the two properties that
|
||
/// separate a correctly located index run from one read a couple of bytes off.
|
||
/// A shifted run wires arbitrary vertices, which shows up as **degenerate**
|
||
/// triangles (a repeated index) and a winding agreement drifting toward the 0.5
|
||
/// middle; a real block has zero degenerate triangles and agreement ≈1 or ≈0.
|
||
/// Used by [`anchor_pool_mesh`] to choose between pads that all validate.
|
||
fn index_run_quality(
|
||
bytes: &[u8],
|
||
ib: usize,
|
||
vb: usize,
|
||
index_count: usize,
|
||
decl: &VertexDecl,
|
||
) -> (usize, f32) {
|
||
// The pool only needs as many vertices as the run references.
|
||
let mut max_idx = 0usize;
|
||
for k in 0..index_count {
|
||
let at = ib + k * 2;
|
||
if at + 2 > bytes.len() {
|
||
return (usize::MAX, 0.0);
|
||
}
|
||
max_idx = max_idx.max(be16(bytes, at) as usize);
|
||
}
|
||
let m = read_pool_mesh(bytes, ib, vb, index_count, max_idx + 1, decl);
|
||
if m.normals.is_empty() {
|
||
return (0, 1.0); // no normals: degeneracy alone decides
|
||
}
|
||
let (_, degen, na, _) = topology_report(&m.indices, &m.positions, &m.normals);
|
||
(degen, na.max(1.0 - na))
|
||
}
|
||
|
||
/// 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,
|
||
vbuf_offset: Some(vb),
|
||
}
|
||
}
|
||
|
||
// ── 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 of one XBG7 **sub-mesh**, parsed from the element triples
|
||
/// that follow its index marker. A grouped pool may declare a different layout
|
||
/// per sub-mesh — see [`all_vertex_decls`].
|
||
#[derive(Clone)]
|
||
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
|
||
}
|
||
|
||
/// Every sub-mesh's OWN declaration, in file order.
|
||
///
|
||
/// A grouped pool is **not** one declaration repeated: each index marker is
|
||
/// followed by its own element triples, and they can differ. `n201_01`
|
||
/// (`Stage_S02.xpr`) declares strides 24, 24, 24 and **28** — the last sub-mesh
|
||
/// has a fourth element — which a 2026-08-13 runtime capture confirms
|
||
/// (`stride=28` on that draw, and a different vertex shader for each sub-mesh).
|
||
/// Reading the first declaration for the whole pool walks the last buffer out of
|
||
/// phase and the resource is declined; see docs/re/structures/xbg7-mesh.md.
|
||
fn all_vertex_decls(desc: &[u8]) -> Vec<VertexDecl> {
|
||
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) {
|
||
match parse_decl_at(desc, rel + 8) {
|
||
Some(d) => out.push(d),
|
||
// Keep the positions aligned with `all_index_markers`: a
|
||
// marker whose declaration will not parse still occupies a
|
||
// slot, so fall back to the resource's first declaration.
|
||
None => match out.first() {
|
||
Some(d) => out.push(d.clone()),
|
||
None => return Vec::new(),
|
||
},
|
||
}
|
||
rel += 8;
|
||
continue;
|
||
}
|
||
}
|
||
rel += 4;
|
||
}
|
||
out
|
||
}
|
||
|
||
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
|
||
let mk = find_index_marker(desc)?.0;
|
||
parse_decl_at(desc, mk + 8)
|
||
}
|
||
|
||
/// Parse the element triples that start at `at` (just past an index marker).
|
||
fn parse_decl_at(desc: &[u8], at: usize) -> Option<VertexDecl> {
|
||
let mk = at.checked_sub(8)?;
|
||
|
||
// 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())
|
||
}
|
||
|
||
// ── Per-sub-mesh material graph ──────────────────────────────────────────────
|
||
|
||
/// Find the next `rou_…_col` NUL-terminated ASCII string in `d` within `limit`
|
||
/// bytes of `from`, returned with the `rou_` prefix stripped so it matches the
|
||
/// `TX2D` resource name directly (`rou_f001_bdy_04_col` → `f001_bdy_04_col`).
|
||
fn next_col_name(d: &[u8], from: usize, limit: usize) -> Option<String> {
|
||
let end = (from + limit).min(d.len());
|
||
let mut i = from;
|
||
while i + 4 <= end {
|
||
if &d[i..i + 4] == b"rou_" {
|
||
let mut j = i;
|
||
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||
j += 1;
|
||
}
|
||
if let Ok(s) = std::str::from_utf8(&d[i..j]) {
|
||
if s.ends_with("_col") {
|
||
return Some(s.trim_start_matches("rou_").to_string());
|
||
}
|
||
}
|
||
i = j.max(i + 1);
|
||
} else {
|
||
i += 1;
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Per-sub-mesh albedo texture name from the XBG7 node/material graph.
|
||
///
|
||
/// The XBG7 descriptor is a scene graph in which each geometry record
|
||
/// `[vtx:u32][0:u32][idx:u32][tail:u32]` is immediately followed by its material
|
||
/// node, whose first `rou_…_col` string names the albedo texture. Stripping the
|
||
/// `rou_` prefix yields the exact `TX2D` resource name, so a caller can texture
|
||
/// each sub-mesh individually (matching by `(vtx_count, idx_count)`) instead of
|
||
/// painting the whole hull with a single map. Returns `(vtx, idx, albedo_name)`
|
||
/// per record in descriptor order for the XBG7 resource named `resource_name`
|
||
/// (empty when the file/resource/graph can't be read).
|
||
pub fn submesh_albedos(bytes: &[u8], resource_name: &str) -> Vec<(usize, usize, String)> {
|
||
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,
|
||
};
|
||
const DIR_BASE: usize = 0x10;
|
||
let (mut desc, mut desc_end) = (0usize, 0usize);
|
||
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 nm = read_cstr(bytes, e.name_offset as usize + DIR_BASE).unwrap_or_default();
|
||
if nm == resource_name {
|
||
desc = e.data_offset as usize + DIR_BASE;
|
||
desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
|
||
break;
|
||
}
|
||
}
|
||
if desc == 0 || desc >= desc_end {
|
||
return out;
|
||
}
|
||
let d = &bytes[desc..desc_end];
|
||
let mut rel = 0usize;
|
||
while rel + 16 <= d.len() {
|
||
let vtx = be32(d, rel);
|
||
let z = be32(d, rel + 4);
|
||
let idx = be32(d, rel + 8);
|
||
let tail = be32(d, rel + 12);
|
||
if (3..=65535).contains(&vtx)
|
||
&& z == 0
|
||
&& idx >= 3
|
||
&& idx % 3 == 0
|
||
&& idx < 400_000
|
||
&& tail > 0
|
||
&& tail < 0x10_0000
|
||
{
|
||
// The material node with the `_col` name sits a few dozen bytes past
|
||
// the record (≤ 0x100 in observed ships); bound the scan so a record
|
||
// without its own material can't borrow the next part's.
|
||
if let Some(name) = next_col_name(d, rel + 16, 0x100) {
|
||
out.push((vtx as usize, idx as usize, name));
|
||
}
|
||
rel += 16;
|
||
continue;
|
||
}
|
||
rel += 4;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Locate the descriptor byte range of the named XBG7 resource in an XPR2 file.
|
||
/// Diagnostic access to a resource's raw XBG7 descriptor byte range — used by
|
||
/// the node-graph reverse-engineering examples. Not part of the decode API.
|
||
#[doc(hidden)]
|
||
pub fn xbg7_descriptor_range(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> {
|
||
xbg7_descriptor(bytes, resource_name)
|
||
}
|
||
|
||
fn xbg7_descriptor(bytes: &[u8], resource_name: &str) -> Option<(usize, usize)> {
|
||
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
|
||
return None;
|
||
}
|
||
let mut cur = Cursor::new(bytes);
|
||
let header = Xpr2Header::read(&mut cur).ok()?;
|
||
const DIR_BASE: usize = 0x10;
|
||
for _ in 0..header.num_resources {
|
||
let e = Xpr2ResourceEntry::read(&mut cur).ok()?;
|
||
if &e.type_tag != b"XBG7" {
|
||
continue;
|
||
}
|
||
let nm = read_cstr(bytes, e.name_offset as usize + DIR_BASE).unwrap_or_default();
|
||
if nm == resource_name {
|
||
let desc = e.data_offset as usize + DIR_BASE;
|
||
let end = (desc + e.descriptor_size as usize).min(bytes.len());
|
||
return (desc < end).then_some((desc, end));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// One material draw-group within a (possibly merged) sub-mesh: a slice
|
||
/// `indices[idx_offset .. idx_offset + idx_count]` textured with `albedo`.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct MaterialGroup {
|
||
pub idx_offset: usize,
|
||
pub idx_count: usize,
|
||
/// TX2D albedo resource name (`rou_` prefix stripped).
|
||
pub albedo: String,
|
||
}
|
||
|
||
/// Split a merged sub-mesh into its material draw-groups using the XBG7 scene
|
||
/// graph. Each part is drawn as one or more `[vtx][idx_offset][idx_count][4]`
|
||
/// records, each tagged with its own `_col` albedo. A single-material part is
|
||
/// one record (`idx_offset == 0`); the hull **body** is several records that
|
||
/// share one index buffer, their offsets chaining cumulatively (0, 18, 174, …).
|
||
///
|
||
/// Given the merged mesh's `target` index count, returns the ordered groups
|
||
/// forming the cumulative chain that sums to `target` — so the caller can slice
|
||
/// the merged index array and texture each slice correctly (the Delta Saber body
|
||
/// = bdy_01a hull + bdy_01b + bdy_02/03 + daiza stand). Empty when no chain
|
||
/// matches (caller falls back to a single stem-matched albedo).
|
||
pub fn material_groups(bytes: &[u8], resource_name: &str, target: usize) -> Vec<MaterialGroup> {
|
||
let Some((desc, desc_end)) = xbg7_descriptor(bytes, resource_name) else {
|
||
return Vec::new();
|
||
};
|
||
let d = &bytes[desc..desc_end];
|
||
|
||
// Collect every `[vtx][idx_offset][idx_count][tail=4]` draw record that has a
|
||
// `_col` material within reach.
|
||
struct Rec {
|
||
off: usize,
|
||
cnt: usize,
|
||
albedo: String,
|
||
}
|
||
let mut recs: Vec<Rec> = Vec::new();
|
||
let mut rel = 0usize;
|
||
while rel + 16 <= d.len() {
|
||
let vtx = be32(d, rel);
|
||
let off = be32(d, rel + 8 - 4); // idx_offset at rel+4
|
||
let cnt = be32(d, rel + 8);
|
||
let tail = be32(d, rel + 12);
|
||
if (1..=70000).contains(&vtx)
|
||
&& tail == 4
|
||
&& cnt >= 3
|
||
&& cnt % 3 == 0
|
||
&& cnt < 400_000
|
||
&& (off as usize) < 4_000_000
|
||
{
|
||
if let Some(albedo) = next_col_name(d, rel + 16, 0x140) {
|
||
recs.push(Rec {
|
||
off: off as usize,
|
||
cnt: cnt as usize,
|
||
albedo,
|
||
});
|
||
rel += 16;
|
||
continue;
|
||
}
|
||
}
|
||
rel += 4;
|
||
}
|
||
|
||
// Follow the cumulative chain from each `idx_offset == 0` start; return the
|
||
// one whose running total equals `target`.
|
||
for (si, s) in recs.iter().enumerate() {
|
||
if s.off != 0 {
|
||
continue;
|
||
}
|
||
let mut chain = vec![MaterialGroup {
|
||
idx_offset: 0,
|
||
idx_count: s.cnt,
|
||
albedo: s.albedo.clone(),
|
||
}];
|
||
let mut total = s.cnt;
|
||
// Extend while a later record picks up exactly where this one ends.
|
||
loop {
|
||
if total == target {
|
||
return chain;
|
||
}
|
||
if total > target {
|
||
break;
|
||
}
|
||
let want = total;
|
||
match recs
|
||
.iter()
|
||
.enumerate()
|
||
.find(|(j, r)| *j != si && r.off == want && r.cnt > 0)
|
||
{
|
||
Some((_, r)) => {
|
||
chain.push(MaterialGroup {
|
||
idx_offset: r.off,
|
||
idx_count: r.cnt,
|
||
albedo: r.albedo.clone(),
|
||
});
|
||
total += r.cnt;
|
||
}
|
||
None => break,
|
||
}
|
||
}
|
||
}
|
||
Vec::new()
|
||
}
|
||
|
||
#[inline]
|
||
fn be_f64(b: &[u8], o: usize) -> f64 {
|
||
if o + 8 > b.len() {
|
||
return 0.0;
|
||
}
|
||
f64::from_be_bytes([
|
||
b[o],
|
||
b[o + 1],
|
||
b[o + 2],
|
||
b[o + 3],
|
||
b[o + 4],
|
||
b[o + 5],
|
||
b[o + 6],
|
||
b[o + 7],
|
||
])
|
||
}
|
||
|
||
/// Placement of one drawn geometry-node *instance* from the XBG7 scene graph.
|
||
///
|
||
/// A geometry may be drawn several times (a mirrored fin pair, L/R winglets);
|
||
/// each draw is one `NodePlacement`. The world transform is `m·v + t` applied to
|
||
/// the local vertex `v` (vertex space: X right, Y up, Z fore/aft).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct NodePlacement {
|
||
/// Index of the decoded sub-mesh this instance draws (sub-meshes come out of
|
||
/// the decoder in the graph's first-encounter geometry order, so this is
|
||
/// stable). Match a placement to `model.meshes[sub_index]`.
|
||
pub sub_index: usize,
|
||
/// Vertex count of the geometry — a cross-check against the sub-mesh.
|
||
pub vtx_count: usize,
|
||
/// 3×3 linear part (rotation, and an X-reflection for mirrored instances),
|
||
/// row-major: `world[r] = Σ_c m[r][c]·v[c] + t[r]`.
|
||
pub m: [[f32; 3]; 3],
|
||
/// World translation (vertex space).
|
||
pub t: [f32; 3],
|
||
/// True when this instance is an X-reflection (its triangle winding is
|
||
/// flipped — reverse index order to keep front faces outward).
|
||
pub reflect: bool,
|
||
}
|
||
|
||
impl NodePlacement {
|
||
/// Apply the world transform to a local vertex.
|
||
pub fn apply(&self, v: [f32; 3]) -> [f32; 3] {
|
||
[
|
||
self.m[0][0] * v[0] + self.m[0][1] * v[1] + self.m[0][2] * v[2] + self.t[0],
|
||
self.m[1][0] * v[0] + self.m[1][1] * v[1] + self.m[1][2] * v[2] + self.t[1],
|
||
self.m[2][0] * v[0] + self.m[2][1] * v[1] + self.m[2][2] * v[2] + self.t[2],
|
||
]
|
||
}
|
||
}
|
||
|
||
// Small 3×3 affine helpers (formats crate is glam-free).
|
||
type M3 = [[f32; 3]; 3];
|
||
const M3_ID: M3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||
fn m3_mul(a: M3, b: M3) -> M3 {
|
||
let mut o = [[0.0f32; 3]; 3];
|
||
for r in 0..3 {
|
||
for c in 0..3 {
|
||
o[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c];
|
||
}
|
||
}
|
||
o
|
||
}
|
||
fn m3_vec(a: M3, v: [f32; 3]) -> [f32; 3] {
|
||
[
|
||
a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2],
|
||
a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2],
|
||
a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2],
|
||
]
|
||
}
|
||
/// Rotation about X (lateral) — mixes Y and Z (fin/flap pitch).
|
||
fn rot_x(a: f32) -> M3 {
|
||
let (s, c) = a.sin_cos();
|
||
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
|
||
}
|
||
/// Rotation about Y (vertical) — mixes X and Z (yaw).
|
||
fn rot_y(a: f32) -> M3 {
|
||
let (s, c) = a.sin_cos();
|
||
[[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
|
||
}
|
||
/// Rotation about Z (fore/aft) — mixes X and Y (V-tail cant / roll).
|
||
fn rot_z(a: f32) -> M3 {
|
||
let (s, c) = a.sin_cos();
|
||
[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
|
||
}
|
||
|
||
/// Read a node's **9-channel TRS** `[TX TY TZ RY RX RZ SX SY SZ]` from its joint
|
||
/// table at `t44`.
|
||
///
|
||
/// The table holds **8 pointer slots**, but the node has **nine** keyframe
|
||
/// tracks (3 translation, 3 rotation, 3 scale), each a 0x50-byte record with its
|
||
/// value at `+8`. The pointers are shifted one record forward: `ptr[k]` points at
|
||
/// track `k+1`'s record, so channel `k+1`'s value is `f64 @ ptr[k]+8` and channel
|
||
/// 0 (TX!) — which no pointer names — sits one record *before* the first, at
|
||
/// `ptr[0] − 0x48`.
|
||
///
|
||
/// This off-by-one is what hid every lateral offset: the old 8-slot read took
|
||
/// TY as "X", RY as "Y" (usually 0 — why hulls stacked on the centreline) and
|
||
/// never saw TX at all (the ±264 hull pair offset). Derived 2026-07-26 from the
|
||
/// e106 F10 runtime capture and verified against the captured transforms of all
|
||
/// 8 destroyer parts (see docs/re/ship-placement-runtime-capture.md).
|
||
fn read_trs9(d: &[u8], t44: usize) -> [f64; 9] {
|
||
let mut v = [0.0f64; 9];
|
||
let p0 = be32(d, t44) as usize;
|
||
if p0 >= 0x48 && p0 + 16 <= d.len() {
|
||
v[0] = be_f64(d, p0 - 0x48);
|
||
}
|
||
for k in 0..8 {
|
||
let p = be32(d, t44 + k * 4) as usize;
|
||
if p != 0 && p + 16 <= d.len() {
|
||
v[k + 1] = be_f64(d, p + 8);
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
/// Compose a node's local rotation from its three Euler channels
|
||
/// `(ch3, ch4, ch5) = (RY, RX, RZ)` — **`Ry·Rx·Rz`**, angles applied directly.
|
||
/// Convention pinned by the e106 engine-rig runtime capture: the nacelle node
|
||
/// stores `(RX, RZ) = (−15°, +30°)` and the captured WorldView rotation equals
|
||
/// `Rx(−15°)·Rz(30°)` exactly (decomposed element-for-element). The older saber
|
||
/// analysis script recovered the transpose of this convention; the fin override
|
||
/// [`saber_measured`] keeps those parts pinned either way.
|
||
fn node_rotation(ry: f64, rx: f64, rz: f64) -> M3 {
|
||
m3_mul(m3_mul(rot_y(ry as f32), rot_x(rx as f32)), rot_z(rz as f32))
|
||
}
|
||
|
||
/// The DeltaSaber fin-assembly part names — the structural signature that marks a
|
||
/// DeltaSaber-family model (`_T`/`_W`/`_A` = f001/f002/f004; same layout, slightly
|
||
/// different vertex counts) so the measured placements below apply to all three.
|
||
const SABER_PARTS: [&str; 5] = ["bdy_04", "bdy_06", "bdy_07", "bdy_10", "bdy_11"];
|
||
|
||
/// Ground-truth world placement for a DeltaSaber fin part, keyed by node-name
|
||
/// suffix, MEASURED from the running game (Canary draw log:
|
||
/// `world = inv(body_WVP)·part_WVP` over the vertex-shader constants — see
|
||
/// tools/analyze_drawlog_wvp.py). The static XBG7 node graph places the fin
|
||
/// assemblies inboard at the centreline and omits the per-nacelle mount transform
|
||
/// the engine applies at runtime; these matrices are that runtime placement (the
|
||
/// LEFT instance — the right is its X-reflection). Keyed by part role so it
|
||
/// transfers across the identically-laid-out `_T`/`_W`/`_A` variants.
|
||
///
|
||
/// Verified: the recovered rotations match the graph exactly (fin = Rz(0.297),
|
||
/// flap = Rz(0.297)·Rx(−0.33)); only the translation carried the missing nacelle
|
||
/// offset (world X ≈ −4.4..−6.6, absent from the file).
|
||
fn saber_measured(part: &str) -> Option<(M3, [f32; 3])> {
|
||
Some(match part {
|
||
// fin bdy_04 — V-tail, canted; mounted on the nacelle.
|
||
"bdy_04" => (
|
||
[[0.9563, -0.2924, 0.0], [0.2924, 0.9563, 0.0], [0.0, 0.0, 1.0]],
|
||
[-5.2350, 1.0679, -9.7182],
|
||
),
|
||
// flaps bdy_04_2 / bdy_04_3 — ride the fin (compound rotation).
|
||
"bdy_04_2" => (
|
||
[[0.9563, -0.2766, -0.0947], [0.2924, 0.9048, 0.3098], [-0.0001, -0.3240, 0.9461]],
|
||
[-6.1562, 3.3121, -10.5028],
|
||
),
|
||
"bdy_04_3" => (
|
||
[[0.9563, -0.2766, -0.0947], [0.2924, 0.9048, 0.3098], [-0.0001, -0.3240, 0.9461]],
|
||
[-5.7261, 3.4436, -10.5026],
|
||
),
|
||
// winglet bdy_06 + tip bdy_07 — on the nacelle (no cant).
|
||
"bdy_06" => (M3_ID, [-6.5988, -1.9838, -15.4623]),
|
||
"bdy_07" => (M3_ID, [-6.5988, -1.9838, -16.2893]),
|
||
// small fin bdy_10 + tip bdy_11 — inboard nacelle stub.
|
||
"bdy_10" => (M3_ID, [-4.4496, -4.3962, -16.9703]),
|
||
"bdy_11" => (M3_ID, [-4.4496, -4.3962, -17.4694]),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Recover per-node placement from the XBG7 scene graph.
|
||
///
|
||
/// The graph's head is a node hierarchy: each node stores an 8-value TRS
|
||
/// (3 translation, 2 rotation, 3 scale) as big-endian **f64** in a joint table
|
||
/// pointed to at `node+0x44`, and the vertex count of its geometry at
|
||
/// `node+0x48 (+0x1C)`. The body (`bdy_01`) is identity, but the fins are placed
|
||
/// by a translation toward the tail (fore-aft ≈ −15) that the *rigid* vertex
|
||
/// buffer omits — the game applies it in the vertex shader, which is why the raw
|
||
/// buffer (and our decode) puts the fins at the front. `Fin_*_root` nodes carry
|
||
/// the transform for their otherwise-identity geometry children.
|
||
///
|
||
/// The hierarchy is a first-child / next-sibling tree: each node record ends
|
||
/// with `[child_ptr][sibling_ptr][0xFFFFFFFF]`, and a pointer's target node name
|
||
/// starts 4 bytes before it. That lets us recover each node's subtree extent
|
||
/// exactly (a node owns every record between it and its next sibling), so a
|
||
/// child's transform composes onto its parent's — the flaps ride the fin, the
|
||
/// winglets ride their `Fin_*_root` mount.
|
||
///
|
||
/// Mirroring: a geometry drawn twice (fin V-tail pair, L/R winglets, L/R small
|
||
/// fins) stores identical or sign-flipped data for the two copies; the engine
|
||
/// draws the second as an **X-reflection** of the first. We reproduce that — the
|
||
/// second and later instances of a geometry reflect the first instance's world.
|
||
///
|
||
/// Returns one [`NodePlacement`] per drawn instance (so a mirrored pair yields
|
||
/// two). Empty when the graph can't be read.
|
||
pub fn node_transforms(bytes: &[u8], resource_name: &str) -> Vec<NodePlacement> {
|
||
let Some((desc, desc_end)) = xbg7_descriptor(bytes, resource_name) else {
|
||
return Vec::new();
|
||
};
|
||
let d = &bytes[desc..desc_end];
|
||
// Nodes live in the head, before the first geometry record (~0x1684).
|
||
let head_end = d.len().min(0x1684);
|
||
let prefix = format!("rou_{resource_name}_");
|
||
|
||
// 9-channel TRS `[TX TY TZ RY RX RZ SX SY SZ]` from the node's `+0x44` joint
|
||
// table (see [`read_trs9`] — the 8 pointers are shifted one track forward).
|
||
let read_trs = |t44: usize| read_trs9(d, t44);
|
||
|
||
// Phase 1: collect node records in document order, each with its local
|
||
// affine and the offset where its subtree ends (its next sibling).
|
||
struct Rec {
|
||
name_start: usize,
|
||
part: String, // node name with the `rou_<model>_` prefix stripped
|
||
vtx: usize,
|
||
t48: usize,
|
||
local_m: M3,
|
||
local_t: [f32; 3],
|
||
sib_end: Option<usize>,
|
||
}
|
||
let mut recs: Vec<Rec> = Vec::new();
|
||
let mut i = 0usize;
|
||
while i + 4 <= head_end {
|
||
if &d[i..i + 4] != b"rou_" {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
let mut j = i;
|
||
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||
j += 1;
|
||
}
|
||
let name = std::str::from_utf8(&d[i..j]).unwrap_or("");
|
||
let is_node = name.starts_with(&prefix)
|
||
&& !name.ends_with("_col")
|
||
&& !name.ends_with("_spc")
|
||
&& !name.ends_with("_gls")
|
||
&& !name.ends_with("_lum");
|
||
if !is_node {
|
||
i = j.max(i + 1);
|
||
continue;
|
||
}
|
||
// Each node ends with [child_ptr][sibling_ptr][0xFFFFFFFF]; the two table
|
||
// offsets precede the child/sibling pair.
|
||
let mut ff = i;
|
||
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
|
||
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
|
||
ff += 4;
|
||
}
|
||
if ff >= scan_end || ff < 0x10 {
|
||
i = j.max(i + 1);
|
||
continue;
|
||
}
|
||
let t44 = be32(d, ff - 0x10) as usize;
|
||
let t48 = be32(d, ff - 0x0C) as usize;
|
||
let sib_ptr = be32(d, ff - 0x04) as usize;
|
||
let vtx = if t48 != 0 && t48 + 0x20 <= d.len() {
|
||
be32(d, t48 + 0x1C) as usize
|
||
} else {
|
||
0
|
||
};
|
||
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
||
read_trs(t44)
|
||
} else {
|
||
[0.0; 9]
|
||
};
|
||
if std::env::var("XNODEDUMP").is_ok() {
|
||
eprintln!(
|
||
"NODE {name} vtx={vtx} t44={t44:#x} t48={t48:#x} ff@{ff:#x} sib@{sib_ptr:#x} TRS=[{:.4} {:.4} {:.4} | {:.4} {:.4} {:.4} | {:.4} {:.4} {:.4}]",
|
||
trs[0], trs[1], trs[2], trs[3], trs[4], trs[5], trs[6], trs[7], trs[8]
|
||
);
|
||
}
|
||
// Local transform: translation (TX, TY, TZ) + Euler rotation (see
|
||
// [`node_rotation`]).
|
||
let local_t = [trs[0] as f32, trs[1] as f32, trs[2] as f32];
|
||
let local_m = node_rotation(trs[3], trs[4], trs[5]);
|
||
// Next sibling: its name starts 4 bytes before the pointer, and there a
|
||
// real node record begins with "rou_". Everything between here and there
|
||
// is this node's subtree.
|
||
let sib_end = sib_ptr
|
||
.checked_sub(4)
|
||
.filter(|&s| s > i && s + 4 <= head_end && &d[s..s + 4] == b"rou_");
|
||
let part = name.strip_prefix(&prefix).unwrap_or(name).to_string();
|
||
recs.push(Rec { name_start: i, part, vtx, t48, local_m, local_t, sib_end });
|
||
i = j.max(i + 1);
|
||
}
|
||
|
||
// A DeltaSaber-family model (its fin assemblies need the measured runtime
|
||
// nacelle mount the static graph omits) is identified by its part-name set.
|
||
let is_saber = SABER_PARTS
|
||
.iter()
|
||
.all(|want| recs.iter().any(|r| r.vtx > 0 && r.part == *want));
|
||
|
||
// Phase 2: walk the subtree intervals, composing each node onto its parent's
|
||
// world, and emit a placement per drawn geometry instance.
|
||
let mut out = Vec::new();
|
||
let mut stack: Vec<(usize, M3, [f32; 3])> = Vec::new(); // (subtree_end, world_m, world_t)
|
||
let mut order: Vec<usize> = Vec::new(); // unique t48, first-encounter → sub_index
|
||
let mut first_world: std::collections::HashMap<usize, (M3, [f32; 3])> =
|
||
std::collections::HashMap::new();
|
||
for rec in &recs {
|
||
while stack.last().is_some_and(|s| s.0 <= rec.name_start) {
|
||
stack.pop();
|
||
}
|
||
let (pm, pt) = stack.last().map(|s| (s.1, s.2)).unwrap_or((M3_ID, [0.0; 3]));
|
||
// world = parent ∘ local
|
||
let wm = m3_mul(pm, rec.local_m);
|
||
let r = m3_vec(pm, rec.local_t);
|
||
let wt = [r[0] + pt[0], r[1] + pt[1], r[2] + pt[2]];
|
||
let end = rec
|
||
.sib_end
|
||
.unwrap_or_else(|| stack.last().map(|s| s.0).unwrap_or(head_end));
|
||
if rec.vtx > 0 {
|
||
let sub_index = match order.iter().position(|&x| x == rec.t48) {
|
||
Some(k) => k,
|
||
None => {
|
||
order.push(rec.t48);
|
||
order.len() - 1
|
||
}
|
||
};
|
||
// For a DeltaSaber fin part, use the MEASURED runtime placement (the
|
||
// nacelle mount the static graph omits); otherwise the graph world.
|
||
let (node_m, node_t) = match is_saber.then(|| saber_measured(&rec.part)).flatten() {
|
||
Some((mm, mt)) => (mm, mt),
|
||
None => (wm, wt),
|
||
};
|
||
if let Some((fm, ft)) = first_world.get(&rec.t48).copied() {
|
||
// A repeat draw of this geometry → X-reflection of the first.
|
||
let s: M3 = [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||
out.push(NodePlacement {
|
||
sub_index,
|
||
vtx_count: rec.vtx,
|
||
m: m3_mul(s, fm),
|
||
t: [-ft[0], ft[1], ft[2]],
|
||
reflect: true,
|
||
});
|
||
} else {
|
||
first_world.insert(rec.t48, (node_m, node_t));
|
||
out.push(NodePlacement {
|
||
sub_index,
|
||
vtx_count: rec.vtx,
|
||
m: node_m,
|
||
t: node_t,
|
||
reflect: false,
|
||
});
|
||
}
|
||
}
|
||
stack.push((end, wm, wt));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A geometry resource placed by a composite scene graph, with its world affine.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ScenePart {
|
||
/// The geometry resource to draw (e.g. `e106_bdy_01`).
|
||
pub resource: String,
|
||
/// World rotation (row-major 3×3).
|
||
pub m: [[f32; 3]; 3],
|
||
/// World translation, `(X, up→Y, fore/aft→Z)`.
|
||
pub t: [f32; 3],
|
||
/// Per-axis local scale (a shield generator ships at 0.5, a jet FX at 2.4).
|
||
pub s: [f32; 3],
|
||
}
|
||
|
||
impl ScenePart {
|
||
/// Apply the world transform to a local vertex: `R·(S·v) + T` (scale is a
|
||
/// leaf-local factor; the composite's structural nodes are all unit-scale).
|
||
pub fn apply(&self, v: [f32; 3]) -> [f32; 3] {
|
||
let sv = [v[0] * self.s[0], v[1] * self.s[1], v[2] * self.s[2]];
|
||
[
|
||
self.m[0][0] * sv[0] + self.m[0][1] * sv[1] + self.m[0][2] * sv[2] + self.t[0],
|
||
self.m[1][0] * sv[0] + self.m[1][1] * sv[1] + self.m[1][2] * sv[2] + self.t[1],
|
||
self.m[2][0] * sv[0] + self.m[2][1] * sv[1] + self.m[2][2] * sv[2] + self.t[2],
|
||
]
|
||
}
|
||
}
|
||
|
||
/// Walk a composite scene graph (`e_rou_eNNN`) and return **every** node with its
|
||
/// composed world transform (parent∘child down the first-child/next-sibling tree).
|
||
///
|
||
/// A capital ship's parts are authored around the origin in their own resources;
|
||
/// the composite's node tree carries the world placement — `rou_<id>_*` nodes name
|
||
/// the hull geometry resources, and `GN_*` nodes are the Vessel hardpoint frames
|
||
/// (bridge / engine / shield-generator / turret mounts). The [`crate::ship`]
|
||
/// assembler resolves those names to resources and places the parts. Same node
|
||
/// record layout + TRS convention as [`node_transforms`], but cross-resource.
|
||
pub fn scene_world_nodes(bytes: &[u8], composite_name: &str) -> Vec<ScenePart> {
|
||
let Some((desc, desc_end)) = xbg7_descriptor(bytes, composite_name) else {
|
||
return Vec::new();
|
||
};
|
||
let d = &bytes[desc..desc_end];
|
||
let head_end = d.len();
|
||
|
||
let read_trs = |t44: usize| read_trs9(d, t44);
|
||
|
||
// A node record begins with a name starting `rou_` or `GN_`.
|
||
let node_name_at = |i: usize| -> Option<(String, usize)> {
|
||
let starts = i + 4 <= d.len() && &d[i..i + 4] == b"rou_"
|
||
|| i + 3 <= d.len() && &d[i..i + 3] == b"GN_";
|
||
if !starts {
|
||
return None;
|
||
}
|
||
let mut j = i;
|
||
while j < d.len() && d[j] != 0 && d[j].is_ascii_graphic() {
|
||
j += 1;
|
||
}
|
||
let name = std::str::from_utf8(&d[i..j]).ok()?.to_string();
|
||
if name.ends_with("_col")
|
||
|| name.ends_with("_spc")
|
||
|| name.ends_with("_gls")
|
||
|| name.ends_with("_lum")
|
||
{
|
||
return None;
|
||
}
|
||
Some((name, j))
|
||
};
|
||
|
||
struct Rec {
|
||
name_start: usize,
|
||
name: String,
|
||
local_m: M3,
|
||
local_t: [f32; 3],
|
||
local_s: [f32; 3],
|
||
sib_end: Option<usize>,
|
||
}
|
||
let mut recs: Vec<Rec> = Vec::new();
|
||
let mut i = 0usize;
|
||
while i + 3 <= head_end {
|
||
let Some((name, j)) = node_name_at(i) else {
|
||
i += 1;
|
||
continue;
|
||
};
|
||
let mut ff = i;
|
||
let scan_end = (i + 0x90).min(d.len().saturating_sub(4));
|
||
while ff < scan_end && be32(d, ff) != 0xFFFF_FFFF {
|
||
ff += 4;
|
||
}
|
||
if ff >= scan_end || ff < 0x10 {
|
||
i = j.max(i + 1);
|
||
continue;
|
||
}
|
||
let t44 = be32(d, ff - 0x10) as usize;
|
||
let sib_ptr = be32(d, ff - 0x04) as usize;
|
||
let trs = if t44 != 0 && t44 + 32 <= d.len() {
|
||
read_trs(t44)
|
||
} else {
|
||
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
|
||
};
|
||
let local_t = [trs[0] as f32, trs[1] as f32, trs[2] as f32];
|
||
let local_m = node_rotation(trs[3], trs[4], trs[5]);
|
||
// Channels 6–8 are per-axis scale (a hardpoint part ships at e.g. 0.5).
|
||
let sc = |v: f64| if v.abs() < 1e-6 { 1.0 } else { v as f32 };
|
||
let local_s = [sc(trs[6]), sc(trs[7]), sc(trs[8])];
|
||
// Next sibling begins 4 bytes before its pointer, at a `rou_`/`GN_` name.
|
||
let sib_end = sib_ptr.checked_sub(4).filter(|&s| {
|
||
s > i && s + 4 <= head_end && (&d[s..s + 4] == b"rou_" || &d[s..s + 3] == b"GN_")
|
||
});
|
||
recs.push(Rec { name_start: i, name, local_m, local_t, local_s, sib_end });
|
||
i = j.max(i + 1);
|
||
}
|
||
|
||
// Compose transforms down the first-child / next-sibling tree.
|
||
let mut out = Vec::new();
|
||
let mut stack: Vec<(usize, M3, [f32; 3])> = Vec::new();
|
||
for rec in &recs {
|
||
while stack.last().is_some_and(|s| s.0 <= rec.name_start) {
|
||
stack.pop();
|
||
}
|
||
let (pm, pt) = stack.last().map(|s| (s.1, s.2)).unwrap_or((M3_ID, [0.0; 3]));
|
||
let wm = m3_mul(pm, rec.local_m);
|
||
let r = m3_vec(pm, rec.local_t);
|
||
let wt = [r[0] + pt[0], r[1] + pt[1], r[2] + pt[2]];
|
||
out.push(ScenePart { resource: rec.name.clone(), m: wm, t: wt, s: rec.local_s });
|
||
let end = rec
|
||
.sib_end
|
||
.unwrap_or_else(|| stack.last().map(|s| s.0).unwrap_or(head_end));
|
||
stack.push((end, wm, wt));
|
||
}
|
||
out
|
||
}
|
||
|
||
// ── 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)
|
||
));
|
||
}
|
||
}
|