feat(formats,viewer): movie subtitles, voice decode, and the movie manifest

The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE
manifest (the authoritative movie -> subtitle -> voice index). Also flushes
several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship
decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md.

Subtitles (movie_subtitle.rs):
- Full movie->track->text chain; join multi-line captions sharing one timing
  (fixes S13A dropped "Look at it father" line); overlap-safe active_cues();
  Latin-1 accents preserved.

Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its
declared data size (fixes S10-S16 alternate-take garble); list_voice_clips.

Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real
movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\).
Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the
demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game
and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem).

Viewer: manifest-driven voice (movie player toggle + solo button), standalone
"Voice Lines" browser, stacked caption overlay.

Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc
tests; full workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 20:15:41 +02:00
parent 578c71a1b9
commit 95de29f290
22 changed files with 4684 additions and 493 deletions

View File

@@ -385,10 +385,31 @@ impl 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)
}
/// [`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> {
let mut out = Vec::new();
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return out;
@@ -471,7 +492,17 @@ impl Xbg7Model {
starts_by_stride.insert(s, vertex_run_starts(bytes, data_base, s));
}
for r in &resources {
// Anchor each resource. Resources are independent (the shared
// `starts_by_stride` is read-only from here on), so a big stage's
// hundreds of sub-models are decoded in parallel on native builds —
// the dominant cost of loading a stage container. `filter_map(...).collect()`
// preserves resource order, so the output is identical to the sequential
// decode. `should_cancel()` is polled per resource so a superseded load
// stops promptly.
let decode_one = |r: &Res| -> Option<Xbg7Model> {
if should_cancel() {
return None;
}
let starts = &starts_by_stride[&r.decl.stride];
let meshes = if r.markers.len() == 1 {
// Single sub-mesh → the proven per-block adjacency anchor
@@ -499,12 +530,20 @@ impl Xbg7Model {
.collect()
}
};
if !meshes.is_empty() {
out.push(Xbg7Model {
name: r.name.clone(),
meshes,
});
}
(!meshes.is_empty()).then(|| Xbg7Model {
name: r.name.clone(),
meshes,
})
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out = resources.par_iter().filter_map(decode_one).collect();
}
#[cfg(target_arch = "wasm32")]
{
out = resources.iter().filter_map(decode_one).collect();
}
out
}
@@ -1210,6 +1249,540 @@ fn read_cstr(b: &[u8], o: usize) -> Option<String> {
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.
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 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]]
}
/// 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}_");
// 8-value TRS from a node's `+0x44` joint table (8 pointers, each f64 @ +8);
// returns identity-safe zeros when the table is absent.
let read_trs = |t44: usize| -> [f64; 8] {
let mut v = [0.0f64; 8];
for (k, slot) in v.iter_mut().enumerate() {
let p = be32(d, t44 + k * 4) as usize;
if p != 0 && p + 16 <= d.len() {
*slot = be_f64(d, p + 8);
}
}
v
};
// 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; 8]
};
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}]",
trs[0], trs[1], trs[2], trs[3], trs[4], trs[5], trs[6], trs[7]
);
}
// Local transform: translation (t0=X, t2=up→Y, t1=fore/aft→Z), and a
// rotation Rz(r1)·Rx(r0) (r1 = V-tail cant about fore/aft, r0 = pitch).
let local_t = [trs[0] as f32, trs[2] as f32, trs[1] as f32];
let local_m = m3_mul(rot_z(trs[4] as f32), rot_x(trs[3] as f32));
// 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
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]