capture_ib_truth now proposes an identity for each drawn buffer our decoder cannot place, by matching (vertex, index) counts against declared-but-not-decoded resources. That identified n201_01/_02/_03 in Stage_S02.xpr — three of the 63 resources that decode in no container — and the capture pins all four sub-meshes: #0 vb 0x32BA71C ib 0x32B39FC 4464 idx 777 v stride 24 #1 vb 0x32BEFF4 ib 0x32B5CDC 4464 idx 869 v stride 24 #2 vb 0x32C416C ib 0x32B7FBC 576 idx 192 v stride 24 #3 vb 0x32C536C ib 0x32B843C 4464 idx 869 v stride 28 <- different The layout matches our assumptions exactly (tight index packing, last buffer flush against vb0 so pad 0, span 27936 == align4-summed markers, contiguous vertex buffers, max index == verts-1 everywhere). The defect is that sub-mesh #3 has a different stride AND its own vertex shader: anchor_grouped_meshes parses one declaration per resource and applies its stride to every sub-mesh, so it reads #3 out of phase (372 non-finite position components of 2607), and since the pivot is the largest index count with ties going to the last marker, the pivot IS that sub-mesh — so the whole resource is declined. Also adds examples/miss_targets.rs (which container to aim a capture at): 63 resources decode nowhere, 58 of them in exactly one container, clustering as Stage_S16 21 (e901_wing_05_*), ptc_pack 12, Base 6, then per-stage n2xx groups. Flying stage 16 did not draw the e901 wings — the container is resident but the unit must also be on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
223 lines
10 KiB
Rust
223 lines
10 KiB
Rust
//! Where does a drawn block's INDEX buffer really live?
|
||
//!
|
||
//! Our XBG7 anchor scan only ever *assumes* the layout `[index buffer][vertex
|
||
//! buffer]` with a pad of at most 3 bytes between them (`anchor_pool_mesh`:
|
||
//! `ib = vb - idx_count*2 - pad`). Nothing on disc states it, and it is the gate
|
||
//! that rejected the capture-proven `e106_eng_02_l` block
|
||
//! (docs/re/captures/…): the block's index data was not where the decoder
|
||
//! looked. The F10 ship capture was extended on 2026-08-13 to log each draw's
|
||
//! index-buffer base, count and min/max index value, so the assumption is now
|
||
//! directly checkable:
|
||
//!
|
||
//! * `vbase - ibase` is the real gap in guest memory, and a stage container is
|
||
//! uploaded contiguously (see `shared_vbase_check`), so the same difference
|
||
//! holds in the file;
|
||
//! * `max index vs vcount` says whether a draw covers its whole vertex pool —
|
||
//! the "buffer not covered" miss class is a *sub-range draw* if it does not.
|
||
//!
|
||
//! Usage:
|
||
//! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>...
|
||
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, 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_ib_truth <container.xpr> <capture.log>...");
|
||
std::process::exit(2);
|
||
}
|
||
let bytes = std::fs::read(&a[1]).expect("container");
|
||
|
||
// One entry per (log, vbase): the capture already de-dups per placement, and
|
||
// a buffer drawn at several transforms has the same index buffer each time.
|
||
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) {
|
||
// One entry per (log, vbase, index range): the engine issues SEVERAL
|
||
// draws over one vertex buffer, each with its own index sub-range, and
|
||
// it is their UNION that describes the block. (Captures taken before
|
||
// 2026-08-13 de-dup by (vbase, transform) and so hold only the first
|
||
// batch — such a log reads as a mysteriously short draw.)
|
||
let k = d.ib.map(|i| (i.ibase, i.icount)).unwrap_or((0, 0));
|
||
if d.ib.is_some() && d.pos.len() >= 4 && seen.insert((log.clone(), d.vbase, k)) {
|
||
draws.push(d);
|
||
}
|
||
}
|
||
}
|
||
eprintln!("{} drawn buffers with an index buffer", draws.len());
|
||
|
||
// ── Place the drawn buffers in the file: POSITION is f32×3 big-endian at
|
||
// vertex offset 0, so the dumped positions are a literal byte pattern.
|
||
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(); // (delta, file offset, draw)
|
||
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 {
|
||
let delta = d.vbase as i64 - off as i64;
|
||
*deltas.entry(delta).or_default() += 1;
|
||
hits.push((delta, off as usize, d));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let Some((&base_delta, &n)) = deltas.iter().max_by_key(|(_, n)| **n) else {
|
||
eprintln!("no draw could be placed in this container");
|
||
std::process::exit(1);
|
||
};
|
||
println!("container load constant: vbase - file_offset = 0x{base_delta:X} ({n} buffers agree)");
|
||
|
||
// ── Our decoder's view of the same container.
|
||
let models = Xbg7Model::stage_models(&bytes);
|
||
let mut by_off: HashMap<usize, Vec<(String, usize, usize)>> = 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.len()));
|
||
}
|
||
}
|
||
}
|
||
println!("decoded {} resources, {} distinct vertex offsets\n", models.len(), by_off.len());
|
||
|
||
// Declared-but-not-decoded resources, indexed by their first marker's
|
||
// (vertex, index) counts. A drawn buffer our decoder cannot name is the one
|
||
// thing a capture can give the residual misses: ground truth for where the
|
||
// block actually is. Matching on counts is enough to propose an identity —
|
||
// then `debug_try_anchor` at that offset says which gate rejects it.
|
||
let decoded_names: std::collections::HashSet<String> =
|
||
models.iter().map(|m| m.name.clone()).collect();
|
||
let mut undecoded_by_counts: HashMap<(usize, usize), Vec<String>> = HashMap::new();
|
||
for n in xbg7_resource_names(&bytes) {
|
||
if decoded_names.contains(&n) {
|
||
continue;
|
||
}
|
||
if let Some((markers, _)) = debug_resource_params(&bytes, &n) {
|
||
if let Some(&(v, i)) = markers.first() {
|
||
undecoded_by_counts.entry((v, i)).or_default().push(n);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── The report: one row per drawn BUFFER, aggregating its index batches.
|
||
let mut per_buf: HashMap<u32, (usize, Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>, u32)> =
|
||
HashMap::new();
|
||
for (delta, voff, d) in &hits {
|
||
if *delta != base_delta {
|
||
continue;
|
||
}
|
||
let e = per_buf.entry(d.vbase).or_insert((*voff, Vec::new(), d.vcount));
|
||
let ib = d.ib.unwrap();
|
||
if !e.1.contains(&ib) {
|
||
e.1.push(ib);
|
||
}
|
||
}
|
||
|
||
let (mut pad0, mut pad_small, mut pad_off, mut unnamed) = (0usize, 0usize, 0usize, 0usize);
|
||
let (mut cover_exact, mut cover_short, mut idx_equal, mut idx_partial) = (0usize, 0usize, 0usize, 0usize);
|
||
let mut rows: Vec<(usize, String)> = Vec::new();
|
||
for (_, (voff, ibs, vcount)) in per_buf.iter() {
|
||
let batches = ibs.len();
|
||
let total: u32 = ibs.iter().map(|i| i.icount).sum();
|
||
let lo = ibs.iter().map(|i| i.ibase).min().unwrap() as i64 - base_delta;
|
||
let hi = ibs.iter().map(|i| i.ibase + i.icount * 2).max().unwrap() as i64 - base_delta;
|
||
let umax = ibs.iter().map(|i| i.imax).max().unwrap();
|
||
let gap = *voff as i64 - hi; // bytes from the end of the index data to the vertex buffer
|
||
let names = by_off.get(voff);
|
||
let dec_idx = names
|
||
.and_then(|v| v.iter().find(|(_, p, _)| *p as u32 == *vcount).map(|(_, _, i)| *i as u32));
|
||
// The decoder's assumption, scored: it expects the whole index buffer at
|
||
// `vb - 2*idx_count - pad`, pad ≤ 3.
|
||
let dec_pad = dec_idx.map(|i| *voff as i64 - (i as i64) * 2 - lo);
|
||
match dec_pad {
|
||
Some(0) => pad0 += 1,
|
||
Some(p) if (1..=3).contains(&p) => pad_small += 1,
|
||
Some(_) => pad_off += 1,
|
||
None => unnamed += 1,
|
||
}
|
||
if umax + 1 == *vcount {
|
||
cover_exact += 1;
|
||
} else {
|
||
cover_short += 1;
|
||
}
|
||
match dec_idx {
|
||
Some(i) if i == total => idx_equal += 1,
|
||
Some(_) => idx_partial += 1,
|
||
None => {}
|
||
}
|
||
rows.push((
|
||
*voff,
|
||
format!(
|
||
"vb 0x{:07X} v={:<6} batches {:<3} idx {:<6} span {:<7} gap {:<8} decpad {:<7} cover {:<10} {}",
|
||
voff,
|
||
vcount,
|
||
batches,
|
||
total,
|
||
hi - lo,
|
||
gap,
|
||
dec_pad.map(|p| p.to_string()).unwrap_or_else(|| "?".into()),
|
||
if umax + 1 == *vcount { "exact".to_string() } else { format!("{}/{}", umax, vcount - 1) },
|
||
names
|
||
.map(|v| v
|
||
.iter()
|
||
.map(|(n, p, i)| format!("{n}(v{p},i{i})"))
|
||
.collect::<Vec<_>>()
|
||
.join(" "))
|
||
.unwrap_or_else(|| {
|
||
// Nothing of ours sits here — is it a resource that never
|
||
// decodes? Propose it by (vertex, index) counts.
|
||
undecoded_by_counts
|
||
.get(&(*vcount as usize, total as usize))
|
||
.map(|v| format!("MISSED? {}", v.join(" ")))
|
||
.unwrap_or_else(|| "-".into())
|
||
})
|
||
),
|
||
));
|
||
}
|
||
rows.sort();
|
||
for (_, r) in &rows {
|
||
println!("{r}");
|
||
}
|
||
println!("\nplaced {} drawn buffers in this container", rows.len());
|
||
println!(
|
||
"DECODER layout assumption — whole index buffer at vb - 2*idx_count - pad: pad 0 {pad0} · pad 1..3 {pad_small} · elsewhere {pad_off} · not decoded here {unnamed}"
|
||
);
|
||
println!(
|
||
"index extent: our idx_count == sum of captured batches for {idx_equal} buffers, differs for {idx_partial}"
|
||
);
|
||
println!("vertex-pool coverage by the union of batches: exact {cover_exact} · short {cover_short}");
|
||
}
|