re(xbg7): a capture names a never-decoding family, and the cause is mixed strides in one pool

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
This commit is contained in:
2026-08-13 10:52:35 +00:00
parent 92d36ec10f
commit 27577538cb
5 changed files with 179 additions and 2 deletions

View File

@@ -17,7 +17,7 @@
//!
//! Usage:
//! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>...
use sylpheed_formats::mesh::Xbg7Model;
use sylpheed_formats::mesh::{debug_resource_params, xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
use std::collections::HashMap;
@@ -113,6 +113,25 @@ fn main() {
}
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();
@@ -177,7 +196,14 @@ fn main() {
.map(|(n, p, i)| format!("{n}(v{p},i{i})"))
.collect::<Vec<_>>()
.join(" "))
.unwrap_or_else(|| "-".into())
.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())
})
),
));
}

View File

@@ -0,0 +1,2 @@
fn main(){let a:Vec<String>=std::env::args().collect();let b=std::fs::read(&a[1]).unwrap();
for l in sylpheed_formats::mesh::debug_grouped_report(&b,&a[2],a[3].parse().unwrap()){println!("{l}")}}

View File

@@ -0,0 +1,86 @@
//! Which container should the next runtime capture aim at?
//!
//! The 2026-08-13 stage-05 capture showed that a mission keeps several containers
//! resident and that its OWN `Stage_SNN.xpr` is among them (the f101 ACROPOLIS was
//! drawn from `Stage_S05.xpr`), so a capture can be aimed by choosing the mission.
//! This ranks the targets: for every XBG7 resource on the disc it records the
//! containers where it decodes and the ones where it does not, then reports
//!
//! * resources that decode **nowhere** — the real gaps, where a capture is the
//! only ground truth left;
//! * per container, how many of those it holds (fly that mission);
//! * resources that miss in one container but decode in another — a defect worth
//! fixing, but the geometry is already recoverable, so a capture is not needed.
//!
//! Usage: miss_targets <resource3d_dir>
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use std::collections::{BTreeMap, BTreeSet};
fn main() {
let dir = std::env::args().nth(1).expect("resource3d dir");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("xpr"))
.collect();
files.sort();
// resource -> (containers where it decodes, containers where it misses)
let mut ok: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut miss: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let where_ = f.file_name().unwrap().to_string_lossy().to_string();
let declared: BTreeSet<String> = xbg7_resource_names(&bytes).into_iter().collect();
if declared.is_empty() {
continue;
}
let decoded: BTreeSet<String> =
Xbg7Model::stage_models(&bytes).into_iter().map(|m| m.name).collect();
for n in &declared {
if decoded.contains(n) {
ok.entry(n.clone()).or_default().insert(where_.clone());
} else {
miss.entry(n.clone()).or_default().insert(where_.clone());
}
}
}
let never: BTreeMap<&String, &BTreeSet<String>> =
miss.iter().filter(|(n, _)| !ok.contains_key(*n)).collect();
let recoverable: Vec<&String> = miss.keys().filter(|n| ok.contains_key(*n)).collect();
println!("resources that decode NOWHERE: {}", never.len());
println!("resources that miss somewhere but decode elsewhere: {}\n", recoverable.len());
// Rank containers by how many never-decoding resources they hold.
let mut per_container: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
for (n, wheres) in &never {
for w in wheres.iter() {
per_container.entry(w).or_default().push(n);
}
}
let mut ranked: Vec<_> = per_container.iter().collect();
ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
println!("container never-decoding resources it holds");
for (w, v) in ranked.iter().take(14) {
let sample: Vec<&str> = v.iter().take(6).map(|s| s.as_str()).collect();
println!("{:<26} {:>3} {}", w, v.len(), sample.join(", "));
}
// Exclusives: a never-decoding resource that only ONE container holds is only
// reachable through whatever loads that container.
let excl: Vec<(&&String, &&BTreeSet<String>)> =
never.iter().filter(|(_, w)| w.len() == 1).collect();
println!("\nof the never-decoding, {} live in exactly one container", excl.len());
let mut by_one: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
for (n, w) in &excl {
by_one.entry(w.iter().next().unwrap()).or_default().push(n);
}
let mut b: Vec<_> = by_one.iter().collect();
b.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
for (w, v) in b.iter().take(10) {
println!(" {:<24} {:>3} {}", w, v.len(), v.iter().take(5).map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
}
}

View File

@@ -0,0 +1,11 @@
fn main(){
let a:Vec<String>=std::env::args().collect();
let bytes=std::fs::read(&a[1]).unwrap();
let filter=a.get(2).cloned().unwrap_or_default();
for n in sylpheed_formats::mesh::xbg7_resource_names(&bytes) {
if !filter.is_empty() && !n.contains(&filter) { continue }
if let Some((m,stride))=sylpheed_formats::mesh::debug_resource_params(&bytes,&n) {
println!("{n:<28} stride {stride} markers {:?}", &m[..m.len().min(4)]);
}
}
}

View File

@@ -1550,3 +1550,55 @@ which is a static question (resource names per container × the stage's
`EnumUnit_SNN` roster), not another capture run. That is the cheap way to aim the
next capture, and the first-ever truth rows for a non-`Stage_S02` container (the
five f101 rows above) show the method works the moment the roster does.
### 🔎 ROOT CAUSE for a never-decoding family: a grouped pool can MIX STRIDES (2026-08-13)
A capture can do more than check anchors — it can name a resource we never decode.
`capture_ib_truth` now proposes an identity for every drawn buffer our decoder
cannot place, by matching the buffer's `(vertex, index)` counts against
**declared-but-not-decoded** resources. In the stage-05 and stage-16 mission
captures that fired on one buffer, and it identified a member of the
never-decoding set: **`n201_01` / `_02` / `_03`** in `Stage_S02.xpr` (three of the
63 resources that decode in no container at all).
The capture pins the whole group. Its four declared markers are
`(777,4464) (869,4464) (192,576) (869,4464)`, and the drawn offsets are
| sub | vertex buffer | index buffer | indices | verts | **stride** | shader |
|---|---|---|---|---|---|---|
| #0 | `0x32BA71C` | `0x32B39FC` | 4464 | 777 | **24** | `0xD2D7612373F4AE3D` |
| #1 | `0x32BEFF4` | `0x32B5CDC` | 4464 | 869 | **24** | `0xD742809411292720` |
| #2 | `0x32C416C` | `0x32B7FBC` | 576 | 192 | **24** | `0x57A54C86C90995F8` |
| #3 | `0x32C536C` | `0x32B843C` | 4464 | 869 | **28** | `0x4F5B7E6C2ED3460A` |
Measured against the file, the layout is **exactly what the decoder assumes**: the
index buffers pack tightly (`#0` ends where `#1` starts, …), the last one ends
*flush* at `vb0` (so pad 0, and the pool span 27 936 matches
`align4`-summed markers to the byte), the vertex buffers are contiguous, and every
sub-mesh's max index is `verts 1`. Nothing about the grouping is wrong.
**What is wrong is one stride.** Sub-mesh #3 is stride **28**, not 24 — and each
sub-mesh has its **own vertex shader**, i.e. its own declaration.
`anchor_grouped_meshes` parses a *single* declaration per resource and applies its
stride to the whole pool, so it reads #3 out of phase: dumping that pool at stride
24 gives 372 non-finite position components out of 2 607, and
`mesh::debug_grouped_report` at the capture-proven `vb0` reports exactly that —
`pad 0: position component NaN is not finite/plausible`. The pivot is chosen as the
largest index count, ties going to the last marker, which lands **on the one
sub-mesh whose stride is wrong**, so the entire resource is declined.
So this family is not a threshold problem and not an anchoring problem: **the
descriptor must carry a declaration per sub-mesh**, and reading one for all of them
is the defect. Next step is to look for those per-sub-mesh declarations in the
descriptor (the four distinct shader hashes say the game has four), then let the
grouped path use them.
**Targeting, for the record** (`examples/miss_targets.rs`): of the misses, **63
decode nowhere** and 13 miss in one container while decoding in another. The
never-decoding set clusters as `Stage_S16.xpr` 21 (all `e901_wing_05_*`),
`ptc_pack.xpr` 12 (`.DAT` composites), `Base.xpr` 6 (`g005`, `t170`, `t180`, …),
then per-stage `n2xx` station groups (S02 `n201`, S06 `n202`, S07 `n203`,
S09/S25 `n205`, S15 `n207_208`). 58 of the 63 live in exactly one container.
Flying stage 16 did **not** get the `e901` wings drawn (the boss appears later in
the mission), which is the next lesson: choosing the mission puts a container in
memory, but the unit still has to be **on screen** for a draw to exist.