Files
Syplheed-Reborn/crates/sylpheed-formats/src/mesh.rs
MechaCat02 737b61242e ship: STATIC assembly is now EXACT — node-TRS off-by-one cracked via the capture
The runtime capture served its true purpose: as the answer key that exposed the
static encoding. The XBG7 node joint table (node+0x44) holds NINE keyframe
channels [TX TY TZ RY RX RZ SX SY SZ] behind EIGHT pointer slots shifted one
track forward: channel k+1 = f64@(ptr[k]+8), and channel 0 -- TX, which no
pointer names -- sits at ptr[0]-0x48 (tracks are 0x50-byte records, value at
+8). The old 8-slot read took TY as X, RY as Y (usually 0 -- why both hulls
stacked on the centreline) and never saw TX at all (the +-264 hull offsets).
Euler order is Ry(ch3)*Rx(ch4)*Rz(ch5) with angles applied directly, pinned by
the captured engine nacelle Rx(-15)*Rz(30) decomposition. mesh.rs gains
read_trs9/node_rotation; scene_world_nodes and node_transforms both fixed
(saber fin overrides unaffected).

assemble_ship rewritten fully static and exact:
- every composite-node instance placed (alias duplicates collapsed, real
  instances kept) including cross-id turret mounts (rou_e303_wep_01_root x2)
- the e_rou_<id>_eng cluster rig mounts at GN_Engine_01 (two mirrored nacelles
  +-131 with -+30-degree cant + centre engine) -- world = frame o rig_local
- brg/sld at their exact GN frames (frames were always exact)
- shared-geometry twin pairs at +-TX: X-reflect the instance whose offset
  opposes the geometry's dominant side (viewer reverses winding on det<0)
- squeezed node names resolve (wep02 -> wep_02_01)

Ground-truth gate: ship::tests::static_assembly_matches_runtime_capture asserts
static == the baked e106 capture for all 8 parts (T<1.0, R<0.02) plus both
nacelles, both turrets, and the starboard-hull mirror. Viewer now uses pure
static assembly; ship_capture + the baked table remain as the verification
oracle. Renders show a coherent, bilaterally-symmetric destroyer.

81 formats-lib + all disc tests green; viewer builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:00:49 +02:00

2051 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! XBG7 mesh geometry decoder (geometry resources inside XPR2 containers).
//!
//! ## Clean-room note
//!
//! This format was reverse-engineered **purely by static observation of the
//! retail disc's `hidden/resource3d/*.xpr` files** (hex inspection + geometric
//! validation of the recovered triangles). No game code was decompiled or
//! copied. See `docs/re/structures/xbg7-mesh.md` for the evidence log.
//!
//! ## Where XBG7 lives
//!
//! Ship / weapon / prop models are `XPR2` containers (see [`crate::texture`]).
//! Their resource directory holds `TX2D` texture resources **and** one or more
//! `XBG7` geometry resources. The `XBG7` *descriptor* (at the resource's
//! `data_offset`) is a scene/material graph; the actual vertex and index
//! buffers live in the container's shared data section (from `header_size`).
//!
//! ## The "simple" layout decoded here (CONFIRMED)
//!
//! For single-stream models (weapons, simple props — 36 of the 166 disc models)
//! the data section is a straight sequence of sub-meshes, each:
//!
//! ```text
//! [ 12-byte vertex-buffer header (contents undecoded) ]
//! [ index buffer : idx_count × u16 big-endian ] triangle LIST
//! [ vertex buffer : vtx_count × stride bytes ] (declaration-driven)
//! (pad to 16 bytes → next sub-mesh)
//! ```
//!
//! The index buffer is a triangle **list**: `idx_count` is a multiple of 3 and
//! each consecutive triple is one triangle. This is confirmed two ways — the
//! Canary GPU draw capture logs `prim=4` (triangle list), and the objective
//! `XVERIFY` diagnostic shows stored-normal agreement of **1.000** under the list
//! reading (every face normal points the way its vertices' normals do — only
//! possible with correct topology + winding) versus **~0.49** (random) under a
//! strip reading. A brief 2026-07 attempt to read these as triangle strips was a
//! mistake: it over-generated ~2.5× the triangles, filling holes with an
//! overlapping garbage soup that *looked* solid but had random normals.
//!
//! Correctness is enforced by a **winding-consistency gate**: a correctly-carved
//! sub-mesh agrees with its stored normals either ≈always (≈1.0) or ≈never (≈0.0,
//! inverted winding — still a real single-sided mesh); a mis-carve wires arbitrary
//! vertices and scatters to the ≈0.5 middle. Sub-meshes with `max(na, 1-na) <
//! 0.90` are declined rather than emitted as a spike-mess.
//!
//! The vertex layout is **not fixed** — it comes from a **vertex declaration**
//! in the descriptor: a table of `{offset, format-code, usage}` triples (usage
//! `0x00` POSITION `f32×3`, `0x03` NORMAL `f16×4`, `0x05` TEXCOORD `f16×2`).
//! Models omit UV or use fewer elements, so stride varies (20 = pos+normal,
//! 24 = pos+normal+uv, …). Each element is read in naive big-endian component
//! order. Correct alignment is pinned by the recovered normals being exactly
//! unit-length. **Cross-checked against a Canary GPU vertex-fetch capture**,
//! which confirmed the primitive type (triangle list), formats, and offsets —
//! see `docs/re/structures/xbg7-mesh.md`.
//!
//! `vtx_count` / `idx_count` come from per-sub-mesh records in the descriptor:
//! a `[vtx_count:u32][0:u32][idx_count:u32][tail:u32]` tuple (big-endian), read
//! in file order. Every index is validated to be `< vtx_count`; if any
//! sub-mesh fails to carve cleanly the whole model is rejected
//! ([`MeshError::UnsupportedLayout`]) rather than emitting garbage.
//!
//! ## The grouped-pool layout (CONFIRMED — hero ships)
//!
//! Detailed models (`DeltaSaber_*.xpr` and ~100 others) don't interleave each
//! sub-mesh's index buffer with its vertex buffer. Instead a resource's *several*
//! sub-meshes share **two grouped pools**:
//!
//! ```text
//! index pool : [ ib0 | ib1 | … ] each buffer 4-byte aligned, in marker order
//! vertex pool : [ vb0 | vb1 | … ] each pool `vtx_count × stride`, same order
//! ```
//!
//! with the index pool ending exactly where the vertex pool begins. This is the
//! **same vertex format** as weapons (stride 24, triangle list) — it was declined
//! only for *location*, not format. [`Xbg7Model::anchor_models`] decodes it: it
//! pivots on the one unknown, `vb0` (found by the unit-normal vertex-run scan),
//! and derives every other index/vertex offset (see [`anchor_grouped_meshes`]).
//! Reversed statically and cross-checked against a Canary GPU draw-log capture of
//! `DeltaSaber_T.xpr` (`f001` = body + 7 detail parts = 8650 tris, every sub-mesh
//! at 0 degenerate / full coverage). See `docs/re/structures/xbg7-mesh.md`.
//!
//! Resources whose grouped pivot cannot be validated (a few multi-stream /
//! quantized bodies remain) are still cleanly declined rather than emitted as
//! garbage.
use crate::texture::{Xpr2Header, Xpr2ResourceEntry};
use binrw::BinRead;
use std::io::Cursor;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MeshError {
#[error("Not an XPR2 container")]
NotXpr2,
#[error("No XBG7 geometry resource in container")]
NoGeometry,
#[error("Mesh layout not supported (multi-stream / quantized body mesh)")]
UnsupportedLayout,
#[error("Parse error: {0}")]
Parse(#[from] binrw::Error),
}
/// A decoded 3D mesh ready for Bevy.
#[derive(Debug, Default, Clone)]
pub struct GameMesh {
/// Vertex positions in model space `[x, y, z]`.
pub positions: Vec<[f32; 3]>,
/// Vertex normals `[nx, ny, nz]` — empty when not stored (compute smooth
/// normals from geometry instead).
pub normals: Vec<[f32; 3]>,
/// Texture coordinates `[u, v]`. **Best-guess channel** (attr halves 0 & 2)
/// pending in-game visual confirmation — see the module doc.
pub uvs: Vec<[f32; 2]>,
/// Triangle-list indices (3 per triangle).
pub indices: Vec<u32>,
/// Sub-mesh / node name from the descriptor, when available.
pub name: Option<String>,
}
/// A model = the set of sub-meshes recovered from one XPR2 container's first
/// XBG7 resource, plus the resource's name.
#[derive(Debug, Default, Clone)]
pub struct Xbg7Model {
pub name: String,
pub meshes: Vec<GameMesh>,
}
/// Count the `XBG7` geometry resources in an XPR2 container. Single-model files
/// (weapons / props) have exactly one; the multi-resource `Stage_*.xpr`
/// collections have many. Used to route decoding: one → the validated
/// records-based list decode ([`Xbg7Model::from_xpr2`]); many → the
/// content-anchored [`Xbg7Model::stage_models`]. (Content-anchoring a
/// single-model file fabricates phantom / duplicate blocks — see `decode`.)
pub fn count_xbg7(bytes: &[u8]) -> usize {
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return 0;
}
let mut cur = Cursor::new(bytes);
let header = match Xpr2Header::read(&mut cur) {
Ok(h) => h,
Err(_) => return 0,
};
let mut n = 0usize;
for _ in 0..header.num_resources {
match Xpr2ResourceEntry::read(&mut cur) {
Ok(e) if &e.type_tag == b"XBG7" => n += 1,
Ok(_) => {}
Err(_) => break,
}
}
n
}
/// 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,
});
off = align16(ve) - base;
}
Ok(Xbg7Model { name, meshes })
}
/// Decode **every** locatable XBG7 geometry resource in a container.
///
/// "Stage" containers (`hidden/resource3d/Stage_*.xpr`) are collections of
/// many enemy / prop sub-models, each an independent XBG7 resource. Unlike
/// the single-stream weapon layout (index buffer immediately followed by its
/// vertex buffer), a stage's index buffers and vertex buffers live in
/// **separate grouped pools**, and the container stores each resource's
/// buffer *sizes* (index count via the marker, vertex count 32 bytes before
/// it) but **not** an explicit data offset — the on-disc block layout is a
/// separate allocation order we have not reversed.
///
/// Rather than guess that order, each resource's `[index buffer][vertex
/// buffer]` block is located by **content**: the unique offset in the data
/// section where (a) all `index_count` indices are `< vertex_count`, (b) the
/// stored normals are unit length, and (c) the resulting triangles are
/// non-degenerate with a real spatial extent. This signature is strong
/// enough to pin a block unambiguously in a multi-megabyte file. Resources
/// that cannot be located and validated this way are **skipped** (never
/// emitted as garbage) — including the biggest hero bodies, which use the
/// same quantized/complex layout that [`Xbg7Model::from_xpr2`] declines.
///
/// Returns one [`Xbg7Model`] per decoded resource (empty if none decode).
///
/// Multi-resource stage files use `min_consistency = 0.0` (no winding gate):
/// the stage corpus is large and its enemy meshes span a continuous
/// consistency range (0.51.0), so a hard gate would drop many legitimate
/// blocks. The single-model **weapon fallback** ([`decode` in the CLI]) calls
/// [`Xbg7Model::anchor_models`] with a strict `0.85`, where a mis-anchor is an
/// obvious phantom / spike-mess that must be declined.
pub fn stage_models(bytes: &[u8]) -> Vec<Xbg7Model> {
Self::anchor_models(bytes, 0.0)
}
/// 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)
}
fn anchor_models_filtered(
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,
}
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());
if let Some(w) = wanted {
if !w.contains(&name) {
continue;
}
}
resources.push(Res {
name,
markers,
decl,
});
}
if resources.is_empty() {
return out;
}
// ── One O(file) pass per distinct stride: find vertex-block *starts*. ──
//
// Each geometry block is `[12B header][index buffer][vertex buffer]`, and
// the blocks are scattered among texture data with no stored offset. But
// a vertex buffer is a run of stride-sized records whose NORMAL (f16×4 at
// +12) is unit length; a *block start* is the unique offset where that
// run begins — the previous stride slot is NOT a unit-normal vertex (it's
// index bytes / header). Collecting those starts turns the per-resource
// search from O(file) into a scan of a few hundred candidates.
let mut strides: Vec<usize> = resources.iter().map(|r| r.decl.stride).collect();
strides.sort_unstable();
strides.dedup();
let mut starts_by_stride: std::collections::BTreeMap<usize, Vec<usize>> =
std::collections::BTreeMap::new();
for &s in &strides {
starts_by_stride.insert(s, vertex_run_starts(bytes, data_base, s));
}
// 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 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)
.into_iter()
.collect()
} else {
// Several sub-meshes sharing grouped index/vertex pools → the
// deterministic grouped-pool decode (hero ships et al.).
let grouped = anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl);
if !grouped.is_empty() {
grouped
} else {
// The grouped pivot didn't validate (the extra markers were a
// coincidence, or this isn't a grouped-pool model). Fall back
// to the original single-block adjacency anchor on the first
// marker so coverage is never *below* the pre-grouped decode.
let (vtx_count, index_count) = r.markers[0];
anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency)
.into_iter()
.collect()
}
};
(!meshes.is_empty()).then(|| Xbg7Model {
name: r.name.clone(),
meshes,
})
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out = resources.par_iter().filter_map(decode_one).collect();
}
#[cfg(target_arch = "wasm32")]
{
out = resources.iter().filter_map(decode_one).collect();
}
out
}
}
/// Scan the data section for offsets that begin a `stride`-sized unit-normal
/// vertex run (NORMAL is `f16×4` at vertex offset +12). A run *start* is an
/// offset whose normal is unit while the preceding stride slot's is not — i.e.
/// the first vertex of a buffer, not a mid-buffer position. Returns the sorted
/// candidate starts (block vertex-buffer offsets).
fn vertex_run_starts(bytes: &[u8], data_base: usize, stride: usize) -> Vec<usize> {
const NRM: usize = 12; // POSITION f32×3 occupies [0,12); NORMAL f16×4 follows
let mut starts = Vec::new();
if stride < NRM + 8 {
return starts;
}
let is_unit = |o: usize| -> bool {
if o + NRM + 6 > bytes.len() {
return false;
}
let nx = half(bytes, o + NRM);
let ny = half(bytes, o + NRM + 2);
let nz = half(bytes, o + NRM + 4);
let l = (nx * nx + ny * ny + nz * nz).sqrt();
(0.85..=1.15).contains(&l)
};
// Vertex buffers begin on 4-byte boundaries in practice; step 4.
let end = bytes.len().saturating_sub(NRM + 6);
let mut o = data_base;
while o <= end {
if is_unit(o) && (o < data_base + stride || !is_unit(o - stride)) {
starts.push(o);
}
o += 4;
}
starts
}
/// Locate a stage resource's `[index buffer][vertex buffer]` block among the
/// precomputed vertex-run `starts` (see [`vertex_run_starts`]) and decode it, or
/// return `None` if no candidate validates. A candidate `vb` is accepted when
/// the `index_count` indices ending just before it are all `< vtx_count`,
/// reference (nearly) all vertices, and produce non-degenerate triangles with a
/// real spatial extent — a signature strong enough to pin the block.
fn anchor_pool_mesh(
bytes: &[u8],
starts: &[usize],
index_count: usize,
vtx_count: usize,
decl: &VertexDecl,
min_consistency: f32,
) -> Option<GameMesh> {
let idx_bytes = index_count * 2;
for &vb in starts {
// The index buffer sits just before the vertex buffer, which is 4-byte
// aligned — so 0..=3 bytes of padding may separate them (`ib = vb
// idx_bytes pad`). pad 0 is the immediate-adjacency case (all stages so
// far); some weapons need pad 2. A shifted pad reads garbled indices, so
// pad>0 is gated at a strict 0.85 consistency to avoid a false anchor in
// the ungated (`min_consistency == 0`) stage path — pad 0 keeps its exact
// prior behaviour.
for pad in 0..=3usize {
if vb < idx_bytes + pad {
continue;
}
let ib = vb - idx_bytes - pad;
let mc = if pad == 0 {
min_consistency
} else {
min_consistency.max(0.85)
};
if validate_block(bytes, ib, vb, vtx_count, index_count, decl, mc, true) {
// ── Accepted: read the full mesh. ──
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
}
}
}
None
}
/// Check whether the `index_count` big-endian `u16` indices at `ib`, read against
/// the `vtx_count`-vertex pool at `vb`, form a coherent triangle-list sub-mesh:
/// every index in range, ~all vertices referenced, non-degenerate triangles with
/// a real spatial extent, connected edges, and (when `min_consistency > 0`) a
/// winding consistent with the stored normals. This is the shared acceptance test
/// for both the adjacency anchor ([`anchor_pool_mesh`]) and the grouped-pool
/// anchor ([`anchor_grouped_meshes`]); the two differ only in how they *place*
/// `ib`/`vb`, not in how they validate a placement.
fn validate_block(
bytes: &[u8],
ib: usize,
vb: usize,
vtx_count: usize,
index_count: usize,
decl: &VertexDecl,
min_consistency: f32,
strict_connectivity: bool,
) -> bool {
let stride = decl.stride;
let idx_bytes = index_count * 2;
if ib + idx_bytes > bytes.len() {
return false;
}
let vtx_bytes = match vtx_count.checked_mul(stride) {
Some(v) => v,
None => return false,
};
if vb + vtx_bytes > bytes.len() {
return false;
}
// ── Full index validation: every index in range, uses ~all vertices. ──
let mut max_idx = 0u32;
for k in 0..index_count {
let i = be16(bytes, ib + k * 2) as u32;
if i >= vtx_count as u32 {
return false;
}
max_idx = max_idx.max(i);
}
if (max_idx as usize) + 4 < vtx_count {
return false;
}
// ── Triangle quality: finite, non-degenerate, real spatial extent. ──
let pos = decl.pos_offset;
let mut lo = [f32::MAX; 3];
let mut hi = [f32::MIN; 3];
let mut degenerate = 0usize;
let mut sampled = 0usize;
let mut edge_sum = 0.0f32;
let mut nrm_agree = 0usize;
let mut nrm_counted = 0usize;
let tris = index_count / 3;
let tstep = (tris / 96).max(1);
let mut t = 0;
while t < tris {
let mut p = [[0.0f32; 3]; 3];
let mut tri_idx = [0usize; 3];
for (c, pc) in p.iter_mut().enumerate() {
let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
tri_idx[c] = vi;
let base = vb + vi * stride + pos;
for (a, slot) in pc.iter_mut().enumerate() {
let x = bef(bytes, base + a * 4);
if !x.is_finite() || x.abs() > 1.0e6 {
return false;
}
*slot = x;
lo[a] = lo[a].min(x);
hi[a] = hi[a].max(x);
}
}
let u = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
let w = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
let cx = [
u[1] * w[2] - u[2] * w[1],
u[2] * w[0] - u[0] * w[2],
u[0] * w[1] - u[1] * w[0],
];
if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
degenerate += 1;
} else if let Some(no) = decl.normal_offset {
// Stored-normal agreement: the face normal should point the way
// the three vertices' stored normals do. A wrong anchor wires
// arbitrary vertices → this collapses toward 50%.
let mut sn = [0.0f32; 3];
for &vi in &tri_idx {
let nb = vb + vi * stride + no;
sn[0] += half(bytes, nb);
sn[1] += half(bytes, nb + 2);
sn[2] += half(bytes, nb + 4);
}
if cx[0] * sn[0] + cx[1] * sn[1] + cx[2] * sn[2] > 0.0 {
nrm_agree += 1;
}
nrm_counted += 1;
}
// Sum the triangle's three edge lengths (for the connectivity check).
let e3 = [p[2][0] - p[1][0], p[2][1] - p[1][1], p[2][2] - p[1][2]];
edge_sum += (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt()
+ (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
+ (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
sampled += 1;
t += tstep;
}
let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
return false; // too flat, or >30% degenerate → not this block
}
// Connectivity check: a correctly-anchored mesh has triangle edges that
// are SMALL relative to its overall size (~0.050.15 of the bbox
// diagonal). A wrong anchor / cross-wired index buffer connects distant
// vertices, so its mean edge spans a large fraction of the model (a spiky
// mess). Reject those. Only applied when the placement was *searched*
// ([`anchor_pool_mesh`] / grouped-pool pivot): a small flat sub-mesh (a fin,
// an antenna) legitimately has large edges relative to its own diagonal, so
// the check is skipped for *derived* grouped-pool parts, whose in-range +
// consistency signature already pins them unambiguously.
if strict_connectivity {
let diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
.sqrt()
.max(1e-6);
let mean_edge = edge_sum / (sampled as f32 * 3.0);
if mean_edge / diag > 0.28 {
return false;
}
}
// Winding-consistency gate (same as `from_xpr2`): a correctly-anchored
// block is internally consistent — face normals agree with stored normals
// either almost always (≈1.0) or almost never (≈0.0, inverted winding);
// a false anchor / cross-wired buffer scatters to the ≈0.5 middle. Gate on
// `max(na,1-na)` so both real windings pass (stage meshes use both) and
// only the random middle is rejected. 0.85 tolerates small-block sampling.
if nrm_counted > 0 && min_consistency > 0.0 {
let na = nrm_agree as f32 / nrm_counted as f32;
if na.max(1.0 - na) < min_consistency {
return false;
}
}
true
}
/// Decode a **grouped-pool** XBG7 resource: one whose descriptor holds *several*
/// index markers (sub-meshes), all sharing one contiguous index pool and one
/// contiguous vertex pool. This is the hero-ship / detailed-model layout
/// (`DeltaSaber_*.xpr` and ~100 others) that the per-block adjacency anchor
/// ([`anchor_pool_mesh`]) cannot decode, because a sub-mesh's index buffer is
/// **not** immediately before its vertex buffer.
///
/// The layout, reversed from the retail disc + cross-checked against a Canary GPU
/// draw-log capture of `DeltaSaber_T.xpr` (see `docs/re/structures/xbg7-mesh.md`):
///
/// ```text
/// index pool : [ ib0 | ib1 | … ] each buffer 4-byte aligned, in marker order
/// vertex pool : [ vb0 | vb1 | … ] each pool `vtx_count × stride` bytes, same order
/// ```
///
/// and crucially **the index pool ends exactly where the vertex pool begins**.
/// So the whole resource pivots on a single unknown — the first vertex pool start
/// `vb0` (= index-pool end). Everything else is derived:
/// `ib0 = vb0 span`, `ib[i] = align4(ib[i-1] + idx_count[i-1]·2)`,
/// `vb[i] = vb[i-1] + vtx_count[i-1]·stride`. `vb0` is found by scanning the
/// unit-normal vertex-run `starts` and accepting the first for which sub-mesh 0
/// validates ([`validate_block`], strict 0.85 consistency — the pivot must be
/// unambiguous). Each derived sub-mesh is validated too; the chain stops at the
/// first that fails (emit what validated, never unvalidated geometry).
fn anchor_grouped_meshes(
bytes: &[u8],
data_base: usize,
starts: &[usize],
markers: &[(usize, usize)], // (vtx_count, idx_count) in descriptor/file order
decl: &VertexDecl,
) -> Vec<GameMesh> {
let n = markers.len();
if n == 0 {
return Vec::new();
}
let stride = decl.stride;
// Relative index-buffer offsets (ib0 = 0), 4-byte aligned between buffers,
// and cumulative vertex offsets (vb0 = 0) — both derived from the marker list.
let mut rel_ib = Vec::with_capacity(n);
let mut off_v = Vec::with_capacity(n);
let (mut acc_i, mut acc_v) = (0usize, 0usize);
for &(vc, ic) in markers {
rel_ib.push(acc_i);
off_v.push(acc_v);
acc_i = align4(acc_i + ic * 2);
acc_v += vc * stride;
}
// The index pool spans ib0 .. end-of-last-buffer. The vertex pool that
// follows is **4-byte aligned**, so up to 3 bytes of padding can sit between
// the last index buffer and vb0: `vb0 = align4(ib0 + span)`, i.e.
// `ib0 = vb0 span pad` with `pad ∈ 0..=3`. (DeltaSaber's index pool ended
// already-aligned → pad 0; many weapons need pad 2.) We try each pad and let
// `validate_block` pick the one that yields a coherent sub-mesh.
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
// Pivot on the **largest** sub-mesh, not `markers[0]`: it's the one whose
// triangle-quality + connectivity signature most reliably confirms the
// (ib0, vb0) alignment. Some weapons lead with a tiny, elongated bracket that
// fails the connectivity gate even when perfectly placed, which would reject
// an otherwise-correct pivot.
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
let (vck, ick) = markers[kmax];
for &vb0 in starts {
for pad in 0..=3usize {
if vb0 < span + pad {
continue;
}
let ib0 = vb0 - span - pad;
if ib0 < data_base {
continue;
}
// Strict anchor on the pivot sub-mesh: require high winding
// consistency AND connectivity here regardless of the stage-wide
// `min_consistency`. A wrong pad reads shifted indices → agreement
// collapses well below 0.85, so only the true pad/pivot passes.
let ib_k = ib0 + rel_ib[kmax];
let vb_k = vb0 + off_v[kmax];
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, 0.85, true) {
continue;
}
// Pivot confirmed the exact alignment ⇒ every marker up to the pivot
// is correctly placed; read those unconditionally (a legitimately
// tiny/flat lead part may fail the quality gates yet still be real).
// Markers after the pivot are validated so a stray trailing marker
// ends the chain instead of appending garbage.
let mut meshes = Vec::with_capacity(n);
let mut vb = vb0;
for i in 0..n {
let (vc, ic) = markers[i];
let ib = ib0 + rel_ib[i];
if ib + ic * 2 > bytes.len()
|| vc.checked_mul(stride).map_or(true, |b| vb + b > bytes.len())
{
break;
}
// Parts are placed deterministically; in-range + consistency pins
// them, so the connectivity heuristic (which mis-rejects small
// flat fins) is relaxed here.
let ok = validate_block(bytes, ib, vb, vc, ic, decl, 0.85, false);
if !ok && i > kmax {
break; // chain diverged — emit the validated prefix, no garbage
}
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
vb += vc * stride;
}
return meshes;
}
}
Vec::new()
}
/// Read positions / normals / uvs / indices for an anchored stage block.
fn read_pool_mesh(
bytes: &[u8],
ib: usize,
vb: usize,
index_count: usize,
vtx_count: usize,
decl: &VertexDecl,
) -> GameMesh {
let stride = decl.stride;
// Triangle LIST (see `from_xpr2`): each consecutive index triple is a
// triangle. `anchor_pool_mesh` already validated this block as a list.
let indices: Vec<u32> = (0..index_count)
.map(|k| be16(bytes, ib + k * 2) as u32)
.collect();
let mut positions = Vec::with_capacity(vtx_count);
let mut normals = Vec::with_capacity(vtx_count);
let mut uvs = Vec::with_capacity(vtx_count);
for v in 0..vtx_count {
let o = vb + v * stride;
let p = o + decl.pos_offset;
positions.push([bef(bytes, p), bef(bytes, p + 4), bef(bytes, p + 8)]);
if let Some(no) = decl.normal_offset {
let nb = o + no;
normals.push([half(bytes, nb), half(bytes, nb + 2), half(bytes, nb + 4)]);
}
if let Some(uo) = decl.uv_offset {
let ub = o + uo;
uvs.push([half(bytes, ub), half(bytes, ub + 2)]);
}
}
GameMesh {
positions,
normals,
uvs,
indices,
name: None,
}
}
// ── Layout constants ────────────────────────────────────────────────────────
/// Size of the header that precedes each sub-mesh block's index buffer
/// (`[12-byte header][index buffer][vertex buffer]`). Contents not yet decoded.
const VERTEX_BUFFER_GAP: usize = 12;
// ── Vertex declaration ───────────────────────────────────────────────────────
/// The vertex layout for one XBG7 resource, parsed from the descriptor's
/// declaration table (shared by all its sub-meshes).
struct VertexDecl {
/// Bytes per vertex.
stride: usize,
/// Byte offset of the POSITION element (`f32×3`) within a vertex.
pos_offset: usize,
/// Byte offset of the NORMAL element (`f16×4`), if present.
normal_offset: Option<usize>,
/// Byte offset of the TEXCOORD element (`f16×2`), if present.
uv_offset: Option<usize>,
}
/// Known element format codes → element size in bytes (from the GPU capture:
/// POSITION `f32×3`, NORMAL `f16×4`, TEXCOORD `f16×2`).
fn decl_code_size(code: u32) -> Option<usize> {
match code {
0x2A_23B9 => Some(12), // f32×3 (POSITION)
0x1A_2360 => Some(8), // f16×4 (NORMAL)
0x2C_235F => Some(4), // f16×2 (TEXCOORD)
_ => None,
}
}
/// Parse the XBG7 vertex declaration: a table of `{offset:u32, code:u32,
/// usage<<16:u32}` big-endian triples that follows the `(index_bytes,
/// index_count)` marker, terminated by an `offset == 0x00FF0000` /
/// `code == 0xFFFFFFFF` sentinel. Usage codes: `0` POSITION, `3` NORMAL,
/// `5` TEXCOORD. Stride is the max element extent; unknown element sizes are
/// inferred from the next element's offset.
/// Locate the `(index_bytes, index_count)` marker in a descriptor: the first
/// big-endian pair where `index_bytes == index_count * 2` and `index_count` is a
/// positive multiple of 3. Returns `(rel_offset, index_count)`.
fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> {
let mut rel = 0usize;
while rel + 40 <= desc.len() {
let a = be32(desc, rel);
let c = be32(desc, rel + 4);
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
return Some((rel, c as usize));
}
rel += 4;
}
None
}
/// Collect **all** `(vtx_count, idx_count)` sub-mesh records in a descriptor, in
/// file order, for the grouped-pool layout (see [`anchor_grouped_meshes`]).
///
/// Each record is an `(index_bytes, index_count)` marker (as in
/// [`find_index_marker`]) whose vertex count sits 32 bytes earlier — the same
/// `(mk_rel 32)` convention the single-marker anchor uses. Records with an
/// implausible vertex count are skipped so a stray `a == c·2` coincidence cannot
/// corrupt the derived index/vertex chain.
fn all_index_markers(desc: &[u8]) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut rel = 0usize;
while rel + 8 <= desc.len() {
let a = be32(desc, rel);
let c = be32(desc, rel + 4);
if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 && rel >= 32 {
let vc = be32(desc, rel - 32) as usize;
if (3..=200_000).contains(&vc) {
out.push((vc, c as usize));
rel += 8;
continue;
}
}
rel += 4;
}
out
}
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
let mk = find_index_marker(desc)?.0;
// Read declaration triples.
let mut elems: Vec<(usize, u32, u32)> = Vec::new(); // (offset, code, usage)
let mut r = mk + 8;
for _ in 0..16 {
if r + 12 > desc.len() {
break;
}
let off = be32(desc, r);
let code = be32(desc, r + 4);
let usage = be32(desc, r + 8) >> 16;
if off == 0x00FF_0000 || code == 0xFFFF_FFFF {
break;
}
if off as usize > 0x1000 {
break; // out-of-range offset — not a real element
}
elems.push((off as usize, code & 0x00FF_FFFF, usage));
r += 12;
}
if elems.is_empty() {
return None;
}
let mut stride = 0usize;
for (i, &(off, code, _)) in elems.iter().enumerate() {
let size = decl_code_size(code).unwrap_or_else(|| {
if i + 1 < elems.len() {
elems[i + 1].0.saturating_sub(off)
} else {
4
}
});
stride = stride.max(off + size);
}
if stride == 0 || stride > 256 {
return None;
}
let pos_offset = elems
.iter()
.find(|&&(_, c, u)| c == 0x2A_23B9 || u == 0)
.map(|&(o, _, _)| o)
.unwrap_or(0);
let normal_offset = elems.iter().find(|&&(_, _, u)| u == 3).map(|&(o, _, _)| o);
let uv_offset = elems.iter().find(|&&(_, _, u)| u == 5).map(|&(o, _, _)| o);
Some(VertexDecl {
stride,
pos_offset,
normal_offset,
uv_offset,
})
}
// ── Descriptor sub-mesh record scan ─────────────────────────────────────────
/// Scan an XBG7 descriptor for the ordered list of per-sub-mesh
/// `(vtx_count, idx_count)` records.
///
/// The record is a big-endian tuple `[vtx:u32][0:u32][idx:u32][tail:u32]` with
/// `3 ≤ vtx ≤ 65535`, the second word zero, `idx` a positive multiple of 3, and
/// a small non-zero `tail`. Found by a sliding 4-byte scan (records are not on
/// a fixed stride in the scene graph).
fn submesh_records(desc: &[u8]) -> Vec<(usize, usize)> {
let mut out = Vec::new();
if desc.len() < 16 {
return out;
}
let mut rel = 0usize;
while rel + 16 <= desc.len() {
let a = be32(desc, rel);
let z = be32(desc, rel + 4);
let c = be32(desc, rel + 8);
let t = be32(desc, rel + 12);
if (3..=65535).contains(&a)
&& z == 0
&& c >= 3
&& c <= 200_000
&& c % 3 == 0
&& (1..=64).contains(&t)
{
out.push((a as usize, c as usize));
rel += 16; // consume the record
} else {
rel += 4;
}
}
out
}
// ── Little primitive readers ────────────────────────────────────────────────
#[inline]
fn align16(x: usize) -> usize {
(x + 15) & !15
}
#[inline]
fn align4(x: usize) -> usize {
(x + 3) & !3
}
/// Expand a triangle-**strip** index buffer into a triangle list (alternating
/// winding, degenerates dropped). **Diagnostic only** — XBG7 index buffers are
/// triangle LISTS, not strips (proven by `XVERIFY`: normal-agree 1.000 as a list
/// vs ~0.49 as a strip, plus the GPU capture's `prim=4`). Retained so `XVERIFY`
/// can keep demonstrating that the strip reading is wrong; NOT used in decode.
fn expand_triangle_strip(strip: &[u32]) -> Vec<u32> {
let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3);
for w in 0..strip.len().saturating_sub(2) {
let (a, b, c) = if w % 2 == 0 {
(strip[w], strip[w + 1], strip[w + 2])
} else {
(strip[w + 1], strip[w], strip[w + 2])
};
if a != b && b != c && a != c {
out.extend_from_slice(&[a, b, c]);
}
}
out
}
/// Topology-correctness metrics for a flat triangle-list index buffer, used to
/// decide list-vs-strip objectively. Returns `(tris, degenerate, normal_agree,
/// edge2_frac)`:
/// - `normal_agree`: fraction of non-degenerate triangles whose geometric face
/// normal (cross product) dots positively with the sum of its three vertices'
/// stored normals. Correct topology + consistent winding ⇒ near 1.0; a
/// mis-interpreted buffer wires arbitrary vertices ⇒ near 0.5.
/// - `edge2_frac`: fraction of distinct undirected edges shared by exactly 2
/// triangles. A closed manifold surface ⇒ near 1.0.
fn topology_report(
indices: &[u32],
positions: &[[f32; 3]],
normals: &[[f32; 3]],
) -> (usize, usize, f32, f32) {
use std::collections::HashMap;
let tris = indices.len() / 3;
let mut degen = 0usize;
let mut agree = 0usize;
let mut counted = 0usize;
let mut edges: HashMap<(u32, u32), u32> = HashMap::new();
for t in indices.chunks_exact(3) {
let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
if a == b || b == c || a == c {
degen += 1;
continue;
}
for &(i, j) in &[(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
let e = if i < j { (i, j) } else { (j, i) };
*edges.entry(e).or_insert(0) += 1;
}
let (p0, p1, p2) = (positions[a], positions[b], positions[c]);
let u = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
let w = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
let fx = u[1] * w[2] - u[2] * w[1];
let fy = u[2] * w[0] - u[0] * w[2];
let fz = u[0] * w[1] - u[1] * w[0];
if !normals.is_empty() {
let sn = [
normals[a][0] + normals[b][0] + normals[c][0],
normals[a][1] + normals[b][1] + normals[c][1],
normals[a][2] + normals[b][2] + normals[c][2],
];
let dot = fx * sn[0] + fy * sn[1] + fz * sn[2];
if dot > 0.0 {
agree += 1;
}
counted += 1;
}
}
let two = edges.values().filter(|&&c| c == 2).count();
let edge2 = if edges.is_empty() {
0.0
} else {
two as f32 / edges.len() as f32
};
let na = if counted == 0 {
f32::NAN
} else {
agree as f32 / counted as f32
};
(tris, degen, na, edge2)
}
#[inline]
fn be16(b: &[u8], o: usize) -> u16 {
u16::from_be_bytes([b[o], b[o + 1]])
}
#[inline]
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
#[inline]
fn bef(b: &[u8], o: usize) -> f32 {
f32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
/// Big-endian IEEE-754 half → f32.
#[inline]
fn half(b: &[u8], o: usize) -> f32 {
f16_to_f32(be16(b, o))
}
/// Minimal IEEE-754 binary16 → binary32 (no external dep).
fn f16_to_f32(h: u16) -> f32 {
let sign = (h >> 15) & 1;
let exp = (h >> 10) & 0x1F;
let mant = h & 0x3FF;
let bits: u32 = match exp {
0 if mant == 0 => (sign as u32) << 31, // ±0
0 => {
// subnormal → normalize
let mut e: i32 = -1;
let mut m = mant as u32;
loop {
e += 1;
m <<= 1;
if m & 0x400 != 0 {
break;
}
}
let exp32 = (127 - 15 - e) as u32;
((sign as u32) << 31) | (exp32 << 23) | ((m & 0x3FF) << 13)
}
0x1F => ((sign as u32) << 31) | (0xFF << 23) | ((mant as u32) << 13), // Inf/NaN
_ => {
let exp32 = (exp as i32 - 15 + 127) as u32;
((sign as u32) << 31) | (exp32 << 23) | ((mant as u32) << 13)
}
};
f32::from_bits(bits)
}
fn read_cstr(b: &[u8], o: usize) -> Option<String> {
if o >= b.len() {
return None;
}
let end = b[o..].iter().position(|&c| c == 0).map(|p| o + p)?;
if end == o {
return None;
}
Some(String::from_utf8_lossy(&b[o..end]).into_owned())
}
// ── 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 68 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)
));
}
}