fix(mesh): grouped pools no longer emit sub-meshes with out-of-range indices

coverage_audit measures index coverage per decoded sub-mesh. 8586 of them
reference their last vertex exactly, so the 'buffer not covered' gate is well
founded -- but 18 had NEGATIVE slack: indices up to 364 vertices past the end of
their own buffer, emitted because anchor_grouped_meshes reads pre-pivot
sub-meshes unconditionally. Quality gates stay relaxed there (a tiny flat lead
part is legitimately poor) but index range is now required.

Coverage 6069/6294 unchanged, inconsistency 56 unchanged, truth table still 46/46
claimed, suite green; the vertex total drops by exactly the 1546 garbage verts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 04:41:17 +00:00
parent c9c98cf942
commit 6634d79c2e
3 changed files with 89 additions and 1 deletions

View File

@@ -0,0 +1,48 @@
//! Do real index buffers address their whole vertex pool?
//!
//! `validate_block` rejects a block whose indices reach fewer than `vtx_count4`
//! vertices ("buffer not covered"). That gate is the furthest-reached rejection
//! for a handful of resources that never decode — so the question is whether it
//! is well founded. This measures the slack on every block that DOES decode: if
//! real geometry always covers its pool, under-coverage is good evidence of a
//! wrong candidate and the gate stands.
use sylpheed_formats::mesh::Xbg7Model;
use std::collections::BTreeMap;
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
let mut hist: BTreeMap<i64, usize> = BTreeMap::new();
let mut worst: Vec<(i64, String)> = Vec::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
for m in Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false) {
for sub in &m.meshes {
if sub.positions.is_empty() || sub.indices.is_empty() {
continue;
}
let max_idx = *sub.indices.iter().max().unwrap() as i64;
let slack = sub.positions.len() as i64 - 1 - max_idx;
*hist.entry(slack.min(20)).or_default() += 1;
if slack > 4 {
worst.push((slack, format!("{} in {}", m.name, f.file_name().unwrap().to_string_lossy())));
}
}
}
}
println!("unreferenced tail vertices (vtx_count 1 max index), over decoded sub-meshes:");
for (slack, n) in &hist {
println!(" {:>3}{} : {n}", slack, if *slack == 20 { "+" } else { " " });
}
worst.sort_by_key(|(s, _)| std::cmp::Reverse(*s));
for (s, w) in worst.iter().take(5) {
println!(" largest slack {s}: {w}");
}
}

View File

@@ -1391,7 +1391,17 @@ fn anchor_grouped_meshes(
if !ok && i > kmax { if !ok && i > kmax {
break; // chain diverged — emit the validated prefix, no garbage break; // chain diverged — emit the validated prefix, no garbage
} }
// Sub-meshes BEFORE the pivot are emitted even when they fail the
// quality gates (a tiny flat lead part is legitimately poor), but
// an index that addresses past its own vertex buffer is not a
// quality question — it is unusable. Measured 2026-08-12: 18
// sub-meshes disc-wide carried indices up to 364 vertices past
// the end (`coverage_audit`), which any renderer would fault on.
let in_range =
(0..ic).all(|k| (be16(bytes, ib + k * 2) as usize) < vc);
if in_range {
meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl)); meshes.push(read_pool_mesh(bytes, ib, vb, ic, vc, decl));
}
vb += vc * stride; vb += vc * stride;
} }
return meshes; return meshes;

View File

@@ -965,6 +965,36 @@ container holds two direct copies **and** two mirrored ones
(`0x33b7754`, `0x342e284`); our twins take the two direct copies, which is (`0x33b7754`, `0x342e284`); our twins take the two direct copies, which is
self-consistent but unverified — `n206` appears in no captured stage. self-consistent but unverified — `n206` appears in no captured stage.
### ✅ Fixed: grouped pools emitted sub-meshes with out-of-range indices
Chasing whether the "buffer not covered" gate is well founded turned up a real
defect instead. `examples/coverage_audit.rs` measures, for every decoded
sub-mesh, how many tail vertices its indices never reference:
| unreferenced tail vertices | sub-meshes |
|---|---|
| **0** (indices reach the last vertex exactly) | **8 586** |
| 13 (inside the gate's ±4 tolerance) | 48 |
| 9, 80 (`f102_break.dat`, `f104_break.dat` in `ptc_pack.xpr`) | 2 |
| **negative — indices point PAST the vertex buffer** | **18** |
The first row answers the original question: real geometry covers its pool
**exactly**, so under-coverage is good evidence of a wrong candidate and the gate
stands as written.
The last row is the defect. `anchor_grouped_meshes` reads sub-meshes *before* the
pivot unconditionally — deliberately, since a tiny flat lead part legitimately
fails the quality gates — but that also skipped the **index-range** check, which
is not a quality question. Eighteen sub-meshes disc-wide were emitted with
indices reaching up to **364 vertices past the end** of their own buffer, which
any renderer would fault on or draw as garbage. Pre-pivot sub-meshes are now
required to be in range (quality gates still relaxed); out-of-range ones are
dropped and the pool's remaining parts are kept.
Everything else holds: 6 069/6 294 decoded, cross-container inconsistency 56, the
capture truth table still 46/46 claimed with 0 unclaimed, suite green. The vertex
total falls by 1 546 — exactly the garbage that is no longer emitted.
### Coverage has a denominator now, and the misses have a cause breakdown ### Coverage has a denominator now, and the misses have a cause breakdown
Coverage has been quoted as "resources decoded" with no total. `examples/undecoded.rs` Coverage has been quoted as "resources decoded" with no total. `examples/undecoded.rs`