//! Exact capital-ship part placement from a Canary **F10 ship-capture** log —
//! the runtime ground truth that static [`crate::ship::assemble_ship`] only
//! approximates for external parts.
//!
//! ## Why a capture is needed
//!
//! A captured part's vertex BUFFER holds **local** coordinates (byte-identical to
//! the `.xpr`), so capital-ship parts are placed **entirely in the vertex shader**
//! — the buffer carries no placement. The per-part transform lives in the ship
//! shader's **vertex float constants**: `c0..c2` are the **WorldViewProjection**
//! matrix rows. Each row's norm is `(sx, sy, 1)` (the projection x/y scales, with
//! `sy/sx ≈ 1.78 = 16:9`); dividing a row by its norm yields the rigid
//! **WorldView** row (verified orthonormal, `det = +1`) and `row[3]/norm` is that
//! axis' view-space translation.
//!
//! The camera View cancels when every part is expressed relative to a **reference
//! part**: `rel_p = WV_ref⁻¹ · WV_p = (Rᵀ_ref·R_p , Rᵀ_ref·(T_p − T_ref))` — a pure
//! ship-space rigid transform. That is what [`correlate`] emits and what the
//! checked-in [placement table](parse_table) stores, so the viewer can assemble a
//! ship exactly without re-capturing.
//!
//! ## Pipeline
//!
//! 1. Canary F10 → `xenia_ship_capture.log` (per-draw `vbase`/`vcount` + the first
//!    48 VS float4 constants). [`parse_capture`] → [`CapturedDraw`]s.
//! 2. [`correlate`] matches each ship base part to a draw **by vertex count**
//!    (unique per part) and expresses it in the reference part's frame →
//!    [`ShipPlacement`].
//! 3. [`serialize_table`]/[`parse_table`] persist it as a checked-in data file
//!    (`data/ship_placements.txt`, embedded via [`embedded_placement`]); the viewer
//!    prefers it over the static assembler when present.

use crate::mesh::ScenePart;

type M3 = [[f64; 3]; 3];

/// One captured draw's rigid **WorldView**: rotation rows `r` + view-space
/// translation `t`, recovered from the `c0..c2` WVP constants.
#[derive(Debug, Clone, PartialEq)]
pub struct CapturedDraw {
    /// Guest vertex-buffer base address (the draw's identity for de-duping).
    pub vbase: u32,
    /// Vertex count — the key that matches a draw to a decoded part.
    pub vcount: u32,
    /// WorldView rotation rows (orthonormal).
    pub r: M3,
    /// WorldView view-space translation.
    pub t: [f64; 3],
    /// First few LOCAL vertex positions dumped with the draw (buffer order).
    /// Used to disambiguate same-vcount twins (mirrored port/starboard parts).
    pub pos: Vec<[f32; 3]>,
}

/// A ship part to match against the capture. `part` is the **base** part name
/// (what goes in the placement table); `vcount`/`ref_pos` come from whichever
/// resource variant is being tried (base or an `_m`/`_l` LOD copy — a LOD is the
/// same part in the same local frame, so its captured transform is the part's).
#[derive(Debug, Clone, PartialEq)]
pub struct PartKey {
    /// Base part name for the table, e.g. `e106_bdy_01`.
    pub part: String,
    /// The tried resource's vertex count (the draw match key).
    pub vcount: u32,
    /// The tried resource's decoded positions (any order — validation is
    /// set-based), used to validate a vcount hit and to route mirrored twins.
    /// Empty = match by vcount alone.
    pub ref_pos: Vec<[f32; 3]>,
}

/// A part's ship-relative rigid placement (in the reference part's frame).
#[derive(Debug, Clone, PartialEq)]
pub struct PartPlacement {
    /// Geometry resource name, e.g. `e106_eng_01`.
    pub part: String,
    /// Rotation rows.
    pub m: [[f32; 3]; 3],
    /// Ship-relative translation.
    pub t: [f32; 3],
}

/// A whole ship's captured placement: every part in a shared ship-local frame.
#[derive(Debug, Clone, PartialEq)]
pub struct ShipPlacement {
    /// Ship family id, e.g. `e106`.
    pub id: String,
    /// The reference part whose frame the placements are expressed in.
    pub reference: String,
    /// Ship-relative placement of each matched part.
    pub parts: Vec<PartPlacement>,
}

/// Parse a Canary ship-capture log into per-draw rigid WorldView transforms.
///
/// Only draws that carry the `c0..c2` constants are returned (a culled/occluded
/// part produces no draw and is simply absent). Robust to the exact spacing of
/// the `DRAW …` / `vsconst …` lines.
pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
    let mut out = Vec::new();
    let mut vbase = 0u32;
    let mut vcount = 0u32;
    let mut pos: Vec<[f32; 3]> = Vec::new();
    let mut consts: Vec<(usize, [f64; 4])> = Vec::new();

    let flush = |vbase: u32,
                 vcount: u32,
                 pos: &mut Vec<[f32; 3]>,
                 consts: &[(usize, [f64; 4])],
                 out: &mut Vec<CapturedDraw>| {
        let pos = std::mem::take(pos);
        if vbase == 0 {
            return;
        }
        let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
        let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else {
            return; // no WorldView for this draw — skip it
        };
        if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
            out.push(CapturedDraw { vbase, vcount, r, t, pos });
        }
    };

    for line in text.lines() {
        let l = line.trim();
        if let Some(rest) = l.strip_prefix("DRAW ") {
            flush(vbase, vcount, &mut pos, &consts, &mut out);
            consts.clear();
            let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
            vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0);
            vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0);
        } else if l.starts_with("pos:") || l.starts_with("positions:") {
            pos = parse_pos_line(l, 8);
        } else if l.starts_with("vsconst") {
            for cap in l.split('c').skip(1) {
                let Some((idx, rest)) = cap.split_once('=') else { continue };
                let Ok(i) = idx.trim().parse::<usize>() else { continue };
                let nums: Vec<f64> = rest
                    .trim_start_matches('(')
                    .split(')')
                    .next()
                    .unwrap_or("")
                    .split(',')
                    .filter_map(|x| x.trim().parse().ok())
                    .collect();
                if nums.len() == 4 {
                    consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
                }
            }
        }
    }
    flush(vbase, vcount, &mut pos, &consts, &mut out);
    out
}

/// Parse a `pos: (x,y,z) (x,y,z) …` dump line into up to `max` positions.
fn parse_pos_line(l: &str, max: usize) -> Vec<[f32; 3]> {
    let mut out = Vec::new();
    for group in l.split('(').skip(1) {
        let Some(inner) = group.split(')').next() else { continue };
        let nums: Vec<f32> = inner.split(',').filter_map(|x| x.trim().parse().ok()).collect();
        if nums.len() == 3 {
            out.push([nums[0], nums[1], nums[2]]);
            if out.len() >= max {
                break;
            }
        }
    }
    out
}

/// The vertex shader capital ships (and the player fighter) are drawn with — the
/// `c0..c2` WVP-row layout [`parse_capture`]/[`parse_drawlog`] rely on.
pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";

/// Parse the **draw-logger** format (`xenia_re_draws.log` / `mission_draws.log`,
/// the `--log_draws` cvar) into per-part rigid transforms — the alternative to the
/// F10 [`parse_capture`] snapshot. That log is what a normal instrumented run
/// already produces, so a capital ship seen in-mission can be baked without a
/// dedicated F10 capture.
///
/// A capital ship's parts each own a **distinct vertex buffer**, so we group by
/// the `stream … base=…` address, take its vertex count as `size_words /
/// stride_words`, and keep the first `c0..c2` WVP seen for that buffer. Only draws
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
/// ignored. (The player fighter shares ONE buffer across its fin draws and so
/// collapses to a single entry here — fine, capital ships are the target.)
/// Split a capture into blocks that are guaranteed to share one camera.
///
/// **Why this is not optional.** One F10 press dumps a flat list of draws with
/// no frame delimiter, and it spans ~14 frames (the same vertex buffer recurs
/// that many times). The placement math is `WV_ref⁻¹ · WV_p`, which cancels the
/// camera **only when both draws come from the same frame** — mix frames and
/// the residual is the camera's motion between them. With a static ship and a
/// static camera that error is invisible, which is how the single validated
/// `e106` capture passed; closing on a cruiser at ~760 u/s it is hundreds of
/// units, and two frames of the same ship then disagree about where its parts
/// are (measured 2026-08-10: `f105_bdy_02` at `[488, 736, -620]` vs
/// `[0, 0, -1090]`).
///
/// The split rule is the recurrence itself: a vertex buffer that appears again
/// starts a new block. Splitting too eagerly is harmless (a block is still one
/// camera, just with fewer parts in it) and it separates two instances of the
/// same class as a bonus; failing to split is what corrupts the result.
pub fn segment_frames(draws: &[CapturedDraw]) -> Vec<Vec<CapturedDraw>> {
    let mut out: Vec<Vec<CapturedDraw>> = Vec::new();
    let mut cur: Vec<CapturedDraw> = Vec::new();
    let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
    for d in draws {
        if !seen.insert(d.vbase) {
            out.push(std::mem::take(&mut cur));
            seen.clear();
            seen.insert(d.vbase);
        }
        cur.push(d.clone());
    }
    if !cur.is_empty() {
        out.push(cur);
    }
    out
}

pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
    let mut out = Vec::new();
    let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
    let mut is_ship = false;
    let mut base = 0u32;
    let mut stride = 0u32;
    let mut size = 0u32;
    let mut pos: Vec<[f32; 3]> = Vec::new();
    let mut consts: Vec<(usize, [f64; 4])> = Vec::new();

    let mut flush = |base: u32,
                     size: u32,
                     stride: u32,
                     pos: &mut Vec<[f32; 3]>,
                     consts: &[(usize, [f64; 4])],
                     seen: &mut std::collections::HashSet<u32>,
                     out: &mut Vec<CapturedDraw>| {
        let pos = std::mem::take(pos);
        if base == 0 || stride == 0 || !seen.insert(base) {
            return;
        }
        let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
        let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { return };
        if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
            out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos });
        }
    };

    for line in text.lines() {
        let l = line.trim();
        if let Some(rest) = l.strip_prefix("DRAW ") {
            flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out);
            consts.clear();
            base = 0;
            stride = 0;
            size = 0;
            is_ship = rest.contains(&format!("vs={SHIP_VS_HASH}"));
        } else if is_ship && l.starts_with("positions:") {
            pos = parse_pos_line(l, 8);
        } else if is_ship && l.starts_with("stream ") {
            let f = |k: &str| l.split_whitespace().find_map(|t| t.strip_prefix(k));
            if let Some(b) = f("base=0x").and_then(|s| u32::from_str_radix(s, 16).ok()) {
                base = b;
            }
            stride = f("stride_words=").and_then(|s| s.parse().ok()).unwrap_or(stride);
            size = f("size_words=").and_then(|s| s.parse().ok()).unwrap_or(size);
        } else if is_ship && l.starts_with('c') {
            // `c<idx>  x y z w` — the vsconst rows (space-separated).
            let mut it = l.splitn(2, char::is_whitespace);
            let Some(tag) = it.next() else { continue };
            let Ok(i) = tag[1..].parse::<usize>() else { continue };
            let nums: Vec<f64> = it.next().unwrap_or("").split_whitespace().filter_map(|x| x.parse().ok()).collect();
            if nums.len() >= 4 {
                consts.push((i, [nums[0], nums[1], nums[2], nums[3]]));
            }
        }
    }
    flush(base, size, stride, &mut pos, &consts, &mut seen, &mut out);
    out
}

/// Normalize the three `c0..c2` WVP rows to a rigid WorldView `(R rows, T)` by
/// dividing each row by its (projection-scale) norm. `None` if any row is
/// degenerate.
fn normalize_wvp(rows: [[f64; 4]; 3]) -> Option<(M3, [f64; 3])> {
    let mut r = [[0.0; 3]; 3];
    let mut t = [0.0; 3];
    for (i, row) in rows.iter().enumerate() {
        let n = (row[0] * row[0] + row[1] * row[1] + row[2] * row[2]).sqrt();
        if n < 1e-6 {
            return None;
        }
        r[i] = [row[0] / n, row[1] / n, row[2] / n];
        t[i] = row[3] / n;
    }
    Some((r, t))
}

/// Validate a vcount hit by the draw's dumped positions against the part's
/// decoded position SET (order-independent — decode order can differ from buffer
/// order). Returns `Some((hits, mirrored))` when at least half the dumped
/// positions are found among the part's vertices, either directly or **all
/// X-negated** — the engine uploads the second of a mirrored port/starboard pair
/// as an X-reflection of the shared file geometry, so the guest buffer disagrees
/// in X sign with every decoded copy. `None` = the dump belongs to a different
/// model (a coincidental vcount).
fn pos_validate(draw: &CapturedDraw, ref_pos: &[[f32; 3]]) -> Option<(usize, bool)> {
    if draw.pos.is_empty() || ref_pos.is_empty() {
        return Some((0, false)); // no data to validate with — accept neutrally
    }
    let near = |a: &[f32; 3], b: &[f32; 3]| {
        (a[0] - b[0]).abs() <= 1e-2 && (a[1] - b[1]).abs() <= 1e-2 && (a[2] - b[2]).abs() <= 1e-2
    };
    let mut direct = 0usize;
    let mut mirror = 0usize;
    for p in &draw.pos {
        if ref_pos.iter().any(|v| near(p, v)) {
            direct += 1;
        }
        let pm = [-p[0], p[1], p[2]];
        if ref_pos.iter().any(|v| near(&pm, v)) {
            mirror += 1;
        }
    }
    let need = draw.pos.len().div_ceil(2);
    if direct >= need && direct >= mirror {
        Some((direct, false))
    } else if mirror >= need {
        Some((mirror, true))
    } else {
        None
    }
}

/// Correlate captured draws to a ship's parts and express each in the reference
/// part's frame.
///
/// Each [`PartKey`]'s `vcount` is the match key; when several draws share the
/// vcount (mirrored port/starboard twins) the draw whose dumped positions match
/// the part's `ref_pos` validates best is chosen. `ref_sub` selects the reference part by
/// substring (e.g. `bdy_04`); the first matched part is used if none contains it.
/// Parts with no matching captured draw (culled at that camera angle) are
/// omitted. Returns `None` if nothing matched.
pub fn correlate(
    id: &str,
    draws: &[CapturedDraw],
    parts: &[PartKey],
    ref_sub: &str,
) -> Option<ShipPlacement> {
    let mut matched: Vec<(String, M3, [f64; 3], bool)> = Vec::new();
    let mut used: std::collections::HashSet<u32> = std::collections::HashSet::new();
    for key in parts {
        // Several PartKeys may carry the same part (one per LOD-variant vcount);
        // the first that validates wins, the rest are skipped.
        if matched.iter().any(|(p, ..)| p == &key.part) {
            continue;
        }
        // A vcount hit alone can be a coincidence (small LODs share counts across
        // unrelated models — a 51-vert draw once matched the bridge but was a
        // different mesh). Candidates failing position validation are REJECTED;
        // among validated candidates the best hit count wins (routes twins), and
        // a mirror-validated match records the X-reflection.
        let mut best: Option<(&CapturedDraw, usize, bool)> = None;
        for d in draws.iter().filter(|d| d.vcount == key.vcount && !used.contains(&d.vbase)) {
            let Some((score, mirrored)) = pos_validate(d, &key.ref_pos) else {
                continue; // positions disagree — not this part
            };
            if best.map_or(true, |(_, s, _)| score > s) {
                best = Some((d, score, mirrored));
            }
        }
        if let Some((d, _, mirrored)) = best {
            used.insert(d.vbase);
            matched.push((key.part.clone(), d.r, d.t, mirrored));
        }
    }
    let ref_idx = matched.iter().position(|(p, ..)| p.contains(ref_sub)).unwrap_or(0);
    let (ref_part, ref_r, ref_t, _) = matched.get(ref_idx)?.clone();
    let rt_ref = transpose(&ref_r);

    let parts_out = matched
        .iter()
        .map(|(part, r, t, mirrored)| {
            let mut rel_r = mmul(&rt_ref, r);
            let dt = [t[0] - ref_t[0], t[1] - ref_t[1], t[2] - ref_t[2]];
            let rel_t = mat_vec(&rt_ref, dt);
            // The captured WorldView transforms the *uploaded* buffer; for the
            // mirrored twin that buffer is the X-reflection of the file geometry,
            // so the file-local placement is R·diag(−1,1,1) — negate column 0.
            if *mirrored {
                for row in &mut rel_r {
                    row[0] = -row[0];
                }
            }
            PartPlacement {
                part: part.clone(),
                m: snap_m3(&rel_r),
                t: [rel_t[0] as f32, rel_t[1] as f32, rel_t[2] as f32],
            }
        })
        .collect();
    Some(ShipPlacement { id: id.to_string(), reference: ref_part, parts: parts_out })
}

/// Snap near-axis rotation entries (float noise from the WV products) to exact
/// 0/±1 so the checked-in table is clean; real rotations are untouched.
fn snap_m3(m: &M3) -> [[f32; 3]; 3] {
    let snap = |v: f64| -> f32 {
        if v.abs() < 5e-4 {
            0.0
        } else if (v - 1.0).abs() < 5e-4 {
            1.0
        } else if (v + 1.0).abs() < 5e-4 {
            -1.0
        } else {
            v as f32
        }
    };
    [
        [snap(m[0][0]), snap(m[0][1]), snap(m[0][2])],
        [snap(m[1][0]), snap(m[1][1]), snap(m[1][2])],
        [snap(m[2][0]), snap(m[2][1]), snap(m[2][2])],
    ]
}

/// Convert a captured placement into viewer [`ScenePart`]s (rigid, unit scale).
pub fn to_scene_parts(ship: &ShipPlacement) -> Vec<ScenePart> {
    ship.parts
        .iter()
        .map(|p| ScenePart { resource: p.part.clone(), m: p.m, t: p.t, s: [1.0, 1.0, 1.0] })
        .collect()
}

/// The checked-in placement table, embedded at build time. Empty until captures
/// are baked in with `correlate_capture --emit`.
const EMBEDDED_TABLE: &str = include_str!("../data/ship_placements.txt");

/// The captured placement for ship `id` from the embedded table, if baked in.
pub fn embedded_placement(id: &str) -> Option<ShipPlacement> {
    parse_table(EMBEDDED_TABLE).into_iter().find(|s| s.id == id)
}

/// Serialize a placement table to the checked-in text format (see [`parse_table`]).
pub fn serialize_table(ships: &[ShipPlacement]) -> String {
    let mut s = String::new();
    s.push_str("# Capital-ship part placements — runtime-captured ground truth.\n");
    s.push_str("# Generated by: cargo run --release --example correlate_capture -- \\\n");
    s.push_str("#   <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] --emit\n");
    s.push_str("# Per line: <part> <R00 R01 R02 R10 R11 R12 R20 R21 R22> <T0 T1 T2>\n");
    for ship in ships {
        s.push_str(&format!("\nship {} ref={}\n", ship.id, ship.reference));
        for p in &ship.parts {
            s.push_str(&format!(
                "  {} {} {} {} {} {} {} {} {} {} {} {} {}\n",
                p.part,
                p.m[0][0], p.m[0][1], p.m[0][2],
                p.m[1][0], p.m[1][1], p.m[1][2],
                p.m[2][0], p.m[2][1], p.m[2][2],
                p.t[0], p.t[1], p.t[2],
            ));
        }
    }
    s
}

/// Parse the checked-in placement table. `#` comments and blank lines are ignored;
/// a `ship <id> ref=<part>` line starts a block, and each following
/// `<part> <9 rotation floats> <3 translation floats>` line is one placement.
pub fn parse_table(text: &str) -> Vec<ShipPlacement> {
    let mut ships: Vec<ShipPlacement> = Vec::new();
    for line in text.lines() {
        let l = line.trim();
        if l.is_empty() || l.starts_with('#') {
            continue;
        }
        if let Some(rest) = l.strip_prefix("ship ") {
            let mut it = rest.split_whitespace();
            let id = it.next().unwrap_or("").to_string();
            let reference = it
                .next()
                .and_then(|s| s.strip_prefix("ref="))
                .unwrap_or("")
                .to_string();
            ships.push(ShipPlacement { id, reference, parts: Vec::new() });
        } else if let Some(ship) = ships.last_mut() {
            let mut it = l.split_whitespace();
            let part = it.next().unwrap_or("").to_string();
            let nums: Vec<f32> = it.filter_map(|x| x.parse().ok()).collect();
            if part.is_empty() || nums.len() != 12 {
                continue;
            }
            ship.parts.push(PartPlacement {
                part,
                m: [
                    [nums[0], nums[1], nums[2]],
                    [nums[3], nums[4], nums[5]],
                    [nums[6], nums[7], nums[8]],
                ],
                t: [nums[9], nums[10], nums[11]],
            });
        }
    }
    ships
}

fn transpose(m: &M3) -> M3 {
    [
        [m[0][0], m[1][0], m[2][0]],
        [m[0][1], m[1][1], m[2][1]],
        [m[0][2], m[1][2], m[2][2]],
    ]
}
fn mmul(a: &M3, b: &M3) -> M3 {
    let mut o = [[0.0; 3]; 3];
    for i in 0..3 {
        for j in 0..3 {
            o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
        }
    }
    o
}
fn mat_vec(m: &M3, v: [f64; 3]) -> [f64; 3] {
    [
        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A `vsconst` line for an already-unit WorldView (norm 1) so R = rows and
    /// T = row[3]: identity rotation, translation `t`.
    fn draw_line(vbase: u32, vcount: u32, t: [f64; 3]) -> String {
        format!(
            "DRAW vbase=0x{vbase:X} stride=32 vcount={vcount} indices=0 prim=tri vs=0x1\n  \
             vsconst base=0: c0=(1,0,0,{}) c1=(0,1,0,{}) c2=(0,0,1,{})\n",
            t[0], t[1], t[2]
        )
    }

    fn key(part: &str, vcount: u32) -> PartKey {
        PartKey { part: part.to_string(), vcount, ref_pos: Vec::new() }
    }

    #[test]
    fn parse_recovers_worldview() {
        let log = draw_line(0x1000, 3, [10.0, 0.0, 0.0]);
        let draws = parse_capture(&log);
        assert_eq!(draws.len(), 1);
        assert_eq!(draws[0].vcount, 3);
        assert_eq!(draws[0].t, [10.0, 0.0, 0.0]);
        assert_eq!(draws[0].r, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
    }

    #[test]
    fn parse_normalizes_projection_scale() {
        // A row scaled by the projection sx=2 must normalize back to unit, and its
        // translation divides by the same norm.
        let log = "DRAW vbase=0x2000 vcount=4\n  \
                   vsconst base=0: c0=(2,0,0,20) c1=(0,2,0,0) c2=(0,0,1,0)\n";
        let d = &parse_capture(log)[0];
        assert!((d.r[0][0] - 1.0).abs() < 1e-9);
        assert!((d.t[0] - 10.0).abs() < 1e-9, "20/2 = 10");
    }

    #[test]
    fn correlate_expresses_parts_in_reference_frame() {
        // Two parts, identity rotation: ref at view (10,0,0), other at (10,0,50).
        // Relative to ref, the other must sit at (0,0,50).
        let log = format!(
            "{}{}",
            draw_line(0x1000, 3, [10.0, 0.0, 0.0]),
            draw_line(0x2000, 4, [10.0, 0.0, 50.0])
        );
        let draws = parse_capture(&log);
        let parts = vec![key("e106_bdy_04", 3), key("e106_eng_01", 4)];
        let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
        assert_eq!(ship.reference, "e106_bdy_04");
        let refp = ship.parts.iter().find(|p| p.part == "e106_bdy_04").unwrap();
        assert_eq!(refp.t, [0.0, 0.0, 0.0]);
        let eng = ship.parts.iter().find(|p| p.part == "e106_eng_01").unwrap();
        assert_eq!(eng.t, [0.0, 0.0, 50.0]);
    }

    #[test]
    fn drawlog_format_parses_capital_ship_parts() {
        // Two capital-ship parts, each its own vertex buffer (distinct base),
        // stride 6 → vcount = size_words/6. Identity WVP with known translations.
        let log = format!(
            "DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n  \
             stream fc=0 base=0x12A60228 stride_words=6 size_words=9798 endian=2 type=3\n  \
             vsconst:\n    c0   1.0 0.0 0.0 10.0\n    c1   0.0 1.0 0.0 0.0\n    c2   0.0 0.0 1.0 0.0\n\
             DRAW prim=4 indices=6 src=0 index[...] vs={SHIP_VS_HASH}\n  \
             stream fc=0 base=0x12ABF2CC stride_words=6 size_words=4890 endian=2 type=3\n  \
             vsconst:\n    c0   1.0 0.0 0.0 10.0\n    c1   0.0 1.0 0.0 0.0\n    c2   0.0 0.0 1.0 50.0\n\
             DRAW prim=4 indices=3 src=0 index[...] vs=0xDEADBEEF00000000\n  \
             stream fc=0 base=0x99990000 stride_words=6 size_words=18 endian=2 type=3\n  \
             vsconst:\n    c0   1.0 0.0 0.0 0.0\n    c1   0.0 1.0 0.0 0.0\n    c2   0.0 0.0 1.0 0.0\n"
        );
        let draws = parse_drawlog(&log);
        // Two ship-shader buffers; the non-ship shader draw is ignored.
        assert_eq!(draws.len(), 2);
        assert_eq!(draws[0].vcount, 1633); // 9798/6
        assert_eq!(draws[1].vcount, 815); //  4890/6
        // Correlate: part B sits 50 along Z from reference part A.
        let parts = vec![key("e106_bdy_04", 1633), key("e106_bdy_03", 815)];
        let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
        let b = ship.parts.iter().find(|p| p.part == "e106_bdy_03").unwrap();
        assert_eq!(b.t, [0.0, 0.0, 50.0]);
    }

    /// The real e106 bdy_01/bdy_02 case: both twin resources decode to
    /// IDENTICAL file geometry; the engine uploads the second instance as an
    /// X-reflection, so one captured buffer disagrees in X sign with the file.
    /// Both draws must be placed, and the mirrored one must bake the X-flip
    /// (negated first matrix column) so file-local geometry lands port-side.
    #[test]
    fn runtime_mirrored_twin_placed_with_reflection() {
        let log = "DRAW vbase=0x1000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n  \
                   pos: (134.4215,133.8319,-118.1757) (178.8384,85.3847,238.0463)\n  \
                   vsconst base=0: c0=(1,0,0,264) c1=(0,1,0,0) c2=(0,0,1,0)\n\
                   DRAW vbase=0x2000 stride=24 vcount=426 indices=21 prim=4 vs=0x1\n  \
                   pos: (-134.4215,133.8319,-118.1757) (-178.8384,85.3847,238.0463)\n  \
                   vsconst base=0: c0=(1,0,0,-264) c1=(0,1,0,0) c2=(0,0,1,0)\n\
                   DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n  \
                   vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n";
        let draws = parse_capture(log);
        assert_eq!(draws.len(), 3);
        assert_eq!(draws[0].pos.len(), 2);
        // Both twins carry the SAME (file) positions — +X side geometry.
        let file_pos = vec![[134.4215, 133.8319, -118.1757], [178.8384, 85.3847, 238.0463]];
        let parts = vec![
            PartKey { part: "e106_bdy_01".to_string(), vcount: 426, ref_pos: file_pos.clone() },
            PartKey { part: "e106_bdy_02".to_string(), vcount: 426, ref_pos: file_pos },
            key("e106_bdy_04", 558),
        ];
        let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
        assert_eq!(ship.parts.len(), 3, "both twins + reference placed");
        let get = |p: &str| ship.parts.iter().find(|x| x.part == p).unwrap().clone();
        // bdy_01 validated directly → the +264 draw, identity rotation.
        let a = get("e106_bdy_01");
        assert_eq!(a.t, [264.0, 0.0, 0.0]);
        assert_eq!(a.m[0][0], 1.0);
        // bdy_02 validated as the MIRROR → the −264 draw, X-flip baked in.
        let b = get("e106_bdy_02");
        assert_eq!(b.t, [-264.0, 0.0, 0.0]);
        assert_eq!(b.m[0][0], -1.0, "mirrored twin must negate the X column");
        assert_eq!(b.m[1][1], 1.0);
    }

    /// A draw whose vcount matches but whose position dump disagrees must be
    /// REJECTED, not placed — the real false-positive: a foreign 51-vert model
    /// matched `e106_brg_01_l` by count alone and put the bridge 2 km off-hull.
    #[test]
    fn vcount_coincidence_rejected_by_positions() {
        let log = "DRAW vbase=0x1000 stride=24 vcount=51 indices=36 prim=4 vs=0x1\n  \
                   pos: (-0.0000,46.2359,-12.9454) (-0.0000,-6.1936,264.8687)\n  \
                   vsconst base=0: c0=(1,0,0,1975) c1=(0,1,0,0) c2=(0,0,1,0)\n\
                   DRAW vbase=0x3000 stride=24 vcount=558 indices=9 prim=4 vs=0x1\n  \
                   vsconst base=0: c0=(1,0,0,0) c1=(0,1,0,0) c2=(0,0,1,0)\n";
        let draws = parse_capture(log);
        let parts = vec![
            PartKey {
                part: "e106_brg_01".to_string(),
                vcount: 51,
                // The REAL bridge LOD's vertices — disagree with the dump.
                ref_pos: vec![[35.2480, 26.0376, 49.2504], [6.0, 55.9632, 41.1370]],
            },
            key("e106_bdy_04", 558),
        ];
        let ship = correlate("e106", &draws, &parts, "bdy_04").unwrap();
        assert!(
            !ship.parts.iter().any(|p| p.part == "e106_brg_01"),
            "coincidental vcount match must not place the bridge"
        );
        assert_eq!(ship.parts.len(), 1);
    }

    #[test]
    fn table_round_trips() {
        let ship = ShipPlacement {
            id: "e106".to_string(),
            reference: "e106_bdy_04".to_string(),
            parts: vec![
                PartPlacement {
                    part: "e106_bdy_04".to_string(),
                    m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
                    t: [0.0, 0.0, 0.0],
                },
                PartPlacement {
                    part: "e106_eng_01".to_string(),
                    m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
                    t: [131.0, -133.0, -132.0],
                },
            ],
        };
        let text = serialize_table(std::slice::from_ref(&ship));
        let back = parse_table(&text);
        assert_eq!(back, vec![ship]);
    }

    #[test]
    fn embedded_table_parses() {
        // The checked-in data file must always parse (even if empty).
        let _ = parse_table(EMBEDDED_TABLE);
    }

    /// The baked e106 capture (2026-07-26 F10, Stage_S01): all 8 parts placed,
    /// port/starboard pair symmetric with the mirror on bdy_02, bridge on the
    /// centreline. Guards the checked-in data against accidental edits.
    #[test]
    fn embedded_e106_is_complete() {
        let e106 = embedded_placement("e106").expect("e106 baked in");
        assert_eq!(e106.reference, "e106_bdy_04");
        assert_eq!(e106.parts.len(), 8, "all 8 e106 parts placed");
        let get = |p: &str| e106.parts.iter().find(|x| x.part == p).unwrap();
        // Port/starboard hull pair: X = ∓264, the starboard copy mirrored.
        assert!((get("e106_bdy_01").t[0] + 264.0).abs() < 0.1);
        assert!((get("e106_bdy_02").t[0] - 264.0).abs() < 0.1);
        assert_eq!(get("e106_bdy_02").m[0][0], -1.0);
        assert_eq!(get("e106_bdy_01").m[0][0], 1.0);
        // Bridge: centreline, above and aft of the hull reference.
        let brg = get("e106_brg_01");
        assert!(brg.t[0].abs() < 0.1 && brg.t[1] > 150.0 && brg.t[2] < -160.0);
    }
}
