feat(xbg7): read the per-sub-mesh vertex declarations (XBG7_SUBMESH_DECLS, off by default)

desc_dump shows each index marker is followed by its OWN element triples: n201_01
declares strides 24, 24, 24 and 28 (the last sub-mesh has a fourth element), which
matches the runtime capture's stride=28 on that draw and the four distinct vertex
shaders. parse_vertex_decl read the first declaration for the whole pool.

all_vertex_decls reads one per marker; anchor_grouped_meshes uses each sub-mesh's
own stride for the pool walk, the pivot validation and the read. debug_grouped_report
follows the same setting so the diagnostic cannot blame the wrong gate — at n201_01's
capture-proven pool start it now reports "pad 0: ACCEPTED" instead of a NaN position.

With XBG7_SUBMESH_DECLS=1: resources that never decode 85 -> 47, resources decoding
in no container 63 -> 30, capture oracles unchanged (93/93 index runs, 42/42 index
counts), consistency unchanged at 96.

Off by default because selection has not caught up: the three n201_0x copies then
settle on one pool (twin_pairs_do_not_share_a_buffer fails), production still picks
a start 4 bytes before sub-mesh #1 rather than the proven one even though the proven
start validates and is unclaimed, and four newly decoded ptc_pack .dat composites
carry degenerate triangles. Format truth is settled; choosing among candidates is
the remaining work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
This commit is contained in:
2026-08-13 11:45:20 +00:00
parent 27577538cb
commit 4cdbbc2d48
6 changed files with 272 additions and 23 deletions

View File

@@ -513,6 +513,9 @@ impl Xbg7Model {
/// → the grouped-pool layout ([`anchor_grouped_meshes`]).
markers: Vec<(usize, usize)>,
decl: VertexDecl,
/// One declaration per marker (same order). A grouped pool can mix
/// strides, so the grouped path must use these rather than `decl`.
decls: Vec<VertexDecl>,
}
let mut resources: Vec<Res> = Vec::new();
for _ in 0..header.num_resources {
@@ -551,10 +554,17 @@ impl Xbg7Model {
// at a different offset when requested alone. The filter is applied
// to the OUTPUT instead, so a subset is always a subset of the
// container's own answer.
let mut decls = if submesh_decls() { all_vertex_decls(d) } else { Vec::new() };
if decls.len() != markers.len() {
// Never seen on the disc, but if the two walks disagree fall back
// to the single declaration rather than mis-pair them.
decls = vec![decl.clone(); markers.len()];
}
resources.push(Res {
name,
markers,
decl,
decls,
});
}
if resources.is_empty() {
@@ -614,7 +624,7 @@ impl Xbg7Model {
// 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, &empty_taken);
anchor_grouped_meshes(bytes, data_base, starts, &r.markers, &r.decls, &empty_taken);
if !grouped.is_empty() {
grouped
} else {
@@ -728,7 +738,7 @@ impl Xbg7Model {
// Grouped pool: re-place the WHOLE pool past everything
// claimed, or keep what we had.
let alt = anchor_grouped_meshes(
bytes, data_base, starts, &r.markers, &r.decl, &taken,
bytes, data_base, starts, &r.markers, &r.decls, &taken,
);
if !alt.is_empty() {
m.meshes = alt;
@@ -915,6 +925,40 @@ pub fn debug_best_rejection(bytes: &[u8], name: &str) -> Option<(usize, String)>
Some(best)
}
/// Diagnostic: the stride of every sub-mesh declaration of a named resource.
/// A grouped pool may mix them (`n201_01` → 24, 24, 24, 28).
pub fn debug_decl_strides(bytes: &[u8], name: &str) -> Vec<usize> {
decls_of(bytes, name).map(|v| v.iter().map(|d| d.stride).collect()).unwrap_or_default()
}
/// Every sub-mesh declaration of a named resource (see [`all_vertex_decls`]).
fn decls_of(bytes: &[u8], name: &str) -> Option<Vec<VertexDecl>> {
if bytes.len() < 16 || &bytes[..4] != b"XPR2" {
return None;
}
let mut cur = Cursor::new(bytes);
let header = Xpr2Header::read(&mut cur).ok()?;
const DIR_BASE: usize = 0x10;
for _ in 0..header.num_resources {
let Ok(e) = Xpr2ResourceEntry::read(&mut cur) else { break };
if &e.type_tag != b"XBG7" {
continue;
}
let desc = e.data_offset as usize + DIR_BASE;
let desc_end = (desc + e.descriptor_size as usize).min(bytes.len());
if desc >= bytes.len() || desc_end <= desc {
continue;
}
let rname = read_cstr(bytes, e.name_offset as usize + DIR_BASE)
.unwrap_or_else(|| "XBG7".to_string());
if rname != name {
continue;
}
return Some(all_vertex_decls(&bytes[desc..desc_end]));
}
None
}
/// Diagnostic: why does the decoder refuse a grouped-pool resource at a given
/// pool start? Recomputes the pool layout exactly as [`anchor_grouped_meshes`]
/// does and reports the pivot sub-mesh's verdict for each index/vertex pad —
@@ -927,6 +971,12 @@ pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec<String>
if n == 0 {
return vec!["no index markers".into()];
}
// Per-sub-mesh declarations, like the production path: a pool can mix strides
// and reporting with one of them blames the wrong gate.
let decls = match decls_of(bytes, name).filter(|v| v.len() == n && submesh_decls()) {
Some(v) => v,
None => vec![decl.clone(); n],
};
let (mut rel_ib, mut acc_i) = (Vec::with_capacity(n), 0usize);
for &(_, ic) in &markers {
rel_ib.push(acc_i);
@@ -936,8 +986,8 @@ pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec<String>
let kmax = (0..n).max_by_key(|&i| markers[i].1).unwrap_or(0);
let (vck, ick) = markers[kmax];
let mut off_v = 0usize;
for &(vc, _) in markers.iter().take(kmax) {
off_v += vc * decl.stride;
for (i, &(vc, _)) in markers.iter().take(kmax).enumerate() {
off_v += vc * decls[i].stride;
}
let mut out = vec![format!(
"{name}: {n} sub-meshes, pivot #{kmax} ({vck} verts, {ick} idx), pool span {span}"
@@ -957,7 +1007,7 @@ pub fn debug_grouped_report(bytes: &[u8], name: &str, vb0: usize) -> Vec<String>
vb0 + off_v,
vck,
ick,
&decl,
&decls[kmax],
0.85,
true,
);
@@ -1093,6 +1143,25 @@ fn pad0_consistency() -> f32 {
/// [`anchor_pool_mesh`] takes the FIRST pad that validates (the pre-fix
/// behaviour) instead of the pad whose index run is cleanest. Kept so the two
/// behaviours can be diffed on the disc; see docs/re/structures/xbg7-mesh.md.
/// Use the **per-sub-mesh** vertex declarations in a grouped pool
/// (`XBG7_SUBMESH_DECLS=1`, default off).
///
/// The format truth is not in question: each index marker is followed by its own
/// element triples and they can differ — `n201_01` (`Stage_S02.xpr`) declares
/// strides 24, 24, 24, **28**, which a runtime capture confirms draw-for-draw. With
/// this on, the disc-wide misses fall **85 → 47** and the resources that decode in
/// no container at all fall **63 → 30**.
///
/// It is off by default because selection has not caught up: the three `n201_0x`
/// copies then land on ONE pool (`tests/mesh_consistency_disc.rs::twin_pairs_do_not_share_a_buffer`
/// fails), and four newly decoded `ptc_pack` `.dat` composites carry degenerate
/// triangles. The capture-proven pool start now VALIDATES (`debug_grouped_report`
/// reports `pad 0: ACCEPTED` where it used to report a NaN position), so what
/// remains is choosing it — see docs/re/structures/xbg7-mesh.md.
fn submesh_decls() -> bool {
std::env::var("XBG7_SUBMESH_DECLS").map(|v| v == "1").unwrap_or(false)
}
fn pad_first_match() -> bool {
std::env::var("XBG7_PAD_FIRST_MATCH").map(|v| v == "1").unwrap_or(false)
}
@@ -1603,25 +1672,27 @@ fn anchor_grouped_meshes(
data_base: usize,
starts: &[usize],
markers: &[(usize, usize)], // (vtx_count, idx_count) in descriptor/file order
decl: &VertexDecl,
decls: &[VertexDecl], // one per marker — a pool CAN mix strides
taken: &std::collections::HashSet<usize>,
) -> Vec<GameMesh> {
let n = markers.len();
if n == 0 {
if n == 0 || decls.len() < n {
return Vec::new();
}
let stride = decl.stride;
let decl = &decls[0];
// 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 {
for (i, &(vc, ic)) in markers.iter().enumerate() {
rel_ib.push(acc_i);
off_v.push(acc_v);
acc_i = align4(acc_i + ic * 2);
acc_v += vc * stride;
// Each sub-mesh advances by ITS OWN stride: `n201_01` ends with a
// stride-28 sub-mesh after three stride-24 ones (capture-confirmed).
acc_v += vc * decls[i].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
@@ -1665,10 +1736,10 @@ fn anchor_grouped_meshes(
// 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, grouped_consistency(), true) {
if !validate_block(bytes, ib_k, vb_k, vck, ick, &decls[kmax], grouped_consistency(), true) {
continue;
}
let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, decl);
let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, &decls[kmax]);
let cand = (degen, -wind, pad);
if best.map_or(true, |b| cand < b) {
best = Some(cand);
@@ -1691,14 +1762,14 @@ fn anchor_grouped_meshes(
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())
|| vc.checked_mul(decls[i].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);
let ok = validate_block(bytes, ib, vb, vc, ic, &decls[i], 0.85, false);
if !ok && i > kmax {
break; // chain diverged — emit the validated prefix, no garbage
}
@@ -1722,9 +1793,9 @@ fn anchor_grouped_meshes(
i < vc
});
if in_range && max_idx + cover_slack() >= vc {
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, &decls[i]));
}
vb += vc * stride;
vb += vc * decls[i].stride;
}
return meshes;
}
@@ -1814,8 +1885,10 @@ const VERTEX_BUFFER_GAP: usize = 12;
// ── Vertex declaration ───────────────────────────────────────────────────────
/// The vertex layout for one XBG7 resource, parsed from the descriptor's
/// declaration table (shared by all its sub-meshes).
/// The vertex layout of one XBG7 **sub-mesh**, parsed from the element triples
/// that follow its index marker. A grouped pool may declare a different layout
/// per sub-mesh — see [`all_vertex_decls`].
#[derive(Clone)]
struct VertexDecl {
/// Bytes per vertex.
stride: usize,
@@ -1887,8 +1960,51 @@ fn all_index_markers(desc: &[u8]) -> Vec<(usize, usize)> {
out
}
/// Every sub-mesh's OWN declaration, in file order.
///
/// A grouped pool is **not** one declaration repeated: each index marker is
/// followed by its own element triples, and they can differ. `n201_01`
/// (`Stage_S02.xpr`) declares strides 24, 24, 24 and **28** — the last sub-mesh
/// has a fourth element — which a 2026-08-13 runtime capture confirms
/// (`stride=28` on that draw, and a different vertex shader for each sub-mesh).
/// Reading the first declaration for the whole pool walks the last buffer out of
/// phase and the resource is declined; see docs/re/structures/xbg7-mesh.md.
fn all_vertex_decls(desc: &[u8]) -> Vec<VertexDecl> {
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) {
match parse_decl_at(desc, rel + 8) {
Some(d) => out.push(d),
// Keep the positions aligned with `all_index_markers`: a
// marker whose declaration will not parse still occupies a
// slot, so fall back to the resource's first declaration.
None => match out.first() {
Some(d) => out.push(d.clone()),
None => return Vec::new(),
},
}
rel += 8;
continue;
}
}
rel += 4;
}
out
}
fn parse_vertex_decl(desc: &[u8]) -> Option<VertexDecl> {
let mk = find_index_marker(desc)?.0;
parse_decl_at(desc, mk + 8)
}
/// Parse the element triples that start at `at` (just past an index marker).
fn parse_decl_at(desc: &[u8], at: usize) -> Option<VertexDecl> {
let mk = at.checked_sub(8)?;
// Read declaration triples.
let mut elems: Vec<(usize, u32, u32)> = Vec::new(); // (offset, code, usage)