Files
Syplheed-Reborn/docs/re/structures/xbg7-mesh.md
MechaCat02 4096b2d2a5 feat(formats): declaration-driven XBG7 decode (variable stride)
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>
2026-07-12 18:42:21 +02:00

7.7 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:
  [ 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 ≈2734, matching bbox 30.0) found only at high offsets (data+0x28634C, …) → multi-stream, undecoded.