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:
71
crates/sylpheed-formats/examples/desc_dump.rs
Normal file
71
crates/sylpheed-formats/examples/desc_dump.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Dump an XBG7 resource's descriptor around each index marker, to see whether a
|
||||
//! grouped pool carries a declaration PER sub-mesh (the 2026-08-13 mixed-stride
|
||||
//! finding says it must: n201's four sub-meshes use strides 24/24/24/28 and four
|
||||
//! different vertex shaders).
|
||||
//! Usage: desc_dump <container.xpr> <resource>
|
||||
|
||||
fn be32(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_be_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
let bytes = std::fs::read(&a[1]).unwrap();
|
||||
let want = &a[2];
|
||||
// Walk the XPR2 directory by hand (the crate's reader is private).
|
||||
// 16-byte directory entries at 0x10: type tag, data offset, descriptor size,
|
||||
// name offset — all relative to 0x10 (see texture::Xpr2ResourceEntry).
|
||||
let num = be32(&bytes, 12) as usize;
|
||||
for i in 0..num {
|
||||
let e = &bytes[0x10 + i * 16..0x10 + i * 16 + 16];
|
||||
let tag = &e[0..4];
|
||||
let data_off = be32(e, 4) as usize + 0x10;
|
||||
let desc_size = be32(e, 8) as usize;
|
||||
let name_off = be32(e, 12) as usize + 0x10;
|
||||
if tag != b"XBG7" {
|
||||
continue;
|
||||
}
|
||||
let name = {
|
||||
let s = name_off.min(bytes.len());
|
||||
let end = bytes[s..].iter().position(|&c| c == 0).unwrap_or(0) + s;
|
||||
String::from_utf8_lossy(&bytes[s..end]).to_string()
|
||||
};
|
||||
if name != *want {
|
||||
continue;
|
||||
}
|
||||
let desc = &bytes[data_off..(data_off + desc_size).min(bytes.len())];
|
||||
println!("{name}: descriptor at 0x{data_off:X}, {} bytes", desc.len());
|
||||
// markers: a == c*2, c%3==0, vertex count 32 bytes earlier
|
||||
let mut rel = 0usize;
|
||||
while rel + 8 <= desc.len() {
|
||||
let (x, c) = (be32(desc, rel), be32(desc, rel + 4));
|
||||
if c >= 3 && c % 3 == 0 && c < 400_000 && x == c * 2 && rel >= 32 {
|
||||
let vc = be32(desc, rel - 32);
|
||||
if (3..=200_000).contains(&vc) {
|
||||
println!("\n marker @0x{rel:X}: {vc} verts / {c} indices — declaration triples after it:");
|
||||
let mut r = rel + 8;
|
||||
let mut stride = 0u32;
|
||||
for _ in 0..12 {
|
||||
if r + 12 > desc.len() {
|
||||
break;
|
||||
}
|
||||
let (o, code, usage) = (be32(desc, r), be32(desc, r + 4), be32(desc, r + 8) >> 16);
|
||||
if o == 0x00FF_0000 || code == 0xFFFF_FFFF || o > 0x1000 {
|
||||
println!(" end marker @0x{r:X}: off=0x{o:X} code=0x{code:X}");
|
||||
break;
|
||||
}
|
||||
println!(" off {o:>3} code 0x{:06X} usage {usage}", code & 0xFF_FFFF);
|
||||
stride = stride.max(o + 4);
|
||||
r += 12;
|
||||
}
|
||||
println!(" (min stride from element offsets: {stride})");
|
||||
rel += 8;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rel += 4;
|
||||
}
|
||||
return;
|
||||
}
|
||||
eprintln!("resource not found");
|
||||
}
|
||||
@@ -5,7 +5,9 @@ fn main(){
|
||||
for n in sylpheed_formats::mesh::xbg7_resource_names(&bytes) {
|
||||
if !filter.is_empty() && !n.contains(&filter) { continue }
|
||||
if let Some((m,stride))=sylpheed_formats::mesh::debug_resource_params(&bytes,&n) {
|
||||
println!("{n:<28} stride {stride} markers {:?}", &m[..m.len().min(4)]);
|
||||
let strides = sylpheed_formats::mesh::debug_decl_strides(&bytes, &n);
|
||||
println!("{n:<28} decl-stride {stride} markers {:?} per-sub-mesh strides {:?}",
|
||||
&m[..m.len().min(4)], strides);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
crates/sylpheed-formats/examples/starts_probe.rs
Normal file
7
crates/sylpheed-formats/examples/starts_probe.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
|
||||
let stride:usize=a[2].parse().unwrap(); let want:usize=a[3].parse().unwrap();
|
||||
let s=sylpheed_formats::mesh::debug_vertex_run_starts(&b,stride);
|
||||
println!("{} candidate starts at stride {}", s.len(), stride);
|
||||
println!("contains {:#x}: {}", want, s.contains(&want));
|
||||
let near:Vec<String>=s.iter().filter(|&&o| o.abs_diff(want)<0x200).map(|o|format!("{o:#x}")).collect();
|
||||
println!("nearby: {}", near.join(" "));}
|
||||
@@ -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)
|
||||
|
||||
@@ -468,10 +468,11 @@ fn decoded_index_runs_have_almost_no_degenerate_triangles() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The one: `_rou_f402_dead` in `Stage_S09`, whose degenerate-free block is
|
||||
// claimed by `e_rou_f003_Near` — both 24-vertex bounding boxes, the identity
|
||||
// class that needs descriptor-level data, not another heuristic. Anything
|
||||
// else appearing here is a regression.
|
||||
// The one at default settings: `_rou_f402_dead` in `Stage_S09`, whose
|
||||
// degenerate-free block is claimed by `e_rou_f003_Near` — both 24-vertex
|
||||
// bounding boxes, the identity class that needs descriptor-level data. (With
|
||||
// `XBG7_SUBMESH_DECLS=1` four newly decoded `ptc_pack` `.dat` composites join
|
||||
// it; that knob is off by default.) Anything else here is a regression.
|
||||
assert!(
|
||||
offenders.len() <= 1,
|
||||
"{} decoded index runs contain degenerate triangles (expected ≤ 1): {:?}",
|
||||
|
||||
@@ -1602,3 +1602,55 @@ S09/S25 `n205`, S15 `n207_208`). 58 of the 63 live in exactly one container.
|
||||
Flying stage 16 did **not** get the `e901` wings drawn (the boss appears later in
|
||||
the mission), which is the next lesson: choosing the mission puts a container in
|
||||
memory, but the unit still has to be **on screen** for a draw to exist.
|
||||
|
||||
### 🔎 The descriptor carries a declaration PER SUB-MESH — worth 38 misses, held behind a knob (2026-08-13)
|
||||
|
||||
Following the `n201` mixed-stride finding: the descriptor was dumped around every
|
||||
index marker (`examples/desc_dump.rs`), and the layout is unambiguous — **each
|
||||
marker is followed by its own element triples**, terminated by
|
||||
`off=0x00FF0000 / code=0xFFFFFFFF`:
|
||||
|
||||
| `n201_01` marker | verts / indices | elements (offset, code) | stride |
|
||||
|---|---|---|---|
|
||||
| `@0x428` | 777 / 4464 | 0 `2A23B9`, 12 `1A2360`, 20 `182886` | 24 |
|
||||
| `@0x900` | 869 / 4464 | 0 `2A23B9`, 12 `1A2360`, 20 `2C235F` | 24 |
|
||||
| `@0xDD8` | 192 / 576 | 0 `2A23B9`, 12 `1A2360`, 20 `182886` | 24 |
|
||||
| `@0x12B0` | 869 / 4464 | 0 `2A23B9`, 12 `1A2360`, 20 `182886`, **24 `2C235F`** | **28** |
|
||||
|
||||
That is exactly the capture's `stride=28` on the fourth draw, and it explains the
|
||||
four distinct vertex-shader hashes. `parse_vertex_decl` read the **first**
|
||||
declaration and applied it to the whole pool; `all_vertex_decls` now reads one per
|
||||
marker, and `anchor_grouped_meshes` uses each sub-mesh's own stride for the vertex
|
||||
pool walk, the pivot validation and the read.
|
||||
|
||||
**Measured with `XBG7_SUBMESH_DECLS=1`:**
|
||||
|
||||
| | default | per-sub-mesh declarations |
|
||||
|---|---|---|
|
||||
| resources that never decode (disc-wide) | 85 | **47** |
|
||||
| resources that decode in **no** container | 63 | **30** |
|
||||
| capture oracles (`Stage_S02`) | 93/93 runs, 42/42 index counts | **unchanged** |
|
||||
| cross-container minority decodes | 96 | 96 |
|
||||
| decoded index runs with a degenerate triangle | 1 | 5 |
|
||||
|
||||
And `debug_grouped_report` at `n201_01`'s **capture-proven** pool start now reads
|
||||
`pad 0: ACCEPTED`, where it used to read `position component NaN` — the block the
|
||||
engine draws from is finally acceptable to the decoder.
|
||||
|
||||
**Why it is off by default.** Selection has not caught up:
|
||||
|
||||
* the three `n201_0x` copies all settle on ONE pool, so
|
||||
`tests/mesh_consistency_disc.rs::twin_pairs_do_not_share_a_buffer` fails — a twin
|
||||
collapse is the one thing this project has decided never to ship;
|
||||
* production still picks a pool start 4 bytes before sub-mesh **#1** (`0x32BEFF0`)
|
||||
instead of the proven `0x32BA71C`, even though that start **is** in the candidate
|
||||
list, validates at pad 0, and is claimed by nobody in the final output — so the
|
||||
remaining defect is in *choosing* among candidates, not in the format reading;
|
||||
* four newly decoded `ptc_pack` `.dat` composites carry degenerate triangles
|
||||
(`f202_break`, `e108_break`, `f106_break`, `eff_s901_e04`) — new coverage of
|
||||
imperfect quality rather than a regression, but not clean either.
|
||||
|
||||
So the format question is **settled** (and capture-confirmed), the coverage win is
|
||||
real and measured, and what stands between the two is the same
|
||||
selection/distinct-assignment machinery that the pad work already improved once.
|
||||
That is the next step, with `n201`'s proven offsets as the acceptance test.
|
||||
|
||||
Reference in New Issue
Block a user