Dynamic-RE follow-up: capture Canary's GPU vertex-fetch + draw calls and
feed the ground truth back into the static decoder.
The GPU capture confirmed the reverse-engineered layout exactly — meshes
draw as triangle LISTs (prim=4) with pos f32x3 @0, normal f16x4 @0x0C,
uv f16x2 @0x14 — and revealed the XBG7 vertex format is NOT fixed-stride:
models omit elements (stride 20 = pos+normal, no UV; 24 = pos+normal+uv).
- mesh.rs: parse the descriptor's vertex declaration ({offset, format-code,
usage} triples; 0x2A23B9=pos f32x3, 0x1A2360=normal f16x4, 0x2C235F=uv
f16x2) to drive per-model stride + element offsets, instead of assuming
stride 24. Coverage 25 -> 36 fully-validated models (e.g. the Stage_S*
props, which are pos+normal only). Same index-range + unit-normal safety
gates; complex/mismatched layouts still declined.
- Endianness note (documented): the capture's fetch endian=k8in32 describes
the GPU's guest-memory copy, NOT the .xpr file bytes — reading the file
with k8in32 breaks the normals (|n|->1.33); naive big-endian per element
is correct (the game rearranges vertex data on load).
- tests: a coverage-regression test (>=35 models) + the existing weapon /
body-declined disc tests still pass.
Confirmed against a Canary draw-log capture; see docs/re/structures/
xbg7-mesh.md (evidence log + declaration table).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
7.7 KiB
Markdown
127 lines
7.7 KiB
Markdown
# 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:
|
||
[ index buffer : idx_count × u16 BE ] triangle list (prim=4, GPU-confirmed)
|
||
[ 12-byte vertex-buffer header (contents undecoded) ]
|
||
[ vertex buffer : vtx_count × stride bytes ] ← declaration-driven
|
||
(pad to 16 bytes → next sub-mesh)
|
||
```
|
||
|
||
**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.
|
||
|
||
## 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 — `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**.
|