Files
Syplheed-Reborn/docs/re/structures/xbg7-mesh.md
Claude (auto-RE) 0776beb6f4 fix(xbg7): degenerate index runs 582 -> 1 (grouped path + prefer a clean candidate)
Two follow-ups to the pad-scoring fix, both driven by the same invariant (a
correctly located index run has no degenerate triangles):

- anchor_grouped_meshes picked its pad by first-match too; scoring the pivot run
  the same way cleared every remaining ptc_pack composite (f102/f104/e107).
- anchor_pool_mesh now prefers a degenerate-free candidate over an earlier dirty
  one. examples/better_home.rs showed the last two resources each had exactly one
  degenerate-free, pool-covering block, sitting later in file order than the
  lookalike we took. First-match order is kept for every clean hit, and a dirty
  block is still used if nothing clean exists, so coverage cannot regress.

  degenerate index runs, disc-wide:      582 -> 11 -> 1
  captured index runs identical:         93/93 (unchanged)
  resources decoded / misses:            6 209 / 85 (unchanged)
  index runs changed / anchors moved:    590 / 10 (_rou_f402_dead x8, e201_bdy_03_m x2)

Cross-container minority decodes 89 -> 96, and that is progress: all seven new
rows are _rou_f402_dead, which now has a majority (32x25x8) for the first time, so
its seven wrong copies are named instead of hidden behind "no majority".

The last dirty run (_rou_f402_dead in Stage_S09) is blocked by distinct assignment
— its clean block is claimed by e_rou_f003_Near, both 24-vertex bounding boxes. A
winding-floor escalation for that case was written, measured to fire for nothing,
and reverted; the reasoning is kept as a comment.

Regression threshold tightened to 1. Suite green with --include-ignored apart from
the pre-existing known-failing cross-container consistency test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 08:52:41 +00:00

88 KiB
Raw Blame History

XBG7 — mesh geometry (inside XPR2 model containers)

  • Confidence: 🟡 PROBABLE for the single-stream layout (below); HYPOTHESIS / undecoded for the complex multi-stream body layout.
  • Parser in: sylpheed-formats/src/mesh.rs (Xbg7Model::from_xpr2), tests tests/mesh_disc.rs. Container parsing reused from src/texture.rs (Xpr2Header / Xpr2ResourceEntry).
  • Applies to: ship / weapon / prop models in hidden/resource3d/*.xpr (166 files).
  • Method: clean-room — static hex inspection of the retail disc + geometric validation of the recovered triangles (non-degenerate area, indices in range, bbox matches the descriptor's stored size). No game code decompiled or copied.

Where XBG7 lives

Models are ordinary XPR2 containers (see the XPR2 texture doc / texture.rs). The 16-byte resource-directory entries (from file offset 0x10) carry TX2D texture resources and one or more XBG7 geometry resources:

entry = [ tag:4 ][ data_offset:u32 ][ descriptor_size:u32 ][ name_offset:u32 ]   (big-endian)

Offsets are relative to the directory base 0x10. The XBG7 descriptor (at data_offset+0x10, descriptor_size bytes) is a scene / material / node graph — it holds node names (rou_f001_mnt1_root, Light, …), a bounding value (0x41F00000 = 30.0 ≈ the ship's ~30-unit length), material names matching the TX2D channels (_col albedo, _spc specular, _gls gloss, _lum luminance), and per-sub-mesh records. The vertex / index buffers live in the container's shared data section (from header_size).

Sub-mesh records (in the descriptor)

Read in file order by a sliding 4-byte scan; each is a big-endian tuple:

[ vtx_count:u32 ][ 0:u32 ][ idx_count:u32 ][ tail:u32 ]
   3..=65535        ==0     mult. of 3        1..=64

(For rou_f001_wep_00: vtx_count=215, idx_count=1092 — matches the recovered geometry exactly.)

The single-stream data layout — 🟡 PROBABLE (decoded, GPU-cross-checked)

For 36 of the 166 models (weapons, simple props) the data section is a straight sequence of sub-meshes, carved from header_size in record order:

per sub-mesh block:
  [ 12-byte header  (contents undecoded) ]
  [ index buffer  : idx_count × u16 BE ]        triangle list (prim=4, GPU-confirmed)
  [ vertex buffer : vtx_count × stride bytes ]  ← declaration-driven
  (pad to 16 bytes → next sub-mesh)

The 12-byte header precedes the index buffer (the same block shape as stage resources — see below); the vertex buffer follows the indices with no further gap. (Earlier this was mis-modelled as [index][12-byte gap][vertex], which put the vertex buffer at the identical offset but read the index buffer 12 bytes too early — turning the 12 header bytes into 6 junk indices = 2 leading degenerate triangles (a stray-triangle artifact) and dropping the last 6 real indices. Skipping the header fixes the triangle list with no change to vertex coverage.)

Vertex declaration. The layout is not fixed-stride. The descriptor holds a declaration table (right after the (index_bytes, index_count) marker) of {offset:u32, format-code:u32, usage<<16:u32} big-endian triples, terminated by offset == 0x00FF0000 / code == 0xFFFFFFFF:

usage element format code format size
0x00 POSITION 0x2A23B9 f32×3 12 B
0x03 NORMAL 0x1A2360 f16×4 (use xyz) 8 B
0x05 TEXCOORD 0x2C235F f16×2 (u,v) 4 B

Stride = max element extent. Models omit elements → variable stride (20 = pos+normal, no UV; 24 = pos+normal+uv). Each element is read in naive big-endian component order (the raw file bytes; see the endianness note below). Assuming a fixed stride-24 was why the old decoder mis-aligned and declined the pos+normal-only models.

Alignment pinned by the normals. The vertex buffer starts at index_end + 12 (a fixed 12-byte header — NOT align16, which lands 4 bytes early on most models). The correct offset is the unique one where recovered normals are exactly unit-length.

Safety gate: every index is validated < vtx_count, and when the declaration has a normal element the mean recovered |normal| must be ≈1 ([0.5, 2.0]). A model failing either is rejected (MeshError::UnsupportedLayout) rather than emitting garbage.

Endianness — file bytes are naive-BE, k8in32 is a red herring

The Canary GPU capture reports every vertex stream with fetch endian = 2 (k8in32, each 32-bit word byte-reversed). This describes the guest-memory copy the GPU fetches — not the .xpr file bytes. Reading the file with a k8in32 transform breaks the normals (mean |normal| → 1.33); the plain per-element big-endian read yields exactly unit normals. So the game rearranges the vertex data between the on-disc .xpr and the uploaded buffer; the decoder reads the file directly and must use naive BE.

Stage containers — multi-resource, grouped pools (content-anchored)

hidden/resource3d/Stage_S*.xpr are not single models but collections of enemy / prop sub-models — up to ~400 XBG7 resources each (e.g. Stage_S07 = 378). Their data layout differs from the weapon files:

  • Each resource is a block [12-byte header][index buffer][vertex buffer]. Unlike weapons there is no 12-byte gap between index and vertex — the vertex buffer directly follows the indices.
  • The index count is the descriptor's (index_bytes, index_count) marker (the total for the resource — a resource may have several sub-meshes summing to it), not the first sub-mesh record. The vertex count is a u32 stored 32 bytes before the marker.
  • The blocks are scattered through the data section, interleaved with the container's texture data, in an allocation order that is not directory order and is not stored in any descriptor field we could find (the descriptor holds sizes — rel 160 ≈ index_bytes+3, rel 164 = 0x1000_0000 | (vertex_bytes+2) — but no data offset). Reconstructing that allocation order is unsolved.

Because the offset is not stored, each resource's block is located by content: parser Xbg7Model::stage_models does one O(file) pass per distinct stride to find every vertex-buffer start (an offset whose NORMAL — f16×4 at vertex +12 — is unit length while the previous stride slot's is not, i.e. a run boundary; ~one candidate per block, not millions), then pins each resource to the unique candidate where the index_count indices ending just before it are all < vertex_count, reference nearly all vertices, and yield non-degenerate triangles with real extent. This is fast (≤ ~2 s on a 70 MB stage) and unambiguous (no two resources collide). Blocks that fail validation — the few quantized hero bodies — are skipped, never emitted as garbage.

Coverage: 5662 sub-models decode across the 22 stage files (e.g. Stage_S07 366/378, Stage_S10 7/9 — including the main enemy bodies e003/e005, their LODs, weapons, and props). The viewer (spawn_stage_models) lays the decoded sub-models out as a side-by-side "cast sheet", skipping the few huge skybox-plane resources (300 k-unit quads). Cross-checked: e003 = 2383 v / 1436 t bbox 23.5×9.5×32.5; e005 = 2566 v / 1507 t; both 0 degenerate.

Not yet decoded — the complex body layout

The hero-ship body meshes (DeltaSaber_A/_T/_W.xpr resource f004, and ~100 other models) still decline. Two open sub-problems: (1) multi-sub-mesh models whose first sub-mesh decodes but a later one's inter-mesh offset isn't yet handled (the align16 advance is a guess) — these are declined whole; (2) the big body meshes, where the data section does not start with an index buffer and the geometry sits at descriptor-addressed offsets. NB the GPU capture showed every rendered mesh is single-stream (just wider strides, e.g. 44 bytes = pos + f32×3 + colour + 2×f16×4), so the body is likely single-stream-with-a-richer-declaration rather than the "separate streams" first guessed — it was simply not rendered in the captured session (menu only). A capture taken in a mission (where DeltaSaber renders) would hand over its exact declaration directly. DeltaSaber_A's 5 XBG7 blocks are f004 (body) + _rou_f004_mnv01_L/_R, _mnv02, _turn180 (maneuver / pose).

Evidence log

  • 2026-07-12 — rou_f001_wep_00.xpr: XPR2 dir = 1×XBG7 (f001_wep_00) + 3×TX2D (_col/_gls/_spc). Data section starts with a u16-BE index run (max 214), then stride-24 vertices. Descriptor record [215,0,1092,4] at desc +0x2B0; index-buffer byte size 0x888 (=2184=1092×2) at desc +0x1A0. Recovered mesh = 215 v / 364 t, 362 non-degenerate, median tri area 0.015 — coherent. → single-stream layout PROBABLE.
  • 2026-07-12 — descriptor-driven sequential carving over all 166 models: 42 carve under the index-only check. Real 3D extent confirmed on rou_f001_wep_04 (bbox 3.7×1.2×3.5).
  • 2026-07-12 (refine) — attribute ranges differed per model (wep_00 UV≈attr0/2, wep_04 attr4/5, wep_03 attr1≈±60000 = garbage) → refuted the fixed "6 half attrs, UV=attr0/2" reading. Found the descriptor vertex declaration (offsets 0x0C/0x14, usages 0x03 NORMAL / 0x05 TEXCOORD). Solved the vertex-base offset with a unit-normal validator: index_end + 12 gives median |normal| = 1.000 on every weapon model (wep_00/02/03/04), vs align16 landing 4 bytes early. UVs then land in [0,1]. Adding the normal gate: 25 models decode clean + normal-valid (the rest — incl. Stage_S* degenerate blobs — correctly declined).
  • 2026-07-12 (DYNAMIC) — added a cvar-gated draw logger to Canary (command_processor.cc::LogDrawForRE, cvar log_draws), captured the Ready Room / Briefings. GPU ground truth confirmed the static layout exactly: primitive prim=4 = triangle LIST (settles the list-vs-strip question), and a stream with f32x3 @offset0 + f16x4 @3dw + f16x2 @5dw, stride 6 dwords = 24 bytes — matching POSITION@0, NORMAL@0x0C, TEXCOORD@0x14. Revealed stride varies (24, 20, 28, 44 …) and that all streams are single-stream → parsed the declaration for variable stride: coverage 25 → 36 (e.g. wep_05 is pos+normal, stride 20, no UV — previously mis-aligned). Also confirmed the endianness note above: fetch endian=2 (k8in32) is the guest copy; file stays naive-BE. Stage_S* now decode (stride 20).
  • 2026-07-12 (STAGE) — Stage_S*.xpr decoded as multi-resource containers. Found each geometry block is [12B hdr][index buffer][vertex buffer], index count = descriptor marker (total, e.g. e003 = 4308 spanning 2 sub-meshes), vertex count = u32 at marker32 (e003 = 2383 → verified by max-index 2382 and 0 degenerate tris, bbox 23.5×9.5×32.5). Blocks are scattered among texture data with no stored offset (descriptor rel 160 = idx_bytes+3, rel 164 = 0x1000_0000 | (vtx_bytes+2) are sizes, not offsets; block starts e.g. e003@0x10230, e003_l@0x5d000, e005_l@0x9b000 are not directory-ordered). Solved by content anchoring: a single per-stride pass finds vertex-run starts (unit NORMAL at +12 whose previous slot isn't), then match each resource by strict index+triangle validation. 5662 sub-models decode across 22 stages (S07 366/378), incl. the previously-declined main bodies e005 (2566 v) and weapons — ≤2 s on 70 MB. Parser Xbg7Model::stage_models, tests stage_models_{decode,sweep,quality_audit}.
  • 2026-07-17 — triangle-LIST re-confirmed; a strip interlude refuted; winding-consistency gate added. A 2026-07 change had briefly re-read the index buffers as triangle strips (to "fill holes"). Refuted objectively with a new XVERIFY diagnostic that compares both readings by stored-normal agreement (each triangle's cross-product face normal vs the sum of its vertices' stored normals): the LIST reading gives agreement 1.000 on every clean weapon (wep_00/03/04/19 = only possible with correct topology + winding), the STRIP reading ~0.49 (random). The strip reading also over-generated ~2.5× the triangles (wep_00: 938 vs 364) — a hole-filling garbage soup. Reverted to LIST in both paths (from_xpr2, read_pool_mesh), matching the prim=4 GPU capture. Added an objective winding-consistency gate max(na, 1-na): a correct carve is internally consistent (agreement ≈1.0, or ≈0.0 for inverted-but-consistent winding — a real single-sided mesh), a mis-carve scatters to the ≈0.5 middle. from_xpr2 declines sub-meshes below 0.90 (e.g. wep_23 na=0.398 → declined instead of a spike-mess); the single-model content-anchor fallback gates at 0.85; the large multi-resource stage path stays ungated (its enemy meshes span a continuous 0.51.0 consistency range — a hard gate there dropped ~48/314 legit S07 blocks). Routing fixed: decode_models (CLI) and the viewer now route by count_xbg7 (1 → validated records-based list decode, fallback to strict-gated anchor; >1 → stage anchor) instead of the old "whichever decoder yields more verts" rule — that rule let stage content-anchoring win on single-model weapon files and fabricate phantom blocks (a wep_00 clone appearing inside wep_19), duplicates, and spike-mess anchors. Weapons now: 33 clean-decode / 26 declined (declined = genuinely multi-stream or un-carvable, shown as nothing rather than garbage); stage coverage unchanged (S07 314). expand_triangle_strip retained as an XVERIFY-only diagnostic.
  • 2026-07-12 — DeltaSaber_A.xpr body: data does not begin with indices; plain-f32×3 runs with ship-scale extent (span ≈2734, matching bbox 30.0) found only at high offsets (data+0x28634C, …) → multi-stream, undecoded.
  • 2026-07-18 — GROUPED-POOL layout cracked → the hero ship (Delta Saber) fully decodes. The detailed models (DeltaSaber_*.xpr + ~100 others) were declined for location, not format — their vertex format is the standard stride-24 triangle list. A resource's several sub-meshes don't interleave [idx][vtx] per block; they share two grouped pools: an index pool (buffers concatenated in descriptor-marker order, each 4-byte aligned) followed by a vertex pool (each sub-pool vtx_count × stride, same order), with the index pool ending exactly where the vertex pool begins. So the whole resource pivots on one unknown, the first vertex-pool start vb0 (= index-pool end, found by the unit-normal vertex-run scan); everything else is derived: ib0 = vb0 span, ib[i] = align4(ib[i-1] + idx_count[i-1]·2), vb[i] = vb[i-1] + vtx_count[i-1]·stride. Reversed statically from DeltaSaber_T.xpr and cross-checked against a Canary GPU draw-log capture (mission ship = DeltaSaber_T.xpr, found via the --log_file_io kernel hook): f001 = body (idx@data+0xC = 0x5500C, vtx@0x61ACC, 10891 v / 8187 t) + 7 detail parts (fins/cockpit/wingtips, markers at descriptor 0x3BEC…0x58FC) = 8650 tris, and every sub-mesh decodes at 0 degenerate / full coverage / winding-agreement 1.000. This is the layout the per-block adjacency anchor (ib = vb idx_bytes) rendered as a spiky phantom (it read 24561 indices starting 2782 B too late, agree 0.64, 1277 degenerate). Insight: a single index marker reduces the grouped model to index_end = vb0, i.e. the existing adjacency ib = vb idx_bytes — so grouped generalises the single-block anchor (n=1 is identical). Implemented as anchor_grouped_meshes (mesh.rs): anchor_models routes resources with

    1 index marker to it (validated per sub-mesh; on failure falls back to the old first-marker adjacency anchor so stage coverage never regresses); single-marker stages/props keep the exact prior path. The shared acceptance test is factored into validate_block (the connectivity heuristic is relaxed for derived grouped parts, which are pinned by in-range + consistency, so small flat fins aren't mis-rejected). Render self-check: sylpheed-cli mesh render DeltaSaber_T.xpr --only f001 (exact-name match excludes the _rou_f001_mnv* animation poses) → clean complete fighter. Test hero_ship_grouped_pool_decodes. Colours/UVs still pending the running-game oracle.

  • 2026-07-18 (refinement) — 4-byte vertex-pool alignment + weapon recovery. The grouped-pool rule "index pool ends exactly where the vertex pool begins" is really "the vertex pool is 4-byte aligned after the index pool": vb0 = align4(ib0 + span), so 0..=3 bytes of padding can sit between them. DeltaSaber's index pool ended already-aligned (pad 0), which hid this; 19 weapon/*_hangar models (single- and multi-marker: wep_08/11/34/58/62/69/81/83…) have pad 2 and so decoded to nothing — the viewer then showed them as a flat 2D texture instead of a model. Fix: both anchors try pad ∈ 0..=3 (ib = vb idx_bytes pad for the single-block adjacency anchor; ib0 = vb0 span pad for the grouped pivot), validated — a wrong pad reads shifted indices → agreement collapses < 0.85, so only the true pad passes. pad>0 in the ungated stage path is gated at a strict 0.85 to avoid a false anchor; pad 0 keeps its exact prior behaviour (stages unchanged). Result: all 19 now decode as clean models (e.g. wep_34 1243 v / 1233 t, a long-barrelled gun-pod; wep_08 3 sub-meshes / 478 t). Viewer routing already falls through from_xpr2anchor_models(0.85) for single-XBG7 files, so the recovered grouped/padded weapons now preview as meshes.
  • 2026-07-18 (refinement 2) — pivot on the largest sub-mesh; all 19 recovered. Three weapons (wep_81, wep_81_hangar, wep_30_hangar) still declined because the grouped pivot validated markers[0], which for these is a tiny elongated lead bracket that fails the connectivity gate even when perfectly placed. Fixed by pivoting the alignment check on the largest marker (max index count) — the sub-mesh whose triangle-quality/connectivity signature most reliably confirms (ib0, vb0). Once the pivot validates, markers up to it are read unconditionally (a legitimately tiny/flat lead part may fail the quality gates yet still be real), and markers after it stay validated so a stray trailing marker ends the chain. Result: all 19 previously-declined weapons decode (wep_81 460 t missile w/ tail fins; wep_30_hangar 334 t). DeltaSaber unchanged (its body IS the largest marker → same pivot). 7/7 disc tests green, stage quality audit unchanged.

The declined set, measured (2026-08-11)

The module note said "a few multi-stream / quantized bodies remain" declined. Measured across all 166 hidden/resource3d/*.xpr:

  • 6 294 XBG7 resources, 5 480 decoded (87.1 %), 814 declined, in 31 of 166 containers. Worst: Stage_S09 64/380, Stage_S06 58/324, ptc_pack 57/136.
  • The declined set is not "a few hero bodies". By name prefix it is 492 e* (enemy craft), 142 f*, 73 n*, 23 eff*, plus destroyed variants (_rou_f402_dead, _rou_f302_base_dead) and one weapon (_rou_e011_wep04).

A shortcut that does not work

The resource descriptor's third word looked like a format/stream flag — decoded g001…g003 carry 0x00010001 while declined t170/t180 carry 0x00020004, which reads temptingly as (streams << 16) | format. It is not that. Histogramming it over the whole disc puts decoded and declined resources at every value:

word[2]      decoded  declined
0x00010001      4479       328
0x00010002       126        27
0x00010003       120       112
0x00010004       142        84
…               …          …

Its low half runs 1…0x52 and tracks sub-mesh count, not vertex format. So decodability is not declared in the descriptor — it is a property of whether the unit-normal anchor scan can locate vb0, which is exactly what the current code already tests. Anyone attacking this should not spend time on the descriptor: 229 of the declined resources even carry the most common 0x00010001 with under 1 KB of data, i.e. they are small meshes the scan has too little signal to anchor, not exotic formats.

Silent mis-decodes: a detector, and how many there are (2026-08-11)

The declined set is the honest failure mode — 814 resources the decoder refuses. This is the other kind: geometry that decodes without complaint and is wrong.

The case that exposed it

e303_wep_01 decodes from fourteen containers. In eleven it is a 49 × 23 × 42 turret with organic vertices (24.55, 0.00, 4.46 …). In Stage_S02, S08 and S26 the same resource — identical 172 vertices and 330 indices — decodes to 1600 × 2100 × 4800 of axis-aligned box corners:

Stage_S01   [   24.55    0.00    4.46]  [  24.55   9.84   2.91]   normals varied
Stage_S02   [   42.00 -900.00 2400.00]  [-600.00 -500.00 -500.00]  normals (0,0,1)
            [ -600.00 -500.00 -950.00]  [-600.00 -950.00 -950.00]  ← box face corners

The anchor scan located a different buffer that happens to share the vertex and index counts, so every size-based check it makes passes. This is exactly the "declined only for location, not format" risk the module notes describe — except here it does not decline, it succeeds wrongly.

The detector: cross-container bounds consistency

A resource shared by several containers must decode to the same bounds. That needs no ground truth, and it measures the problem:

  • 681 resources appear in ≥2 containers.
  • 125 of them decode to different bounds while reporting identical vertex and triangle counts — a lower bound on silent mis-decodes (a resource wrong in every container is invisible to this test).

Examples: _rou_f401 decodes as 62×25×10 in 16 containers and 4738×3147×4738 in 2; _rou_e011_wep05 produces four different spans across 8 containers.

Repair candidate, and its limits

Taking the majority span across containers resolves 104 of the 125; 14 are exact 50/50 splits that a vote cannot decide. It agrees with the ground truth in the one case that has independent evidence — e303_wep_01, where the 11-container majority is the turret the render and the runtime capture both support.

🟡 It is a heuristic and is otherwise unvalidated. For _rou_e302_base_break the majority is the larger span (685×1206×1444, 8 of 15) and nothing yet says which is right. Use the detector to flag; do not silently rewrite geometry on a vote.

ROOT CAUSE: the candidate list is container-global

Traced 2026-08-12. anchor_pool_mesh (the per-block path, which is the one that handles single-sub-mesh resources like e303_wep_01) walks a candidate list built by vertex_run_starts(bytes, data_base, stride)one scan of the whole container per stride, shared by every resource of that stride. It accepts the first candidate that validates.

So a resource is anchored to whatever block matches its signature first in file order, and nothing ties that block to the resource it belongs to. Two resources sharing (stride, vertex count, index count) are interchangeable to this search.

The wrong block is not distinguishable by quality. Tracing the accept for e303_wep_01:

Stage_S01   ACCEPT vb=4600480   pad=0  span=  49 ×  23 ×  42   passes 0.85 = true
Stage_S02   ACCEPT vb=18403456  pad=0  span=1600 × 2100 × 4800 passes 0.85 = true

Both clear the strict winding-consistency gate, because the wrongly-taken block is real, coherent geometry — just another resource's. That rules out a whole family of fixes: no threshold, no scoring, no "pick the best candidate" changes this, and the earlier attempt to add best-of-N selection in the grouped-pool anchor duly changed nothing.

The search space has to be constrained instead — and the fix is now pinned down.

The correct block is already in the candidate list. Enumerating every validating candidate for e303_wep_01 in Stage_S02 gives exactly two:

vb = 18 403 456   span 1600 × 2100 × 4800   ← what the decoder takes, only because it is first
vb = 52 257 440   span   49 ×   23 ×   42   ← correct: the same size all 11 good containers give

So nothing needs to be found that the scan is missing; the wrong one merely appears earlier in file order.

Locality picks the right one. Recording each resource's accepted anchor in descriptor order shows that global monotonicity is refuted — only 25 of 47 steps increase in Stage_S01 and 130 of 248 in Stage_S02, i.e. no better than chance. But neighbourhood holds strongly: in Stage_S02 this resource's descriptor neighbours anchor at 51 974 668 and 52 218 424, its correct candidate is 52 257 440, and the block it wrongly takes is at 18 403 456 — two thirds of the file away from its own family.

Proposed rule: among candidates that validate, prefer the one nearest the anchors of the neighbouring resources (equivalently: decode in descriptor order and prefer candidates close to the previous resource's anchor), falling back to first-match when there is no neighbour yet. That needs no new format knowledge, and it selects 52 257 440 here.

⚠️ WITHDRAWN (2026-08-12) — it halved inconsistency but regressed the twin mirror

anchor_pool_mesh_near tries candidates in order of distance from a reference, and anchor_models_filtered runs two passes: pass 1 anchors first-match to learn where resources land, then pass 2 re-anchors each resource preferring its neighbourhood — the median anchor of its ±2 descriptor neighbours. A resource with too few anchored neighbours keeps pass 1's result, so nothing regresses to guesswork.

decoded shared inconsistent e106 mirror
shipped (today) 5 480 / 6 294 681 125 matches capture
neighbourhood anchor 5 480 / 6 294 681 63 flipped
+ refining the map 5 480 / 6 294 681 51 flipped

Refining matters because pass 1's anchor map contains the very mistakes the neighbourhood is meant to correct, so a resource beside a mis-anchored neighbour inherits a bad reference. Re-anchoring against the improving map and repeating converges quickly — two rounds, with a third changing nothing.

On its own metric this looked complete: coverage unchanged, inconsistency halved, e303_wep_01 decoding to 49 × 23 × 42 in all containers, and e106 rendering as a destroyer instead of a slab (before · after).

It was reverted anyway. ship::tests::static_assembly_matches_runtime_capture is gated on SYLPHEED_ISO and therefore skips in an ordinary cargo test; run with the ISO it fails:

e106_bdy_01: static M row0 [-1.0, 0.0, 0.0] != captured [1.0, 0.0, 0.0]

Why: e106_bdy_01 and e106_bdy_02 are a mirrored pair whose two vertex buffers hold the same geometry reflected in X, and both resources currently decode to the same buffer — identical vertex count, identical span, identical mean_x. apply_twin_mirrors decides which instance to reflect from the sign of that mean_x, so which of the two buffers gets picked flips the decision:

before the change   both twins decode with mean_x = 66.83  → mirror bdy_02  (matches the capture)
after  the change   both twins decode with mean_x = +66.83  → mirror bdy_01  (contradicts it)

The runtime capture is ground truth, so a change that contradicts it does not ship.

Correction (measured after the fact): the first write-up of this said "two distinct resources sharing one decode is itself the bug". That is wrong. Sharing is normal here — 1 043 of 5 480 decoded resources (19 %) share geometry with another resource, and of 1 242 related pairs, 1 241 are identical in every container they co-occur in, which is what legitimate asset reuse looks like. A mirrored pair like bdy_01/bdy_02 is supposed to share one geometry, with the reflection applied at placement — exactly what apply_twin_mirrors does.

What actually matters is which of two mirrored buffers is canonical. The disc holds both an X+ and an X version; the engine treats one as the base, and apply_twin_mirrors was tuned against that. The neighbourhood anchor moved these resources to the nearer buffer, which is the other one — hence the flip. So the real fix is not "give each twin its own buffer" but pin which buffer is canonical, with the capture as the oracle.

Exactly one pair is provably mis-anchored by this test: e105_bdy_02_l / e105_brg_m share a decode in 7 of the 15 containers holding both and differ in the rest — two names cannot be the same geometry only sometimes.

The filtered path needed care. models_named (what the viewer's ship rendering uses) drops non-wanted resources, which would leave a filtered decode with no neighbourhood at all — and silently keep the old behaviour. Resources are now collected regardless of the filter, but only the asked-for ones and their ±2 neighbours are decoded in pass 1, so a filtered decode stays proportional to what was asked for.

51 remain, and they cluster in _l (LOD) and _dead variants — e001_l, e010_bdy_01_l, e011_bdy_01_l, e016_l, e104_bdy_05_l, e106_eng_02_l, e501_01_l, _rou_f301_base_dead, _rou_f302_base_dead, e303_base_dead.

A tempting explanation, tested and false. The obvious reading is that a variant shares its base's vertex and index counts, so the two are mutually confusable and adjacent, defeating locality. Checked across every container: 2 714 variant/base pairs, and exactly zero share identical counts.

What is actually happening: one region is a universal false positive. e010_bdy_01_l is 171 verts / 90 tris and no other resource in its container shares those counts — yet in Stage_S02 and S26 it decodes to 1600 × 2100 × 4800, the same bounds e303_wep_01 (172 verts / 110 tris) produced before the fix. Differently-shaped resources are landing on the same place. So the attractor is not "another mesh with my shape" but a region of round, axis-aligned box data that validates for many different (vtx, idx) shapes at once — every index lands in range and the triangles are coherent boxes.

That also explains why the neighbourhood fix helped so broadly: it steers resources away from a single strong attractor rather than resolving many pairwise confusions.

Selection-based fixes are exhausted — tested. The proposed tiebreak was implemented as a last resort (accept the attractor only if nothing else validates), first keyed on "all coordinates multiples of 50" and then on the sharper "all coordinates integral" — the attractor reads (42, 900, 2400), (600, 500, 950) while real geometry carries fractions like (24.55, 9.84, 4.46). Neither changed anything: still 51.

That null result is itself the answer. A mechanism that defers the attractor whenever another candidate exists, changing nothing, means no alternative candidate validates for any of the 51 — the correct block is not in the candidate list at all. Both attempts were reverted rather than kept.

So the residual is not a selection problem, and no reordering, scoring or tiebreak will move it. The frontier is vertex_run_starts — the unit-normal run scan that builds the candidate list — which does not emit a start for these resources' real vertex buffers. That is where the remaining 51 live.

The ignored test mesh_consistency_disc.rs still asserts the target state and now records 63 rather than 125; the remaining cases are where the neighbourhood is itself wrong or absent.

Where the mis-decode is not: the grouped-pool anchor

An attempt to fix it by making anchor_grouped_meshes choose the best-scoring vb0 (rather than the first candidate clearing the 0.85 gate) changed nothing — still 5 480 of 6 294 decoded and still 125 inconsistent — and instrumenting the pivot loop shows why: for e303_wep_01 it never runs. The resource has a single sub-mesh, so it is decoded by the per-block adjacency path (anchor_pool_mesh), not the grouped-pool anchor.

So the silent mis-decode lives in the per-block anchor. That is worth knowing before anyone else spends time on the grouped-pool pivot, which is the more prominent and better-documented of the two and the natural first suspect.

The change was reverted: it was untargeted, unproven, and added a scoring path with no demonstrated benefit. (Its one reusable idea — that several vb0 candidates can clear the gate and first-in-scan-order is an arbitrary tiebreak — still applies to whichever anchor turns out to be at fault.)

Two follow-ups on the anchor (2026-08-12)

The descriptor does not address the geometry. If it did, the whole candidate-scan could be replaced by direct addressing. It cannot: across e106's resources in Stage_S01, anchored_vb entry.data_offset ranges from 1 199 052 to 4 173 988 with no constant or stride. data_offset locates the descriptor, and nothing in the first six descriptor words tracks the vertex pool. The scan is necessary.

Cross-container agreement does not prove legitimate reuse. The earlier measurement — 1 241 of 1 242 related pairs identical in every container — was read as "sharing is normal". It is weaker than that: a systematic error is invisible to a consistency test, because it is consistent. The same dump shows e106_bdy_02 and e106_bdy_03_m anchoring to the identical offset (1 505 556), and e106_bdy_01_l with e106_brg_01_m (4 251 208) — a hull half and a different body's medium LOD, or a hull half and a bridge LOD. Those are different parts; one of each pair must be wrong.

So the honest position is: sharing is common (19 %), some of it is certainly legitimate (a mirrored twin pair genuinely shares one geometry), and some is certainly not — and cross-container consistency cannot tell them apart. A test that can: compare a shared pair against a runtime capture, which is ground truth for what the engine actually draws.

The capture answers it at population level — and the logs are still on disc

/sylph-home/re/shipcap/xenia_ship_capture_*.log (kept from the 2026-07 capture sessions) carry the raw per-draw lines the baked table was distilled from:

DRAW vbase=0x150CCAC0 stride=28 vcount=1 indices=1 prim=1 vs=0x…
DRAW vbase=0x150CCAC0 stride=24 vcount=10891 indices=18 prim=4 vs=0x…

vbase is the GPU vertex base — ground truth for which buffer the engine draws a part from, which is exactly the oracle the sharing question needs. Over the three logs, restricted to the ship-geometry stride 24:

  • 6 093 draws from 2 291 distinct vbases
  • only 77 vbases (3.4 %) are drawn more than once

⚠️ That 3.4 % measures less than it first appears — corrected 2026-08-12. The capture code (command_processor.cc, CaptureShipDrawForRE) de-duplicates by (vbase, WVP-transform hash), so a buffer drawn many times at one transform — which is what a mesh split into per-material sub-draws looks like — appears once. The figure therefore counts buffers drawn at several placements (multi-instance parts), not buffers serving several parts. It is not the population-level evidence about sharing it was first written up as; the bdy_01_l/bdy_02_l result below is direct evidence and stands on its own.

Two more field semantics, read off the same patch rather than guessed: vcount = fetch.size × 4 / stride is the buffer's capacity, not the draw's vertex usage (which is why it matches a decoded resource's vertex count so exactly), and indices is VGT_DRAW_INITIATOR.num_indices, that draw's index count. So the 119-vertex twin logging indices=21 against our 246-index marker most likely means the engine issues the mesh as several sub-range draws and the log keeps the first — likely, not proven.

How to use it per-part: correlate_capture already matches a draw to a resource by vcount plus decoded positions. The same match yields, for each part, the vbase the engine used — so two resources that our decoder gives the same geometry can be checked directly: different vbase in the capture ⇒ our shared decode is wrong. That is the per-part oracle any future anchor work should be validated against, and it needs no new capture run.

It was run — and the capture gives file-offset ground truth

examples/shared_vbase_check.rs does the per-part check above, and then goes one step further than planned. Three results, in order of strength.

1. A draw's vbase is the container file offset plus a constant. Vertex POSITION is f32×3 big-endian at vertex offset 0, so a draw's dumped positions are a value pattern that can be searched for in the .xpr itself. Doing that for every draw in xenia_ship_capture_01/02.log and histogramming vbase offset:

xenia_ship_capture_01.log: 11 distinct vbases located, 208 not in this container
   vbase - offset = 0x1A94FFF4  ×8      ← same constant in log 02

The 208 "not in this container" are draws whose geometry lives in Common.xpr, a weapon pack or a backdrop — expected. The eight that do belong to Stage_S01 share one constant, and the same constant in a second run, so the container is uploaded contiguously and a capture names the exact file offset of every buffer the engine drew. Log 03 loaded the container at a different address, so the constant is per-run, not baked.

2. Read against our anchor scan, that is a defect list. GameMesh now carries vbuf_offset — the offset the anchor scan actually placed a sub-mesh at — so the comparison is exact (captures/stage-s01-capture-truth-offsets.txt):

drawn offset vcount our resource anchored there our resources with that vcount
0x3b3ee8 119 e106_bdy_01_l, e106_bdy_02_l bdy_01_l, bdy_02_l
0x3c55d8 119 nobody bdy_01_l, bdy_02_l
0x3dd2c4 146 e106_bdy_03_l bdy_03_l
0x40763c 179 e106_bdy_04_l bdy_04_l
0x40e418 51 nobody (ours sit 0x5d0 earlier) brg_01_b_02, brg_01_l
0x444ccc 58 e106_eng_01_l eng_01_l
0x44a32c 44 nobody eng_02_l
0x45705c 82 e106_wep_02_01_l wep_02_01_l
0x38788 0x4b8b8 0xb6574 0xdbbac 0x133da0 0x162840 181, 93, 41, 77, 76, 60 — nobody mostly none (other objects in the stage)

Four of the ship's drawn buffers are anchored exactly right. Two are the twin collapse below. Two are mis-anchors of a size we do have: the engine's 51-vertex bridge buffer is at 0x40e418 while both our 51-vertex bridge resources sit at 0x40de48, and its 44-vertex eng_02_l is at 0x44a32c while ours is elsewhere entirely. The remaining six belong to other objects in the stage (n041, n042, e303), only two of which we decode at the right size.

A trap worth recording. The first version of this table located our resources by searching the container for their leading vertices instead of asking the decoder, and it read much worse — full and _m resources appearing to start inside their own _l buffer. That was an artifact: the same leading vertex run occurs at several offsets in one container (e106_bdy_03's first eight positions occur at four, bdy_01's at three). That multiplicity is itself the reason the anchor scan is ambiguous — but it makes a position search useless for asking where a resource was anchored. Hence vbuf_offset.

3. The twin pair is an anchoring error, and the mirror is in the data. For e106_bdy_01_le106_bdy_02_l the capture shows two 119-vertex buffers per run, 0x3b3ee8 and 0x3c55d8; the first is byte-for-byte what we decode, and the second is its exact X-reflection (every dumped position matches ours with x negated). So the container carries both halves as separate baked geometry, the engine draws each from its own buffer, and our decoder returning one buffer for both names is the defect — which correlate's mirror flag and ship::apply_twin_mirrors have been compensating for downstream all along.

That settles the question this section opened with, for this pair: not legitimate reuse. It also pins what the withdrawn neighbourhood-anchor fix could not: 0x3b3ee8 stays with whichever twin we already decode there, and the other twin must move to 0x3c55d8. The invariant is checkable without a capture — mirrored twins must decode to X-reflected buffers, never identical ones.

4. Root cause, for these three: selection, not the run scan. mesh::debug_vertex_run_starts exposes the candidate list the anchor scan works from. Stage_S01 yields 15 710 stride-24 candidate starts, and all three capture-proven offsets are in it0x3c55d8 (the mirrored twin), 0x40e418 (the drawn bridge buffer) and 0x44a32c (eng_02_l). The scan sees the right offsets; anchor_pool_mesh walks the list in ascending order and takes the first that validates, so an earlier lookalike wins — our bridge resources sit 0x5d0 before the buffer the engine drew.

This is scoped: it says the current decoder's e106 mis-anchors are selection failures. It does not overturn the earlier finding that the residual 51 under the withdrawn neighbourhood fix had no validating candidate at all — a different population, and the two can both be true.

What the twins suggest as the fix: selection is per-resource and greedy, so two resources can and do claim one buffer while a validating buffer sits unused. An assignment that is distinct by construction — each candidate used at most once — resolves the twin case by shape rather than by heuristic. Whether the proven offsets actually validate for their resources is the next thing to test; if they do, distinctness alone is the fix.

5. Do the proven offsets validate? Two of three — and that splits the fix. mesh::debug_try_anchor(bytes, name, vb, max_pad) asks validate_block directly (examples/try_anchor.rs):

resource proven offset verdict
e106_bdy_01_l / e106_bdy_02_l 0x3b3ee8 and 0x3c55d8 accepted for both, at both (v=119, idx=246, pad=0)
e106_brg_01_l / e106_brg_01_b_02 0x40e418 accepted for both (v=51, idx=126, pad=0)
e106_eng_02_l 0x44a32c rejected — and still rejected with the pad widened to 64

So the twin case is exactly what it looked like: the correct block is perfectly acceptable and simply lost the first-match race, and a distinct assignment (each candidate buffer claimed by at most one resource) fixes it — the two resources have two accepted offsets between them. The bridge pair is weaker: 0x40e418 is accepted by both, our current 0x40de48 is accepted too, so distinctness would separate them but not choose correctly.

eng_02_l is a different failure: the offset the engine drew from is not acceptable at all, so no selection policy can reach it. That is the "residual" class this file describes above, now with one member pinned to a concrete offset for the first time.

An open discrepancy, recorded not explained. The capture's DRAW lines carry an indices= field that does not agree with the descriptor's index count: the 119-vertex twin draws log indices=21 where our marker says 246, and the 44-vertex draw logs indices=12. Whether that field is an index count of a sub-range, a different unit, or a Xenia-side artifact is unknown — it may matter for eng_02_l, whose block validation is exactly what an index-count mismatch would break.

6. Why eng_02_l's real block is rejected: the connectivity heuristic. mesh::debug_find_index_buffer scans the whole container for an index buffer that validates against a known vertex buffer, instead of assuming adjacency (examples/find_ib.rs). For e106_eng_02_l at the capture-proven 0x44a32c, with the connectivity test on, nothing in the container validates. With it off (SOFT_IB=1) the nearest hit is ib 0x44a29cvb ib = 144 = 72 × 2, i.e. exact pad-0 adjacency. So the index buffer is exactly where the decoder assumes it is; the block is thrown out by one heuristic.

That heuristic is mean_edge / bbox_diag > 0.28 → reject. Measured on the real blocks:

block mean edge bbox diagonal ratio verdict
eng_02_l (24 tris) ib 0x44a29c → vb 0x44a32c 109.21 261.96 0.417 rejected (cap 0.28)
bdy_02_l (82 tris) ib 0x3c53ec → vb 0x3c55d8 131.70 786.66 0.167 passes
bdy_01_l (82 tris) ib 0x3b3cfc → vb 0x3b3ee8 131.70 786.66 0.167 passes

This is precisely the false positive the check's own comment predicts — "a small flat sub-mesh legitimately has large edges relative to its own diagonal" — caught in the wild for the first time, with the runtime naming the block it rejects. A 24-triangle engine LOD is coarse by construction, so its edges are a large fraction of its size.

(The twins' two real blocks having identical mean edge and diagonal is a free corroboration that they are mirror images: reflection preserves lengths.)

So the residual class is not one bug. eng_02_l has its vertex start in the candidate list and its index buffer exactly adjacent, and still fails — a validator problem, not a scan or selection one. Raising the cap is not the fix to reach for blind: the threshold trades against false anchors, and now that a capture can name true blocks, it can be calibrated against them rather than guessed. Not changed here.

7. Calibrating the cap: a real trade, not a free win. XBG7_EDGE_CAP (and XBG7_SMALL_TRIS / XBG7_EDGE_CAP_SMALL for a triangle-count-aware variant) make the threshold sweepable without changing the default; examples/edge_cap_sweep.rs reports coverage and cross-container consistency per setting. Over the whole resource3d directory:

cap resources decoded anchors moved vs 0.28 shared inconsistent consistent → inconsistent inconsistent → consistent
0.28 (shipped) 5 480 678 125
0.35 5 955 707 140
0.42 6 069 254 714 153 18 3
0.45 6 093 254 716 153 18 3
0.28, but 0.45 below 64 tris 6 090 236 716 151 16 3

Nothing is ever lost — every resource that decoded at 0.28 still decodes — and the capture-proven case is fixed: at any cap above 0.417, e106_eng_02_l anchors at exactly 0x44a32c, and the four e106 parts that were already correct stay correct. So on the only ground truth available, relaxing is a strict improvement (4 → 5 of the ship's drawn buffers correct).

But it is bought: ~590610 resources that previously decoded not at all now do, ~240254 existing anchors silently move, and 1618 shared resources go from cross-container consistent to inconsistent (against 3 repaired). The triangle-aware variant barely narrows that — almost everything the looser cap admits is a small block anyway.

So the cap is not changed here. The evidence says 0.28 is too tight and that mean-edge-over-diagonal is a weak discriminator for coarse LODs; it does not say 0.45 is right, because the 254 movers have no oracle. Deciding needs either a capture covering more ships and stages (the same --truth method extends to any container the engine drew from) or a discriminator that does not degrade for coarse geometry. Both are recorded as the next step rather than guessed at.

8. Provenance correction — the capture is Stage_S02, and the oracle is 4× bigger than reported. examples/capture_truth_scan.rs runs the offset-locating pass over every container in resource3d and reports each one's modal vbase offset. Stage_S02 places 64 buffer-matches at a single constant (0x17FE3FF4); Stage_S01, which sections 17 above used, places only 16. The logs also contain f101 (the ACROPOLIS escort, 25 448 verts), f105/f106 (TCAF cruiser and destroyer) and e105 — a Stage-02 cast. The loaded container was Stage_S02.

Why Stage_S01 nevertheless produced a consistent constant: the two containers carry the shared block verbatim and contiguously. The e106 twin buffers sit at 0x3b3ee8/0x3c55d8 in Stage_S01 and 0x2d1fee8/0x2d315d8 in Stage_S02 — the same 0x116F0 apart. So the earlier findings are still true statements about Stage_S01's content (both mirrored buffers are in it, and our decoder collapses them), and they reproduce in Stage_S02; only the claim that 0x1A94FFF4 was the loaded container's base was an artifact.

The Stage_S02 table (captures/stage-s02-capture-truth-offsets.txt) places 46 drawn buffers: 34 have a claimant (29 of them a single resource of exactly the drawn size), 12 are claimed by nobody. It also resolves the six mystery buffers of the Stage_S01 table — 181, 93, 77, 76, 60, 41 vertices — as e105 parts (bdy_04_l, bdy_05_l, brg_l, eng_01_l, wep_01_l), another ship in the same mission, several of which are the same "right size, wrong place" failure as eng_02_l.

A new defect class to chase: _rou_f105_break — a destruction composite — claims a run of twelve consecutive drawn offsets whose sizes match other ships' LODs (f105_bdy_01_m, f105_bdy_03_m, e106_wep_02_01_l, …). Either the break model genuinely mirrors those buffers or our decoder is handing a whole address range to one composite. Not resolved here.

9. The cap, scored against ground truth — and what it reveals about the selection. Re-running the sweep against the 46 capture-named Stage_S02 buffers (the oracle section 7 lacked):

cap exact anchors claimed by several unclaimed vs 0.28
0.28 29 4 12
0.35 30 3 12 +1 exact, 0 lost
0.42 31 4 10 +2 exact, 0 lost
0.45 31 4 10 +2 exact, 0 lost

So against the runtime, relaxing is monotone: two more buffers get exactly the right resource and no previously-correct anchor is lost. That is the opposite reading from the cross-container consistency metric, which showed 18 regressions — and the two measures can be reconciled by looking at what those 18 actually are.

Of the 18, only two touch a drawn buffer, and one is decisive: the 32-vertex buffer at 0x24ce5f4 is unclaimed at 0.28 and at 0.42 is claimed by both f303_body_l and e302_barrel_l, two 32-vertex resources. That is not a mis-placement — it is a collision. The looser cap admits the true block and lets a second resource grab it, because selection is per-resource and greedy.

Conclusion: the cap and the selection have to move together. Relaxing alone buys real anchors and pays in collisions; distinct assignment alone (section 5) fixes the twins but cannot reach blocks the validator still rejects. The pair of changes is the fix; either alone is a half-measure, which is why neither has been made yet.

Fixed: distinct anchor assignment (2026-08-12)

The selection is no longer per-resource-greedy. After the parallel decode, the models are walked in container order: the first claimant keeps a buffer, and any later resource that chose the same one is re-anchored past every buffer already claimed. A resource that finds no free candidate keeps its collided decode, so coverage can never regress. Grouped-pool models are untouched.

Measured against the 46 capture-named Stage_S02 buffers, and disc-wide:

exact anchors unclaimed resources decoded shared inconsistent
greedy, cap 0.28 (before) 29 12 5 480 125
distinct, cap 0.28 (now) 40 4 5 480 46
distinct, cap 0.42 45 0 6 069 62

Nothing decodes that did not decode before, cross-container inconsistency drops by 63 %, and 11 more of the ship's drawn buffers get exactly the right resource. The cap stays at its shipped 0.28 — that is a separate change with its own evidence (section 9), and this one is worth being able to revert alone.

One convention changed, and it is the point of the fix. With the twins sharing a buffer, the reflection had to be synthesised downstream: ship::apply_twin_mirrors flipped one hull, and correlate baked an X-flip into e106_bdy_02's captured matrix (diag(-1,1,1)). Now each twin decodes to its own, already-mirrored buffer, so the mirror lives in the data and both placements are proper rotations. Re-emitting the block from the capture confirms it independently (1 0 0 0 1 0 0 0 1 264.04343 …). The embedded placement row and the two assertions that encoded the old convention were updated, each with the reason in place; the full suite including the disc- and ISO-gated ship tests is green.

Fixed: the connectivity cap is 0.42 (2026-08-12)

With distinct assignment in place the cap was swept again against the 46 capture-named buffers. It saturates:

cap exact anchors unclaimed resources decoded shared inconsistent
0.28 (old) 40 4 5 480 46
0.35 42 3 5 955 56
0.42 (now) 45 0 6 069 62
0.45 45 0 6 093 62
0.60 45 0

Nothing above 0.42 anchors anything more, so 0.42 is the least permissive value that captures the whole measured gain — 45 of the ship's 46 drawn buffers get exactly the right resource, none is left unclaimed, and 589 more resources decode than at 0.28 with nothing lost.

The one metric that worsens is cross-container consistency (46 → 62). That is the proxy, and this file already documents why it is the weaker witness: a systematic mis-anchor is invisible to it because it is consistent. Where the two disagree, the capture wins. Full suite green including the disc- and ISO-gated ship tests.

Closed — the grouped composite is a false alarm. _rou_f105_break (the f105 destruction model) claims twelve consecutive drawn buffers while f105_bdy_01_m, f105_bdy_03_m and f105_eng_01_m sit elsewhere, which looked like the twins' bug in the grouped-pool path. It is not. examples/locate_draw.rs counts how many copies of a captured buffer a container holds, directly and X-mirrored:

drawn buffer vcount direct copies mirrored copies
0x1bf596c 2446 3 0
0x1c0fe2c 2336 6 6
0x1c211cc 1402 4 0
0x1bf40f4 261 1 0

The container stores these parts several times over, and every live LOD checked (f105_bdy_01_m at 0x1ecb4c0, f105_bdy_03_m at 0x1f902a4, f105_eng_01_m at 0x1ffb884) is anchored on a direct copy — byte-identical geometry to the one the engine drew. So the composite taking "the drawn" copy costs nothing: the decode is the same vertices either way. (The draws use the ordinary ship shader 0xEEA84C59D7F95371, the same one as the validated e106 hull draws, so these are intact-ship draws, not debris.)

⚠️ This also calibrates the oracle metric. "Exact" in the tables above means anchored at the offset the engine drew from, which is stricter than correct: a resource anchored on an identical copy is equally right. The 45/46 figure stands as a lower bound, and an "unclaimed" row is not automatically a defect.

What the copy counts do leave open: with six direct and six mirrored copies of one buffer, a resource landing on a mirrored copy would be a real defect and would look identical to a correct decode in every count-based metric. Only a capture (or the twin-pair invariant) can catch it.

Distinct assignment extended to grouped pools (2026-08-12)

anchor_grouped_meshes now takes the same taken set: a pool whose start another resource already claimed is skipped, and a grouped model that collides is re-placed whole past everything claimed (or keeps what it had, so coverage cannot regress).

oracle exact / 46 unclaimed resources decoded shared inconsistent
single-mesh distinctness only 45 0 6 069 62
+ grouped pools (now) 45 0 6 069 56

The runtime oracle is unchanged and cross-container inconsistency falls another 10 %. The suite, including the disc- and ISO-gated tests, stays green.

It clears the n206 collapse too — a correction to what was written here first. n206_02 now anchors at 0x342d984 instead of sharing 0x33b6e54 with its twin. The earlier "no alternative pool validates" reading was wrong twice over: mesh::debug_grouped_report (examples/why_rejected.rs) shows that pool ACCEPTED at pad 0 under the production gates, and 0x342d984 is in the candidate start list. What actually misled the check was the audit itself — it classified twins by decoded geometry, and 0x342d984 is a direct (unmirrored) copy of 0x33b6e54, so the pair still looked "identical" after it had been separated.

The audit now distinguishes the two: identical geometry is only a collapse when it comes from one buffer. Re-run disc-wide, of 34 equal-count twin pairs: 18 exact X-mirror, 16 related another way, 0 identical-sharing-a-buffer, 0 unrelated — and the regression test no longer needs its n206 exception.

Left open: whether n206_01/n206_02 should be a mirrored pair at all. The container holds two direct copies and two mirrored ones (0x33b7754, 0x342e284); our twins take the two direct copies, which is self-consistent but unverified — n206 appears in no captured stage.

Fixed: grouped pools emitted sub-meshes with out-of-range indices

Chasing whether the "buffer not covered" gate is well founded turned up a real defect instead. examples/coverage_audit.rs measures, for every decoded sub-mesh, how many tail vertices its indices never reference:

unreferenced tail vertices sub-meshes
0 (indices reach the last vertex exactly) 8 586
13 (inside the gate's ±4 tolerance) 48
9, 80 (f102_break.dat, f104_break.dat in ptc_pack.xpr) 2
negative — indices point PAST the vertex buffer 18

The first row answers the original question: real geometry covers its pool exactly, so under-coverage is good evidence of a wrong candidate and the gate stands as written.

The last row is the defect. anchor_grouped_meshes reads sub-meshes before the pivot unconditionally — deliberately, since a tiny flat lead part legitimately fails the quality gates — but that also skipped the index-range check, which is not a quality question. Eighteen sub-meshes disc-wide were emitted with indices reaching up to 364 vertices past the end of their own buffer, which any renderer would fault on or draw as garbage. Pre-pivot sub-meshes are now required to be in range (quality gates still relaxed); out-of-range ones are dropped and the pool's remaining parts are kept.

Everything else holds: 6 069/6 294 decoded, cross-container inconsistency 56, the capture truth table still 46/46 claimed with 0 unclaimed, suite green. The vertex total falls by 1 546 — exactly the garbage that is no longer emitted.

The same reasoning finished the job. The two remaining outliers were the only decoded blocks that did not cover their pool: f102_break.dat had a sub-mesh declared 414 vertices whose indices stopped at 404, and f104_break.dat one declared 160 whose indices stopped at 79 — both reading a neighbouring block's index buffer against the wrong declaration. These are grouped .dat composites in ptc_pack.xpr whose marker lists (9 and 10 entries) clearly do not map 1:1 onto the stored blocks; only 2 of 9 and 4 of 10 sub-meshes ever decoded. Applying the coverage requirement to pre-pivot sub-meshes as well drops exactly those two mismatched pieces and keeps the rest.

Every decoded sub-mesh on the disc now covers its own vertex pool: 8 580 at slack 0, 49 within the ±4 tolerance, none beyond it, none negative. Coverage, consistency and the capture oracle are all unchanged by the tightening.

Fixed: winding consistency replaces the connectivity heuristic

With every miss attributed (below), connectivity accounted for 153 of 225 — by far the largest blocker, and the one gate already known to reject a capture-proven block. So it was tested against the alternative the decoder already trusts elsewhere: winding consistency, max(na, 1na), where a real mesh sits at ≈1.0 or ≈0.0 and a mis-carve lands near 0.5. It is an objective topology test; the edge-ratio is a shape heuristic.

Swapping them (connectivity inert, winding gating the pad-0 path):

winding floor resources decoded shared inconsistent capture oracle
— (connectivity 0.42, previous default) 6 069 56 46/46
0.60 6 214 53
0.70 (now) 6 212 39 46/46
0.80 5 770 1
0.85 5 770 0 46/46

0.70 dominates the previous default on both axes — 143 more resources decode and 17 fewer shared resources disagree across containers — with the capture oracle unchanged at 46/46 claimed, 0 unclaimed, the twin invariant still clean (18 exact mirrors, 22 related, 0 collapses, 0 unrelated across 40 pairs, up from 34), and every decoded sub-mesh still covering its pool. So it ships, and the connectivity cap drops to an inert 1.0, kept as a knob and a backstop.

The cliff at 0.80 is recorded rather than taken: it buys perfect cross-container consistency (0 inconsistent) for 442 resources. Consistency is the weaker witness — a systematic mis-anchor is consistent — so paying that much coverage for it is not obviously right, and both points are one env var apart (XBG7_PAD0_CONSISTENCY, XBG7_EDGE_CAP) for anyone who wants the conservative end.

Fixed by looking at the output: exact pool coverage (2026-08-12)

Rendering the assembled e106 from Stage_S02 — something no metric had done — showed the old slab back: e106_bdy_03 spanning 600×1600×998, a blocky mass beside the hull. The same resource decodes to 276×236×941 in Stage_S01, Stage_S03 and Stage_S04. The tell was already in the data:

container anchor slack span
Stage_S01 0x3c9b7c 0 276×236×941
Stage_S02 0x12d1d44 3 600×1600×998
Stage_S03 0x1e07b7c 0 276×236×941
Stage_S04 0x164337c 0 276×236×941
Stage_S06 0x1f05298 0 414×636×1121

The coverage gate tolerated up to three unreferenced tail vertices, and that tolerance was hiding a mis-anchor: real blocks reach their pool's last vertex exactly (8 580 of 8 629). Requiring exact coverage moves Stage_S02's e106_bdy_03 to 0x2d35b7c, slack 0, 276×236×941 — the block three other containers agree on — and the slab disappears from the render (captures/e106-cover-slack-before-after.png).

Cost: 3 resources disc-wide (6 212 → 6 209); cross-container inconsistency 39 → 38; capture oracle unchanged at 46/46 claimed, 0 unclaimed; suite green.

(Stage_S06 still gives a third answer at slack 0, so e106_bdy_03 is not fully settled — but it is now consistent across four of the six containers that carry it instead of three.)

The other capital ships are clean — and an attempt to automate the slab check failed

e106 was the first ship rendered, and it had the defect. The other four in the Stage_S02 cast — f101 (ACROPOLIS), f105, f106, e105 — were rendered the same way and all assemble into coherent hulls with no stray masses.

Automating the check did not work. examples/slab_screen.rs compares each base part against its ship's median extent (min-axis, so a legitimately long thin antenna does not swamp the test) and flags outliers. Run against the decoder before and after the coverage fix, at 4× and at 2.5×, it produces the same 23 / 58 flags either way — it never sees e106_bdy_03, the very part it was built for. At 600×1600×998 against a ship median of ~250 the slab sits under 3×, and lowering the factor buys noise, not sensitivity.

What the eye actually used was not scale but relationship: a blocky mass sitting apart from the hull silhouette. A containment test — does a part's box lie within the envelope its neighbours describe? — is the right numeric analogue and is not built yet.

The screen is kept anyway, because it is honest about what it does see: two curiosities that are legitimate, not defects — f002_bdy_22/_23 span 519 × 100 000 × 519 (a tether/elevator column, consistent in both containers that carry it) and t901_e01_D is 58×59×3013 (a mast). And f101_bdy_01 flags at 6× while being capture-verified exact in the truth table — a useful reminder that a bulky hull is not a bug.

Fixed: a filtered decode no longer depends on what you ask for

Distinct assignment resolves collisions against the set of resources being decoded — and models_named was pruning to the requested subset before that pass. So the answer depended on the request: measured on Stage_S02, 27 of 356 resources came out at a different offset when asked for alone than in a full decode, and not only boxes — f001_bdy_30, f106_sld_02_l/m/d, f101_wep_01_l among them. Both the viewer and ship::assemble_ship decode subsets, so both could get geometry the container's own answer disagrees with.

This was a regression introduced by distinct assignment itself, and it is fixed by applying wanted to the output instead of the input: the assignment always runs over the whole container, and a subset is now a subset of the container's own answer. Verified: one-name, three-name and full decodes now return the identical offset for the same resource.

Cost, and the cache that pays it back. Because the assignment must see every resource, a single-resource query became as expensive as a full decode (~15 s on a 50 MB container). full_decode_cached memoises the whole-container decode, keyed by a fingerprint of the bytes (length + three sampled 4 KB windows) and the consistency setting, holding the last four containers. Decoding five ships from Stage_S02 in turn, as the viewer does:

e007: 2 models in 10.494 s     ← the one full decode
e010: 2 models in    48.6 µs
e105: 37 models in  1.42 ms
e106: 32 models in  92.7 µs
e108: 13 models in  73.7 µs

So a stage costs one decode, not one per ship. The whole-container path (anchor_models_cancellable, what the sweeps use) stays uncached — it is already the thing being measured.

The consistency figure is mostly bounding boxes — real disagreement is ONE resource

examples/consensus_check.rs sharpens the cross-container test: with three or more copies of a resource, the majority span is the reference and the minority names the container that is wrong, not just "these disagree".

Disc-wide: 89 minority decodes across 477 resources that have a majority — and 88 of the 89 are scene composites (rou_* / e_rou_*), not drawable geometry. Inspecting one shows why: e_rou_e106 decodes a 24-vertex, 12-triangle box (span 22×22×22), e_rou_f106 another (745×718×718). A composite's descriptor carries a bounding box, the anchor scan finds it, and because those boxes are interchangeable-looking the assignment shuffles between containers. mesh::scene_world_nodes identifies them structurally: 1 141 of the 6 209 decoded resources have scene nodes, i.e. are composites (478 are named rou_/e_rou_).

So the headline number this file has been quoting — cross-container inconsistency — is dominated by composite bounding boxes. Checking the vertex count of every minority decode settles it:

All 89 are 24-vertex resources. Not one is a real mesh.

That includes the single non-rou_ name in the list, e101_wep_01_l (Stage_S25 [779, 5769, 5769] vs three containers' [206, 545, 545]) — 24 vertices, 36 indices, the same box signature, so it belongs to the same class rather than being the "one real disagreement" first written here.

After the fixes in this file, no real mesh on the disc decodes differently in different containers. What remains is 24-vertex boxes swapping identities: many of them exist, they are structurally identical, and distinct assignment gives each a distinct block without pinning which block belongs to which name. Fixing that needs an ordering rule (descriptor order ↔ ascending offset).

Re-tested, and refuted again. XBG7_MONOTONE=1 adds exactly that rule: per (stride, vtx_count, idx_count) signature, a later resource may not take an earlier block than the previous one of the same signature. Result: 89 minority decodes — unchanged. The reason is structural. Monotonicity constrains the order within a container, but the disagreement is between containers, which hold different numbers of these boxes in different arrangements; a consistent within-container order does not force a consistent name↔box mapping across them.

So the remaining 89 are bounding-box identity ambiguities, and pinning them needs information from the descriptor itself (a composite's own bounds or node data), not another anchoring heuristic. The knob stays, default off, with this measurement recorded so the idea is not tried a third time. Defaults verified unchanged: all ten test suites green.

(The boxes are harmless in themselves — nothing draws them. But no consistency figure should be quoted without saying whether it counts them; the ignored shared_resources_decode_identically_in_every_container test measures the mixed population, so its number is not comparable to this one.)

What the exact-coverage fix actually reached

The fix was justified on one resource (e106_bdy_03) and one render. Comparing whole-disc decodes at XBG7_COVER_SLACK=4 and =1 shows what else moved:

  • 29 resources changed anchor, 3 stopped decoding, 6 207 unchanged.
  • Of the moved ones that appear in several containers, 0 matched the sibling consensus before the fix and 9 match it after — the fix moved them onto the block their own copies agree on, and moved none away from it.
  • 22 of the 29 were carrying another resource's geometry under their own name.

The clearest case is a shift chain in Stage_S28: n054_bdy_l was decoding n056_bdy_l's geometry, and n056_bdy_l was decoding n055_bdy_l's — each resource landing on a neighbour's block, all of them plausible-looking meshes of the right vertex count. n054_bdy_l decoded (396, 315, 328) in four containers where its own copies agree on (439, 284, 270).

That is the failure mode that matters for a reimplementation: not a missing decode, but a confidently wrong one under the right name — and no count-based metric can see it, because every count is correct. It took the coverage invariant (real blocks reach their pool's last vertex) to separate them.

(Seven of the 29 carried geometry no resource claims. And the ptc_pack.xpr eff_* entries in the list are small effect quads that all share one span, so their "owner" attribution is weak — noted rather than counted.)

The containment screen, and a correction: the assembler is NOT at fault

examples/envelope_screen.rs is the metric the eye actually used: assemble a ship, and for each part measure how far its world box protrudes past the box of all the other parts, per axis (a bow legitimately extends the long axis, so protrusion only counts against that axis' own envelope).

It does not flag e106_bdy_03 under the pre-fix decoder, and the reason is now established. Dumping the pre-fix static assembly shows the shared turret e303_wep_01 occupying 1600×2100×4800 around a ~400×400×2000 hull — an envelope nothing can protrude past.

⚠️ This was first written up here as a static-assembler defect. That was wrong. The composite's nodes are clean — rou_e303_wep_01_root carries scale 1.0 and an orthonormal matrix at t[±179, 54, 32] — and under the current decoder the same assembly places the turret as a tidy 49×23×42 box at ±179. The inflation only appears with XBG7_COVER_SLACK=4, i.e. the pre-fix decode: the exact-coverage fix repaired the turret as well as e106_bdy_03. The error came from comparing a deliberately pre-fix render against post-fix measurements of the same resource.

So the screen's blindness had a mundane cause — one mis-decode hid another by inflating the envelope — and it is worth keeping as a forward-looking invariant, with that caveat: it can only see a protruding part when nothing else is inflated.

The anchor work has plateaued at 98.7 % — state and what is left

Four evidence-driven changes took the decoder from 5 480 to 6 212 of 6 294 resources (98.7 %), capture-verified anchors from 29/46 to 46/46, and cross-container inconsistency from 125 to 39:

  1. distinct anchor assignment (single-block, then grouped pools),
  2. the connectivity cap 0.28 → 0.42, then replaced entirely by
  3. the winding-consistency gate at 0.70, and
  4. structural requirements on pre-pivot sub-meshes (index range, pool coverage).

Two further threshold moves were tested and refuted: the scale-free degeneracy test plus a lower extent floor (zero extra resources, inconsistency 39 → 44), and lowering the grouped-pool pivot winding floor (XBG7_GROUPED_CONSISTENCY): 0.85 → 0.80 → 0.75 decodes no more resources and leaves inconsistency at 39, while quietly changing which geometry some grouped models get (the vertex total moves), i.e. strictly worse.

So the remaining 82 misses are not a threshold away. They need a structural answer of the kind the .dat composites already showed — marker lists that do not map 1:1 onto stored blocks — and the honest next step is a capture of a stage containing them, not more tuning.

Where the remaining 82 misses stand — and a refuted fix

With the winding gate shipped, coverage is 6 212 / 6 294 = 98.7 % and only 82 resources never decode (was 225). Re-attributed:

furthest gate reached count
degenerate / implausible positions (extent < 0.5, >30 % degenerate) 42
winding consistency 31
buffer not covered by indices 9
connectivity 0 (inert)

The biggest bucket is not the blocker — which is exactly the caveat this attribution carries. Both of its thresholds are absolute, which on a format with no unit convention is a scale assumption: an area test of < 1e-9 calls every triangle of a small object degenerate (g005 spans 0.346 units and scored 7 of 8), and extent < 0.5 rejects it outright. Replacing the area test with a scale-free collinearity test (|u × w| < 1e-6·|u|·|w|, i.e. sin of the angle between the edges) is the principled version — and measured on this disc it decodes no more resources at all, while raising cross-container inconsistency 39 → 44. Lowering the extent floor to 0.05 adds two.

So the fix that the histogram appeared to point at is refuted: those 42 are resources where some wrong candidate reached that gate, not where the true block was rejected. Both are kept as knobs (XBG7_REL_DEGEN, XBG7_MIN_EXTENT), neither is the default, and the measurement is recorded so the next reader does not re-derive it.

Coverage has a denominator now, and the misses have a cause breakdown

Coverage has been quoted as "resources decoded" with no total. examples/undecoded.rs supplies both by enumerating the XBG7 directory of every container:

6 294 XBG7 resources on the disc — 6 069 decode (96.4 %), 225 are searched and missed, 0 lack a usable descriptor.

examples/gate_histogram.rs then asks, for each miss, which gate the best candidate reached before being rejected (mesh::debug_best_rejection):

furthest gate reached count example
connectivity (mean edge / diagonal) 120 g004: 0.724 > cap 0.42
grouped pool — placed by a different path, not analysed here 74 t170 (2 sub-meshes)
degenerate / implausible positions 15 g005: extent 0.346 (min 0.5), 7/8 degenerate
winding consistency 9 e007_bdy_01_l: 0.667 < 0.85
buffer not covered by indices 7 e101_bdy_02_d: indices reach 13 171 of 15 430

⚠️ Read this as a work-list, not a verdict. "Furthest gate reached" is taken over all candidates, and a wrong candidate can pass more gates than the true block — so this says where to look, not what is broken. What it does establish is that after the cap move to 0.42, connectivity is still the single largest blocker (53 % of single-block misses), and that a third of the misses are grouped-pool resources that need the pivot path analysed on its own terms.

The two smallest buckets are the interesting ones for a fix that cannot go wrong: extent < 0.5 rejects genuinely tiny props (g005 spans 0.346), and "buffer not covered" fires when the index buffer addresses only part of a large vertex pool — which is exactly what a sub-range draw looks like, and the capture's indices= field already showed the engine issuing those.

The twin invariant, checked disc-wide (2026-08-12)

The capture gave a rule that needs no capture to apply: a …_01/…_02 pair of equal vertex count should decode to mirrored geometry, never to the same buffer. examples/twin_mirror_audit.rs applies it to all 166 containers:

twin pairs of equal vertex count 34
exact X-mirror 18
related another way (Y/Z mirror, or the same cloud in another vertex order) 15
identical — a collapse 1
unrelated — no relation at all 0

Two calibration notes, because the first run of this audit got both wrong. Comparing quantised keys exactly reported four false "unrelated" pairs (e101_eng_01/_02): the halves are authored, not bit-negated, so they differ in the last digits — a tolerance is required. And a mirrored pair may be stored in a different vertex order, so the multiset has to be compared mirrored as well as directly. With both fixed, nothing on the disc is unrelated.

The single collapse is n206_01/n206_02 (Stage_S08), both anchored at 0x33b6e54 while the container holds a second direct copy at 0x342d984 and mirrors at 0x33b7754/0x342e284. It survives because both twins are grouped-pool resources (4 sub-meshes), and distinct assignment excludes that path — so this is the concrete next target, and the fix direction is to extend distinctness across grouped models.

tests/mesh_consistency_disc.rs::twin_pairs_do_not_share_a_buffer locks this in: no twin pair may share a buffer, with n206 the one asserted exception.

Not settled: e106_brg_01_b_02e106_brg_01_l (51 verts). A second 51-vertex vbase exists in the logs but is not from this container, and the container holds three near-identical 51-vertex runs, so the pair has no oracle yet. n006_01An006_01B shows a single vbase in all three logs — consistent with real reuse, but equally with only one of the two being on screen.

The [index buffer][vertex buffer] layout is RUNTIME-VERIFIED (2026-08-13)

Every anchor decision in this decoder rests on one assumption nothing on disc states: a block's index buffer sits immediately before its vertex buffer, at vb idx_count*2 pad with pad ≤ 3 (anchor_pool_mesh). It was also the prime suspect for the residual misses — the note above recorded a capture-proven e106_eng_02_l block that the decoder rejected, and "the index buffer is somewhere else" would have explained it.

It is now measured, not assumed. The F10 ship capture in xenia-canary-native was extended to log each draw's index buffer (ib base=… count=… min=… max=…, CaptureShipDrawForRE), and examples/capture_ib_truth.rs scores it against our decode of the same container (Stage_S02, load constant 0x17FE3FF4, full table):

measurement, 42 drawn buffers placed in the container result
our idx_count == sum of the draw's index batches 42 / 42
union of batches covers the vertex pool exactly (max_idx == vcount1) 42 / 42
index data at vb 2*idx_count pad, pad ≤ 3 (single-block path) 30 / 30 (20 at pad 0, 10 at pad 2)
the remaining 12 all grouped pools — one index pool for the whole group, so the per-sub-mesh distance is larger by construction (_rou_f105_break sub-meshes, n301_02B)

So the layout assumption is correct, the index count we decode is exactly what the engine indexes, and eng_02_l's rejection was the connectivity gate (since replaced by the winding gate), not the index location. The coverage rule shipped earlier (XBG7_COVER_SLACK = 1, "a real block reaches its last vertex") is independently confirmed: every drawn buffer's index union ends exactly at vcount1.

The trap that hid this: the capture kept only the FIRST batch

The engine issues several draws over one vertex buffer, each indexing a sub-range (29 batches here; the player craft's 10 891-vertex buffer takes 9). The capture de-duped by (vbase, WVP transform), so it recorded one batch per placement — which is exactly the recorded mystery "the capture's indices= field disagrees with the descriptor index count (119-vert twin draws log indices=21 vs our 246)". Not a disagreement: 21 was the first of two batches, and 21 + 225 = 246. Both twins now read batches 2 · idx 246 · span 492 · gap 0. Mixing the index range into the de-dup key (same commit) makes every batch appear. Any conclusion drawn from a pre-2026-08-13 capture's indices= value, or from vbase ibase, is about one batch and not about the block.

FIXED, and it is the biggest silent defect found so far: the index run was one element late (2026-08-13)

Comparing captured index VALUES (not just counts) against our decode turned the layout check above into a byte-level oracle — examples/capture_index_bytes.rs lines each draw batch up against GameMesh::indices at the batch's own offset. Result on Stage_S02: 76 of 93 index runs identical, 17 differing — and every difference was a shift by exactly one element, on buffers whose real index data sits at pad 2.

Cause: anchor_pool_mesh took the first pad that validated, and pad 0 is tried first with the looser gate (XBG7_PAD0_CONSISTENCY 0.70, against 0.85 for pad ≥ 1). For a pad-2 block, reading at pad 0 yields [true[1], true[2], …, garbage] — every index still in range, the pool still covered, the positions untouched, and the winding often just above 0.70. So it validated, and every triangle came out mis-wired.

The signature is decidable offline (examples/index_pad_check.rs): a shifted run wires arbitrary vertices, so triangles come out degenerate (a repeated index). In Stage_S02, 32 resources read at pad 0 with 12 156 degenerate triangles and winding 0.630.76, while the same blocks at pad 2 give zero degenerate triangles and winding 0.981.00. And degeneracy is near-perfectly clean as an invariant: 282 of 283 correctly anchored blocks in that container have zero degenerate triangles.

Fix: score every validating pad by (degenerate triangles, then winding) and keep the best, instead of returning the first. Revert knob XBG7_PAD_FIRST_MATCH=1 restores the old behaviour, which is how the before/after below was measured.

measurement first-match (old) scored (new)
captured index runs identical to ours (Stage_S02, 2 025 elements) 76 / 93 93 / 93
decoded sub-meshes whose index run holds a degenerate triangle (disc-wide) 579 16
sub-meshes whose index run changed 575 of 8 850
resources decoded · vertex anchors · cross-container minority decodes 6 209 · — · 89 unchanged (6 209 · identical vb · 89)

So 575 sub-meshes — 6.5 % of the disc's geometry — were being decoded with mis-wired triangles under a completely correct-looking decode: right resource, right buffer, right vertex count, right coverage. No count-based metric could see it; only the captured index values, and then the degeneracy signature they pointed at. Locked in by tests/mesh_disc.rs::decoded_index_runs_have_almost_no_degenerate_triangles.

Honest limits: of the moved runs, 92 had a pad-0 reading with no degenerate triangle and moved on the winding tie-break alone (83 such cases existed before the fix, so the tie-break newly decides 9) — weaker evidence than the degeneracy signature, and unverified by the capture. The 16 remaining degenerate runs are the grouped .dat break composites in ptc_pack (f102/f104/e107, whose marker lists are documented not to map onto the stored blocks), e201_bdy_03_m (2 containers) and _rou_f402_dead (9) — each already suspect on other grounds, and now the concrete next targets.

And it is plainly visible — the check the numbers kept failing at. ship_render --static on f101 (the ACROPOLIS, whose 25 448-vertex hull carried 2 156 degenerate triangles) both ways: before / after. The old decode is a torn mess of shards; the new one is a coherent capital ship with flat decks, 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.