sylpheed.db has the machinery named: IdxdLoad_Dispatch 0x824486c0, Idxd_Parse 0x82449640, Reflect_FindFieldIndex 0x8244a2f0, Reflect_SetField 0x8244a4b0. FindFieldIndex looks a field up by NAME at runtime (strlen + compare), which supports the reflective-read reading -- but Idxd_Parse is a text parser that calls SetField on a target object, so a parsed record does exist and the earlier 'no flat record' hypothesis is weakened. The useful consequence: Idxd_Parse only writes fields the text contains, so a DEFAULTED field keeps whatever the constructor stored. The defaults are therefore immediates in each definition class's constructor -- reachable by walking the call graph above IdxdLoad_Dispatch -- not values to be scanned for in RAM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
235 lines
13 KiB
Markdown
235 lines
13 KiB
Markdown
# Live unit definitions from a running Stage 02 (2026-08-12)
|
||
|
||
The Route-B question — what value does a field that the disc leaves **defaulted**
|
||
actually take at runtime — needs the game running. This is the first snapshot of
|
||
the parsed definition objects taken straight out of guest RAM, plus the recipe
|
||
that works in the box today, because getting there was most of the work.
|
||
|
||
## The recipe that works
|
||
|
||
Everything must happen inside **one blocking foreground call**. A process that
|
||
outlives the call that started it is reaped — `nohup`, a harness background job
|
||
and a `wait`-holding wrapper were all tried and all died within a minute of the
|
||
turn ending (see the session-lifetime notes).
|
||
|
||
```bash
|
||
tools/re-capture/launch_mission.sh fly # title → LOAD → slot 01 → READY ROOM → TAKE OFF
|
||
python3 tools/re-capture/gmem.py find hex:820af844 400 # live definition objects
|
||
python3 tools/re-capture/gmem.py words <va> 96 # dump each one
|
||
```
|
||
|
||
Two environment facts that cost an hour each:
|
||
|
||
- **Audio must be `--audio --apu=sdl` with `SDL_AUDIODRIVER=dummy`.** There is no
|
||
PulseAudio server in the box, so muting (`--apu=nop`, `run-canary`'s default)
|
||
looks like the safe choice. It is not: the log then fills with
|
||
`AudioSystem::RegisterClient: CreateDriver failed for index=0`, the guest never
|
||
gets past the movie, and the window stays black for 8+ minutes. `nav_probe.sh`
|
||
and `launch_mission.sh` already pass the right pair — do not "fix" them.
|
||
- **Boot is fast once the caches are warm.** The first boot took minutes; with
|
||
the shader/code cache populated, `launch_mission.sh` reached the flight HUD in
|
||
**25 s**, which is what makes a launch-and-dump fit in a single call.
|
||
|
||
`entities2.py`, `flight_probe.py` and the other analysis scripts **do not run
|
||
here** — no `numpy` (and no `PIL` for image work). `gmem.py` is pure stdlib and
|
||
works, so the pattern is: dump raw words to disc inside the call, analyse
|
||
afterwards.
|
||
|
||
## What came out
|
||
|
||
14 live definition objects (vtable `0x820af844`), 96 words each:
|
||
[`captures/stage02-live-unit-definitions.txt`](captures/stage02-live-unit-definitions.txt),
|
||
with the flight screenshot they were taken from.
|
||
|
||
| object | `+0x30` | `+0x34` | `+0x38` | `+0x54` |
|
||
|---|---|---|---|---|
|
||
| `0xbd3b6a00` | 2500 | 3100 | 2600 | 10000 |
|
||
| `0xbd3d2d80` | 80 | 80 | 350 | 4000 |
|
||
| `0xbd3da100` | 10 | 7 | 29 | 1500 |
|
||
| `0xbd3db980` | 350 | 70 | 200 | 10000 |
|
||
| `0xbd3dbd00` | 10 | 5 | 20 | 300 |
|
||
| `0xbd3dea80` | 20 | 9 | 22 | 100 |
|
||
| `0xbd3dfc00` | 10 | 7 | 29 | 1000 |
|
||
| `0xbd3e0680` | 300 | 300 | 2800 | 3000 |
|
||
| `0xbd3e1f00` | 400 | 400 | 1400 | 25000 |
|
||
| `0xbd3e6f80` | 300 | 300 | 2100 | 10000 |
|
||
| `0xbd3ee300` | 200 | 200 | 2000 | 10000 |
|
||
| `0xbd3faa80` | 100 | 40 | 50 | 500 |
|
||
| `0xbd3fd800` | 600 | 600 | 3800 | 30000 |
|
||
| `0xbd40e200` | 700 | 700 | 3800 | 30000 |
|
||
|
||
This **corroborates** the layout solved earlier from a single in-mission
|
||
snapshot (`+0x054` = HP, `+0x30…0x38` = `Size_X/Y/Z`) rather than re-proving it:
|
||
the `+0x54` column reads as hull values across three orders of magnitude
|
||
(100 for something small, 30 000 twice for capital ships), and the sizes scale
|
||
with them. It also gives independent support to the **`Size_Y` inherits `Size_X`**
|
||
default rule — five of the fourteen have `+0x30 == +0x34` exactly
|
||
(300/300, 400/400, 200/200, 600/600, 700/700) while the rest differ.
|
||
|
||
## Identity without a name: match on the values
|
||
|
||
Nothing in the first 96 words is a name pointer, so identity comes from the
|
||
numbers themselves. `examples/unit_signatures.rs` prints `(HP, Size_X, Size_Y,
|
||
Size_Z)` for every craft unit and vessel on the disc; a live object is then the
|
||
record whose *known* fields it reproduces exactly.
|
||
|
||
Seven of the fourteen match a disc record on all four values — the player's
|
||
Delta Saber (`10, 7, 29`, HP 1500 in its player form and 1000 otherwise), the
|
||
ArrowHead, an ADAN turret, an Attacker. **The other seven match nothing, and
|
||
that is the point:** their disc records leave a field defaulted, so there is
|
||
nothing to match against. **18 of the 23 vessel records on disc are missing at
|
||
least one of these four fields**, nearly always `Size_Y`.
|
||
|
||
## ✅ Route B: 13 defaulted fields read out of the running game
|
||
|
||
Matching each disc record that defaults exactly one field against the live object
|
||
that reproduces its remaining fields resolves the missing value:
|
||
|
||
| unit | field | runtime value | live object (sx, sy, sz, hp) |
|
||
|---|---|---|---|
|
||
| `UN_e201_ADAN_ISCMissile` | `Size_Y` | **300** | `0xbd3e0680` (300, 300, 2800, 3000) |
|
||
| `UN_f106_TCAF_Destroyer` | `Size_Y` | **200** | `0xbd3ee300` (200, 200, 2000, 10000) |
|
||
| `UN_f106_TCAF_Destroyer_Inv` | `Size_Y` | **200** | `0xbd3ee300` |
|
||
| `UN_e105_ADAN_Cruiser` | `Size_Y` | **600** | `0xbd3fd800` (600, 600, 3800, 30000) |
|
||
| `UN_e105_ADAN_CruiserEX` | `Size_Y` | **600** | `0xbd3fd800` |
|
||
| `UN_f105_TCAF_Cruiser` | `Size_Y` | **700** | `0xbd40e200` (700, 700, 3800, 30000) |
|
||
| `UN_f105_TCAF_Cruiser_EX5` | `Size_Y` | **700** | `0xbd40e200` |
|
||
| `UN_f105_TCAF_Cruiser_Inv` | `Size_Y` | **700** | `0xbd40e200` |
|
||
| `UN_e108_ADAN_ASFrigate` | `Size_Y` | **80** | `0xbd3d2d80` (80, 80, 350, 4000) |
|
||
| `UN_e108_ADAN_ASFrigateEX` | `Size_Y` | **80** | `0xbd3d2d80` |
|
||
| `UN_e106_ADAN_Destroyer` | `Size_Y` | **300** | `0xbd3e6f80` (300, 300, 2100, 10000) |
|
||
| `UN_e106_ADAN_DestroyerEX` | `Size_Y` | **300** | `0xbd3e6f80` |
|
||
| `UN_f101_TCAF_Acropolis` | `Size_Y` | **400** | `0xbd3e1f00` (400, 400, 1400, 25000) |
|
||
|
||
**Every resolved value equals that unit's `Size_X`** — so the running game
|
||
confirms the `Size_Y` inherits `Size_X` rule that was derived statically from
|
||
sibling records, this time from the engine's own parsed definitions.
|
||
|
||
Two honest limits. Variants share a live object (`_Inv`, `_EX5`, `EX` map to the
|
||
same definition as their base), so the snapshot cannot distinguish them — the
|
||
value is right for the class, and per-variant differences in *other* fields are
|
||
not addressed. And this run only reaches the units Stage 02 instantiates: the
|
||
`…Ratio` / `…Count` family (`HQRatio`, `PowerRatio`, `ShieldRatio`, `HatchCount`,
|
||
`NodeCount`, `SequencingCount`, …) is defaulted on 5–23 records each and is
|
||
**not** resolved here, because those fields' offsets in the live object are not
|
||
yet known. Finding them is the next run: dump deeper than 96 words and look for
|
||
the constants the disc *does* set on the records that set them.
|
||
|
||
## Locating fields inside the live object
|
||
|
||
The next step — reading the `…Ratio` / `…Count` family — needs to know *where*
|
||
each field sits in the live object. The method is to anchor on a unit whose disc
|
||
record **sets** a field and look for that value in its dumped words. Anchoring on
|
||
`UN_f106_TCAF_Destroyer` (disc sets `Size_Radius 0.1`, `ThrusterRatio 0.8`,
|
||
`ResistanceParalyze 0.97`) and then checking the same offsets across five capital
|
||
ships and the player's fighter:
|
||
|
||
| offset | reading | `f106` | `f105` | `e105` | `e106` | `f101` | Delta Saber |
|
||
|---|---|---|---|---|---|---|---|
|
||
| `+0x30` | `Size_X` ✅ | 200 | 700 | 600 | 300 | 400 | 10 |
|
||
| `+0x34` | `Size_Y` ✅ | 200 | 700 | 600 | 300 | 400 | 7 |
|
||
| `+0x38` | `Size_Z` ✅ | 2000 | 3800 | 3800 | 2100 | 1400 | 29 |
|
||
| `+0x54` | `HP` ✅ | 10000 | 30000 | 30000 | 10000 | 25000 | 1500 |
|
||
| `+0x74` | 🟡 `ThrusterRatio` | 0.8 | 0.8 | 0.8 | 0.8 | 0.8 | 1 |
|
||
| `+0x84` | 🟡 `ResistanceParalyze` | 0.97 | 0.97 | 0.97 | 0.97 | 0.97 | 0.97 |
|
||
| `+0x40` | ❔ | 0.1 | 0.1 | 1 | 1 | 1 | 0.1 |
|
||
|
||
`+0x74` and `+0x84` are marked 🟡 because they rest on one anchoring unit: the
|
||
value is right for the Destroyer and constant across the others, which is
|
||
consistent with a field the rest default, but a second anchoring unit that sets
|
||
them to something *different* is what would prove it. `+0x40` is 0.1 on two units
|
||
and 1 on three, so it is a real per-unit field — just not identified.
|
||
|
||
⚠️ **The automated version finds almost nothing, and the reason is not what it
|
||
first looked like.** `examples/live_offsets.rs` correlates every `get_f32(key)`
|
||
against every dumped word and keeps offsets all units agree on. Over five units
|
||
it produced exactly **one** hit, `YawDragFactor → +0x0c`, and that hit is
|
||
**wrong**: `+0x0c` holds the integer 2 (printed as the denormal `2.8e-45`), which
|
||
collided with `YawDragFactor = 2.0`. Any float-compare against small integers has
|
||
that failure mode.
|
||
|
||
The first explanation written here — that `get_f32` is unreliable on
|
||
default-heavy records — was **wrong**, and measuring killed it: `get_f32`
|
||
resolves **46–57 numeric fields per unit** (Destroyer 46, `e105` Cruiser 57,
|
||
`f105` Cruiser 49, `e106` Destroyer 51, Acropolis 45). It is the documented,
|
||
reliable API and it works.
|
||
|
||
The real problem is that **the values are not in this object**. Dumping 512 words
|
||
(2 KB) instead of 96 changes nothing at all: still exactly **4 of ~50** disc
|
||
values found, for every one of the five units. So the structure at vtable
|
||
`0x820af844` is not the parsed definition record — it carries the handful of
|
||
fields above (sizes, HP, a couple of ratios) and the rest of the record lives
|
||
elsewhere, presumably behind a pointer.
|
||
|
||
Next probe, therefore, is not a bigger window: search guest RAM for a value that
|
||
is distinctive to one unit (e.g. the Acropolis' `HP 25000` = `0x46c35000`) and see
|
||
what *other* structure holds it, then map that one.
|
||
|
||
## Where the rest of the record is — three probes, and a hypothesis
|
||
|
||
Following "search RAM for a unit-distinctive value" produced no parsed record:
|
||
|
||
1. **`HP 25000`** (`0x46c35000`, the Acropolis) — **200+ hits**, the search limit.
|
||
Too common to anchor on: it is also a generic constant.
|
||
2. **`AttackVesselPoint 0.08`** (`0x3da3d70a`, an unusual float) — 64+ hits, and
|
||
**none of the eight windows dumped around them contained the unit's other
|
||
values** (`Size_X 400`, `Size_Z 1400`, `HP 25000`, `RadarRange 60000`). One hit
|
||
sits in the executable range (`0x820b62f4`), i.e. a code/rdata constant.
|
||
3. **The ID string itself.** `UN_f101_TCAF_Acropolis` appears **twice** in RAM,
|
||
each copy referenced by a pointer exactly **16 bytes before the string**. But a
|
||
256-word window around it contains **none** of the unit's numbers — because
|
||
that region is the IDXD **string pool**, where `25000.0` is stored as *text*.
|
||
|
||
🟡 **Hypothesis: there is no flat parsed record.** IDXD is a reflective format,
|
||
and the evidence fits the engine reading values out of the pool by key when it
|
||
needs them, caching only the few it touches per frame. That is exactly what the
|
||
`0x820af844` object looks like: sizes (collision), HP (damage), a couple of
|
||
ratios — and nothing else, no matter how deep it is dumped.
|
||
|
||
If that is right, it reframes Route B. For a **defaulted** field there is no value
|
||
in the pool at all, so the default is applied by *code* at read time and can never
|
||
be found by scanning data structures. It would have to come from either the read
|
||
path (trace where a key hash is looked up and what the miss path substitutes) or
|
||
from the title's own code — which is the DuckDB static route this project already
|
||
has. The 13 `Size_Y` values recovered above worked precisely because `Size_Y` is
|
||
one of the cached, every-frame fields.
|
||
|
||
This is a hypothesis, not a finding: it explains three negative probes but has not
|
||
been confirmed by watching a read happen.
|
||
|
||
## The static route says where the defaults must be
|
||
|
||
The DuckDB image (`xenia-rs/sylpheed.db`, 12 156 functions / 1.87 M instructions)
|
||
has the reflective machinery under real names:
|
||
|
||
| address | name |
|
||
|---|---|
|
||
| `0x824486c0` | `IdxdLoad_Dispatch` |
|
||
| `0x82448d00` | `IdxdLoad_Variant2` |
|
||
| `0x82449640` | `Idxd_Parse` |
|
||
| `0x8244a2f0` | `Reflect_FindFieldIndex` |
|
||
| `0x8244a4b0` | `Reflect_SetField` |
|
||
|
||
`Reflect_FindFieldIndex` measures a C string, indexes a buffer and calls a
|
||
compare routine — it looks a field up **by name at runtime**, which is the
|
||
reflective read the hypothesis above predicted. But `Idxd_Parse` is a **text
|
||
parser** (it checks for a UTF-16 BOM, walks characters through a jump table at
|
||
`0x824497f8`) and it calls `Reflect_SetField` on a target object passed in `r3`.
|
||
|
||
That last point **weakens the "no parsed record" hypothesis**: parsing writes
|
||
values into a real object. So a parsed record does exist — my three RAM probes
|
||
simply did not find the right one — and, more usefully, it tells us where a
|
||
*defaulted* field's value comes from. `Idxd_Parse` only ever writes the fields the
|
||
text actually contains, so a defaulted field keeps **whatever the object had
|
||
before parsing** — i.e. the value its **constructor** stored.
|
||
|
||
So the defaults are neither in the disc data (by definition) nor in a table to be
|
||
scanned for: they are immediates in the constructor of each definition class.
|
||
Reading them is a disassembly job — find the allocation site above
|
||
`IdxdLoad_Dispatch`, then the constructor it calls, and read the stores. That is
|
||
the next step, and it would settle the whole `…Ratio` / `…Count` family at once
|
||
rather than one field per flight.
|
||
|
||
(The `vtables`, `classes` and `methods` tables are empty in this DB build, as
|
||
`CLAUDE.md` warns, so the class has to be reached through the call graph.)
|