diff --git a/crates/sylpheed-formats/examples/capture_ib_truth.rs b/crates/sylpheed-formats/examples/capture_ib_truth.rs index 6e63c5c..19a3925 100644 --- a/crates/sylpheed-formats/examples/capture_ib_truth.rs +++ b/crates/sylpheed-formats/examples/capture_ib_truth.rs @@ -17,7 +17,7 @@ //! //! Usage: //! cargo run --release --example capture_ib_truth -- ... -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 = + models.iter().map(|m| m.name.clone()).collect(); + let mut undecoded_by_counts: HashMap<(usize, usize), Vec> = 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)> = HashMap::new(); @@ -177,7 +196,14 @@ fn main() { .map(|(n, p, i)| format!("{n}(v{p},i{i})")) .collect::>() .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()) + }) ), )); } diff --git a/crates/sylpheed-formats/examples/grouped_report.rs b/crates/sylpheed-formats/examples/grouped_report.rs new file mode 100644 index 0000000..1361ae4 --- /dev/null +++ b/crates/sylpheed-formats/examples/grouped_report.rs @@ -0,0 +1,2 @@ +fn main(){let a:Vec=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}")}} diff --git a/crates/sylpheed-formats/examples/miss_targets.rs b/crates/sylpheed-formats/examples/miss_targets.rs new file mode 100644 index 0000000..432ffc2 --- /dev/null +++ b/crates/sylpheed-formats/examples/miss_targets.rs @@ -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 +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> = BTreeMap::new(); + let mut miss: BTreeMap> = 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 = xbg7_resource_names(&bytes).into_iter().collect(); + if declared.is_empty() { + continue; + } + let decoded: BTreeSet = + 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> = + 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)> = + 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::>().join(", ")); + } +} diff --git a/crates/sylpheed-formats/examples/params.rs b/crates/sylpheed-formats/examples/params.rs new file mode 100644 index 0000000..6279b43 --- /dev/null +++ b/crates/sylpheed-formats/examples/params.rs @@ -0,0 +1,11 @@ +fn main(){ + let a:Vec=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)]); + } + } +} diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index 30d54b9..93a7dcb 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -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.