# 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-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**.