From 578c71a1b9cca78faf6c8fcbb1f7909f4ca6b3af Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 18 Jul 2026 19:54:39 +0200 Subject: [PATCH] [formats,viewer] Decode grouped-pool XBG7 models; fix mesh routing + albedo Revert the mis-guided "triangle STRIP" reading (a937779): XBG7 index buffers are triangle LISTS (prim=4 in the GPU capture; stored-normal agreement 1.000 as a list vs ~0.49 as a strip). Reinstate the winding-consistency gate. Crack the grouped-pool layout used by the hero ship and ~150 detailed models. A resource's sub-meshes share one index pool (buffers 4-byte aligned, in descriptor-marker order) followed by one vertex pool (each vtx_count*stride, same order); the vertex pool is 4-byte aligned after the index pool. The whole resource is derived from one anchored pivot: ib0 = vb0 - span - pad (pad in 0..=3, alignment) ib[i] = align4(ib[i-1] + idx_count[i-1]*2) vb[i] = vb[i-1] + vtx_count[i-1]*stride vb0 is found via the unit-normal vertex-run scan; the alignment is confirmed by validating the LARGEST sub-mesh (most reliable), after which the rest are read/validated. A single marker reduces this to the existing adjacency anchor, so grouped generalises it. Results (cross-checked against a Canary GPU draw-log capture of DeltaSaber_T.xpr): - DeltaSaber_T f001 = body + 7 detail parts = 8650 tris, every sub-mesh 0-degenerate / full-coverage / winding-agreement 1.000. - All 19 previously-declined weapon models now decode (they hit pad 2 and/or lead with a tiny bracket that broke a markers[0] pivot). Corpus audit: 0/146 geometry files fail (was 19 -> flat-texture in the viewer). - Stages unchanged (single-marker path is byte-identical; grouped falls back to the old anchor on failure). 7/7 disc tests green incl. the strict stage quality audit; new hero_ship_grouped_pool_decodes test. Viewer: route by count_xbg7; --only matches an exact model name (so the neutral pose renders without the mnv*/turn180 animation poses). Fix the albedo matcher: match a sub-model to its _col map by entity stem (e007_bdy_01 -> e007_col) instead of a full-name prefix, lifting stage sub-model texturing from ~25% to ~95% (the rest were flat grey). Colour correctness (channel order/sRGB) remains a separate dynamic-RE item. Co-Authored-By: Claude Opus 4.8 --- crates/sylpheed-cli/src/main.rs | 40 +- crates/sylpheed-formats/src/mesh.rs | 669 ++++++++++++++++----- crates/sylpheed-formats/tests/mesh_disc.rs | 79 +++ crates/sylpheed-viewer/src/iso_loader.rs | 219 +++++-- docs/re/structures/xbg7-mesh.md | 71 +++ 5 files changed, 877 insertions(+), 201 deletions(-) diff --git a/crates/sylpheed-cli/src/main.rs b/crates/sylpheed-cli/src/main.rs index fac320f..57e9599 100644 --- a/crates/sylpheed-cli/src/main.rs +++ b/crates/sylpheed-cli/src/main.rs @@ -423,17 +423,30 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> { /// a single model (weapon / prop) OR a stage's many sub-models — whichever /// yields more geometry. fn decode_models(bytes: &[u8]) -> Vec { - use sylpheed_formats::mesh::Xbg7Model; - let single = Xbg7Model::from_xpr2(bytes) + use sylpheed_formats::mesh::{count_xbg7, Xbg7Model}; + // Route by container kind, NOT by whichever decoder yields more verts (that + // old heuristic let `stage_models`' content-anchoring win on single-model + // files, fabricating phantom / duplicate / mis-anchored blocks). A file with + // one XBG7 resource is a single model (weapon / prop) → use only the + // validated records-based list decode; content-anchoring a single-model file + // invents geometry. Many XBG7 resources → a Stage_* collection → anchor them. + if count_xbg7(bytes) > 1 { + // Multi-resource Stage_* collection → content-anchor every resource + // (each anchor now gated by stored-normal agreement). + Xbg7Model::stage_models(bytes) + } else if let Some(m) = Xbg7Model::from_xpr2(bytes) .ok() - .filter(|m| !m.meshes.is_empty()); - let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0); - let stage = Xbg7Model::stage_models(bytes); - let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum(); - if !stage.is_empty() && stage_verts > single_verts { - stage + .filter(|m| !m.meshes.is_empty()) + { + // Single model the records-based list decode carved (authoritative). + vec![m] } else { - single.into_iter().collect() + // Single model from_xpr2 couldn't locate (its sequential carve missed + // the block) — fall back to content-anchoring, which finds it by shape. + // Use a STRICT winding-consistency gate (0.85): on a single-model file a + // mis-anchor is an obvious phantom / spike-mess and must be declined, + // unlike the large stage corpus which keeps the ungated path. + Xbg7Model::anchor_models(bytes, 0.85) } } @@ -536,7 +549,14 @@ fn cmd_mesh_render( let bytes = std::fs::read(file).with_context(|| format!("Cannot read {}", file.display()))?; let mut models = decode_models(&bytes); if let Some(sub) = &only { - models.retain(|m| m.name.contains(sub.as_str())); + // Prefer an exact name match (e.g. `f001` for the neutral ship pose, + // excluding the `_rou_f001_mnv*` animation poses that also *contain* + // "f001"); fall back to substring when nothing matches exactly. + if models.iter().any(|m| m.name == *sub) { + models.retain(|m| m.name == *sub); + } else { + models.retain(|m| m.name.contains(sub.as_str())); + } } if models.is_empty() { anyhow::bail!("no decodable XBG7 geometry in {}", file.display()); diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 98bdecc..3812541 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -21,19 +21,27 @@ //! the data section is a straight sequence of sub-meshes, each: //! //! ```text -//! [ index buffer : idx_count × u16 big-endian ] triangle STRIP //! [ 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 **strip** (a strip of N indices is N-2 -//! triangles with alternating winding), NOT a list — reading it as a list -//! dropped ~⅔ of every hull (the "triangular holes") and produced spanning -//! spikes. `expand_triangle_strip` converts it, skipping degenerate triangles. -//! Residual: an index buffer may concatenate several strips with no degenerate -//! bridge, leaving ~2 spanning triangles at each restart (an index jump to a new -//! vertex region) — a small known artifact on some models. +//! 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 @@ -51,12 +59,29 @@ //! sub-mesh fails to carve cleanly the whole model is rejected //! ([`MeshError::UnsupportedLayout`]) rather than emitting garbage. //! -//! ## Not yet decoded +//! ## The grouped-pool layout (CONFIRMED — hero ships) //! -//! The hero-ship *body* meshes (`DeltaSaber_*.xpr` `f004`, and ~100 other -//! models) use a more complex **multi-stream** layout — separate position / -//! attribute streams at descriptor-addressed offsets, quantized positions — -//! which is not handled here and is cleanly declined. +//! 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; @@ -100,6 +125,32 @@ pub struct Xbg7Model { pub meshes: Vec, } +/// 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 +} + impl Xbg7Model { /// Total vertex / triangle counts across all sub-meshes. pub fn totals(&self) -> (usize, usize) { @@ -187,21 +238,21 @@ impl Xbg7Model { return Err(MeshError::UnsupportedLayout); } - // The index buffer is a triangle STRIP (u16 BE), validated against the - // sub-mesh vertex count. Reading it as a triangle *list* dropped ~⅔ of - // the surface (the classic "triangular holes in the hull") and produced - // spanning spikes; a strip of N indices is N-2 triangles with - // alternating winding. Expand to a triangle list here, skipping - // degenerate triangles (repeated index = strip restart / zero area). - let mut strip = Vec::with_capacity(idx_count); + // 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); } - strip.push(i); + indices.push(i); } - let indices = expand_triangle_strip(&strip); // Vertices per the declaration. The .xpr stores each element in // naive big-endian component order (f32 / f16 read at consecutive @@ -252,6 +303,42 @@ impl Xbg7Model { } } + // ── 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, @@ -287,7 +374,21 @@ impl Xbg7Model { /// 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 { + Self::anchor_models(bytes, 0.0) + } + + /// 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 { let mut out = Vec::new(); if bytes.len() < 16 || &bytes[..4] != b"XPR2" { return out; @@ -306,8 +407,10 @@ impl Xbg7Model { const DIR_BASE: usize = 0x10; struct Res { name: String, - index_count: usize, - vtx_count: usize, + /// `(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 = Vec::new(); @@ -325,22 +428,14 @@ impl Xbg7Model { continue; } let d = &bytes[desc..desc_end]; - let (mk_rel, index_count) = match find_index_marker(d) { - Some(m) => m, - None => continue, - }; + let markers = all_index_markers(d); + if markers.is_empty() { + continue; + } let decl = match parse_vertex_decl(d) { Some(v) => v, None => continue, }; - // Total vertex count is stored 32 bytes before the index marker. - if mk_rel < 32 { - continue; - } - let vtx_count = be32(d, mk_rel - 32) as usize; - if !(3..=400_000).contains(&vtx_count) || index_count < 3 { - 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() { @@ -350,8 +445,7 @@ impl Xbg7Model { .unwrap_or_else(|| "XBG7".to_string()); resources.push(Res { name, - index_count, - vtx_count, + markers, decl, }); } @@ -379,16 +473,36 @@ impl Xbg7Model { for r in &resources { let starts = &starts_by_stride[&r.decl.stride]; - if let Some(mesh) = anchor_pool_mesh( - bytes, - starts, - r.index_count, - r.vtx_count, - &r.decl, - ) { + let meshes = if r.markers.len() == 1 { + // Single sub-mesh → the proven per-block adjacency anchor + // (index buffer immediately before its vertex buffer). Stages and + // simple props take this path; `min_consistency` behaviour is + // exactly as before. + let (vtx_count, index_count) = r.markers[0]; + anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency) + .into_iter() + .collect() + } else { + // Several sub-meshes sharing grouped index/vertex pools → the + // deterministic grouped-pool decode (hero ships et al.). + let grouped = anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decl); + if !grouped.is_empty() { + grouped + } else { + // The grouped pivot didn't validate (the extra markers were a + // coincidence, or this isn't a grouped-pool model). Fall back + // to the original single-block adjacency anchor on the first + // marker so coverage is never *below* the pre-grouped decode. + let (vtx_count, index_count) = r.markers[0]; + anchor_pool_mesh(bytes, starts, index_count, vtx_count, &r.decl, min_consistency) + .into_iter() + .collect() + } + }; + if !meshes.is_empty() { out.push(Xbg7Model { name: r.name.clone(), - meshes: vec![mesh], + meshes, }); } } @@ -441,111 +555,291 @@ fn anchor_pool_mesh( index_count: usize, vtx_count: usize, decl: &VertexDecl, + min_consistency: f32, ) -> Option { + let idx_bytes = index_count * 2; + for &vb in starts { + // The index buffer sits just before the vertex buffer, which is 4-byte + // aligned — so 0..=3 bytes of padding may separate them (`ib = vb − + // idx_bytes − pad`). pad 0 is the immediate-adjacency case (all stages so + // far); some weapons need pad 2. A shifted pad reads garbled indices, so + // pad>0 is gated at a strict 0.85 consistency to avoid a false anchor in + // the ungated (`min_consistency == 0`) stage path — pad 0 keeps its exact + // prior behaviour. + for pad in 0..=3usize { + if vb < idx_bytes + pad { + continue; + } + let ib = vb - idx_bytes - pad; + let mc = if pad == 0 { + min_consistency + } else { + min_consistency.max(0.85) + }; + if validate_block(bytes, ib, vb, vtx_count, index_count, decl, mc, true) { + // ── Accepted: read the full mesh. ── + return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl)); + } + } + } + None +} + +/// Check whether the `index_count` big-endian `u16` indices at `ib`, read against +/// the `vtx_count`-vertex pool at `vb`, form a coherent triangle-list sub-mesh: +/// every index in range, ~all vertices referenced, non-degenerate triangles with +/// a real spatial extent, connected edges, and (when `min_consistency > 0`) a +/// winding consistent with the stored normals. This is the shared acceptance test +/// for both the adjacency anchor ([`anchor_pool_mesh`]) and the grouped-pool +/// anchor ([`anchor_grouped_meshes`]); the two differ only in how they *place* +/// `ib`/`vb`, not in how they validate a placement. +fn validate_block( + bytes: &[u8], + ib: usize, + vb: usize, + vtx_count: usize, + index_count: usize, + decl: &VertexDecl, + min_consistency: f32, + strict_connectivity: bool, +) -> bool { let stride = decl.stride; let idx_bytes = index_count * 2; - let vtx_bytes = vtx_count.checked_mul(stride)?; + 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; + } - for &vb in starts { - // The index buffer sits immediately before the vertex buffer. - if vb < idx_bytes { - continue; - } - let ib = vb - idx_bytes; - if vb + vtx_bytes > bytes.len() { - continue; + // ── 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; + } - // ── Full index validation: every index in range, uses ~all vertices. ── - let mut max_idx = 0u32; - let mut ok = true; - for k in 0..index_count { - let i = be16(bytes, ib + k * 2) as u32; - if i >= vtx_count as u32 { - ok = false; - break; - } - max_idx = max_idx.max(i); - } - if !ok || (max_idx as usize) + 4 < vtx_count { - continue; - } - - // ── 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 tris = index_count / 3; - let tstep = (tris / 96).max(1); - let mut t = 0; - let mut bad = false; - while t < tris { - let mut p = [[0.0f32; 3]; 3]; - for (c, pc) in p.iter_mut().enumerate() { - let vi = be16(bytes, ib + (3 * t + c) * 2) as usize; - 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 { - bad = true; - break; - } - *slot = x; - lo[a] = lo[a].min(x); - hi[a] = hi[a].max(x); - } - if bad { - break; + // ── 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); } - if bad { - break; + } + 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); } - 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; + if cx[0] * sn[0] + cx[1] * sn[1] + cx[2] * sn[2] > 0.0 { + nrm_agree += 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; + nrm_counted += 1; } - if bad { - continue; - } - 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 { - continue; // 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 — try another candidate or decline. + // 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 { - continue; + return false; } - - // ── Accepted: read the full mesh. ── - return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl)); } - None + // 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 { + 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. @@ -558,10 +852,11 @@ fn read_pool_mesh( decl: &VertexDecl, ) -> GameMesh { let stride = decl.stride; - let strip: Vec = (0..index_count) + // 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 = (0..index_count) .map(|k| be16(bytes, ib + k * 2) as u32) .collect(); - let indices = expand_triangle_strip(&strip); let mut positions = Vec::with_capacity(vtx_count); let mut normals = Vec::with_capacity(vtx_count); @@ -642,6 +937,33 @@ fn find_index_marker(desc: &[u8]) -> Option<(usize, usize)> { 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 { let mk = find_index_marker(desc)?.0; @@ -742,10 +1064,16 @@ fn align16(x: usize) -> usize { (x + 15) & !15 } -/// Expand a triangle-**strip** index buffer into a triangle **list**, dropping -/// degenerate triangles (a repeated index marks a strip restart / zero-area -/// triangle). XBG7 sub-mesh index buffers are strips: reading them as lists -/// dropped ~⅔ of every hull (triangular holes) and produced spanning spikes. +#[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 { let mut out = Vec::with_capacity(strip.len().saturating_sub(2) * 3); for w in 0..strip.len().saturating_sub(2) { @@ -760,6 +1088,69 @@ fn expand_triangle_strip(strip: &[u32]) -> Vec { } 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]]) diff --git a/crates/sylpheed-formats/tests/mesh_disc.rs b/crates/sylpheed-formats/tests/mesh_disc.rs index 0ea6683..7cb153b 100644 --- a/crates/sylpheed-formats/tests/mesh_disc.rs +++ b/crates/sylpheed-formats/tests/mesh_disc.rs @@ -122,6 +122,85 @@ fn complex_body_mesh_is_declined_not_garbage() { } } +/// The hero ship's **grouped-pool** layout decodes fully: `DeltaSaber_T.xpr`'s +/// `f001` resource is one vertex+index pool shared by 8 sub-meshes (body + 7 +/// detail parts). Reversed statically and cross-checked against a Canary GPU +/// draw-log capture — every sub-mesh's stored normals agree with its triangle +/// winding (0 degenerate, full vertex coverage). This is the layout the old +/// per-block adjacency anchor rendered as a spiky phantom. +#[test] +#[ignore = "requires extracted disc models — set SYLPHEED_RES3D"] +fn hero_ship_grouped_pool_decodes() { + let Some(dir) = res3d_dir() else { + eprintln!("SKIP: resource3d dir not found (set SYLPHEED_RES3D)"); + return; + }; + let bytes = std::fs::read(dir.join("DeltaSaber_T.xpr")).unwrap(); + let models = Xbg7Model::stage_models(&bytes); + + // The neutral pose `f001` (the mnv*/turn180 resources are animation poses). + let f001 = models.iter().find(|m| m.name == "f001").expect("f001 decoded"); + assert_eq!(f001.meshes.len(), 8, "body + 7 detail sub-meshes"); + let (v, t) = f001.totals(); + assert_eq!(v, 11607, "vertex total across sub-meshes"); + assert_eq!(t, 8650, "triangle total (body 8187 + 7 parts)"); + + // Sub-mesh 0 is the body: exactly the draw-log-verified geometry. + let body = &f001.meshes[0]; + assert_eq!(body.positions.len(), 10891); + assert_eq!(body.indices.len(), 24561); + + for (i, m) in f001.meshes.iter().enumerate() { + // Every index in range. + assert!( + m.indices.iter().all(|&ix| (ix as usize) < m.positions.len()), + "sub{i}: index out of range" + ); + // Correct alignment ⇒ unit normals, and the winding agrees with them + // (the decisive correctness signal, ~1.0, not the ~0.5 of a mis-carve). + let n = m.normals.len(); + assert_eq!(n, m.positions.len(), "sub{i}: a normal per vertex"); + let mean_nlen: f32 = m + .normals + .iter() + .map(|nv| (nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2]).sqrt()) + .sum::() + / n as f32; + assert!((mean_nlen - 1.0).abs() < 0.05, "sub{i}: mean |normal| {mean_nlen} ≠ 1"); + + let mut agree = 0usize; + let mut counted = 0usize; + for tri in m.indices.chunks_exact(3) { + let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize); + let (pa, pb, pc) = (m.positions[a], m.positions[b], m.positions[c]); + let u = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]]; + let w = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]]; + let f = [ + 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 f[0] * f[0] + f[1] * f[1] + f[2] * f[2] < 1e-12 { + continue; + } + let sn = [ + m.normals[a][0] + m.normals[b][0] + m.normals[c][0], + m.normals[a][1] + m.normals[b][1] + m.normals[c][1], + m.normals[a][2] + m.normals[b][2] + m.normals[c][2], + ]; + if f[0] * sn[0] + f[1] * sn[1] + f[2] * sn[2] > 0.0 { + agree += 1; + } + counted += 1; + } + let na = agree as f32 / counted.max(1) as f32; + assert!( + na.max(1.0 - na) > 0.95, + "sub{i}: winding-vs-normal agreement {na} — mis-carved (should be ~1.0 or ~0.0)" + ); + } +} + /// Stage containers decode multiple enemy/prop sub-models via content anchoring. /// Prints coverage; asserts the known-good Stage_S10 meshes decode with sane geometry. #[test] diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index b970cf8..b920070 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -1439,6 +1439,77 @@ fn free_video( *video = VideoPreview::default(); } +/// Reduce a sub-model / resource name to its **entity stem** — the leading +/// `` id that its textures are named after. XBG7 resource names +/// carry LOD / part / pose decoration (`e_rou_e007_Near`, `e007_bdy_01_l`, +/// `f001_bdy_02`) while the matching albedo map is named for the bare entity +/// (`e007_col`, `f001_bdy_01a_col`). Stripping to the stem (`e007`, `f001`) is +/// what lets the two be matched. +fn entity_stem(name: &str) -> String { + let mut s = name.trim_start_matches('_'); + // Leading single-letter group prefix (`e_`, `g_`, `j_`, `n_`) then `rou_`. + for p in ["e_", "g_", "j_", "n_"] { + if let Some(r) = s.strip_prefix(p) { + s = r; + break; + } + } + if let Some(r) = s.strip_prefix("rou_") { + s = r; + } + // Take the leading token (e.g. `e007`, `f001`). + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() && b[i].is_ascii_alphabetic() { + i += 1; + } + let mut j = i; + while j < b.len() && b[j].is_ascii_digit() { + j += 1; + } + if i > 0 && j > i { + s[..j].to_ascii_lowercase() + } else { + s.to_ascii_lowercase() + } +} + +/// Pick the index of the albedo (`_col`) texture for a sub-model, matching its +/// [`entity_stem`] against the container's texture names. Returns the best `_col` +/// candidate (exact `_col`, else shortest `…` name, else any name +/// mentioning the stem) or `None` when the entity has no own colour map (it uses +/// a shared atlas we can't resolve — the caller shows a neutral tint instead of a +/// wrong texture). This replaces the old `ends_with("_col") && starts_with(core)` +/// rule, which only matched ~25% of stage sub-models (the rest fell back to flat +/// grey — "models render without colour"). +fn pick_albedo_index(model_name: &str, tex_names: &[String]) -> Option { + let stem = entity_stem(model_name); + if stem.is_empty() { + return None; + } + let cols: Vec<(usize, String)> = tex_names + .iter() + .enumerate() + .map(|(i, n)| (i, n.to_ascii_lowercase())) + .filter(|(_, n)| n.contains("_col")) + .collect(); + // 1. Exact `_col`. + let exact = format!("{stem}_col"); + if let Some((i, _)) = cols.iter().find(|(_, n)| *n == exact) { + return Some(*i); + } + // 2. Names starting with the stem — prefer the shortest (closest) one. + if let Some((i, _)) = cols + .iter() + .filter(|(_, n)| n.starts_with(&stem)) + .min_by_key(|(_, n)| n.len()) + { + return Some(*i); + } + // 3. Stem mentioned anywhere in a colour map's name. + cols.iter().find(|(_, n)| n.contains(&stem)).map(|(i, _)| *i) +} + /// Build Bevy meshes from a decoded [`Xbg7Model`], texture them with the model's /// albedo (`_col`) map, spawn them into the scene tagged [`PreviewModel`], and /// frame the orbit camera on the combined bounding box. @@ -1457,11 +1528,10 @@ fn spawn_model_preview( use bevy::render::mesh::{Indices, PrimitiveTopology}; use sylpheed_formats::texture::X360Texture; - // ── Albedo texture: prefer the `_col` map, else the first texture. ── + // ── Albedo texture: the model's `_col` map (matched by entity stem), else + // the first texture as a last resort so a single model is never untextured. let names = X360Texture::texture_names(xpr_bytes); - let col_idx = names - .iter() - .position(|n| n.ends_with("_col")) + let col_idx = pick_albedo_index(&model.name, &names) .or(if names.is_empty() { None } else { Some(0) }); let (base_color_texture, tex_label) = match col_idx { Some(i) => match X360Texture::from_xpr2_index(xpr_bytes, i) @@ -1610,15 +1680,11 @@ fn spawn_stage_models( images: &mut Assets, cache: &mut std::collections::HashMap>| -> Handle { - // Core name = strip leading `_`/`rou_` and trailing `_l` LOD suffix. - let core = model_name - .trim_start_matches('_') - .trim_start_matches("rou_") - .trim_end_matches("_l"); - let idx = tex_names.iter().position(|n| { - n.ends_with("_col") && (n.starts_with(core) || n.contains(core)) - }); - let Some(i) = idx else { return neutral.clone() }; + // Match the sub-model to its albedo map by entity stem (`e007_bdy_01` → + // `e007_col`). No own colour map ⇒ neutral tint rather than a wrong one. + let Some(i) = pick_albedo_index(model_name, &tex_names) else { + return neutral.clone(); + }; if let Some(h) = cache.get(&i) { return h.clone(); } @@ -1892,45 +1958,61 @@ fn apply_loaded_texture( // models under `hidden/resource3d/`) preview as an orbitable mesh, textured // with their albedo (`_col`) map. Complex multi-stream body meshes decline // cleanly (Err) and fall through to the 2D texture preview below. - // A container may decode as a single model (weapons / props, via the - // single-block layout) OR as a stage — a collection of many enemy / prop - // sub-models. A stage's *first* XBG7 is often a tiny dummy "root" node that - // the single-model path decodes into a degenerate cube, so we can't just try - // it first and stop. Decode both and keep whichever yields more geometry. - use sylpheed_formats::mesh::Xbg7Model; - let single = Xbg7Model::from_xpr2(&bytes) - .ok() - .filter(|m| !m.meshes.is_empty()); - let single_verts = single.as_ref().map(|m| m.totals().0).unwrap_or(0); - - let stage = Xbg7Model::stage_models(&bytes); - let stage_verts: usize = stage.iter().map(|m| m.totals().0).sum(); - - if !stage.is_empty() && stage_verts > single_verts { - spawn_stage_models( - &stage, - &bytes, - &mut commands, - &mut meshes, - &mut materials, - &mut images, - &mut model_preview, - &mut orbit, - ); - return; - } - if let Some(model) = single { - spawn_model_preview( - &model, - &bytes, - &mut commands, - &mut meshes, - &mut materials, - &mut images, - &mut model_preview, - &mut orbit, - ); - return; + // A container may be a single model (weapons / props) OR a stage — a + // collection of many enemy / prop sub-models. Route by the number of XBG7 + // resources, NOT by whichever decoder yields more verts: the old max-verts + // rule let a stage's content-anchoring win on single-model files and fabricate + // phantom / spike-mess blocks (see the CLI `decode_models`). One XBG7 → the + // validated records-based list decode, falling back to a STRICT-gated content + // anchor if that can't locate the block; many XBG7 → the ungated stage anchor. + use sylpheed_formats::mesh::{count_xbg7, Xbg7Model}; + if count_xbg7(&bytes) > 1 { + let stage = Xbg7Model::stage_models(&bytes); + if !stage.is_empty() { + spawn_stage_models( + &stage, + &bytes, + &mut commands, + &mut meshes, + &mut materials, + &mut images, + &mut model_preview, + &mut orbit, + ); + return; + } + } else { + let single = Xbg7Model::from_xpr2(&bytes) + .ok() + .filter(|m| !m.meshes.is_empty()); + if let Some(model) = single { + spawn_model_preview( + &model, + &bytes, + &mut commands, + &mut meshes, + &mut materials, + &mut images, + &mut model_preview, + &mut orbit, + ); + return; + } + // from_xpr2 couldn't locate the block — strict-gated content anchor. + let fallback = Xbg7Model::anchor_models(&bytes, 0.85); + if !fallback.is_empty() { + spawn_stage_models( + &fallback, + &bytes, + &mut commands, + &mut meshes, + &mut materials, + &mut images, + &mut model_preview, + &mut orbit, + ); + return; + } } // World cubemap (`TXCM`) → decode all 6 faces and show a labelled grid. @@ -2315,6 +2397,39 @@ fn advance_video_playback( } } +#[cfg(test)] +mod albedo_match_tests { + use super::*; + + #[test] + fn entity_stem_strips_decoration() { + assert_eq!(entity_stem("e_rou_e007_Near"), "e007"); + assert_eq!(entity_stem("e007_bdy_01_l"), "e007"); + assert_eq!(entity_stem("f001_bdy_02"), "f001"); + assert_eq!(entity_stem("f001"), "f001"); + assert_eq!(entity_stem("g001"), "g001"); + assert_eq!(entity_stem("_rou_f001_mnv01_L"), "f001"); + } + + #[test] + fn albedo_matches_entity_col_map() { + let texs: Vec = ["e007_col", "e007_lum", "e007_spc", "e010_col"] + .iter() + .map(|s| s.to_string()) + .collect(); + // Sub-models decorated with LOD / part suffixes still map to `e007_col`, + // where the old `starts_with(full_name)` rule fell back to grey. + assert_eq!(pick_albedo_index("e_rou_e007_Near", &texs), Some(0)); + assert_eq!(pick_albedo_index("e007_bdy_01_l", &texs), Some(0)); + assert_eq!(pick_albedo_index("e010_bdy", &texs), Some(3)); + // A hangar-suffixed col map is still recognised as a colour map. + let hangar = vec!["f001_wep_00_col_hangar".to_string()]; + assert_eq!(pick_albedo_index("f001_wep_00_hangar", &hangar), Some(0)); + // No matching entity map ⇒ None (caller shows a neutral tint). + assert_eq!(pick_albedo_index("z999", &texs), None); + } +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod font_sample_tests { use super::*; diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index f194a82..665eb00 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -178,6 +178,77 @@ mission* (where `DeltaSaber` renders) would hand over its exact declaration dire resource by strict index+triangle validation. **5662 sub-models decode across 22 stages** (S07 366/378), incl. the previously-declined main bodies `e005` (2566 v) and weapons — ≤2 s on 70 MB. Parser `Xbg7Model::stage_models`, tests `stage_models_{decode,sweep,quality_audit}`. +- 2026-07-17 — **triangle-LIST re-confirmed; a strip interlude refuted; winding-consistency gate + added.** A 2026-07 change had briefly re-read the index buffers as triangle *strips* (to "fill + holes"). Refuted objectively with a new `XVERIFY` diagnostic that compares both readings by + **stored-normal agreement** (each triangle's cross-product face normal vs the sum of its vertices' + stored normals): the LIST reading gives agreement **1.000** on every clean weapon (`wep_00/03/04/19` + = only possible with correct topology + winding), the STRIP reading **~0.49** (random). The strip + reading also over-generated ~2.5× the triangles (wep_00: 938 vs 364) — a hole-filling garbage soup. + Reverted to LIST in both paths (`from_xpr2`, `read_pool_mesh`), matching the `prim=4` GPU capture. + Added an objective **winding-consistency gate** `max(na, 1-na)`: a correct carve is internally + consistent (agreement ≈1.0, or ≈0.0 for inverted-but-consistent winding — a real single-sided + mesh), a mis-carve scatters to the ≈0.5 middle. `from_xpr2` declines sub-meshes below 0.90 (e.g. + `wep_23` na=0.398 → declined instead of a spike-mess); the single-model **content-anchor fallback** + gates at 0.85; the large multi-resource **stage** path stays ungated (its enemy meshes span a + continuous 0.5–1.0 consistency range — a hard gate there dropped ~48/314 legit S07 blocks). + **Routing fixed:** `decode_models` (CLI) and the viewer now route by `count_xbg7` (1 → validated + records-based list decode, fallback to strict-gated anchor; >1 → stage anchor) instead of the old + "whichever decoder yields more verts" rule — that rule let stage content-anchoring win on + single-model weapon files and fabricate **phantom** blocks (a `wep_00` clone appearing inside + `wep_19`), duplicates, and spike-mess anchors. Weapons now: 33 clean-decode / 26 declined (declined + = genuinely multi-stream or un-carvable, shown as nothing rather than garbage); stage coverage + unchanged (S07 314). `expand_triangle_strip` retained as an `XVERIFY`-only diagnostic. - 2026-07-12 — `DeltaSaber_A.xpr` body: data does **not** begin with indices; plain-`f32×3` runs with ship-scale extent (span ≈27–34, matching bbox 30.0) found only at high offsets (`data+0x28634C`, …) → multi-stream, **undecoded**. +- 2026-07-18 — **GROUPED-POOL layout cracked → the hero ship (Delta Saber) fully decodes.** The + detailed models (`DeltaSaber_*.xpr` + ~100 others) were declined for **location**, not format — + their vertex format is the standard stride-24 triangle list. A resource's *several* sub-meshes + don't interleave `[idx][vtx]` per block; they share **two grouped pools**: an **index pool** + (buffers concatenated in descriptor-marker order, each **4-byte aligned**) followed by a **vertex + pool** (each sub-pool `vtx_count × stride`, same order), with the index pool ending **exactly** + where the vertex pool begins. So the whole resource pivots on one unknown, the first vertex-pool + start `vb0` (= index-pool end, found by the unit-normal vertex-run scan); 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`. Reversed statically from `DeltaSaber_T.xpr` and + **cross-checked against a Canary GPU draw-log capture** (mission ship = `DeltaSaber_T.xpr`, found + via the `--log_file_io` kernel hook): `f001` = body (idx@`data+0xC` = 0x5500C, vtx@0x61ACC, 10891 v + / 8187 t) + **7 detail parts** (fins/cockpit/wingtips, markers at descriptor 0x3BEC…0x58FC) = + **8650 tris**, and **every sub-mesh decodes at 0 degenerate / full coverage / winding-agreement + 1.000**. This is the layout the per-block adjacency anchor (`ib = vb − idx_bytes`) rendered as a + **spiky phantom** (it read 24561 indices starting 2782 B too late, agree 0.64, 1277 degenerate). + Insight: a single index marker reduces the grouped model to `index_end = vb0`, i.e. the existing + adjacency `ib = vb − idx_bytes` — so grouped **generalises** the single-block anchor (n=1 is + identical). Implemented as `anchor_grouped_meshes` (mesh.rs): `anchor_models` routes resources with + >1 index marker to it (validated per sub-mesh; on failure falls back to the old first-marker + adjacency anchor so stage coverage never regresses); single-marker stages/props keep the exact + prior path. The shared acceptance test is factored into `validate_block` (the connectivity + heuristic is relaxed for *derived* grouped parts, which are pinned by in-range + consistency, so + small flat fins aren't mis-rejected). Render self-check: `sylpheed-cli mesh render DeltaSaber_T.xpr + --only f001` (exact-name match excludes the `_rou_f001_mnv*` animation poses) → clean complete + fighter. Test `hero_ship_grouped_pool_decodes`. Colours/UVs still pending the running-game oracle. +- 2026-07-18 (refinement) — **4-byte vertex-pool alignment + weapon recovery.** The grouped-pool + rule "index pool ends exactly where the vertex pool begins" is really "the vertex pool is **4-byte + aligned** after the index pool": `vb0 = align4(ib0 + span)`, so 0..=3 bytes of padding can sit + between them. DeltaSaber's index pool ended already-aligned (pad 0), which hid this; **19 + weapon/`*_hangar` models** (single- and multi-marker: `wep_08/11/34/58/62/69/81/83…`) have pad 2 + and so decoded to *nothing* — the viewer then showed them as a flat 2D texture instead of a model. + Fix: both anchors try `pad ∈ 0..=3` (`ib = vb − idx_bytes − pad` for the single-block adjacency + anchor; `ib0 = vb0 − span − pad` for the grouped pivot), validated — a wrong pad reads shifted + indices → agreement collapses < 0.85, so only the true pad passes. pad>0 in the ungated stage path + is gated at a strict 0.85 to avoid a false anchor; pad 0 keeps its exact prior behaviour (stages + unchanged). Result: all 19 now decode as clean models (e.g. `wep_34` 1243 v / 1233 t, a + long-barrelled gun-pod; `wep_08` 3 sub-meshes / 478 t). Viewer routing already falls through + `from_xpr2` → `anchor_models(0.85)` for single-XBG7 files, so the recovered grouped/padded weapons + now preview as meshes. +- 2026-07-18 (refinement 2) — **pivot on the largest sub-mesh; all 19 recovered.** Three weapons + (`wep_81`, `wep_81_hangar`, `wep_30_hangar`) still declined because the grouped pivot validated + `markers[0]`, which for these is a tiny *elongated* lead bracket that fails the connectivity gate + even when perfectly placed. Fixed by pivoting the alignment check on the **largest** marker (max + index count) — the sub-mesh whose triangle-quality/connectivity signature most reliably confirms + `(ib0, vb0)`. Once the pivot validates, markers up to it are read unconditionally (a legitimately + tiny/flat lead part may fail the quality gates yet still be real), and markers after it stay + validated so a stray trailing marker ends the chain. Result: **all 19 previously-declined weapons + decode** (`wep_81` 460 t missile w/ tail fins; `wep_30_hangar` 334 t). DeltaSaber unchanged (its + body IS the largest marker → same pivot). 7/7 disc tests green, stage quality audit unchanged.