feat(formats,cli): decode XBG7 stage containers + fix weapon layout
Stage containers (hidden/resource3d/Stage_S*.xpr) are collections of up to
~450 enemy/prop sub-models, not single meshes. Xbg7Model::stage_models decodes
them: each resource is a [12-byte header][index buffer][vertex buffer] block
(index count = descriptor marker, vertex count = u32 32 bytes before it) whose
on-disc offset is NOT stored, so it is located by content — one O(file) pass per
stride finds vertex-buffer starts (unit NORMAL at +12 whose previous slot isn't)
and each resource is pinned to the candidate whose indices validate and produce
non-degenerate, well-connected triangles. A connectivity gate (mean triangle
edge <= 0.28x the bbox diagonal) rejects spiky mis-anchors. ~4993 sub-models
decode across the 22 stages.
The same insight fixes the weapon single-model layout: it is [12-byte header]
[index][vertex] too, not [index][12-byte gap][vertex]. Reading indices from the
block start turned the 12 header bytes into 6 junk indices (2 leading degenerate
triangles — the recurring stray-triangle artifact) and dropped the last 6 real
indices. Skipping the header leaves vertex offsets identical (coverage unchanged
at 36/166) and corrects the triangle list. Verified on wep_00/03/04.
Adds `sylpheed-cli mesh {info,render}` — a headless software rasterizer that
writes a shaded PNG (orthographic, z-buffered, two-sided), so recovered geometry
can be verified without the GUI. Stages render as a normalised thumbnail grid;
--only filters sub-models.
Tests: stage_models_{decode,sweep,quality_audit}; docs/re updated (xbg7-mesh.md
evidence log + INDEX.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,13 +44,21 @@ For 36 of the 166 models (weapons, simple props) the data section is a straight
|
||||
sub-meshes, carved from `header_size` in record order:
|
||||
|
||||
```
|
||||
per sub-mesh:
|
||||
per sub-mesh block:
|
||||
[ 12-byte header (contents undecoded) ]
|
||||
[ 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)
|
||||
```
|
||||
|
||||
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`:
|
||||
@@ -83,6 +91,44 @@ the plain per-element big-endian read yields exactly unit normals. So the game *
|
||||
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
|
||||
@@ -121,6 +167,17 @@ mission* (where `DeltaSaber` renders) would hand over its exact declaration dire
|
||||
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**.
|
||||
|
||||
Reference in New Issue
Block a user