Files
Syplheed-Reborn/docs/re/structures/xbg7-mesh.md
MechaCat02 578c71a1b9 [formats,viewer] Decode grouped-pool XBG7 models; fix mesh routing + albedo
Revert the mis-guided "triangle STRIP" reading (a937779): XBG7 index
buffers are triangle LISTS (prim=4 in the GPU capture; stored-normal
agreement 1.000 as a list vs ~0.49 as a strip). Reinstate the
winding-consistency gate.

Crack the grouped-pool layout used by the hero ship and ~150 detailed
models. A resource's sub-meshes share one index pool (buffers 4-byte
aligned, in descriptor-marker order) followed by one vertex pool (each
vtx_count*stride, same order); the vertex pool is 4-byte aligned after
the index pool. The whole resource is derived from one anchored pivot:
  ib0 = vb0 - span - pad   (pad in 0..=3, alignment)
  ib[i] = align4(ib[i-1] + idx_count[i-1]*2)
  vb[i] = vb[i-1] + vtx_count[i-1]*stride
vb0 is found via the unit-normal vertex-run scan; the alignment is
confirmed by validating the LARGEST sub-mesh (most reliable), after
which the rest are read/validated. A single marker reduces this to the
existing adjacency anchor, so grouped generalises it.

Results (cross-checked against a Canary GPU draw-log capture of
DeltaSaber_T.xpr):
- DeltaSaber_T f001 = body + 7 detail parts = 8650 tris, every sub-mesh
  0-degenerate / full-coverage / winding-agreement 1.000.
- All 19 previously-declined weapon models now decode (they hit pad 2
  and/or lead with a tiny bracket that broke a markers[0] pivot). Corpus
  audit: 0/146 geometry files fail (was 19 -> flat-texture in the viewer).
- Stages unchanged (single-marker path is byte-identical; grouped falls
  back to the old anchor on failure). 7/7 disc tests green incl. the
  strict stage quality audit; new hero_ship_grouped_pool_decodes test.

Viewer: route by count_xbg7; --only matches an exact model name (so the
neutral pose renders without the mnv*/turn180 animation poses). Fix the
albedo matcher: match a sub-model to its _col map by entity stem
(e007_bdy_01 -> e007_col) instead of a full-name prefix, lifting stage
sub-model texturing from ~25% to ~95% (the rest were flat grey). Colour
correctness (channel order/sRGB) remains a separate dynamic-RE item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:54:39 +02:00

255 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_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.