//! 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.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)
    }

    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();
        let mut asked_for: Vec<bool> = 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());
            // Keep NON-wanted resources too: a resource is anchored by where its
            // descriptor NEIGHBOURS anchor, so filtering them out here would
            // leave a filtered decode with no neighbourhood (and the pre-2026-08
            // first-in-file-order behaviour). Only a ±2 window is actually
            // decoded — see `need_pass1` below.
            let asked = wanted.map_or(true, |w| w.contains(&name));
            resources.push(Res {
                name,
                markers,
                decl,
            });
            asked_for.push(asked);
        }
        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.
        // `near`: prefer candidate anchors close to this offset (see
        // `anchor_pool_mesh_near`). Pass 1 runs with `None` to learn where each
        // resource lands; pass 2 re-runs with each resource's neighbourhood.
        let decode_one_near = |r: &Res, near: Option<usize>| -> (Option<Xbg7Model>, Option<usize>) {
            if should_cancel() {
                return (None, None);
            }
            let starts = &starts_by_stride[&r.decl.stride];
            let mut anchored_at = None;
            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_near(
                    bytes, starts, index_count, vtx_count, &r.decl, min_consistency, near,
                )
                .map(|(m, vb)| {
                    anchored_at = Some(vb);
                    m
                })
                .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_near(
                        bytes, starts, index_count, vtx_count, &r.decl, min_consistency, near,
                    )
                    .map(|(m, vb)| {
                        anchored_at = Some(vb);
                        m
                    })
                    .into_iter()
                    .collect()
                }
            };
            let model = (!meshes.is_empty()).then(|| Xbg7Model {
                name: r.name.clone(),
                meshes,
            });
            (model, anchored_at)
        };

        /// Median of a resource's neighbours' anchors — the reference a resource
        /// should sit near. `None` when too few neighbours anchored to be useful.
        fn neighbourhood(vbs: &[Option<usize>], i: usize) -> Option<usize> {
            const SPAN: usize = 2;
            let lo = i.saturating_sub(SPAN);
            let hi = (i + SPAN + 1).min(vbs.len());
            let mut near: Vec<usize> = (lo..hi).filter(|&k| k != i).filter_map(|k| vbs[k]).collect();
            if near.len() < 2 {
                return None;
            }
            near.sort_unstable();
            Some(near[near.len() / 2])
        }

        // Pass 1 — first-match, to learn each resource's neighbourhood. Only the
        // asked-for resources and their ±2 neighbours need it, so a filtered
        // decode stays proportional to what was asked for.
        let need_pass1: Vec<bool> = (0..resources.len())
            .map(|i| {
                let lo = i.saturating_sub(2);
                let hi = (i + 3).min(asked_for.len());
                asked_for[lo..hi].iter().any(|&a| a)
            })
            .collect();
        let run1 = |(i, r): (usize, &Res)| -> (Option<Xbg7Model>, Option<usize>) {
            if need_pass1[i] {
                decode_one_near(r, None)
            } else {
                (None, None)
            }
        };
        #[cfg(not(target_arch = "wasm32"))]
        let pass1: Vec<(Option<Xbg7Model>, Option<usize>)> = {
            use rayon::prelude::*;
            resources.par_iter().enumerate().map(run1).collect()
        };
        #[cfg(target_arch = "wasm32")]
        let pass1: Vec<(Option<Xbg7Model>, Option<usize>)> =
            resources.iter().enumerate().map(run1).collect();

        let mut vbs: Vec<Option<usize>> = pass1.iter().map(|(_, vb)| *vb).collect();

        // Refine the anchor map before using it: pass 1's anchors include the
        // very mistakes this is meant to correct, so a resource next to a
        // mis-anchored neighbour inherits a bad reference. Re-anchoring against
        // the improving map and repeating converges quickly; two rounds is
        // enough on this disc (a third changes nothing).
        for _ in 0..2 {
            let refined: Vec<Option<usize>> = (0..resources.len())
                .map(|i| match (need_pass1[i], neighbourhood(&vbs, i)) {
                    (true, Some(anchor)) => decode_one_near(&resources[i], Some(anchor)).1.or(vbs[i]),
                    _ => vbs[i],
                })
                .collect();
            if refined == vbs {
                break;
            }
            vbs = refined;
        }

        // Pass 2 — re-anchor preferring the resource's own neighbourhood, which
        // is what separates its data from another resource's identically-shaped
        // block. Resources without a usable neighbourhood keep pass 1's result.
        let finish = |(i, r): (usize, &Res)| -> Option<Xbg7Model> {
            if !asked_for[i] {
                return None;
            }
            match neighbourhood(&vbs, i) {
                Some(anchor) => decode_one_near(r, Some(anchor))
                    .0
                    .or_else(|| pass1[i].0.clone()),
                None => pass1[i].0.clone(),
            }
        };
        #[cfg(not(target_arch = "wasm32"))]
        {
            use rayon::prelude::*;
            out = resources.par_iter().enumerate().filter_map(finish).collect();
        }
        #[cfg(target_arch = "wasm32")]
        {
            out = resources.iter().enumerate().filter_map(finish).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> {
    anchor_pool_mesh_near(bytes, starts, index_count, vtx_count, decl, min_consistency, None)
        .map(|(m, _)| m)
}

/// As [`anchor_pool_mesh`], but when `near` is given the candidates are tried in
/// order of distance from it, and the accepted vertex-buffer offset is returned
/// alongside the mesh.
///
/// Why: the candidate list is one scan of the **whole container** per stride and
/// is shared by every resource of that stride, so first-in-file-order can hand a
/// resource a block belonging to something else that happens to share its vertex
/// and index counts. Both blocks are real geometry and both pass every quality
/// gate, so only *position* separates them — a resource's own data sits near its
/// descriptor neighbours' (`docs/re/structures/xbg7-mesh.md`).
fn anchor_pool_mesh_near(
    bytes: &[u8],
    starts: &[usize],
    index_count: usize,
    vtx_count: usize,
    decl: &VertexDecl,
    min_consistency: f32,
    near: Option<usize>,
) -> Option<(GameMesh, usize)> {
    let idx_bytes = index_count * 2;
    let mut order: Vec<usize> = starts.to_vec();
    if let Some(anchor) = near {
        order.sort_by_key(|&vb| vb.abs_diff(anchor));
    }
    for &vb in &order {
        // 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),
                    vb,
                ));
            }
        }
    }
    None
}

/// Check whether the `index_count` big-endian `u16` indices at `ib`, read against
/// the `vtx_count`-vertex pool at `vb`, form a coherent triangle-list sub-mesh:
/// every index in range, ~all vertices referenced, non-degenerate triangles with
/// a real spatial extent, connected edges, and (when `min_consistency > 0`) a
/// winding consistent with the stored normals. This is the shared acceptance test
/// for both the adjacency anchor ([`anchor_pool_mesh`]) and the grouped-pool
/// anchor ([`anchor_grouped_meshes`]); the two differ only in how they *place*
/// `ib`/`vb`, not in how they validate a placement.
fn validate_block(
    bytes: &[u8],
    ib: usize,
    vb: usize,
    vtx_count: usize,
    index_count: usize,
    decl: &VertexDecl,
    min_consistency: f32,
    strict_connectivity: bool,
) -> bool {
    let stride = decl.stride;
    let idx_bytes = index_count * 2;
    if ib + idx_bytes > bytes.len() {
        return false;
    }
    let vtx_bytes = match vtx_count.checked_mul(stride) {
        Some(v) => v,
        None => return false,
    };
    if vb + vtx_bytes > bytes.len() {
        return false;
    }

    // ── Full index validation: every index in range, uses ~all vertices. ──
    let mut max_idx = 0u32;
    for k in 0..index_count {
        let i = be16(bytes, ib + k * 2) as u32;
        if i >= vtx_count as u32 {
            return false;
        }
        max_idx = max_idx.max(i);
    }
    if (max_idx as usize) + 4 < vtx_count {
        return false;
    }

    // ── Triangle quality: finite, non-degenerate, real spatial extent. ──
    let pos = decl.pos_offset;
    let mut lo = [f32::MAX; 3];
    let mut hi = [f32::MIN; 3];
    let mut degenerate = 0usize;
    let mut sampled = 0usize;
    let mut edge_sum = 0.0f32;
    let mut nrm_agree = 0usize;
    let mut nrm_counted = 0usize;
    let tris = index_count / 3;
    let tstep = (tris / 96).max(1);
    let mut t = 0;
    while t < tris {
        let mut p = [[0.0f32; 3]; 3];
        let mut tri_idx = [0usize; 3];
        for (c, pc) in p.iter_mut().enumerate() {
            let vi = be16(bytes, ib + (3 * t + c) * 2) as usize;
            tri_idx[c] = vi;
            let base = vb + vi * stride + pos;
            for (a, slot) in pc.iter_mut().enumerate() {
                let x = bef(bytes, base + a * 4);
                if !x.is_finite() || x.abs() > 1.0e6 {
                    return false;
                }
                *slot = x;
                lo[a] = lo[a].min(x);
                hi[a] = hi[a].max(x);
            }
        }
        let u = [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]];
        let w = [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]];
        let cx = [
            u[1] * w[2] - u[2] * w[1],
            u[2] * w[0] - u[0] * w[2],
            u[0] * w[1] - u[1] * w[0],
        ];
        if 0.5 * (cx[0] * cx[0] + cx[1] * cx[1] + cx[2] * cx[2]).sqrt() < 1.0e-9 {
            degenerate += 1;
        } else if let Some(no) = decl.normal_offset {
            // Stored-normal agreement: the face normal should point the way
            // the three vertices' stored normals do. A wrong anchor wires
            // arbitrary vertices → this collapses toward 50%.
            let mut sn = [0.0f32; 3];
            for &vi in &tri_idx {
                let nb = vb + vi * stride + no;
                sn[0] += half(bytes, nb);
                sn[1] += half(bytes, nb + 2);
                sn[2] += half(bytes, nb + 4);
            }
            if cx[0] * sn[0] + cx[1] * sn[1] + cx[2] * sn[2] > 0.0 {
                nrm_agree += 1;
            }
            nrm_counted += 1;
        }
        // Sum the triangle's three edge lengths (for the connectivity check).
        let e3 = [p[2][0] - p[1][0], p[2][1] - p[1][1], p[2][2] - p[1][2]];
        edge_sum += (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt()
            + (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
            + (e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]).sqrt();
        sampled += 1;
        t += tstep;
    }
    let extent = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(hi[2] - lo[2]);
    if extent < 0.5 || sampled == 0 || degenerate * 10 > sampled * 3 {
        return false; // too flat, or >30% degenerate → not this block
    }
    // Connectivity check: a correctly-anchored mesh has triangle edges that
    // are SMALL relative to its overall size (~0.05–0.15 of the bbox
    // diagonal). A wrong anchor / cross-wired index buffer connects distant
    // vertices, so its mean edge spans a large fraction of the model (a spiky
    // mess). Reject those. Only applied when the placement was *searched*
    // ([`anchor_pool_mesh`] / grouped-pool pivot): a small flat sub-mesh (a fin,
    // an antenna) legitimately has large edges relative to its own diagonal, so
    // the check is skipped for *derived* grouped-pool parts, whose in-range +
    // consistency signature already pins them unambiguously.
    if strict_connectivity {
        let diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
            .sqrt()
            .max(1e-6);
        let mean_edge = edge_sum / (sampled as f32 * 3.0);
        if mean_edge / diag > 0.28 {
            return false;
        }
    }
    // Winding-consistency gate (same as `from_xpr2`): a correctly-anchored
    // block is internally consistent — face normals agree with stored normals
    // either almost always (≈1.0) or almost never (≈0.0, inverted winding);
    // a false anchor / cross-wired buffer scatters to the ≈0.5 middle. Gate on
    // `max(na,1-na)` so both real windings pass (stage meshes use both) and
    // only the random middle is rejected. 0.85 tolerates small-block sampling.
    if nrm_counted > 0 && min_consistency > 0.0 {
        let na = nrm_agree as f32 / nrm_counted as f32;
        if na.max(1.0 - na) < min_consistency {
            return false;
        }
    }
    true
}

/// Decode a **grouped-pool** XBG7 resource: one whose descriptor holds *several*
/// index markers (sub-meshes), all sharing one contiguous index pool and one
/// contiguous vertex pool. This is the hero-ship / detailed-model layout
/// (`DeltaSaber_*.xpr` and ~100 others) that the per-block adjacency anchor
/// ([`anchor_pool_mesh`]) cannot decode, because a sub-mesh's index buffer is
/// **not** immediately before its vertex buffer.
///
/// The layout, reversed from the retail disc + cross-checked against a Canary GPU
/// draw-log capture of `DeltaSaber_T.xpr` (see `docs/re/structures/xbg7-mesh.md`):
///
/// ```text
///   index pool  : [ ib0 | ib1 | … ]  each buffer 4-byte aligned, in marker order
///   vertex pool : [ vb0 | vb1 | … ]  each pool `vtx_count × stride` bytes, same order
/// ```
///
/// and crucially **the index pool ends exactly where the vertex pool begins**.
/// So the whole resource pivots on a single unknown — the first vertex pool start
/// `vb0` (= index-pool end). Everything else is derived:
/// `ib0 = vb0 − span`, `ib[i] = align4(ib[i-1] + idx_count[i-1]·2)`,
/// `vb[i] = vb[i-1] + vtx_count[i-1]·stride`. `vb0` is found by scanning the
/// unit-normal vertex-run `starts` and accepting the first for which sub-mesh 0
/// validates ([`validate_block`], strict 0.85 consistency — the pivot must be
/// unambiguous). Each derived sub-mesh is validated too; the chain stops at the
/// first that fails (emit what validated, never unvalidated geometry).
fn anchor_grouped_meshes(
    bytes: &[u8],
    data_base: usize,
    starts: &[usize],
    markers: &[(usize, usize)], // (vtx_count, idx_count) in descriptor/file order
    decl: &VertexDecl,
) -> Vec<GameMesh> {
    let n = markers.len();
    if n == 0 {
        return Vec::new();
    }
    let stride = decl.stride;

    // Relative index-buffer offsets (ib0 = 0), 4-byte aligned between buffers,
    // and cumulative vertex offsets (vb0 = 0) — both derived from the marker list.
    let mut rel_ib = Vec::with_capacity(n);
    let mut off_v = Vec::with_capacity(n);
    let (mut acc_i, mut acc_v) = (0usize, 0usize);
    for &(vc, ic) in markers {
        rel_ib.push(acc_i);
        off_v.push(acc_v);
        acc_i = align4(acc_i + ic * 2);
        acc_v += vc * stride;
    }
    // The index pool spans ib0 .. end-of-last-buffer. The vertex pool that
    // follows is **4-byte aligned**, so up to 3 bytes of padding can sit between
    // the last index buffer and vb0: `vb0 = align4(ib0 + span)`, i.e.
    // `ib0 = vb0 − span − pad` with `pad ∈ 0..=3`. (DeltaSaber's index pool ended
    // already-aligned → pad 0; many weapons need pad 2.) We try each pad and let
    // `validate_block` pick the one that yields a coherent sub-mesh.
    let span = rel_ib[n - 1] + markers[n - 1].1 * 2;

    // Pivot on the **largest** sub-mesh, not `markers[0]`: it's the one whose
    // triangle-quality + connectivity signature most reliably confirms the
    // (ib0, vb0) alignment. Some weapons lead with a tiny, elongated bracket that
    // fails the connectivity gate even when perfectly placed, which would reject
    // an otherwise-correct pivot.
    let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
    let (vck, ick) = markers[kmax];

    for &vb0 in starts {
        for pad in 0..=3usize {
            if vb0 < span + pad {
                continue;
            }
            let ib0 = vb0 - span - pad;
            if ib0 < data_base {
                continue;
            }
            // Strict anchor on the pivot sub-mesh: require high winding
            // consistency AND connectivity here regardless of the stage-wide
            // `min_consistency`. A wrong pad reads shifted indices → agreement
            // collapses well below 0.85, so only the true pad/pivot passes.
            let ib_k = ib0 + rel_ib[kmax];
            let vb_k = vb0 + off_v[kmax];
            if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, 0.85, true) {
                continue;
            }

            // Pivot confirmed the exact alignment ⇒ every marker up to the pivot
            // is correctly placed; read those unconditionally (a legitimately
            // tiny/flat lead part may fail the quality gates yet still be real).
            // Markers after the pivot are validated so a stray trailing marker
            // ends the chain instead of appending garbage.
            let mut meshes = Vec::with_capacity(n);
            let mut vb = vb0;
            for i in 0..n {
                let (vc, ic) = markers[i];
                let ib = ib0 + rel_ib[i];
                if ib + ic * 2 > bytes.len()
                    || vc.checked_mul(stride).map_or(true, |b| vb + b > bytes.len())
                {
                    break;
                }
                // Parts are placed deterministically; in-range + consistency pins
                // them, so the connectivity heuristic (which mis-rejects small
                // flat fins) is relaxed here.
                let ok = validate_block(bytes, ib, vb, vc, ic, decl, 0.85, false);
                if !ok && i > kmax {
                    break; // chain diverged — emit the validated prefix, no garbage
                }
                meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
                vb += vc * stride;
            }
            return meshes;
        }
    }
    Vec::new()
}

/// Read positions / normals / uvs / indices for an anchored stage block.
fn read_pool_mesh(
    bytes: &[u8],
    ib: usize,
    vb: usize,
    index_count: usize,
    vtx_count: usize,
    decl: &VertexDecl,
) -> GameMesh {
    let stride = decl.stride;
    // Triangle LIST (see `from_xpr2`): each consecutive index triple is a
    // triangle. `anchor_pool_mesh` already validated this block as a list.
    let indices: Vec<u32> = (0..index_count)
        .map(|k| be16(bytes, ib + k * 2) as u32)
        .collect();

    let mut positions = Vec::with_capacity(vtx_count);
    let mut normals = Vec::with_capacity(vtx_count);
    let mut uvs = Vec::with_capacity(vtx_count);
    for v in 0..vtx_count {
        let o = vb + v * stride;
        let p = o + decl.pos_offset;
        positions.push([bef(bytes, p), bef(bytes, p + 4), bef(bytes, p + 8)]);
        if let Some(no) = decl.normal_offset {
            let nb = o + no;
            normals.push([half(bytes, nb), half(bytes, nb + 2), half(bytes, nb + 4)]);
        }
        if let Some(uo) = decl.uv_offset {
            let ub = o + uo;
            uvs.push([half(bytes, ub), half(bytes, ub + 2)]);
        }
    }
    GameMesh {
        positions,
        normals,
        uvs,
        indices,
        name: None,
    }
}

// ── Layout constants ────────────────────────────────────────────────────────

/// Size of the header that precedes each sub-mesh block's index buffer
/// (`[12-byte header][index buffer][vertex buffer]`). Contents not yet decoded.
const VERTEX_BUFFER_GAP: usize = 12;

// ── Vertex declaration ───────────────────────────────────────────────────────

/// The vertex layout for one XBG7 resource, parsed from the descriptor's
/// declaration table (shared by all its sub-meshes).
struct VertexDecl {
    /// Bytes per vertex.
    stride: usize,
    /// Byte offset of the POSITION element (`f32×3`) within a vertex.
    pos_offset: usize,
    /// Byte offset of the NORMAL element (`f16×4`), if present.
    normal_offset: Option<usize>,
    /// Byte offset of the TEXCOORD element (`f16×2`), if present.
    uv_offset: Option<usize>,
}

/// Known element format codes → element size in bytes (from the GPU capture:
/// POSITION `f32×3`, NORMAL `f16×4`, TEXCOORD `f16×2`).
fn decl_code_size(code: u32) -> Option<usize> {
    match code {
        0x2A_23B9 => Some(12), // f32×3  (POSITION)
        0x1A_2360 => Some(8),  // f16×4  (NORMAL)
        0x2C_235F => Some(4),  // f16×2  (TEXCOORD)
        _ => None,
    }
}

/// Parse the XBG7 vertex declaration: a table of `{offset:u32, code:u32,
/// usage<<16:u32}` big-endian triples that follows the `(index_bytes,
/// index_count)` marker, terminated by an `offset == 0x00FF0000` /
/// `code == 0xFFFFFFFF` sentinel. Usage codes: `0` POSITION, `3` NORMAL,
/// `5` TEXCOORD. Stride is the max element extent; unknown element sizes are
/// inferred from the next element's offset.
/// Locate the `(index_bytes, index_count)` marker in a descriptor: the first
/// big-endian pair where `index_bytes == index_count * 2` and `index_count` is a
/// positive multiple of 3. Returns `(rel_offset, index_count)`.
fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> {
    let mut rel = 0usize;
    while rel + 40 <= desc.len() {
        let a = be32(desc, rel);
        let c = be32(desc, rel + 4);
        if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 {
            return Some((rel, c as usize));
        }
        rel += 4;
    }
    None
}

/// Collect **all** `(vtx_count, idx_count)` sub-mesh records in a descriptor, in
/// file order, for the grouped-pool layout (see [`anchor_grouped_meshes`]).
///
/// Each record is an `(index_bytes, index_count)` marker (as in
/// [`find_index_marker`]) whose vertex count sits 32 bytes earlier — the same
/// `(mk_rel − 32)` convention the single-marker anchor uses. Records with an
/// implausible vertex count are skipped so a stray `a == c·2` coincidence cannot
/// corrupt the derived index/vertex chain.
fn all_index_markers(desc: &[u8]) -> Vec<(usize, usize)> {
    let mut out = Vec::new();
    let mut rel = 0usize;
    while rel + 8 <= desc.len() {
        let a = be32(desc, rel);
        let c = be32(desc, rel + 4);
        if c >= 3 && c % 3 == 0 && c < 400_000 && a == c * 2 && rel >= 32 {
            let vc = be32(desc, rel - 32) as usize;
            if (3..=200_000).contains(&vc) {
                out.push((vc, c as usize));
                rel += 8;
                continue;
            }
        }
        rel += 4;
    }
    out
}

fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
    let mk = find_index_marker(desc)?.0;

    // Read declaration triples.
    let mut elems: Vec<(usize, u32, u32)> = Vec::new(); // (offset, code, usage)
    let mut r = mk + 8;
    for _ in 0..16 {
        if r + 12 > desc.len() {
            break;
        }
        let off = be32(desc, r);
        let code = be32(desc, r + 4);
        let usage = be32(desc, r + 8) >> 16;
        if off == 0x00FF_0000 || code == 0xFFFF_FFFF {
            break;
        }
        if off as usize > 0x1000 {
            break; // out-of-range offset — not a real element
        }
        elems.push((off as usize, code & 0x00FF_FFFF, usage));
        r += 12;
    }
    if elems.is_empty() {
        return None;
    }

    let mut stride = 0usize;
    for (i, &(off, code, _)) in elems.iter().enumerate() {
        let size = decl_code_size(code).unwrap_or_else(|| {
            if i + 1 < elems.len() {
                elems[i + 1].0.saturating_sub(off)
            } else {
                4
            }
        });
        stride = stride.max(off + size);
    }
    if stride == 0 || stride > 256 {
        return None;
    }

    let pos_offset = elems
        .iter()
        .find(|&&(_, c, u)| c == 0x2A_23B9 || u == 0)
        .map(|&(o, _, _)| o)
        .unwrap_or(0);
    let normal_offset = elems.iter().find(|&&(_, _, u)| u == 3).map(|&(o, _, _)| o);
    let uv_offset = elems.iter().find(|&&(_, _, u)| u == 5).map(|&(o, _, _)| o);

    Some(VertexDecl {
        stride,
        pos_offset,
        normal_offset,
        uv_offset,
    })
}

// ── Descriptor sub-mesh record scan ─────────────────────────────────────────

/// Scan an XBG7 descriptor for the ordered list of per-sub-mesh
/// `(vtx_count, idx_count)` records.
///
/// The record is a big-endian tuple `[vtx:u32][0:u32][idx:u32][tail:u32]` with
/// `3 ≤ vtx ≤ 65535`, the second word zero, `idx` a positive multiple of 3, and
/// a small non-zero `tail`.  Found by a sliding 4-byte scan (records are not on
/// a fixed stride in the scene graph).
fn submesh_records(desc: &[u8]) -> Vec<(usize, usize)> {
    let mut out = Vec::new();
    if desc.len() < 16 {
        return out;
    }
    let mut rel = 0usize;
    while rel + 16 <= desc.len() {
        let a = be32(desc, rel);
        let z = be32(desc, rel + 4);
        let c = be32(desc, rel + 8);
        let t = be32(desc, rel + 12);
        if (3..=65535).contains(&a)
            && z == 0
            && c >= 3
            && c <= 200_000
            && c % 3 == 0
            && (1..=64).contains(&t)
        {
            out.push((a as usize, c as usize));
            rel += 16; // consume the record
        } else {
            rel += 4;
        }
    }
    out
}

// ── Little primitive readers ────────────────────────────────────────────────

#[inline]
fn align16(x: usize) -> usize {
    (x + 15) & !15
}

#[inline]
fn align4(x: usize) -> usize {
    (x + 3) & !3
}

/// Expand a triangle-**strip** index buffer into a triangle list (alternating
/// winding, degenerates dropped). **Diagnostic only** — XBG7 index buffers are
/// triangle LISTS, not strips (proven by `XVERIFY`: normal-agree 1.000 as a list
/// vs ~0.49 as a strip, plus the GPU capture's `prim=4`). Retained so `XVERIFY`
/// can keep demonstrating that the strip reading is wrong; NOT used in decode.
fn expand_triangle_strip(strip: &[u32]) -> Vec<u32> {
    let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3);
    for w in 0..strip.len().saturating_sub(2) {
        let (a, b, c) = if w % 2 == 0 {
            (strip[w], strip[w + 1], strip[w + 2])
        } else {
            (strip[w + 1], strip[w], strip[w + 2])
        };
        if a != b && b != c && a != c {
            out.extend_from_slice(&[a, b, c]);
        }
    }
    out
}
/// Topology-correctness metrics for a flat triangle-list index buffer, used to
/// decide list-vs-strip objectively. Returns `(tris, degenerate, normal_agree,
/// edge2_frac)`:
/// - `normal_agree`: fraction of non-degenerate triangles whose geometric face
///   normal (cross product) dots positively with the sum of its three vertices'
///   stored normals. Correct topology + consistent winding ⇒ near 1.0; a
///   mis-interpreted buffer wires arbitrary vertices ⇒ near 0.5.
/// - `edge2_frac`: fraction of distinct undirected edges shared by exactly 2
///   triangles. A closed manifold surface ⇒ near 1.0.
fn topology_report(
    indices: &[u32],
    positions: &[[f32; 3]],
    normals: &[[f32; 3]],
) -> (usize, usize, f32, f32) {
    use std::collections::HashMap;
    let tris = indices.len() / 3;
    let mut degen = 0usize;
    let mut agree = 0usize;
    let mut counted = 0usize;
    let mut edges: HashMap<(u32, u32), u32> = HashMap::new();
    for t in indices.chunks_exact(3) {
        let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
        if a == b || b == c || a == c {
            degen += 1;
            continue;
        }
        for &(i, j) in &[(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
            let e = if i < j { (i, j) } else { (j, i) };
            *edges.entry(e).or_insert(0) += 1;
        }
        let (p0, p1, p2) = (positions[a], positions[b], positions[c]);
        let u = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
        let w = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
        let fx = u[1] * w[2] - u[2] * w[1];
        let fy = u[2] * w[0] - u[0] * w[2];
        let fz = u[0] * w[1] - u[1] * w[0];
        if !normals.is_empty() {
            let sn = [
                normals[a][0] + normals[b][0] + normals[c][0],
                normals[a][1] + normals[b][1] + normals[c][1],
                normals[a][2] + normals[b][2] + normals[c][2],
            ];
            let dot = fx * sn[0] + fy * sn[1] + fz * sn[2];
            if dot > 0.0 {
                agree += 1;
            }
            counted += 1;
        }
    }
    let two = edges.values().filter(|&&c| c == 2).count();
    let edge2 = if edges.is_empty() {
        0.0
    } else {
        two as f32 / edges.len() as f32
    };
    let na = if counted == 0 {
        f32::NAN
    } else {
        agree as f32 / counted as f32
    };
    (tris, degen, na, edge2)
}

#[inline]
fn be16(b: &[u8], o: usize) -> u16 {
    u16::from_be_bytes([b[o], b[o + 1]])
}
#[inline]
fn be32(b: &[u8], o: usize) -> u32 {
    u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
#[inline]
fn bef(b: &[u8], o: usize) -> f32 {
    f32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
/// Big-endian IEEE-754 half → f32.
#[inline]
fn half(b: &[u8], o: usize) -> f32 {
    f16_to_f32(be16(b, o))
}

/// Minimal IEEE-754 binary16 → binary32 (no external dep).
fn f16_to_f32(h: u16) -> f32 {
    let sign = (h >> 15) & 1;
    let exp = (h >> 10) & 0x1F;
    let mant = h & 0x3FF;
    let bits: u32 = match exp {
        0 if mant == 0 => (sign as u32) << 31, // ±0
        0 => {
            // subnormal → normalize
            let mut e: i32 = -1;
            let mut m = mant as u32;
            loop {
                e += 1;
                m <<= 1;
                if m & 0x400 != 0 {
                    break;
                }
            }
            let exp32 = (127 - 15 - e) as u32;
            ((sign as u32) << 31) | (exp32 << 23) | ((m & 0x3FF) << 13)
        }
        0x1F => ((sign as u32) << 31) | (0xFF << 23) | ((mant as u32) << 13), // Inf/NaN
        _ => {
            let exp32 = (exp as i32 - 15 + 127) as u32;
            ((sign as u32) << 31) | (exp32 << 23) | ((mant as u32) << 13)
        }
    };
    f32::from_bits(bits)
}

fn read_cstr(b: &[u8], o: usize) -> Option<String> {
    if o >= b.len() {
        return None;
    }
    let end = b[o..].iter().position(|&c| c == 0).map(|p| o + p)?;
    if end == o {
        return None;
    }
    Some(String::from_utf8_lossy(&b[o..end]).into_owned())
}

// ── 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)
        ));
    }
}
