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:
175
crates/sylpheed-formats/examples/capture_index_bytes.rs
Normal file
175
crates/sylpheed-formats/examples/capture_index_bytes.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Do our decoded triangle indices equal the ones the GPU actually read?
|
||||
//!
|
||||
//! `capture_ib_truth` established *where* a block's index buffer lives and that
|
||||
//! its length matches ours. This asks the stronger question: are the index VALUES
|
||||
//! the same, in the same order? The capture prints the first 24 indices of every
|
||||
//! draw batch verbatim (`idx: …`), and a batch's `ibase` locates it inside the
|
||||
//! block's index buffer — so for each drawn buffer we can line the captured run
|
||||
//! up against `GameMesh::indices` at the right offset and compare element by
|
||||
//! element. That tests the whole index path at once: the anchor, the u16
|
||||
//! big-endian read, the triangle-list interpretation (a strip would disagree
|
||||
//! immediately), and the sub-mesh carve.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --example capture_index_bytes -- <Stage_SNN.xpr> <capture.log>...
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn q(v: f32) -> i64 {
|
||||
(v as f64 * 1e4).round() as i64
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
if a.len() < 3 {
|
||||
eprintln!("usage: capture_index_bytes <container.xpr> <capture.log>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let bytes = std::fs::read(&a[1]).expect("container");
|
||||
|
||||
let mut draws: Vec<CapturedDraw> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for log in &a[2..] {
|
||||
let text = std::fs::read_to_string(log).expect("log");
|
||||
for d in parse_capture(&text) {
|
||||
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
|
||||
if d.ib.map_or(false, |i| i.head_len > 0) && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
|
||||
draws.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{} draw batches with an index head", draws.len());
|
||||
|
||||
// Place each drawn buffer in the file by its dumped positions (see
|
||||
// capture_ib_truth for the method) and keep the modal load constant.
|
||||
let be = |at: usize| f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
let mut index: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
|
||||
let mut o = 0usize;
|
||||
while o + 12 <= bytes.len() {
|
||||
let (x, y, z) = (be(o), be(o + 4), be(o + 8));
|
||||
if x.is_finite() && y.is_finite() && z.is_finite() && x.abs() < 1e6 && y.abs() < 1e6 && z.abs() < 1e6 {
|
||||
index.entry((q(x), q(y), q(z))).or_default().push(o as u32);
|
||||
}
|
||||
o += 4;
|
||||
}
|
||||
let mut deltas: HashMap<i64, usize> = HashMap::new();
|
||||
let mut hits: Vec<(i64, usize, &CapturedDraw)> = Vec::new();
|
||||
for d in &draws {
|
||||
let k = (q(d.pos[0][0]), q(d.pos[0][1]), q(d.pos[0][2]));
|
||||
for dx in -1..=1i64 {
|
||||
for dy in -1..=1i64 {
|
||||
for dz in -1..=1i64 {
|
||||
let Some(cands) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz)) else { continue };
|
||||
for &off in cands {
|
||||
for stride in (12..=64).step_by(4) {
|
||||
let ok = (1..4).all(|j| {
|
||||
let at = off as usize + j * stride;
|
||||
at + 12 <= bytes.len()
|
||||
&& (0..3).all(|c| (be(at + c * 4) - d.pos[j][c]).abs() <= 1e-4)
|
||||
});
|
||||
if ok {
|
||||
*deltas.entry(d.vbase as i64 - off as i64).or_default() += 1;
|
||||
hits.push((d.vbase as i64 - off as i64, off as usize, d));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((&base_delta, _)) = deltas.iter().max_by_key(|(_, n)| **n) else {
|
||||
eprintln!("no draw could be placed in this container");
|
||||
std::process::exit(1);
|
||||
};
|
||||
println!("load constant 0x{base_delta:X}");
|
||||
|
||||
// Our decode, indexed by vertex offset. A model may hold several sub-meshes;
|
||||
// compare against the one whose vertex count matches the draw.
|
||||
let models = Xbg7Model::stage_models(&bytes);
|
||||
let mut by_off: HashMap<usize, Vec<(String, usize, Vec<u32>)>> = HashMap::new();
|
||||
for m in &models {
|
||||
for sm in &m.meshes {
|
||||
if let Some(off) = sm.vbuf_offset {
|
||||
by_off
|
||||
.entry(off)
|
||||
.or_default()
|
||||
.push((m.name.clone(), sm.positions.len(), sm.indices.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Where does each buffer's index data START? Take it from the capture, not
|
||||
// from an assumed pad: `capture_ib_truth` established that a buffer's batches
|
||||
// tile its index buffer exactly (sum of counts == our idx_count, span ==
|
||||
// 2*count), so the lowest `ibase` over a buffer's batches IS the block's
|
||||
// index start. Assuming `vb - 2*len` instead is wrong for the buffers that
|
||||
// sit at pad 2 and shifts the whole comparison by one element.
|
||||
let mut ib_start: HashMap<u32, i64> = HashMap::new();
|
||||
for (delta, _, d) in &hits {
|
||||
if *delta != base_delta {
|
||||
continue;
|
||||
}
|
||||
let ibase = d.ib.unwrap().ibase as i64;
|
||||
ib_start.entry(d.vbase).and_modify(|e| *e = (*e).min(ibase)).or_insert(ibase);
|
||||
}
|
||||
|
||||
let (mut agree, mut disagree, mut unmatched, mut nooverlap) = (0usize, 0usize, 0usize, 0usize);
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
let mut checked_elems = 0usize;
|
||||
let mut done: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
|
||||
for (delta, voff, d) in &hits {
|
||||
if *delta != base_delta {
|
||||
continue;
|
||||
}
|
||||
let ib = d.ib.unwrap();
|
||||
if !done.insert((d.vbase, ib.ibase)) {
|
||||
continue;
|
||||
}
|
||||
let Some(cands) = by_off.get(voff) else {
|
||||
unmatched += 1;
|
||||
continue;
|
||||
};
|
||||
let Some((name, _, ours)) = cands.iter().find(|(_, p, _)| *p as u32 == d.vcount) else {
|
||||
unmatched += 1;
|
||||
continue;
|
||||
};
|
||||
// Element 0 of our index list is the block's index start, as observed.
|
||||
let ours_start = ib_start[&d.vbase] - base_delta;
|
||||
let batch_off = ib.ibase as i64 - base_delta - ours_start;
|
||||
if batch_off < 0 || batch_off % 2 != 0 {
|
||||
nooverlap += 1;
|
||||
continue;
|
||||
}
|
||||
let first = (batch_off / 2) as usize;
|
||||
let n = (ib.head_len as usize).min(ours.len().saturating_sub(first));
|
||||
if n == 0 {
|
||||
nooverlap += 1;
|
||||
continue;
|
||||
}
|
||||
let mism = (0..n).find(|&k| ours[first + k] != ib.head[k]);
|
||||
checked_elems += n;
|
||||
match mism {
|
||||
None => agree += 1,
|
||||
Some(k) => {
|
||||
disagree += 1;
|
||||
if bad.len() < 8 {
|
||||
bad.push(format!(
|
||||
"{name} vb 0x{:07X} batch@{first}: ours {:?} != captured {:?} (first differs at {k})",
|
||||
voff,
|
||||
&ours[first..first + n.min(12)],
|
||||
&ib.head[..n.min(12)]
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"index runs compared: {agree} identical · {disagree} differing · {unmatched} no decoded resource · {nooverlap} batch outside our buffer"
|
||||
);
|
||||
println!("{checked_elems} index elements checked against the GPU");
|
||||
for b in &bad {
|
||||
println!(" MISMATCH {b}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user