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:
134
crates/sylpheed-formats/examples/index_pad_check.rs
Normal file
134
crates/sylpheed-formats/examples/index_pad_check.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user