[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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-18 19:54:39 +02:00
parent a937779c77
commit 578c71a1b9
5 changed files with 877 additions and 201 deletions

View File

@@ -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<GameMesh>,
}
/// Count the `XBG7` geometry resources in an XPR2 container. Single-model files
/// (weapons / props) have exactly one; the multi-resource `Stage_*.xpr`
/// collections have many. Used to route decoding: one → the validated
/// records-based list decode ([`Xbg7Model::from_xpr2`]); many → the
/// content-anchored [`Xbg7Model::stage_models`]. (Content-anchoring a
/// single-model file fabricates phantom / duplicate blocks — see `decode`.)
pub fn count_xbg7(bytes: &[u8]) -> usize {
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return 0;
}
let mut cur = Cursor::new(bytes);
let header = match Xpr2Header::read(&mut cur) {
Ok(h) => h,
Err(_) => return 0,
};
let mut n = 0usize;
for _ in 0..header.num_resources {
match Xpr2ResourceEntry::read(&mut cur) {
Ok(e) if &e.type_tag == b"XBG7" => n += 1,
Ok(_) => {}
Err(_) => break,
}
}
n
}
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.51.0), so a hard gate would drop many legitimate
/// blocks. The single-model **weapon fallback** ([`decode` in the CLI]) calls
/// [`Xbg7Model::anchor_models`] with a strict `0.85`, where a mis-anchor is an
/// obvious phantom / spike-mess that must be declined.
pub fn stage_models(bytes: &[u8]) -> Vec<Xbg7Model> {
Self::anchor_models(bytes, 0.0)
}
/// 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> {
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<Res> = 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<GameMesh> {
let idx_bytes = index_count * 2;
for &vb in starts {
// The index buffer sits just before the vertex buffer, which is 4-byte
// aligned — so 0..=3 bytes of padding may separate them (`ib = vb
// idx_bytes pad`). pad 0 is the immediate-adjacency case (all stages so
// far); some weapons need pad 2. A shifted pad reads garbled indices, so
// pad>0 is gated at a strict 0.85 consistency to avoid a false anchor in
// the ungated (`min_consistency == 0`) stage path — pad 0 keeps its exact
// prior behaviour.
for pad in 0..=3usize {
if vb < idx_bytes + pad {
continue;
}
let ib = vb - idx_bytes - pad;
let mc = if pad == 0 {
min_consistency
} else {
min_consistency.max(0.85)
};
if validate_block(bytes, ib, vb, vtx_count, index_count, decl, mc, true) {
// ── Accepted: read the full mesh. ──
return Some(read_pool_mesh(bytes, ib, vb, index_count, vtx_count, decl));
}
}
}
None
}
/// Check whether the `index_count` big-endian `u16` indices at `ib`, read against
/// the `vtx_count`-vertex pool at `vb`, form a coherent triangle-list sub-mesh:
/// every index in range, ~all vertices referenced, non-degenerate triangles with
/// a real spatial extent, connected edges, and (when `min_consistency > 0`) a
/// winding consistent with the stored normals. This is the shared acceptance test
/// for both the adjacency anchor ([`anchor_pool_mesh`]) and the grouped-pool
/// anchor ([`anchor_grouped_meshes`]); the two differ only in how they *place*
/// `ib`/`vb`, not in how they validate a placement.
fn validate_block(
bytes: &[u8],
ib: usize,
vb: usize,
vtx_count: usize,
index_count: usize,
decl: &VertexDecl,
min_consistency: f32,
strict_connectivity: bool,
) -> bool {
let stride = decl.stride;
let idx_bytes = index_count * 2;
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.050.15 of the bbox
// diagonal). A wrong anchor / cross-wired index buffer connects distant
// vertices, so its mean edge spans a large fraction of the model (a spiky
// mess). Reject those — 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.050.15 of the bbox
// diagonal). A wrong anchor / cross-wired index buffer connects distant
// vertices, so its mean edge spans a large fraction of the model (a spiky
// mess). Reject those. Only applied when the placement was *searched*
// ([`anchor_pool_mesh`] / grouped-pool pivot): a small flat sub-mesh (a fin,
// an antenna) legitimately has large edges relative to its own diagonal, so
// the check is skipped for *derived* grouped-pool parts, whose in-range +
// consistency signature already pins them unambiguously.
if strict_connectivity {
let diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2))
.sqrt()
.max(1e-6);
let mean_edge = edge_sum / (sampled as f32 * 3.0);
if mean_edge / diag > 0.28 {
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<GameMesh> {
let n = markers.len();
if n == 0 {
return Vec::new();
}
let stride = decl.stride;
// Relative index-buffer offsets (ib0 = 0), 4-byte aligned between buffers,
// and cumulative vertex offsets (vb0 = 0) — both derived from the marker list.
let mut rel_ib = Vec::with_capacity(n);
let mut off_v = Vec::with_capacity(n);
let (mut acc_i, mut acc_v) = (0usize, 0usize);
for &(vc, ic) in markers {
rel_ib.push(acc_i);
off_v.push(acc_v);
acc_i = align4(acc_i + ic * 2);
acc_v += vc * stride;
}
// The index pool spans ib0 .. end-of-last-buffer. The vertex pool that
// follows is **4-byte aligned**, so up to 3 bytes of padding can sit between
// the last index buffer and vb0: `vb0 = align4(ib0 + span)`, i.e.
// `ib0 = vb0 span pad` with `pad ∈ 0..=3`. (DeltaSaber's index pool ended
// already-aligned → pad 0; many weapons need pad 2.) We try each pad and let
// `validate_block` pick the one that yields a coherent sub-mesh.
let span = rel_ib[n - 1] + markers[n - 1].1 * 2;
// Pivot on the **largest** sub-mesh, not `markers[0]`: it's the one whose
// triangle-quality + connectivity signature most reliably confirms the
// (ib0, vb0) alignment. Some weapons lead with a tiny, elongated bracket that
// fails the connectivity gate even when perfectly placed, which would reject
// an otherwise-correct pivot.
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
let (vck, ick) = markers[kmax];
for &vb0 in starts {
for pad in 0..=3usize {
if vb0 < span + pad {
continue;
}
let ib0 = vb0 - span - pad;
if ib0 < data_base {
continue;
}
// Strict anchor on the pivot sub-mesh: require high winding
// consistency AND connectivity here regardless of the stage-wide
// `min_consistency`. A wrong pad reads shifted indices → agreement
// collapses well below 0.85, so only the true pad/pivot passes.
let ib_k = ib0 + rel_ib[kmax];
let vb_k = vb0 + off_v[kmax];
if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, 0.85, true) {
continue;
}
// Pivot confirmed the exact alignment ⇒ every marker up to the pivot
// is correctly placed; read those unconditionally (a legitimately
// tiny/flat lead part may fail the quality gates yet still be real).
// Markers after the pivot are validated so a stray trailing marker
// ends the chain instead of appending garbage.
let mut meshes = Vec::with_capacity(n);
let mut vb = vb0;
for i in 0..n {
let (vc, ic) = markers[i];
let ib = ib0 + rel_ib[i];
if ib + ic * 2 > bytes.len()
|| vc.checked_mul(stride).map_or(true, |b| vb + b > bytes.len())
{
break;
}
// Parts are placed deterministically; in-range + consistency pins
// them, so the connectivity heuristic (which mis-rejects small
// flat fins) is relaxed here.
let ok = validate_block(bytes, ib, vb, vc, ic, decl, 0.85, false);
if !ok && i > kmax {
break; // chain diverged — emit the validated prefix, no garbage
}
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
vb += vc * stride;
}
return meshes;
}
}
Vec::new()
}
/// Read positions / normals / uvs / indices for an anchored stage block.
@@ -558,10 +852,11 @@ fn read_pool_mesh(
decl: &VertexDecl,
) -> GameMesh {
let stride = decl.stride;
let strip: Vec<u32> = (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<u32> = (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<VertexDecl> {
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<u32> {
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<u32> {
}
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]])

View File

@@ -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::<f32>()
/ 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]