re: widen unit coverage to all six tutorials, and prove the fields are run-invariant

Captures the five remaining tutorials (grab_tutorial.sh, one cold boot each)
and re-solves over seven snapshots from seven separate emulator runs. Coverage
18 -> 21 units, confirmed fields 22 -> 27 (Size_Y, MassScore, MinimumVelocity,
AV_PitchPlus_Min, AV_PitchMinus_Min join the zero-contradiction set).

The multi-run union exposed something the single-run check could not: the same
unit's object is NOT byte-identical between runs. unit_runtime.py --crosscheck
pins down why -- exactly 15 words differ, 13 of them holding guest heap
pointers, and none of them is a solved or interpolated field offset. So every
value reported is run-invariant, which is a stronger statement than the
within-run identity check that came before it.

The two non-pointer stragglers are a real caveat, now documented rather than
smoothed over: +0x2c8 reads 8000.0 for UN_f001_TCAF_DeltaSaber_T_Ttrl in two
tutorials and 10000.0 in a third, with a 0/1 flag at +0x2d0. A couple of words
in the object are set per stage, so it is mostly but not entirely the parsed
table. Unidentified, marked NEEDS-HUMAN.

Coverage honesty: all six tutorials together add only 3 units the missions do
not already have -- they reuse one training box, one drone and the player
craft. Further coverage needs story progress, not more tutorials.

Two navigation facts encoded in grab_tutorial.sh, both learned by breaking
them: the main menu is not input-ready for ~10 s after the title tap and early
d-pad presses are dropped (which sends the A to NEW GAME); and NEW GAME is not
a shortcut to Stage 01 -- it gates on DIFFICULTY then plays the prologue. A
NEW GAME excursion to the READY ROOM leaves game01/savedata byte-identical,
verified by diff against a backup.
This commit is contained in:
2026-07-29 18:22:47 +00:00
parent 2196ac112d
commit 1ee2d5e406
6 changed files with 577 additions and 130 deletions

View File

@@ -1,15 +1,16 @@
# Runtime `Unit` struct (craft / vessel definitions) — read from live guest memory
**Confidence: ✅ CONFIRMED** for the 22 fields marked ✅ below (each binding is
reproduced by 1018 independent disc records, on ≥3 distinct values, with
**Confidence: ✅ CONFIRMED** for the 27 fields marked ✅ below (each binding is
reproduced by 1021 independent disc records, on ≥3 distinct values, with
**zero** contradictions), plus the Maneuver block's declaration-order layout
(29 anchors, two bases, no conflicts). 🟡 PROBABLE for the fields interpolated
between confirmed anchors. 🟡/❔ for the thin single-value bindings, which are
listed but must not be trusted yet.
Captured 2026-07-29 from Xenia Canary running the retail disc in the sylph-re
container: the **BASIC CONTROLS tutorial** (4 units) and **Stage 02 "Declaration
of War"** loaded from save slot 01 (14 units). 18 of the disc's 110 units.
container: **all six tutorials** and **Stage 02 "Declaration of War"** loaded
from save slot 01. 21 of the disc's 110 units, over 7 snapshots from 7 separate
emulator runs.
## Why this exists
@@ -46,10 +47,23 @@ The two vtables are **two different things**, and telling them apart matters:
| `0x820af030` | **spawned entity instance** — live state | 12 objects for 4 IDs; the same ID appears many times (one per box in the scene); irregular spacing |
| `0x820af844` | **parsed definition** — the `.tbl` | exactly one object per distinct unit ID; minimum spacing `0x380` |
Only `0x820af844` is used. It is the runtime image of the `.tbl`, and its
objects are **byte-identical across two snapshots taken ~12 minutes apart with
combat in between** (14/14 objects, 0 differing bytes) — definition data, not
state.
Only `0x820af844` is used. It is the runtime image of the `.tbl`:
* **Within one run** it is byte-identical across two snapshots taken ~12 minutes
apart with combat in between (14/14 objects, 0 differing bytes) — definition
data, not live state.
* **Across runs** the same unit is *not* byte-identical, and that had to be
explained rather than waved away. `unit_runtime.py --crosscheck` compares
every unit that appears in more than one snapshot (5 of them, over 7 runs):
exactly **15 words differ**, and 13 of them hold guest pointers
(`0x8xxxxxxx`/`0xbxxxxxxx` — heap addresses, which move per process).
**No solved or interpolated field offset is among the 15** — every value
reported here is run-invariant.
* The two non-pointer stragglers, `+0x2c8` and `+0x2d0`, are **stage-dependent**:
for one and the same unit (`UN_f001_TCAF_DeltaSaber_T_Ttrl`) `+0x2c8` reads
`8000.0` in two tutorials and `10000.0` in a third, with `+0x2d0` a 0/1 flag
beside it. So the object is *mostly* but not *entirely* the parsed table —
a couple of words are set per stage. Unidentified; **NEEDS-HUMAN**.
One `.tbl`**one** object. A unit table is several sub-records (`Generic`,
`Maneuver`, `Shield`, `Explosion`, `Mass`, `Effect`, `SE`, `Turret_00N`), and
@@ -78,7 +92,7 @@ under lavapipe and makes repeated live reads flaky.
Every `AV_*` / `AA_*` / `*Bank*` / `Turn_AngularVelocity` field is stored as
**float32 radians**, while the disc writes **degrees**. The solver needed a
`rad` encoding (`degrees(f32)`) to bind them at all; 15 units agree on
`rad` encoding (`degrees(f32)`) to bind them at all; 18 units agree on
`AV_PitchPlus_Max` alone. A reimplementation reading the `.tbl` must convert.
## Confirmed layout
@@ -87,39 +101,44 @@ Every `AV_*` / `AA_*` / `*Bank*` / `Turn_AngularVelocity` field is stored as
| offset | enc | field | agree | distinct |
|---|---|---|---:|---:|
| `+0x030` | f32 | `Size_X` | 18 | 13 |
| `+0x038` | f32 | `Size_Z` | 16 | 13 |
| `+0x040` | f32 | `Color_R` | 18 | 5 |
| `+0x044` | f32 | `Color_G` | 16 | 6 |
| `+0x048` | f32 | `Color_B` | 14 | 5 |
| `+0x050` | f32 | `Size_Radius` | 11 | 10 |
| `+0x054` | f32 | `HP` | 17 | 10 |
| `+0x074` | f32 | `ResistanceToOptics` | 10 | 3 |
| `+0x08c` | f32 | `ScorePoint` | 18 | 9 |
| `+0x0a0` | f32 | `MaximumVelocity` | 16 | 8 |
| `+0x0a4` | f32 | `CruisingVelocity` | 14 | 7 |
| `+0x0a8` | f32 | `Acceleration` | 15 | 5 |
| `+0x0ac` | f32 | `Deceleration` | 14 | 5 |
| `+0x0b0` | rad | `AV_PitchPlus_Max` | 15 | 7 |
| `+0x0f8` | f32 | `SideThrustVelocity_Max` | 12 | 3 |
| `+0x238` | f32 | `MaxValue` (Shield) | 12 | 6 |
| `+0x244` | f32 | `ChargeSpeed` (Shield) | 11 | 6 |
| `+0x270` | f32 | `DestroyMotionTime` | 16 | 7 |
| `+0x2a0` | f32 | `RadarRange` | 15 | 8 |
| `+0x2a4` | f32 | `FCSRange` | 10 | 6 |
| `+0x2b4` | f32 | `AttackVesselPoint` | 11 | 8 |
| `+0x2bc` | f32 | `DefencePoint` | 10 | 7 |
| `+0x030` | f32 | `Size_X` | 21 | 13 |
| `+0x034` | f32 | `Size_Y` | 12 | 7 |
| `+0x038` | f32 | `Size_Z` | 19 | 13 |
| `+0x040` | f32 | `Color_R` | 21 | 5 |
| `+0x044` | f32 | `Color_G` | 19 | 6 |
| `+0x048` | f32 | `Color_B` | 17 | 5 |
| `+0x050` | f32 | `Size_Radius` | 12 | 10 |
| `+0x054` | f32 | `HP` | 19 | 10 |
| `+0x074` | f32 | `ResistanceToOptics` | 11 | 3 |
| `+0x08c` | f32 | `ScorePoint` | 21 | 9 |
| `+0x094` | f32 | `MassScore` | 10 | 7 |
| `+0x09c` | f32 | `MinimumVelocity` | 11 | 3 |
| `+0x0a0` | f32 | `MaximumVelocity` | 19 | 8 |
| `+0x0a4` | f32 | `CruisingVelocity` | 17 | 7 |
| `+0x0a8` | f32 | `Acceleration` | 18 | 5 |
| `+0x0ac` | f32 | `Deceleration` | 17 | 5 |
| `+0x0b0` | rad | `AV_PitchPlus_Max` | 18 | 7 |
| `+0x0b4` | rad | `AV_PitchPlus_Min` | 10 | 6 |
| `+0x0c4` | rad | `AV_PitchMinus_Min` | 10 | 5 |
| `+0x0f8` | f32 | `SideThrustVelocity_Max` | 14 | 3 |
| `+0x238` | f32 | `MaxValue` (Shield) | 13 | 6 |
| `+0x244` | f32 | `ChargeSpeed` (Shield) | 13 | 6 |
| `+0x270` | f32 | `DestroyMotionTime` | 19 | 7 |
| `+0x2a0` | f32 | `RadarRange` | 18 | 9 |
| `+0x2a4` | f32 | `FCSRange` | 12 | 8 |
| `+0x2b4` | f32 | `AttackVesselPoint` | 14 | 8 |
| `+0x2bc` | f32 | `DefencePoint` | 12 | 7 |
`Size_Y +0x034`, `MassScore +0x094`, `HQRatio +0x058`, `ShieldRatio +0x05c`,
`HQRatio +0x058`, `ShieldRatio +0x05c`,
`ThrusterRatio +0x060`, `ResistanceToShell +0x078`,
`ResistanceToExplosion +0x07c`, `ResistanceToPlayer +0x080`,
`ResistanceParalyze +0x084`, `BridgeCount +0x070` (i32),
`MinimumVelocity +0x09c`, `DryMass +0x274`, `GrossMass +0x278`,
`DryMass +0x274`, `GrossMass +0x278`,
`LowerHPThresholdRatio +0x298`, `AttackCraftPoint +0x2b8` bind with zero
contradictions on fewer records or fewer distinct values — 🟡 PROBABLE. The
full solver output is in
[`captures/unit-runtime-fields.csv`](../captures/unit-runtime-fields.csv)
(1 440 values, `conf` column).
(1 827 values, `conf` column).
## The Maneuver block is laid out in schema declaration order
@@ -212,12 +231,32 @@ every unit**, with real exceptions that only the runtime shows:
## Coverage and how to extend it
18 of 110 units. Unlike weapons — where one snapshot held all 126 — **unit
21 of 110 units. Unlike weapons — where one snapshot held all 126 — **unit
definitions are instantiated per stage**, so coverage is bounded by the stages
reachable from the save (slot 01 is at 5 %, Stage 02). Each further mission or
tutorial adds its own units; `unit_runtime.py` unions any number of snapshots
and re-solves, and more units directly promote the 🟡 bindings to ✅ by adding
distinct values.
reachable from the save (slot 01 is at 5 %, Stage 02). `unit_runtime.py` unions
any number of snapshots and re-solves, and more units directly promote the 🟡
bindings to ✅ by adding distinct values — the six tutorials took the confirmed
set from 22 fields to 27.
The tutorials are nearly exhausted as a source: all six together contribute only
3 units the missions do not already have (`UN_f001_TCAF_DeltaSaber_T_Ttrl`,
`UN_e015_ADAN_Puppy_2`, `UN_f001_TCAF_DeltaSaber_T_Player_Ttrl2`) — they reuse
one training box, one target drone and the player craft. **Real coverage now
needs real missions**, i.e. story progress on the save.
`tools/re-capture/grab_tutorial.sh` captures one tutorial per invocation
(cold boot → menu → Nth entry → snapshot, ~2.5 min). It cold-boots for each
because backing out of a loaded mission via PAUSE → BACK TO MENU wedges the
emulator. Two timing facts it encodes, both learned the hard way: the main menu
is **not input-ready for ~10 s** after the title tap, and d-pad presses before
that are silently dropped — which sends the A to `NEW GAME` instead of
`TUTORIAL`. And `NEW GAME` is not a cheap way to reach Stage 01: it gates on a
DIFFICULTY menu and then plays the prologue movie.
A NEW GAME excursion as far as the READY ROOM leaves
`535107D4/00000001/game01/savedata` byte-identical — only the profile `.gpd`
achievement files change — so it does not endanger the 5 % save. Verified by
diff against a backup, not assumed.
**A stage's whole unit set is parsed at load, not as waves spawn** — checked by
counting the definition objects at three points in Stage 02: immediately after