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:
@@ -17,7 +17,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Usage:
|
||||||
//! cargo run --release --example capture_ib_truth -- <Stage_SNN.xpr> <capture.log>...
|
//! 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 sylpheed_formats::ship_capture::{parse_capture, CapturedDraw};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -113,6 +113,25 @@ fn main() {
|
|||||||
}
|
}
|
||||||
println!("decoded {} resources, {} distinct vertex offsets\n", models.len(), by_off.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.
|
// ── 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)> =
|
let mut per_buf: HashMap<u32, (usize, Vec<sylpheed_formats::ship_capture::CapturedIndexBuffer>, u32)> =
|
||||||
HashMap::new();
|
HashMap::new();
|
||||||
@@ -177,7 +196,14 @@ fn main() {
|
|||||||
.map(|(n, p, i)| format!("{n}(v{p},i{i})"))
|
.map(|(n, p, i)| format!("{n}(v{p},i{i})"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" "))
|
.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())
|
.unwrap_or_else(|| "-".into())
|
||||||
|
})
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
2
crates/sylpheed-formats/examples/grouped_report.rs
Normal file
2
crates/sylpheed-formats/examples/grouped_report.rs
Normal 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}")}}
|
||||||
86
crates/sylpheed-formats/examples/miss_targets.rs
Normal file
86
crates/sylpheed-formats/examples/miss_targets.rs
Normal 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(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/sylpheed-formats/examples/params.rs
Normal file
11
crates/sylpheed-formats/examples/params.rs
Normal 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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
`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
|
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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user