debug_vertex_run_starts exposes the anchor scan's candidate list: Stage_S01 has 15710 stride-24 starts and all three capture-proven offsets (0x3c55d8 twin, 0x40e418 bridge, 0x44a32c eng_02_l) are among them. anchor_pool_mesh takes the first that validates, so an earlier lookalike wins. Scoped to the current decoder's e106 cases; does not overturn the residual-51 finding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
263 lines
12 KiB
Rust
263 lines
12 KiB
Rust
//! Ask the runtime capture whether two resources that our decoder gives the
|
||
//! **same geometry** really are the same geometry.
|
||
//!
|
||
//! Our XBG7 anchor scan sometimes lands two different resource names on one
|
||
//! vertex buffer. Statics cannot separate "the container genuinely reuses a
|
||
//! buffer" from "the scan picked the wrong candidate" — but a capture can: the
|
||
//! engine uploads a buffer per resource and reuses one only 3.4 % of the time
|
||
//! (see docs/re/structures/xbg7-mesh.md), so a group of `k` resources our
|
||
//! decoder collapses onto one buffer should show up as `k` distinct `vbase`s
|
||
//! carrying that same vertex count and those same positions. Fewer means at
|
||
//! most one member of the group is really that geometry.
|
||
//!
|
||
//! Usage:
|
||
//! cargo run --release --example shared_vbase_check -- \
|
||
//! <Stage_SNN.xpr> <capture.log>...
|
||
use sylpheed_formats::mesh::Xbg7Model;
|
||
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog, CapturedDraw};
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
/// Quantised position key — the logs print 4 decimals, so compare at that scale.
|
||
fn key(p: [f32; 3]) -> (i64, i64, i64) {
|
||
(
|
||
(p[0] as f64 * 1e4).round() as i64,
|
||
(p[1] as f64 * 1e4).round() as i64,
|
||
(p[2] as f64 * 1e4).round() as i64,
|
||
)
|
||
}
|
||
|
||
/// Where in the container does a captured buffer live? POSITION is `f32×3` big
|
||
/// endian at vertex offset 0, so a draw's dumped positions are a literal byte
|
||
/// pattern: find the first one, then confirm the next few at a fixed stride.
|
||
/// This turns a capture into ground truth for a resource we mis-anchored.
|
||
fn locate_run(bytes: &[u8], pos: &[[f32; 3]]) -> Vec<(usize, usize)> {
|
||
if pos.len() < 4 {
|
||
return Vec::new();
|
||
}
|
||
// The log prints 4 decimals, so match on value with the printing tolerance
|
||
// rather than on bytes.
|
||
let be = |b: &[u8], at: usize| f32::from_be_bytes(b[at..at + 4].try_into().unwrap());
|
||
let same = |b: &[u8], at: usize, p: [f32; 3]| {
|
||
at + 12 <= b.len() && (0..3).all(|c| (be(b, at + c * 4) - p[c]).abs() <= 1e-4)
|
||
};
|
||
let mut out = Vec::new();
|
||
for o in (0..bytes.len().saturating_sub(12)).step_by(4) {
|
||
if !same(bytes, o, pos[0]) {
|
||
continue;
|
||
}
|
||
for stride in (12..=64).step_by(4) {
|
||
if (1..4).all(|k| same(bytes, o + k * stride, pos[k])) {
|
||
out.push((o, stride));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn main() {
|
||
let args: Vec<String> = std::env::args().collect();
|
||
if args.len() < 3 {
|
||
eprintln!("usage: shared_vbase_check <Stage_SNN.xpr> <capture.log>...");
|
||
std::process::exit(2);
|
||
}
|
||
let bytes = std::fs::read(&args[1]).expect("read container");
|
||
|
||
// Every draw from every log, keyed by vertex count.
|
||
// Keep the logs apart: each is its own emulator run, so a `vbase` only
|
||
// means something within one log.
|
||
let mut logs: Vec<(String, Vec<CapturedDraw>)> = Vec::new();
|
||
for log in args[2..].iter().filter(|a| !a.starts_with("--")) {
|
||
let text = std::fs::read_to_string(log).expect("read log");
|
||
let mut d = parse_capture(&text);
|
||
if d.is_empty() {
|
||
d = parse_drawlog(&text);
|
||
}
|
||
eprintln!("{log}: {} draws", d.len());
|
||
logs.push((log.rsplit('/').next().unwrap_or(log).to_string(), d));
|
||
}
|
||
|
||
// `--map`: is a draw's guest `vbase` just the container file offset plus a
|
||
// constant? If the container is uploaded contiguously it is — and then a
|
||
// capture names the exact offset of every buffer the engine drew, which is
|
||
// ground truth the anchor scan currently has to guess at.
|
||
if args.iter().any(|a| a == "--map") {
|
||
for (log, draws) in &logs {
|
||
let mut seen: BTreeSet<u32> = BTreeSet::new();
|
||
let mut delta: BTreeMap<i64, usize> = BTreeMap::new();
|
||
let mut unfound = 0usize;
|
||
for d in draws {
|
||
if d.pos.len() < 8 || d.vcount < 20 || !seen.insert(d.vbase) {
|
||
continue;
|
||
}
|
||
let at = locate_run(&bytes, &d.pos);
|
||
if at.is_empty() {
|
||
unfound += 1;
|
||
continue;
|
||
}
|
||
for (o, _) in at {
|
||
*delta.entry(d.vbase as i64 - o as i64).or_default() += 1;
|
||
}
|
||
}
|
||
let mut top: Vec<_> = delta.iter().collect();
|
||
top.sort_by_key(|(_, n)| std::cmp::Reverse(**n));
|
||
println!("{log}: {} distinct vbases located, {unfound} not in this container", seen.len() - unfound);
|
||
for (d, n) in top.iter().take(5) {
|
||
println!(" vbase - offset = 0x{:X} ×{n}", d);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Decode the container and group resources by the exact geometry they got.
|
||
let models = Xbg7Model::anchor_models_cancellable(&bytes, 0.0, &|| false);
|
||
|
||
// `--truth <base>`: with the container's guest load address (from `--map`),
|
||
// every draw names a file offset. Print it against the offset our anchor
|
||
// scan chose for each resource — a direct read-out of what we got wrong.
|
||
if let Some(a) = args.iter().find_map(|a| a.strip_prefix("--truth=")) {
|
||
let base = u32::from_str_radix(a.trim_start_matches("0x"), 16).expect("base");
|
||
// Where the anchor scan actually put each sub-mesh — exact, from the
|
||
// decoder, not inferred by searching for its leading vertices (the same
|
||
// leading run occurs at several offsets in a container, so a search
|
||
// cannot tell where a resource was anchored).
|
||
let mut ours: BTreeMap<usize, Vec<(String, usize)>> = BTreeMap::new();
|
||
for m in &models {
|
||
for sub in &m.meshes {
|
||
if let Some(o) = sub.vbuf_offset {
|
||
ours.entry(o).or_default().push((m.name.clone(), sub.positions.len()));
|
||
}
|
||
}
|
||
}
|
||
if args.iter().any(|a| a == "--anchors") {
|
||
println!("{:<12} where our decode put each resource", "file offset");
|
||
for (o, v) in &ours {
|
||
for (n, c) in v {
|
||
println!("0x{o:<10x} {n} ({c} verts)");
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
let mut drawn: BTreeMap<usize, u32> = BTreeMap::new();
|
||
for (_, draws) in &logs {
|
||
for d in draws {
|
||
let off = d.vbase.wrapping_sub(base) as usize;
|
||
if off < bytes.len() && d.vcount >= 20 {
|
||
drawn.insert(off, d.vcount);
|
||
}
|
||
}
|
||
}
|
||
// Which resources have the drawn vertex count, wherever we put them?
|
||
// Right size + wrong place is a different bug from never finding it.
|
||
let mut by_count: BTreeMap<usize, Vec<String>> = BTreeMap::new();
|
||
for m in &models {
|
||
let n: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
|
||
by_count.entry(n).or_default().push(m.name.clone());
|
||
}
|
||
// Is a capture-proven offset even a candidate the scan considers?
|
||
// Absent ⇒ the run scan misses it; present ⇒ selection picked another.
|
||
let starts: BTreeSet<usize> =
|
||
sylpheed_formats::mesh::debug_vertex_run_starts(&bytes, 24).into_iter().collect();
|
||
eprintln!("{} stride-24 candidate starts in this container", starts.len());
|
||
println!("{:<12} {:>7} {:>9} {:<44} our resources with that vcount", "file offset", "vcount", "candidate", "claimed by our decode");
|
||
for (off, vcount) in &drawn {
|
||
let who = ours
|
||
.get(off)
|
||
.map(|v| {
|
||
v.iter().map(|(n, c)| format!("{n}({c})")).collect::<Vec<_>>().join(", ")
|
||
})
|
||
.unwrap_or_else(|| "— NOBODY".into());
|
||
let same = by_count
|
||
.get(&(*vcount as usize))
|
||
.map(|v| v.join(", "))
|
||
.unwrap_or_else(|| "— none".into());
|
||
// Nearest resource we anchored at or before this offset — the
|
||
// likely owner of a buffer nobody claims.
|
||
let near = ours
|
||
.range(..=*off)
|
||
.next_back()
|
||
.map(|(o, v)| format!("{} @ -0x{:x}", v[0].0, off - o))
|
||
.unwrap_or_default();
|
||
let cand = if starts.contains(off) { "yes" } else { "NO" };
|
||
println!("0x{off:<10x} {vcount:>7} {cand:>9} {who:<44} {same:<34} {near}");
|
||
}
|
||
return;
|
||
}
|
||
let mut groups: BTreeMap<Vec<(i64, i64, i64)>, Vec<String>> = BTreeMap::new();
|
||
for m in &models {
|
||
let pos: Vec<(i64, i64, i64)> =
|
||
m.meshes.iter().flat_map(|s| s.positions.iter().copied()).map(key).collect();
|
||
if pos.is_empty() {
|
||
continue;
|
||
}
|
||
groups.entry(pos).or_default().push(m.name.clone());
|
||
}
|
||
let shared: Vec<_> = groups.iter().filter(|(_, n)| n.len() > 1).collect();
|
||
eprintln!(
|
||
"{} models, {} distinct geometries, {} shared by >1 resource",
|
||
models.len(),
|
||
groups.len(),
|
||
shared.len()
|
||
);
|
||
|
||
for (pos, names) in shared {
|
||
let vcount = pos.len() as u32;
|
||
// A draw belongs to this geometry if every dumped position is one of
|
||
// the decoded ones (the log dumps at most the first 64).
|
||
let want: BTreeSet<(i64, i64, i64)> = pos.iter().copied().collect();
|
||
println!("\n{} ({vcount} verts, {} resources)", names.join(" ≡ "), names.len());
|
||
for (log, draws) in &logs {
|
||
let hits: Vec<&CapturedDraw> = draws.iter().filter(|d| d.vcount == vcount).collect();
|
||
let all: BTreeSet<u32> = hits.iter().map(|d| d.vbase).collect();
|
||
let matching: Vec<&&CapturedDraw> = hits
|
||
.iter()
|
||
.filter(|d| !d.pos.is_empty() && d.pos.iter().all(|p| want.contains(&key(*p))))
|
||
.collect();
|
||
let ok: BTreeSet<u32> = matching.iter().map(|d| d.vbase).collect();
|
||
// A buffer we do NOT match may still be the mirrored twin: same
|
||
// geometry with x negated. That is the case our assembler papers
|
||
// over with `apply_twin_mirrors`.
|
||
let mirrored: BTreeSet<u32> = hits
|
||
.iter()
|
||
.filter(|d| !ok.contains(&d.vbase))
|
||
.filter(|d| {
|
||
!d.pos.is_empty()
|
||
&& d.pos.iter().all(|p| want.contains(&key([-p[0], p[1], p[2]])))
|
||
})
|
||
.map(|d| d.vbase)
|
||
.collect();
|
||
println!(
|
||
" {log:32} draws={:<5} vbases@vcount={:<3} ours={} mirrored={} other={}",
|
||
hits.len(),
|
||
all.len(),
|
||
ok.len(),
|
||
mirrored.len(),
|
||
all.len() - ok.len() - mirrored.len()
|
||
);
|
||
// Where does each captured buffer live in the container? One
|
||
// representative draw per vbase is enough.
|
||
let mut done: BTreeSet<u32> = BTreeSet::new();
|
||
for d in &hits {
|
||
if d.pos.len() < 8 || !done.insert(d.vbase) {
|
||
continue;
|
||
}
|
||
let kind = if ok.contains(&d.vbase) {
|
||
"ours"
|
||
} else if mirrored.contains(&d.vbase) {
|
||
"mirror"
|
||
} else {
|
||
"other"
|
||
};
|
||
let at = locate_run(&bytes, &d.pos);
|
||
let shown: Vec<String> =
|
||
at.iter().take(4).map(|(o, s)| format!("0x{o:x}/stride{s}")).collect();
|
||
println!(
|
||
" vbase=0x{:08X} [{kind:6}] in container at: {}",
|
||
d.vbase,
|
||
if shown.is_empty() { "NOT FOUND".into() } else { shown.join(" ") }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|