re(xbg7): the [index][vertex] layout is runtime-verified, and the indices= mystery was batching

The decoder's central unstated assumption — a block's index buffer sits
immediately before its vertex buffer (`vb - idx_count*2 - pad`, pad <= 3) — was
also the prime suspect for the residual anchor misses, since a capture-proven
`e106_eng_02_l` block was rejected outright. Measured it instead of assuming:

- extended the F10 ship capture to log each draw's index buffer (base, count,
  min/max index) and to key its de-dup on the index range, so every draw batch
  is recorded rather than only the first;
- `examples/capture_ib_truth.rs` places each drawn buffer in the container by its
  dumped positions and scores the capture against our decode.

Stage_S02, 42 drawn buffers placed: our idx_count == the sum of the draw's index
batches for 42/42, the batch union covers the vertex pool exactly for 42/42, and
all 30 single-block cases sit at pad <= 3 (20 at pad 0, 10 at pad 2). The other
12 are grouped pools, where one index pool serves the whole group. So the layout
holds, the decoded index count is exact, and eng_02_l died on the connectivity
gate (since replaced by the winding gate) — not on index location. The shipped
exact-coverage rule is independently confirmed.

The recorded "capture indices=21 vs our 246" disagreement was an artefact of the
old de-dup key: 21 was the first of two batches, 21 + 225 = 246. Any conclusion
from a pre-2026-08-13 capture's `indices=` or `vbase - ibase` is about one batch,
not about the block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
This commit is contained in:
2026-08-13 05:57:44 +00:00
parent 1b618f500f
commit 6d92e6c114
5 changed files with 334 additions and 5 deletions

View File

@@ -49,6 +49,27 @@ pub struct CapturedDraw {
/// First few LOCAL vertex positions dumped with the draw (buffer order).
/// Used to disambiguate same-vcount twins (mirrored port/starboard parts).
pub pos: Vec<[f32; 3]>,
/// The draw's INDEX buffer, when the capture recorded one (`ib base=…`,
/// added 2026-08-13): guest base address, index count, and the min/max index
/// value the emulator read out of guest memory. `None` for older logs and
/// for auto-index draws. This is ground truth for two things the offline
/// decoder can only assume — where a block's index buffer lives relative to
/// its vertex buffer, and how much of the vertex pool a draw really covers.
pub ib: Option<CapturedIndexBuffer>,
}
/// The index buffer a captured draw used. See [`CapturedDraw::ib`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapturedIndexBuffer {
/// Guest base address of the index data.
pub ibase: u32,
/// Number of indices the draw issued (== `VGT_DRAW_INITIATOR.num_indices`).
pub icount: u32,
/// Lowest index value in the buffer.
pub imin: u32,
/// Highest index value in the buffer — with `vcount` this says whether the
/// draw covers its whole vertex pool or only a sub-range.
pub imax: u32,
}
/// A ship part to match against the capture. `part` is the **base** part name
@@ -100,13 +121,16 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
let mut vcount = 0u32;
let mut pos: Vec<[f32; 3]> = Vec::new();
let mut consts: Vec<(usize, [f64; 4])> = Vec::new();
let mut ib: Option<CapturedIndexBuffer> = None;
let flush = |vbase: u32,
vcount: u32,
pos: &mut Vec<[f32; 3]>,
ib: &mut Option<CapturedIndexBuffer>,
consts: &[(usize, [f64; 4])],
out: &mut Vec<CapturedDraw>| {
let pos = std::mem::take(pos);
let ib = ib.take();
if vbase == 0 {
return;
}
@@ -115,18 +139,33 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
return; // no WorldView for this draw — skip it
};
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
out.push(CapturedDraw { vbase, vcount, r, t, pos });
out.push(CapturedDraw { vbase, vcount, r, t, pos, ib });
}
};
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("DRAW ") {
flush(vbase, vcount, &mut pos, &consts, &mut out);
flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
consts.clear();
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
vbase = f("vbase=0x").and_then(|s| u32::from_str_radix(s, 16).ok()).unwrap_or(0);
vcount = f("vcount=").and_then(|s| s.parse().ok()).unwrap_or(0);
} else if let Some(rest) = l.strip_prefix("ib base=0x") {
// `ib base=0x… count=N fmt=u16 endian=E len=L delta_vb=D min=a max=b idx: …`
let f = |k: &str| rest.split_whitespace().find_map(|t| t.strip_prefix(k));
let base = rest
.split_whitespace()
.next()
.and_then(|s| u32::from_str_radix(s, 16).ok());
if let (Some(ibase), Some(icount)) = (base, f("count=").and_then(|s| s.parse().ok())) {
ib = Some(CapturedIndexBuffer {
ibase,
icount,
imin: f("min=").and_then(|s| s.parse().ok()).unwrap_or(0),
imax: f("max=").and_then(|s| s.parse().ok()).unwrap_or(0),
});
}
} else if l.starts_with("pos:") || l.starts_with("positions:") {
pos = parse_pos_line(l, 8);
} else if l.starts_with("vsconst") {
@@ -147,7 +186,7 @@ pub fn parse_capture(text: &str) -> Vec<CapturedDraw> {
}
}
}
flush(vbase, vcount, &mut pos, &consts, &mut out);
flush(vbase, vcount, &mut pos, &mut ib, &consts, &mut out);
out
}
@@ -242,7 +281,9 @@ pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
let get = |i: usize| consts.iter().find(|(k, _)| *k == i).map(|(_, v)| *v);
let (Some(c0), Some(c1), Some(c2)) = (get(0), get(1), get(2)) else { return };
if let Some((r, t)) = normalize_wvp([c0, c1, c2]) {
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos });
// The draw-logger format carries an index base too, but it de-dups
// by vertex declaration, so it never lines up per part — left None.
out.push(CapturedDraw { vbase: base, vcount: size / stride, r, t, pos, ib: None });
}
};