# Capital-ship placement — does the `e106` result generalise? (WIP, 2026-07-31)

**Status:** 🚧 **WIP, time-boxed session.** Two results so far: a static audit across all
22 stage containers (done, below) and a first in-mission F10 capture run in Stage 02
(done — three capture logs, but **no capital-ship part correlated**; see "Open").

Context: [`BACKLOG.md`](BACKLOG.md) — "Capital ships assemble wrong in the viewer",
reported 2026-07-30. The oracle and the correlator already exist
([`ship-placement-runtime-capture.md`](ship-placement-runtime-capture.md)); the open
question is whether the rules derived from the one validated ship (`e106`, Stage_S01)
hold for other classes.

## 1. Static audit across all stages (offline, reproducible)

```
cargo run --release --example ship_audit -- ../sylph_extract/hidden/resource3d
```

87 lines of output, of which:

- **Only two OUTLIER lines, and they are the same ship twice**:
  `Stage_S03`/`Stage_S27`, `f002_bdy_05` centroid `[-1398 6251 918]`, `dist=6540`
  vs a cluster spread of `1071`. Every other assembled ship in every other stage
  has all parts inside its own cluster.
  → **The user-visible breakage is NOT a gross static-placement outlier for most
  classes.** Whatever is wrong in the viewer is either subtler than "part flung far
  away" (wrong rotation, wrong mirror, missing part) or lives in the viewer, not in
  `assemble_ship`. `f002_bdy_05` is a genuine, separate, reproducible static bug.
- **MULTIKEY**: joint tracks with more than one keyframe, which `read_trs9`'s
  single-key read does not model. Recurring rigs: `e_rou_f104` (3), `e_rou_f105` (2),
  `e_rou_f106` (2), `e_rou_e102` (6), `e_rou_e108_Missile_open` (2), `e_rou_e501` (1),
  and the `e901` boss with 2–15 tracks per pose. Several of these (`Missile_open`, the
  `e901_attack*` poses) are obviously *animation* and harmless for a static pose; the
  plain hull rigs `f104`/`f105`/`f106`/`e102` are **not** obviously animation and are
  the best hypothesis for a class-specific assembly error. ❔ **HYPOTHESIS — not
  verified.** `e106`, the one validated ship, has **no** multikey tracks, which is
  exactly how a rule that only works for single-key rigs could have passed unnoticed.

Raw audit output is reproducible with the command above (not checked in; it is
deterministic from the disc).

## 2. First in-mission capture run (Stage 02)

New tool: [`tools/re-capture/ship_capture_session.sh`](../../tools/re-capture/ship_capture_session.sh)
— one blocking session (per the session-lifetime rule): boot → Stage 02 in flight →
N× {screenshot, F10, small yaw}. Each F10 writes its own
`xenia_ship_capture_NN.log` next to the binary.

Run 2026-07-31, 5 presses requested:

- **Boot to in-flight took 24 s** (`skip_intro.sh` skipped the movie at 1 s and 6 s,
  title at 11 s, HUD shield bar at 24 s) — much faster than the ~100 s in the notes.
- **3 of 5 F10 presses produced a log** (`_01`…`_03`, 2964 / 3111 / 3668 draws).
  Logs (8–10 MB each) and the screenshots are at `/sylph-home/re/shipcap/`; not
  committed for size. One screenshot is checked in as
  [`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png).
- The screenshot confirms the capture frames are real in-mission combat frames
  (HUD live, `REMAINING OB 004`, ACROPOLIS + a Destroyer labelled on screen, a
  capital-ship hull filling the bottom of the frame).

### Result: no correlation yet ❌

```
correlate_capture xenia_ship_capture_03.log Stage_S02 <id> bdy_01
```
for `f101` (ACROPOLIS), `f105`, `f106`, `e105` reports *"no draw matches any LOD
(culled/off-screen?)"* for essentially every part — only two speculative LOD tries
(`f101_bdy_03` vcount 90 `[l]`, `e105_wep_01` vcount 60 `[l]`) and **zero accepted
matches**.

That is a **negative result, and it is not yet explained**. Facts collected:

- The capture is not empty or degenerate: 3668 draws in `_03`, top shaders
  `0xDA51B0745ABF85D2` (1258), `0xE0BAFB4F520FE441` (1091), `0xEEA84C59D7F95371` (770).
  None is the `e106` ship-shader hash from the 2026-07-26 capture; the F10 path does
  not filter by hash, so this alone is not the cause.
- Large vertex counts *are* present (3024, 2772, 1736, 1612, 1240 …), so capital-ship-
  sized geometry is being drawn.

Candidate explanations, **untested**:
1. the Stage-02 capital ships on screen are drawn from LOD/damage variants
   (`_d00`, `_m`, `_l`) whose vcounts the correlator's variant list does not cover;
2. the position-validation step rejects otherwise-correct vcount hits (the capture
   dumps ≤64 positions — a set-membership test against the wrong variant fails);
3. the ships in view at launch are drawn by a *different* draw path than `e106` in
   Stage_S01 (e.g. instanced/batched), so no single draw equals one part.

**First step next session:** take the largest few vcounts in the capture and ask which
decoded part in `Stage_S02.xpr` has that count (invert the match), instead of asking
per-part whether a draw exists. That distinguishes (1)/(2) from (3) immediately.

## 3. The inverted match — the ships were never drawn (2026-08-10) ✅ explained

The inversion was run and it settles the negative result. Two new tools:

```
cargo run --release --example invert_capture -- <capture.log> Stage_S02 [top_n] [--ship f101]
cargo run --release --example vcount_index  -- ../sylph_extract/hidden/resource3d <capture.log>
```
`invert_capture` asks, of the capture's own vertex counts, which resource in one stage
container has that count; `vcount_index` asks the same across **all 166 containers**
(5480 resources), so a draw whose geometry lives in `Common.xpr`, a `rou_*` weapon pack
or a `DeltaSaber_*` player-craft pack is identified instead of coming back "unknown".

On `xenia_ship_capture_03.log` (3668 draws, Stage 02):

| capture vcount | draws | what it is |
|---|---|---|
| 10891 | 28 | **`DeltaSaber_T:f001`** — the player's own craft |
| 6000 | 14 | `Stage_S02:n006_02` — backdrop |
| 1096 / 1008 / 841 / 215 / 127 | 14–112 | `rou_f001_wep_*` — the player's weapons |
| 417 / 279 / 201 / 167 | 104–448 | `Base:j00*`, `ptc_pack:*` — HUD/particles |
| 8 / 4 / 3 / 1 | 317–590 | particle quads |

- **Not one capital-ship hull part appears.** Per ship: `f101` **1 of 15** resources had a
  drawn vcount (`f101_bdy_03_l`, 90 verts), `e105` 3 of 37, `e106` 3 of 34 — and each of
  those hits is a 44–225-vertex `_l`/`_b` piece whose count also collides with dozens of
  unrelated resources, i.e. probably not even the ship.
- The 3668 draws span **~14 frames** per F10 press and use only **10 distinct vertex
  shaders**, and the player's own craft is captured at **full detail with its `c0..c2`
  WVP rows** — so the capture path itself is healthy and unfiltered.
- The screenshot ([`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png))
  agrees once read carefully: the hull "filling the bottom of the frame" is the **player's
  own craft** in the chase view. The nearest contact on the HUD is a wingman's engine trail.

**So hypothesis (3) is dead, and (1)/(2) never applied.** The correlator's message
"no draw matches any LOD (culled/off-screen?)" was literally true: the ships were far
enough away that the renderer drew nothing of them. `correlate` additionally cannot
anchor without the reference part, and `f101_bdy_01` was never drawn at any LOD.

**The variable that was never controlled is RANGE.** New tooling closes that gap:
[`tools/re-capture/approach_capture.py`](../../tools/re-capture/approach_capture.py)
locks onto a capital ship (definition size-radius ≥ 150 = not a fighter), flies at it
with navigator.py's drift compensation and CPA avoidance, firing disabled, and presses
F10 as each range band is crossed (8000 / 6000 / 4500 / 3000 / 2000 / 1400 / 900),
stamping every capture with its distance in `approach-bands.jsonl`. Driver:
[`ship_capture_close.sh`](../../tools/re-capture/ship_capture_close.sh). Besides giving
the correlator a full-detail frame, the stamped bands measure the game's own **LOD
ladder** per part, which the reborn renderer needs anyway.

## 4. Controlled-range capture — three classes verified (2026-08-10) ✅

`ship_capture_close.sh 240` ran one blocking session: boot → Stage 02 in flight →
lock the `f105` cruiser → close on it at full throttle, F10 at each range band.
Six bands fired (7375 / 5937 / 4394 / 2994 / 1944 / 1259 units, stamped in
`approach-bands.jsonl`); **3 of 6 presses produced a log** — the same 3-of-N as the
earlier session, so a press during a previous 8 MB dump is still lost. Logs at
`/sylph-home/re/shipcap-close/` (not committed, ~9 MB each).

The difference from every earlier capture is immediate: `f105_bdy_01` (10926 verts),
`bdy_02`, `bdy_03`, `eng_01`, `sld_01` are all **drawn at full detail**, and the far
log additionally caught the `e105` and `e106` hulls.

### 4a. One log is ~14 frames, and mixing them silently corrupts the result

`WV_ref⁻¹ · WV_p` cancels the camera **only within one frame**. The capture log has no
frame delimiter, so `correlate_capture` was mixing ~14 frames; with the camera closing
at ~760 u/s that is not a small error — two logs of the same cruiser disagreed by
1090 units on `f105_bdy_02`. New `ship_capture::segment_frames` splits the log wherever
a vertex buffer recurs (over-splitting is harmless — a block is still one camera;
under-splitting is what corrupts), and new
[`correlate_frames`](../../crates/sylpheed-formats/examples/correlate_frames.rs)
correlates each block independently and **cross-checks the blocks against each other**.

Two aggregation rules had to be right, and both were wrong first:
- **Only blocks with the requested reference part count.** A block that fell back to
  another reference expresses its parts in a different frame — averaging them in
  produces a "disagreement" of exactly the distance between the two references.
- **Consensus, not median.** A stage holds several ships of one class; they share
  vertex buffers, and a block can hold one instance's full-LOD part beside another's
  `_m` copy (different buffers, so nothing splits them). The largest cluster of
  mutually-agreeing blocks is the placement; the rest are reported as
  `(+N other-instance)` rather than averaged into nonsense.

With that, every part reproduces across independent frames to **≤1 unit** (typical
spread 0.03–0.2).

### 4b. Static assembly matches the runtime on all three classes ✅

`correlate_frames … --static <Stage_S02.xpr>` diffs `assemble_ship` against the capture,
translation **and rotation**, both re-expressed in the reference part's frame:

| ship | rig | parts compared | worst dT | worst dR |
|---|---|---|---|---|
| `f105` TCAF cruiser | 1 engine, mirrored `sld` pair | 5 | **0.12** | **0.000** |
| `e105` ADAN cruiser | 6 hull bodies, bridge, engine | 7 | **0.05** | 1.711 (`eng_01` only) |
| `e106` ADAN destroyer | 2 nacelles + centre, turret | 8 | **0.43** | 0.098 (`eng`/`wep` only) |

**So the `e106` rules DO generalise.** Translation is exact for every part of every
class — 20 of 21 comparisons under 0.5 units. This is the answer the BACKLOG item asked
for, and it is the opposite of the assumption in it: `assemble_ship` is right, so the
viewer's "capital ships assemble wrong" is the viewer's own transform stack (the
backlog's own "worth ruling out first, cheaply").

Both first-pass exceptions were chased down, and neither survives as an open question:

- ✅ **`e105_brg` was genuinely missing — a real assembler bug, now fixed.** Tier 3
  matched a part to its `GN_*` hardpoint by trailing index, so an index-less part
  (`e105_brg`) compared `"01" == ""` against `GN_Bridge_01` and fell through silently.
  With no index to match on, take the lowest-numbered frame of the category. The
  runtime is the check: `e105_brg` now assembles at `[0.0, 70.0, -1850.0]` relative to
  `e105_bdy_01`, **dT 0.03, dR 0.000** against the capture.
  Reach measured by diffing `assemble_ship` part counts over all containers before and
  after: **34 (stage, ship) entries gain parts** — `e102` +2 (bridge *and* engine),
  `e104` +1, `e105` +1, across Stages 02–29. Every one of those ships was assembling
  without its bridge. `ship_audit` is unchanged (still exactly the `f002_bdy_05`
  outlier), so nothing regressed.
- ✅ **The rotation deltas were an artefact of my own aggregation, plus one real
  articulation.** The static diff was comparing against a rotation taken from the
  first sampled block, which can belong to *another instance* of the class; scoping it
  to the position-agreeing cluster drops `e105_eng_01` from dR 1.711 to **0.000** and
  both `e106` nacelles to **0.000**. What remains is `e106_wep_02_01` at dR 0.134 — and
  that part's rotation varies by **0.182 between blocks that agree on its position**,
  i.e. the runtime disagrees with itself more than it disagrees with the assembler.
  It is a turret aiming, not an assembly error. `correlate_frames` now prints that
  `rotVar` column precisely so "the part moved" cannot be mistaken for "the rotation
  is wrong".

Final numbers, three classes, 21 parts: **worst dT 0.43, worst dR 0.000** for every
part that is not articulating.

`include_external` matters and is a caller-side trap: with `false` an `e106` assembles
as **5** parts and with `true` as **11** — the engine cluster, the bridge and the
cross-id `e303_wep_01` turrets live in separate composites (`e_rou_e106_eng`, 3 nodes)
that the primary-composite pass never reaches. The viewer takes it as a parameter
(`iso_loader.rs:4012`); if it is ever passed `false`, ships lose their engines and
bridge — which looks exactly like "assembles wrong".

## Honest summary

- ✅ Static assembly is **not** grossly broken across stages — 1 outlier ship
  (`f002_bdy_05`), reproducible.
- 🟡 A concrete, testable hypothesis for class-specific breakage exists (multikey joint
  tracks on `f104`/`f105`/`f106`/`e102`; `e106` has none).
- ✅ The earlier zero-match was **range**, not a format or correlator bug (§3): the
  captures were taken where no capital-ship geometry is drawn at all.
- ✅ With range controlled (§4), **three classes** — `f105`, `e105`, `e106` — reproduce
  static assembly to **≤0.43 units** in translation, cross-checked across independent
  frames. The `e106`-derived rules generalise; the MULTIKEY hypothesis in §1 is *not*
  needed to explain anything observed so far (`f105` has 2 multikey tracks and still
  matches exactly).
- ✅ One real assembler bug found and fixed by this route: index-less `brg`/`eng`/`sld`
  parts never matched their `GN_*` frame, so **34 (stage, ship) entries** assembled
  without a bridge (and `e102` also without its engine). Verified against the capture.
- ▶ Next: the viewer itself (`include_external`, node-instance recursion) — the format
  layer is now measured, not assumed. A per-ship regression table over the checked-in
  captures would keep it that way.
