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
125 lines
5.2 KiB
Rust
125 lines
5.2 KiB
Rust
//! How much geometry on the disc was being read one index element late?
|
|
//!
|
|
//! The pad-scoring fix (2026-08-13) makes `anchor_pool_mesh` choose the pad whose
|
|
//! index run is cleanest instead of the first that validates. This measures the
|
|
//! blast radius: per container, how many single-block resources now decode from a
|
|
//! run that is NOT the old first-match (`pad 0`) one, and how many decoded runs
|
|
//! still contain degenerate triangles (the shift signature) afterwards.
|
|
//!
|
|
//! Usage: pad_shift_audit <resource3d_dir>
|
|
use sylpheed_formats::mesh::Xbg7Model;
|
|
|
|
fn be16(b: &[u8], at: usize) -> u32 {
|
|
((b[at] as u32) << 8) | b[at + 1] as u32
|
|
}
|
|
|
|
/// Fraction of triangles whose face normal agrees with the stored normals,
|
|
/// folded to `max(na, 1-na)` so both authored windings read as ≈1.
|
|
fn winding(idx: &[u32], pos: &[[f32; 3]], nrm: &[[f32; 3]]) -> f32 {
|
|
let (mut agree, mut n) = (0usize, 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 || a.max(b).max(c) >= pos.len() || a >= nrm.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],
|
|
];
|
|
let nv = nrm[a];
|
|
n += 1;
|
|
if f[0] * nv[0] + f[1] * nv[1] + f[2] * nv[2] > 0.0 {
|
|
agree += 1;
|
|
}
|
|
}
|
|
if n == 0 {
|
|
return 1.0;
|
|
}
|
|
let fr = agree as f32 / n as f32;
|
|
fr.max(1.0 - fr)
|
|
}
|
|
|
|
fn degenerate(idx: &[u32]) -> usize {
|
|
idx.chunks_exact(3)
|
|
.filter(|t| t[0] == t[1] || t[1] == t[2] || t[0] == t[2])
|
|
.count()
|
|
}
|
|
|
|
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 total, mut moved, mut still_degen) = (0usize, 0usize, 0usize);
|
|
let mut weak = 0usize;
|
|
let mut weak_wind: Vec<(f32, f32, String)> = Vec::new();
|
|
let mut worst: Vec<(usize, String, String)> = Vec::new();
|
|
for f in &files {
|
|
let Ok(bytes) = std::fs::read(f) else { continue };
|
|
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
|
|
let mut c_moved = 0usize;
|
|
for m in Xbg7Model::stage_models(&bytes) {
|
|
if m.meshes.len() != 1 {
|
|
continue;
|
|
}
|
|
let sm = &m.meshes[0];
|
|
let Some(vb) = sm.vbuf_offset else { continue };
|
|
let n = sm.indices.len();
|
|
if n < 6 || vb < n * 2 {
|
|
continue;
|
|
}
|
|
total += 1;
|
|
let pad0: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 + k * 2)).collect();
|
|
if pad0 != sm.indices {
|
|
moved += 1;
|
|
c_moved += 1;
|
|
// How strong was the evidence for moving? A pad-0 run with
|
|
// degenerate triangles is a decisive shift signature; one with
|
|
// none moved on winding alone, which is weaker.
|
|
if degenerate(&pad0) == 0 {
|
|
weak += 1;
|
|
// Record how far apart the two runs' winding is, to see
|
|
// whether these look like the same shift signature as the
|
|
// decisive cases (pad-0 winding drifting toward 0.5) or like
|
|
// noise between two equally clean readings.
|
|
let w0 = winding(&pad0, &sm.positions, &sm.normals);
|
|
let w2: f32 = {
|
|
let l2: Vec<u32> = (0..n).map(|k| be16(&bytes, vb - n * 2 - 2 + k * 2)).collect();
|
|
winding(&l2, &sm.positions, &sm.normals)
|
|
};
|
|
weak_wind.push((w0, w2, m.name.clone()));
|
|
}
|
|
}
|
|
let d = degenerate(&sm.indices);
|
|
if d > 0 {
|
|
still_degen += 1;
|
|
worst.push((d, m.name.clone(), where_.clone()));
|
|
}
|
|
}
|
|
if c_moved > 0 {
|
|
println!("{where_:<24} {c_moved} resources read from a non-pad-0 index run");
|
|
}
|
|
}
|
|
println!("\n{moved} of {total} single-block resources moved off the pad-0 run");
|
|
println!("{still_degen} decoded runs still contain a degenerate triangle");
|
|
println!("of the moved, {weak} had a pad-0 run with no degenerate triangle (moved on winding alone)");
|
|
let clear = weak_wind.iter().filter(|(w0, w2, _)| *w2 - *w0 > 0.15).count();
|
|
let close = weak_wind.iter().filter(|(w0, w2, _)| (*w2 - *w0).abs() <= 0.05).count();
|
|
println!(" of those: {clear} show the shift signature (pad-2 winding > pad-0 by >0.15), {close} are within 0.05 (noise)");
|
|
for (w0, w2, n) in weak_wind.iter().take(8) {
|
|
println!(" {n}: pad0 wind {w0:.3} -> pad2 {w2:.3}");
|
|
}
|
|
worst.sort_by_key(|(d, _, _)| std::cmp::Reverse(*d));
|
|
for (d, n, w) in worst.iter().take(15) {
|
|
println!(" {n} ({w}): {d} degenerate triangles");
|
|
}
|
|
}
|