fix(xbg7): the index run was one element late for 575 sub-meshes

Extending the capture comparison from index COUNTS to index VALUES
(`examples/capture_index_bytes.rs`, using the batch offsets the new ib logging
gives) showed 76 of 93 Stage_S02 index runs identical to the GPU's and 17
differing — every difference a shift by exactly one element, on buffers whose
index data sits at pad 2.

`anchor_pool_mesh` returned the FIRST pad that validated, and pad 0 is tried
first with the looser winding gate (0.70 vs 0.85). Read at pad 0, a pad-2 block
yields [true[1], true[2], …, garbage]: every index in range, the pool covered,
the positions right, the winding often just above 0.70 — so it validated, and
every triangle was mis-wired. Nothing count-based could see it.

The signature is decidable without the capture: a shifted run wires arbitrary
vertices, so triangles come out degenerate. 282 of 283 correctly anchored
Stage_S02 blocks have zero degenerate triangles, while the shifted readings carry
1–2 156. So score every validating pad by (degenerate triangles, then winding)
and keep the best. `XBG7_PAD_FIRST_MATCH=1` restores the old behaviour.

  captured index runs identical:            76/93  ->  93/93  (2 025 elements)
  decoded runs with a degenerate triangle:    579  ->     16  (disc-wide)
  sub-meshes whose index run changed:                    575  of 8 850
  resources decoded / vertex anchors / consistency:  unchanged (6 209 / same vb / 89)

Locked in by tests/mesh_disc.rs::decoded_index_runs_have_almost_no_degenerate_triangles.
Suite green with --include-ignored apart from the pre-existing known-failing
cross-container consistency test (the 24-vertex bounding-box class).

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 07:26:52 +00:00
parent 6d92e6c114
commit 41b59faf5f
9 changed files with 655 additions and 3 deletions

View File

@@ -0,0 +1,134 @@
//! Is our index list one element late? A capture-free check of the `pad` choice.
//!
//! `anchor_pool_mesh` tries `ib = vb idx_count*2 pad` for `pad` in `0..=3`,
//! **pad 0 first and with the looser winding gate** (`XBG7_PAD0_CONSISTENCY`,
//! 0.70) than the pad ≥ 1 path (0.85). The 2026-08-13 index-byte comparison
//! against the runtime showed 17 draw batches whose captured indices equal our
//! decoded list shifted by exactly one element — all of them on buffers whose
//! real index data sits at pad 2. A one-element shift re-wires every triangle,
//! and it is invisible to every count-based metric (the count, the coverage and
//! the positions are all still right).
//!
//! This reproduces that finding from the container alone: for each resource it
//! reads the index run at pad 0 and at pad 2 and scores both on two properties a
//! correct triangle list has — **no degenerate triangles** (two equal indices)
//! and consistent winding against the stored normals.
//!
//! Usage: index_pad_check <container.xpr> [name-substring]
use sylpheed_formats::mesh::Xbg7Model;
fn be16(b: &[u8], at: usize) -> u32 {
((b[at] as u32) << 8) | b[at + 1] as u32
}
/// Degenerate triangles (a repeated index) and the fraction of triangles whose
/// face normal agrees with their vertices' stored normals.
fn score(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> (usize, f32, usize) {
let mut degen = 0usize;
let mut agree = 0usize;
let mut counted = 0usize;
for t in idx.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;
}
if a >= pos.len() || b >= pos.len() || c >= pos.len() {
continue;
}
let e1 = [pos[b][0] - pos[a][0], pos[b][1] - pos[a][1], pos[b][2] - pos[a][2]];
let e2 = [pos[c][0] - pos[a][0], pos[c][1] - pos[a][1], pos[c][2] - pos[a][2]];
let f = [
e1[1] * e2[2] - e1[2] * e2[1],
e1[2] * e2[0] - e1[0] * e2[2],
e1[0] * e2[1] - e1[1] * e2[0],
];
if nrm.len() <= a {
continue;
}
let n = nrm[a];
let dot = f[0] * n[0] + f[1] * n[1] + f[2] * n[2];
counted += 1;
if dot > 0.0 {
agree += 1;
}
}
let frac = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 };
(degen, frac.max(1.0 - frac), counted)
}
fn main() {
let a: Vec<String> = std::env::args().collect();
let bytes = std::fs::read(&a[1]).expect("container");
let filter = a.get(2).cloned().unwrap_or_default();
let models = Xbg7Model::stage_models(&bytes);
println!(
"{:<22} {:>6} {:>6} {:<26} {:<26} verdict",
"resource", "verts", "idx", "pad 0 (what we decode)", "pad 2 (2 bytes earlier)"
);
let (mut better_p2, mut better_p0, mut tie) = (0usize, 0usize, 0usize);
let mut hist_ok: Vec<(usize, String, usize)> = Vec::new();
for m in &models {
if !filter.is_empty() && !m.name.contains(&filter) {
continue;
}
if m.meshes.len() != 1 {
continue; // single-block path only; grouped pools use another formula
}
let sm = &m.meshes[0];
let Some(vb) = sm.vbuf_offset else { continue };
let n = sm.indices.len();
if n < 6 || sm.normals.is_empty() || vb < n * 2 + 2 {
continue;
}
let read_at = |start: usize| -> Vec<u32> { (0..n).map(|k| be16(&bytes, start + k * 2)).collect() };
let l0 = read_at(vb - n * 2);
let l2 = read_at(vb - n * 2 - 2);
// Sanity: l0 must be what the decoder emitted, or the assumption that we
// took pad 0 is wrong for this resource and the comparison is meaningless.
let took_pad0 = l0 == sm.indices;
if !took_pad0 {
continue;
}
let (d0, c0, _) = score(&l0, &sm.positions, &sm.normals);
let (d2, c2, _) = score(&l2, &sm.positions, &sm.normals);
let max_i0 = l0.iter().max().copied().unwrap_or(0);
let max_i2 = l2.iter().max().copied().unwrap_or(0);
let v = sm.positions.len() as u32;
// A shifted list typically also overruns the pool by one vertex or leaves
// the last vertex unreferenced, so carry the coverage as evidence too.
let verdict = if d2 < d0 && c2 >= c0 {
better_p2 += 1;
"pad 2 is the real one"
} else if d0 < d2 || c0 > c2 {
better_p0 += 1;
"pad 0 fine"
} else {
tie += 1;
"indistinguishable"
};
if verdict == "pad 0 fine" {
hist_ok.push((d0, m.name.clone(), n));
}
if verdict != "pad 0 fine" || !filter.is_empty() {
println!(
"{:<22} {:>6} {:>6} degen {:>5} wind {:.3} max {:<5} degen {:>5} wind {:.3} max {:<5} {}",
m.name, v, n, d0, c0, max_i0, d2, c2, max_i2, verdict
);
}
}
println!("\npad 2 wins: {better_p2} · pad 0 wins: {better_p0} · indistinguishable: {tie}");
// Is "no degenerate triangle" a safe invariant for a correctly anchored
// block? Distribution over the resources where pad 0 is the better choice.
let zero = hist_ok.iter().filter(|(d, _, _)| *d == 0).count();
println!(
"of the {} pad-0-correct resources, {zero} have ZERO degenerate triangles",
hist_ok.len()
);
let mut worst: Vec<_> = hist_ok.iter().filter(|(d, _, _)| *d > 0).collect();
worst.sort_by_key(|(d, _, _)| std::cmp::Reverse(*d));
for (d, n, idx) in worst.iter().take(10) {
println!(" {n}: {d} degenerate of {} triangles", idx / 3);
}
}