# 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 markerβˆ’32 (`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.5–1.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 β‰ˆ27–34, 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_xpr2` β†’ `anchor_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.