The runtime capture served its true purpose: as the answer key that exposed the static encoding. The XBG7 node joint table (node+0x44) holds NINE keyframe channels [TX TY TZ RY RX RZ SX SY SZ] behind EIGHT pointer slots shifted one track forward: channel k+1 = f64@(ptr[k]+8), and channel 0 -- TX, which no pointer names -- sits at ptr[0]-0x48 (tracks are 0x50-byte records, value at +8). The old 8-slot read took TY as X, RY as Y (usually 0 -- why both hulls stacked on the centreline) and never saw TX at all (the +-264 hull offsets). Euler order is Ry(ch3)*Rx(ch4)*Rz(ch5) with angles applied directly, pinned by the captured engine nacelle Rx(-15)*Rz(30) decomposition. mesh.rs gains read_trs9/node_rotation; scene_world_nodes and node_transforms both fixed (saber fin overrides unaffected). assemble_ship rewritten fully static and exact: - every composite-node instance placed (alias duplicates collapsed, real instances kept) including cross-id turret mounts (rou_e303_wep_01_root x2) - the e_rou_<id>_eng cluster rig mounts at GN_Engine_01 (two mirrored nacelles +-131 with -+30-degree cant + centre engine) -- world = frame o rig_local - brg/sld at their exact GN frames (frames were always exact) - shared-geometry twin pairs at +-TX: X-reflect the instance whose offset opposes the geometry's dominant side (viewer reverses winding on det<0) - squeezed node names resolve (wep02 -> wep_02_01) Ground-truth gate: ship::tests::static_assembly_matches_runtime_capture asserts static == the baked e106 capture for all 8 parts (T<1.0, R<0.02) plus both nacelles, both turrets, and the starboard-hull mirror. Viewer now uses pure static assembly; ship_capture + the baked table remain as the verification oracle. Renders show a coherent, bilaterally-symmetric destroyer. 81 formats-lib + all disc tests green; viewer builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.7 KiB
Capital-ship part placement — runtime capture (ground truth)
Status: ✅✅ STATIC ASSEMBLY IS EXACT — no captures needed anymore
(2026-07-26). The capture became the oracle that cracked the static encoding:
the node joint tables are 9 keyframe channels [TX TY TZ RY RX RZ SX SY SZ]
with the 8 table pointers shifted one track forward (channel 0 = TX sits at
ptr[0]−0x48; the old 8-slot read took TY as X, never saw TX — why hulls
stacked on the centreline). Euler order Ry·Rx·Rz, angles direct
(mesh.rs::read_trs9 / node_rotation). assemble_ship now places every
composite-node instance (cross-id turrets ×2), mounts the e_rou_<id>_eng
cluster rig at GN_Engine_01 (two mirrored nacelles + centre engine), puts
brg/sld at their exact GN frames, and X-reflects the shared-geometry twin
whose lateral offset opposes the geometry's dominant side.
ship::tests::static_assembly_matches_runtime_capture asserts static ==
captured for all 8 e106 parts (T < 1.0, R < 0.02) + both nacelles + both
turrets + the hull mirror. The viewer uses pure static assembly; the baked
table + correlator remain as the verification oracle only.
Problem
Capital ships ship as split XBG7 parts (hull bodies, bridge, engines, turrets)
in hidden/resource3d/Stage_SNN.xpr. Reconstructing the whole ship needs each
part's placement. Static reverse-engineering of the composite scene graph
(e_rou_<id>, GN_* frames) got the hull right but external parts only
approximately — a GN_* frame is the mount pivot, and the real outboard /
rotational offset lives in a detail sub-rig or in runtime code not recoverable
statically. See sylpheed-formats::ship::assemble_ship (the static best-effort).
Finding
The guest vertex buffer holds LOCAL coordinates — byte-identical to the
.xpr (verified: e106_bdy_04's first vertex (-20.0000,153.1539,45.9997)
matches the decode exactly). So parts are placed entirely in the vertex
shader; the buffer carries no placement. (Contrast: static stage geometry is
pre-transformed into world space and byte-matches the .xpr at file offsets —
that's a different case.)
The per-part transform is in the vertex-shader float constants. For the ship
shader (vs=0xC7F781F4C1D58054), constants c0..c2 are the WorldViewProjection
matrix rows:
- Their norms are
(sx, sy, 1)= the projection x/y scales;sy/sx ≈ 1.78 = 16:9aspect. Dividing each row by its norm gives the rigid WorldView (verified: normalized rows orthonormal to 1e-4,det = +1). - The camera View cancels when expressing every part relative to a reference part:
rel_p = WV_ref⁻¹ · WV_p=(Rᵀ_ref·R_p , Rᵀ_ref·(T_p − T_ref))— a pure ship-space rigid transform. Applyingrel_pto partp's local geometry assembles the ship exactly, in the reference part's frame.
(c4..c14 also vary per part — likely a normal matrix / a second WVP variant /
WorldView split; not needed, c0..c2 suffice.)
Capture instrumentation
Branch capture-ship-placement in the xenia-canary-native worktree (off the
instrument-current-descended native branch, which already has GPU log_draws).
- F10 in the emulator window →
xe::gpu::RequestShipCaptureFrame(). - Dumps the next ≤8000 draws (de-duped by vertex-buffer address) to
xenia_ship_capture.login the binary's dir (build/bin/Linux/Release/). Per draw:DRAW vbase=0x… stride=… vcount=… indices=… prim=… vs=0x… pos: (x,y,z) … up to 64 LOCAL vertex positions (== .xpr) vsconst base=N: c0=(x,y,z,w) c1=… … first 48 VS float4 constants - Files:
src/xenia/gpu/command_processor.{cc,h}(CaptureShipDrawForRE,RequestShipCaptureFrame),src/xenia/app/emulator_window.cc(F10 key). - Run:
run-canary-native.sh(HW Vulkan, interactive). Play into the mission, frame the ship side-on, press F10.
Correlator (now a library module)
The correlation math lives in sylpheed-formats::ship_capture (unit-tested with
synthetic captures): parse_capture → CapturedDraws, correlate(id, draws, parts, ref_sub) → a ShipPlacement (each part in the reference frame), and
serialize_table/parse_table for the checked-in text table.
Two capture formats are accepted (auto-detected): the F10 ship-capture snapshot
([parse_capture]) and the draw-logger format mission_draws.log/
xenia_re_draws.log (parse_drawlog — groups the ship shader SHIP_VS_HASH's
draws by vertex-buffer base, vcount = size_words/stride_words). So a capital
ship seen in a normal instrumented run can be baked without a dedicated F10 pass.
Matching rules (learned from the real 2026-07-26 capture)
- LOD-aware: a distant ship draws its
_m/_lcopies — same part, same local frame, different vcount. Each part tries every variant vcount. - Position-validated: a vcount hit alone can be a coincidence (a foreign
51-vert mesh nearly hijacked the bridge slot). Every candidate draw's dumped
positions must be found in the part's decoded position set or it is rejected.
Validation is set-based (buffer order ≠ decode order) against the union
of the part's variants — the real capture had a draw whose
vcountequalled the_mcount while its buffer held the FULL-detail geometry. - Runtime mirror: a port/starboard pair shares one file geometry; the engine
uploads the second instance X-reflected. A draw that validates only under
X-negation is accepted as the mirror, and the placement bakes
diag(−1,1,1)(the viewer reverses triangle winding for det < 0). - The mission ship shader can be a different hash per lighting variant
(
0x3A0829CC71516789in this capture) — the F10 path doesn't filter by hash, it validates by geometry instead.
examples/correlate_capture.rs is the CLI wrapper:
SYLPHEED_ISO=… cargo run --release --example correlate_capture -- \
<capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--emit]
Decodes the ship's base parts (for their vertex counts = the match key), correlates,
prints each part's ship-relative transform, and with --emit prints the ship …
block to paste into the placement table.
Baked table + viewer preference
crates/sylpheed-formats/data/ship_placements.txt is the checked-in placement
table (embedded via ship_capture::embedded_placement). The viewer's
build_ship_model uses a ship's captured entry when present, else falls back to
the static assemble_ship (external toggle). The table is currently empty —
a real F10 capture log is needed to bake in e106 (and others); paste the
--emit block into that file and rebuild.
Validated result — e106 ADAN Destroyer (Mission 1 / Stage_S01, side view)
Vertex-count → part map (all matched exactly): bdy_01/02 = 1261, bdy_03 = 815, bdy_04 = 1633, eng_01 = 583, eng_02 = 491, wep_02_01 = 1002, brg_01 = 202.
Ship-relative placement (reference = bdy_04, aft hull):
| part | T (ship-relative) | note |
|---|---|---|
| bdy_04 | (0, 0, 0) | reference, aft hull |
| eng_01 | (131, −133, −132) | aft (−Z), starboard |
| eng_02 | (0, −49, −140) | aft, centre |
| bdy_01 | (−264, −150, 1165) | port hull, forward |
| bdy_02 | (+264, −150, 1165) | starboard hull, forward |
| bdy_03 | (0, −35, 1076) | forward keel/spine |
| wep_02_01 | (0, 49, 1009) | forward weapon |
Assembled extent 872 × 670 × 2181 (a coherent ~2181-long destroyer).
Key wins vs static: bdy_01/bdy_02 are a port/starboard pair (X = ∓264),
which static had overlapping at one point; engines land correctly aft; the ship is
not the 1894-tall tower the static Y put out.
Missing: brg_01 (bridge, vcount 202) was culled/occluded at that camera
angle (0 draws) — needs a second capture framing the superstructure.
What the static assembler gets wrong (measured, e106 in Stage_S01)
Decoding the real container (examples/ship_dump.rs) shows the static
assemble_ship is incomplete — the capture is required to fix all of:
bdy_01/bdy_02overlap: both get the same composite transform (T = (−116, 0, 857)); the real ship is a port/starboard pair at X = ∓264 (the mirror is applied at runtime, like the DeltaSaber fins insaber_measured).eng_02andwep_02_01are never placed: they aren'trou_hull nodes and aren't in the external category list, so they drop out entirely.- Only
brg_01+eng_01get the approximateGN_*-frame treatment.
So even the hull tier is not fully correct, and no static signal supplies the missing offsets (verified: the part vertex buffers are pure local coords). The capture-driven table is the only path to correctness — there is nothing more to squeeze statically.
Offline status: the draw logs on this box are the player fighter, not a capital ship, so no capital-ship table can be baked here yet. Capture
e106(Stage_S01, side-on) on the emulator box — F10 or just savemission_draws.logwith the ship on screen — thencorrelate_capture … --emit.
Next steps
Bake: promote the correlator to a module + checked-in table + viewer preference.DONE (ship_capturemodule,data/ship_placements.txt,build_ship_modelprefers it). Remaining: run the captures below and paste the--emitblocks in — needs the emulator (F10), can't be done offline.- e106 + bridge: re-capture
e106(Stage_S01) framing the superstructure sobrg_01(culled last time) is included, then--emit→ bake. - Other ships: one side-on F10 capture each for the cruiser (
e105, Stage_S02), ACROPOLIS (f101), TCAF cruiser/destroyer (f105/f106). - Turrets: the shared
e3NN/e4NNgun models appear as their own draws in the capture too — correlate them to place turrets (static never resolved these).