diff --git a/crates/sylpheed-formats/examples/better_home.rs b/crates/sylpheed-formats/examples/better_home.rs new file mode 100644 index 0000000..af01e0f --- /dev/null +++ b/crates/sylpheed-formats/examples/better_home.rs @@ -0,0 +1,123 @@ +//! Does a resource have a CLEANER home in its container than the one we picked? +//! +//! After the pad-scoring fix, only two resources on the disc still decode to an +//! index run with degenerate triangles — `e201_bdy_03_m` (2 containers) and +//! `_rou_f402_dead` (9). Degeneracy says the run does not fit the pool, so either +//! the vertex block is wrong or the candidate list never offered the right one. +//! This walks every candidate vertex-run start for the resource's declaration and +//! scores each `(start, pad)` the way the anchor now does — degenerate triangles +//! first, then winding, plus coverage — so the answer is one of: +//! * a strictly cleaner candidate exists (the selection is at fault), +//! * several are equally clean (genuinely ambiguous), or +//! * nothing is clean (the block is not in the candidate list at all). +//! +//! Usage: better_home +use sylpheed_formats::mesh::{debug_resource_params, debug_vertex_run_starts, Xbg7Model}; + +fn be16(b: &[u8], at: usize) -> u32 { + ((b[at] as u32) << 8) | b[at + 1] as u32 +} + +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).expect("container"); + let name = &a[2]; + + let Some((markers, stride)) = debug_resource_params(&bytes, name) else { + eprintln!("no such XBG7 resource: {name}"); + std::process::exit(1); + }; + let (vc, ic) = markers[0]; + println!("{name}: {} marker(s), first = {vc} verts / {ic} indices, stride {stride}", markers.len()); + + // Where did the decoder put it? + let ours = Xbg7Model::stage_models(&bytes) + .into_iter() + .find(|m| m.name == *name) + .and_then(|m| m.meshes.first().and_then(|s| s.vbuf_offset)); + println!("our anchor: {ours:?}"); + + let starts = debug_vertex_run_starts(&bytes, stride); + println!("{} candidate vertex-run starts for stride {stride}", starts.len()); + + // Score every (start, pad): degenerate triangles, winding against the stored + // normals, and whether the run covers the pool exactly. + let mut rows: Vec<(usize, f32, usize, usize, usize, bool)> = Vec::new(); // degen, wind, start, pad, max_idx, covered + for &vb in &starts { + for pad in 0..=3usize { + if vb < ic * 2 + pad { + continue; + } + let ib = vb - ic * 2 - pad; + if ib + ic * 2 > bytes.len() || vb + vc * stride > bytes.len() { + continue; + } + let idx: Vec = (0..ic).map(|k| be16(&bytes, ib + k * 2)).collect(); + let max_idx = *idx.iter().max().unwrap_or(&0) as usize; + if max_idx >= vc { + continue; // out of range — not a candidate at all + } + let pos: Vec<[f32; 3]> = (0..vc) + .map(|v| { + let at = vb + v * stride; + [ + f32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()), + f32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()), + f32::from_be_bytes(bytes[at + 8..at + 12].try_into().unwrap()), + ] + }) + .collect(); + if pos.iter().any(|p| p.iter().any(|c| !c.is_finite() || c.abs() > 1e6)) { + continue; + } + let mut degen = 0usize; + let (mut agree, mut counted) = (0usize, 0usize); + for t in idx.chunks_exact(3) { + let (x, y, z) = (t[0] as usize, t[1] as usize, t[2] as usize); + if x == y || y == z || x == z { + degen += 1; + continue; + } + // Winding needs normals; use the geometric centroid normal as a + // stand-in so this stays declaration-agnostic: a consistent mesh + // has all faces pointing away from the centroid on a convex-ish + // hull. Weak, so degeneracy leads the sort. + let e1 = [pos[y][0] - pos[x][0], pos[y][1] - pos[x][1], pos[y][2] - pos[x][2]]; + let e2 = [pos[z][0] - pos[x][0], pos[z][1] - pos[x][1], pos[z][2] - pos[x][2]]; + let f = [ + e1[1] * e2[2] - e1[2] * e2[1], + e1[2] * e2[0] - e1[0] * e2[2], + e1[0] * e2[1] - e1[1] * e2[0], + ]; + let cx: [f32; 3] = { + let mut c = [0.0f32; 3]; + for p in &pos { + for k in 0..3 { + c[k] += p[k] / pos.len() as f32; + } + } + c + }; + let out = [pos[x][0] - cx[0], pos[x][1] - cx[1], pos[x][2] - cx[2]]; + counted += 1; + if f[0] * out[0] + f[1] * out[1] + f[2] * out[2] > 0.0 { + agree += 1; + } + } + let w = if counted == 0 { 0.0 } else { agree as f32 / counted as f32 }; + rows.push((degen, w.max(1.0 - w), vb, pad, max_idx, max_idx + 1 == vc)); + } + } + rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.total_cmp(&a.1))); + println!("\n{} in-range candidates; best 12 by (degenerate, winding):", rows.len()); + for (d, w, vb, pad, mx, cov) in rows.iter().take(12) { + let mark = if Some(*vb) == ours { " <-- ours" } else { "" }; + println!( + " vb 0x{vb:07X} pad {pad} degen {d:>4} wind {w:.3} max_idx {mx}/{} {}{mark}", + vc - 1, + if *cov { "covers" } else { "SHORT" } + ); + } + let clean = rows.iter().filter(|r| r.0 == 0 && r.5).count(); + println!("\n{clean} candidates are degenerate-free AND cover the pool exactly"); +} diff --git a/crates/sylpheed-formats/examples/pad_shift_audit.rs b/crates/sylpheed-formats/examples/pad_shift_audit.rs index 5ea8062..d26606d 100644 --- a/crates/sylpheed-formats/examples/pad_shift_audit.rs +++ b/crates/sylpheed-formats/examples/pad_shift_audit.rs @@ -67,6 +67,16 @@ fn main() { let where_ = f.file_name().unwrap().to_string_lossy().to_string(); let mut c_moved = 0usize; for m in Xbg7Model::stage_models(&bytes) { + // Degeneracy is counted over EVERY sub-mesh (grouped pools included — + // they have their own pad choice); the pad-0 comparison below only + // applies to single-block resources, whose run starts at `vb - 2n`. + for sm in &m.meshes { + let d = degenerate(&sm.indices); + if d > 0 { + still_degen += 1; + worst.push((d, m.name.clone(), where_.clone())); + } + } if m.meshes.len() != 1 { continue; } @@ -98,11 +108,6 @@ fn main() { weak_wind.push((w0, w2, m.name.clone())); } } - let d = degenerate(&sm.indices); - if d > 0 { - still_degen += 1; - worst.push((d, m.name.clone(), where_.clone())); - } } if c_moved > 0 { println!("{where_:<24} {c_moved} resources read from a non-pad-0 index run"); diff --git a/crates/sylpheed-formats/examples/probe_anchor.rs b/crates/sylpheed-formats/examples/probe_anchor.rs new file mode 100644 index 0000000..5c378a2 --- /dev/null +++ b/crates/sylpheed-formats/examples/probe_anchor.rs @@ -0,0 +1,6 @@ +fn main() { + let a: Vec = std::env::args().collect(); + let bytes = std::fs::read(&a[1]).unwrap(); + let vb: usize = a[3].parse().unwrap(); + println!("{:?}", sylpheed_formats::mesh::debug_try_anchor(&bytes, &a[2], vb, 3)); +} diff --git a/crates/sylpheed-formats/src/mesh.rs b/crates/sylpheed-formats/src/mesh.rs index 9d38284..697d7ea 100644 --- a/crates/sylpheed-formats/src/mesh.rs +++ b/crates/sylpheed-formats/src/mesh.rs @@ -1281,6 +1281,9 @@ fn anchor_pool_mesh( min_vb: usize, ) -> Option { let idx_bytes = index_count * 2; + // First accepted candidate whose index run still has degenerate triangles — + // used only if no clean candidate exists anywhere (see the end of the loop). + let mut dirty: Option<(usize, usize)> = None; for &vb in starts { // Monotone assignment (opt-in): resources of one signature are laid out // in descriptor order, so a later one may not take an earlier block. @@ -1339,11 +1342,36 @@ fn anchor_pool_mesh( } } } - if let Some((_, _, pad)) = best { - // ── Accepted: read the full mesh. ── - return Some(read_pool_mesh(bytes, vb - idx_bytes - pad, vb, index_count, vtx_count, decl)); + if let Some((degen, _, pad)) = best { + // A candidate whose triangles are degenerate-free is preferred over an + // earlier one that is not. This keeps first-match order for every + // clean hit (the overwhelming majority) and only searches on when the + // first accepted block is provably mis-fitted — the two resources that + // survived the pad fix (`e201_bdy_03_m`, `_rou_f402_dead`) each have + // exactly ONE degenerate-free block in their container, sitting later + // in file order than the lookalike we were taking. + if degen == 0 || pad_first_match() { + return Some(read_pool_mesh(bytes, vb - idx_bytes - pad, vb, index_count, vtx_count, decl)); + } + if dirty.is_none() { + dirty = Some((vb, pad)); + } } } + // NOT DONE, deliberately: when every validating candidate is degenerate one + // could re-scan without the pad-0 winding floor, since degeneracy is the + // stronger witness. Written and measured 2026-08-13 — it fires for **nothing** + // on the disc. The single remaining dirty resource (`_rou_f402_dead` in + // `Stage_S09`) does have a degenerate-free block that validates at + // `XBG7_PAD0_CONSISTENCY=0`, but it is CLAIMED by `e_rou_f003_Near`, so + // distinct assignment — not the winding floor — is what blocks it. Both are + // 24-vertex bounding boxes, i.e. the box-identity class that needs + // descriptor-level data rather than another anchoring heuristic (see the docs). + // Nothing clean anywhere: keep the first accepted block, so this can never + // cost coverage relative to first-match. + if let Some((vb, pad)) = dirty { + return Some(read_pool_mesh(bytes, vb - idx_bytes - pad, vb, index_count, vtx_count, decl)); + } None } @@ -1617,6 +1645,12 @@ fn anchor_grouped_meshes( if taken.contains(&vb0) { continue; } + // Which pad? Not the first that validates — the same trap the searched + // path had until 2026-08-13: a pool read one index element late still + // validates but wires every triangle wrongly. Score the pivot's index run + // (degenerate triangles first, then winding) and keep the cleanest pad. + // `XBG7_PAD_FIRST_MATCH=1` restores first-match here too. + let mut best: Option<(usize, f32, usize)> = None; for pad in 0..=3usize { if vb0 < span + pad { continue; @@ -1634,6 +1668,17 @@ fn anchor_grouped_meshes( if !validate_block(bytes, ib_k, vb_k, vck, ick, decl, grouped_consistency(), true) { continue; } + let (degen, wind) = index_run_quality(bytes, ib_k, vb_k, ick, decl); + let cand = (degen, -wind, pad); + if best.map_or(true, |b| cand < b) { + best = Some(cand); + } + if pad_first_match() { + break; + } + } + if let Some((_, _, pad)) = best { + let ib0 = vb0 - span - pad; // Pivot confirmed the exact alignment ⇒ every marker up to the pivot // is correctly placed; read those unconditionally (a legitimately @@ -1687,7 +1732,6 @@ fn anchor_grouped_meshes( Vec::new() } -/// Read positions / normals / uvs / indices for an anchored stage block. /// How clean is the triangle list at `ib` against the pool at `vb`? /// /// Returns `(degenerate triangles, winding agreement)` — the two properties that @@ -1720,6 +1764,7 @@ fn index_run_quality( (degen, na.max(1.0 - na)) } +/// Read positions / normals / uvs / indices for an anchored stage block. fn read_pool_mesh( bytes: &[u8], ib: usize, diff --git a/crates/sylpheed-formats/tests/mesh_disc.rs b/crates/sylpheed-formats/tests/mesh_disc.rs index 5630857..6dc5a0f 100644 --- a/crates/sylpheed-formats/tests/mesh_disc.rs +++ b/crates/sylpheed-formats/tests/mesh_disc.rs @@ -430,8 +430,10 @@ fn stage_models_quality_audit() { /// element late still passes every count-based gate but wires arbitrary /// vertices, which produces triangles with a repeated index. Before the fix /// **579** decoded runs on the disc carried such triangles (and a runtime -/// capture confirmed 17 of 93 index batches disagreed with the GPU); after it, -/// 16 sub-meshes do — all in resources that are separately known-broken. Locking the +/// capture confirmed 17 of 93 index batches disagreed with the GPU); after it and +/// the two follow-ups (pad scoring in the grouped path, and preferring a +/// degenerate-free candidate over an earlier dirty one), exactly **one** does. +/// Locking the /// number in, because the defect is invisible to coverage and to the anchor /// oracle: every count stays correct while the geometry is mis-wired. #[test] @@ -466,14 +468,13 @@ fn decoded_index_runs_have_almost_no_degenerate_triangles() { } } } - // The known 16 sub-meshes: the grouped `.dat` break composites in `ptc_pack` - // (`f102`/`f104`/`e107`, whose marker lists are documented not to map onto the - // stored blocks — 5 sub-meshes), plus `e201_bdy_03_m` (2 containers) and - // `_rou_f402_dead` (9). Every one has an independently recorded anchoring - // problem; a NEW name appearing here is a regression. + // The one: `_rou_f402_dead` in `Stage_S09`, whose degenerate-free block is + // claimed by `e_rou_f003_Near` — both 24-vertex bounding boxes, the identity + // class that needs descriptor-level data, not another heuristic. Anything + // else appearing here is a regression. assert!( - offenders.len() <= 16, - "{} decoded index runs contain degenerate triangles (expected ≤ 16): {:?}", + offenders.len() <= 1, + "{} decoded index runs contain degenerate triangles (expected ≤ 1): {:?}", offenders.len(), &offenders[..offenders.len().min(20)] ); diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 0aa821b..5a5d10e 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -19,7 +19,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes | LSTA sprite list | ✅ | `sylpheed-formats/src/lsta.rs` | A display list of inline elements: **T8aD sprites and `PRMD` primitives**. The `count` at `0x04` is **exact and counts both** — `count == T8aD + PRMD` for **64/64** lists on the disc, which retires the old "a few entries disagree" note (it compared sprites against a total including primitives). **All 1 281 sprite frames decode** after the T8aD rectangle-list fix | | IXUD subtitle | 🟡/✅ | `sylpheed-formats/src/ixud.rs` + [movie link](movie-subtitle-link.md) | timed cues. **The movie↔subtitle↔voice link is solved — statically**, from the movie config record in `tables.pak` (schema `0x067025b9`), not from the running game as this row previously assumed: [101 movies mapped](captures/movie-subtitle-voice-map.csv), 94 with subtitles, 83 with voice, 21 with a telop overlay. 93 of 94 subtitle refs resolve in the language paks; **`SUBTITLE_S12B.tbl` is missing from all six languages** — a dangling reference on the disc. Naming is `SUBTITLE_.tbl` / `VOICE_` with six documented exceptions. The record's ~104 **script ids** are ❔ — positional pairing drifts by three because the IDXD pool dedupes repeated values | | Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser | -| XBG7 mesh | ✅/🟡 | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | **6 294 resources, 6 209 decode (98.7 %), 82 searched-and-missed** (2026-08-12, up from 5 480 / 87.1 %). Five evidence-driven fixes got there: **distinct anchor assignment** (no two resources may claim one buffer — proved by a capture showing the container holds both mirrored `e106` hull halves), the connectivity cap replaced by a **winding-consistency gate at 0.70**, **structural requirements on pre-pivot sub-meshes** (index range, then exact pool coverage), and **filtering after the assignment** so a subset query cannot differ from the full decode. Validated against a runtime capture that names the file offset of every buffer the engine drew: **46/46 drawn buffers claimed, 45 anchored exactly**. **No real mesh now decodes differently in different containers** — all 89 remaining cross-container disagreements are interchangeable 24-vertex bounding boxes, which no anchoring rule can pin (monotone order re-tested and refuted). Remaining misses attribute to the degeneracy/extent gate (42), winding (31) and coverage (9); the first was probed and its "obvious" fix refuted. Every decoded sub-mesh covers its own vertex pool. **The `[index buffer][vertex buffer]` layout is now runtime-verified** (2026-08-13): with the F10 capture extended to log each draw's index buffer, all **42** drawn `Stage_S02` buffers match our decoded index count exactly, all 42 have their index union cover the pool exactly, and the 30 single-block cases all sit at `pad ≤ 3` — so `e106_eng_02_l`'s old rejection was the connectivity gate, not a misplaced index buffer. The `indices=` mystery was the capture keeping only the **first of several index batches** per buffer. **And comparing index VALUES found the biggest silent defect yet**: the anchor took the first `pad` that validated, so a block whose index data sits at pad 2 was read **one element late** — 76/93 captured runs matched, all 17 differences a one-element shift. Scoring pads by degenerate triangles + winding fixes it: **93/93** captured runs now match byte for byte, disc-wide degenerate runs **579 → 16**, **575 of 8 850** sub-meshes re-wired, with resources decoded, vertex anchors and cross-container consistency all unchanged | +| XBG7 mesh | ✅/🟡 | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | **6 294 resources, 6 209 decode (98.7 %), 82 searched-and-missed** (2026-08-12, up from 5 480 / 87.1 %). Five evidence-driven fixes got there: **distinct anchor assignment** (no two resources may claim one buffer — proved by a capture showing the container holds both mirrored `e106` hull halves), the connectivity cap replaced by a **winding-consistency gate at 0.70**, **structural requirements on pre-pivot sub-meshes** (index range, then exact pool coverage), and **filtering after the assignment** so a subset query cannot differ from the full decode. Validated against a runtime capture that names the file offset of every buffer the engine drew: **46/46 drawn buffers claimed, 45 anchored exactly**. **No real mesh now decodes differently in different containers** — all 89 remaining cross-container disagreements are interchangeable 24-vertex bounding boxes, which no anchoring rule can pin (monotone order re-tested and refuted). Remaining misses attribute to the degeneracy/extent gate (42), winding (31) and coverage (9); the first was probed and its "obvious" fix refuted. Every decoded sub-mesh covers its own vertex pool. **The `[index buffer][vertex buffer]` layout is now runtime-verified** (2026-08-13): with the F10 capture extended to log each draw's index buffer, all **42** drawn `Stage_S02` buffers match our decoded index count exactly, all 42 have their index union cover the pool exactly, and the 30 single-block cases all sit at `pad ≤ 3` — so `e106_eng_02_l`'s old rejection was the connectivity gate, not a misplaced index buffer. The `indices=` mystery was the capture keeping only the **first of several index batches** per buffer. **And comparing index VALUES found the biggest silent defect yet**: the anchor took the first `pad` that validated, so a block whose index data sits at pad 2 was read **one element late** — 76/93 captured runs matched, all 17 differences a one-element shift. Scoring pads by degenerate triangles + winding fixes it: **93/93** captured runs now match byte for byte, disc-wide degenerate runs **582 → 1** (the grouped path had the same bug; and two resources were anchored on a degenerate lookalike earlier in file order), **590 of 8 850** sub-meshes re-wired with 10 vertex anchors moved, resources decoded unchanged at 6 209. Cross-container minority decodes 89 → 96 — *because* the decoder improved: `_rou_f402_dead` now has a majority (32×25×8) so its seven wrong copies are named instead of hidden. One dirty run remains, blocked by distinct assignment on a 24-vertex box | | Capital-ship part placement | ✅ | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | Placement is **sound** (hull static-exact against the `e106` capture; cross-id mounting genuinely narrow, 2 pairs across 335 ships). The XBG7 mis-decode this row used to blame for "ships assemble wrong" — a shared turret ~100× too large in some containers — is **fixed** (2026-08-12, the exact-coverage requirement): `e303_wep_01` now decodes 49×23×42 everywhere and places at ±179 on the `e106` hull, and no real mesh disagrees across containers. A composite-node audit confirmed the assembler itself never applied a bad scale (all nodes scale 1.0, orthonormal). Still open: `static_assembly_matches_runtime_capture` walks capture parts only, so **extra** static placements cannot fail it | | Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check | | Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. **Re-derived independently 2026-08-13 from the loader's own key strings** (`sub_82341A20`; the field name for each store is a string in the image): **159 fields**, agreeing with this solver on **25 of 25 shared offsets**, verified at **406 values matching the disc and 0 disagreeing** over 11 live objects spanning UNIT and VESSEL — landed as `data/unit_definition_layout.txt` + `sylpheed_formats::unit_layout` + a no-emulator test, with **121 defaulted fields** read out ([live-unit-definitions](live-unit-definitions.md)). Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) | diff --git a/docs/re/structures/xbg7-mesh.md b/docs/re/structures/xbg7-mesh.md index 244fc1c..e20e893 100644 --- a/docs/re/structures/xbg7-mesh.md +++ b/docs/re/structures/xbg7-mesh.md @@ -1458,3 +1458,53 @@ panel lines, bridge tower and funnels. Same 31 991 triangles, same 4 placements, same bounds — only the wiring differs. Third time this project has learned it: **render the output**; a metric that cannot see a 1 600-unit slab could not see this either. + +### ✅ Following it through: degenerate index runs 582 → 1 (2026-08-13) + +The pad-scoring fix left 16 dirty sub-meshes. Two follow-ups cleared all but one. + +**1. The grouped path had the same first-match bug.** `anchor_grouped_meshes` +picks `ib0 = vb0 − span − pad` the same way, so it got the same scoring (pivot +run's degenerate count, then winding). That cleared every remaining `ptc_pack` +composite — `f102_break.dat` (161 degenerate triangles), `f104_break.dat` (3 +sub-meshes) and `e107_break.dat` — 16 → 11. + +**2. Prefer a degenerate-free candidate over an earlier dirty one.** The last two +resources were anchored on a *lookalike earlier in file order*: +`examples/better_home.rs` scores every candidate `(start, pad)` for a resource and +showed **exactly one** degenerate-free, pool-covering block for `e201_bdy_03_m` +(`0x316383C`, ours was `0x248D054` with 4 degenerate triangles) and one for +`_rou_f402_dead`. So `anchor_pool_mesh` now keeps first-match order for every +clean hit and only searches on when the accepted block is provably mis-fitted, +falling back to the dirty block if nothing clean exists (coverage can never +regress). 11 → 1. + +| | first-match | + pad scoring | + these two | +|---|---|---|---| +| decoded sub-meshes with a degenerate index run (disc-wide) | 582 | 11 | **1** | +| captured index runs identical (`Stage_S02`) | 76/93 | 93/93 | **93/93** | +| resources decoded / misses | 6 209 / 85 | 6 209 / 85 | **6 209 / 85** | +| sub-meshes whose index run changed | — | 580 | 590 | +| of those, vertex anchor moved | — | 0 | **10** (`_rou_f402_dead` ×8, `e201_bdy_03_m` ×2) | + +**The consistency screen went 89 → 96 minority decodes, and that is progress.** +All seven new rows are `_rou_f402_dead`: eight containers now agree on a +**32 × 25 × 8** box, which gives the resource a *majority for the first time*, so +the seven copies that still land elsewhere (`160×160×78`, `519×100000×519`, +`856×463×803`, `779×5769×5769`) are finally **named** instead of hiding behind "no +majority". Textbook case of consistency being the weaker witness: the number rose +because the decoder got better. + +**The one remaining dirty run** is `_rou_f402_dead` in `Stage_S09`. Its +degenerate-free block (`0x17AE620`, validates at pad 0) is **claimed by +`e_rou_f003_Near`** — so distinct assignment blocks it, not a gate. Both are +24-vertex bounding boxes: the identity class that needs descriptor-level data, not +another heuristic. A winding-floor escalation for this case was written, measured +to fire for **nothing** on the disc, and reverted (the comment survives in +`anchor_pool_mesh`). + +**Next target, with the caveat that killed the last attempt:** six containers +decode `_rou_f402_dead` to a *clean but wrong* block, so several degenerate-free +candidates exist and file order picks badly. The majority span would separate +them — but "flag, don't silently rewrite geometry on a vote" still stands, so this +needs the box-identity data, or a capture of a stage that draws it.