//! 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 -- ... 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 = std::env::args().collect(); if a.len() < 3 { eprintln!("usage: capture_ib_truth ..."); 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 = 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> = 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 = 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> = 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()); // ── The report: one row per drawn BUFFER, aggregating its index batches. let mut per_buf: HashMap, 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::>() .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}"); }