Tier 3 matched a part to its hardpoint by trailing index, so `e105_brg` compared "01" == "" against GN_Bridge_01 and fell through silently. The runtime capture is what exposed it: the game draws the bridge and places it at [0, 70, -1850] rel e105_bdy_01, and assemble_ship emitted nothing there. With no index to match on, take the lowest-numbered frame of the category. Diffing assemble_ship part counts across every container: 34 (stage, ship) entries gain parts — e102 +2 (bridge and engine), e104 +1, e105 +1, Stages 02-29. ship_audit is unchanged, so nothing regressed, and the capture now agrees to dT 0.03 / dR 0.000. Also fixes the diff itself: correlate_frames compared static against a rotation sampled from the first block, which can belong to another INSTANCE of the class. Scoped to the position-agreeing cluster, e105_eng_01 goes 1.711 -> 0.000 and both e106 nacelles to 0.000. The one remaining rotation delta (e106_wep_02_01, 0.134) is a turret whose rotation varies by 0.182 between blocks that agree on its position — the runtime disagrees with itself more than with the assembler. The new rotVar column makes that distinction visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
248 lines
15 KiB
Markdown
248 lines
15 KiB
Markdown
# 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.
|