sots-re/findings/objects/serializer-struct-recovery.md
alex d7ea0a048c lane D: automated struct recovery from the IStreamable serializers
Every serializable class carries an enumeration of its own fields -- its
Write(Stream&), walking the members in order with a 4-char tag. This decodes
that idiom mechanically for the whole binary in 0.35 s.

Validation first (tools/serializers.py validate), against answers the campaign
already had before the tool existed:
  A  305/307 field offsets+kinds exact across 17 classes, 0 WRONG, vs
     struct-recovery.md 1-4 and observedtech-append.md
  B  sizeof from the container-stride divides: ObservedTech 0x2c, MoraleEvent
     0x50, PlayerReport 0x30, DiplomacyStats 0x24 -- all matching
  C  22 of save_reader.py's shapes, tag order identical (Sys 78 tags,
     Player 104, CreateParams 25, Ship 22): 22 agree, 0 disagree
  D  Read/Write cross-check on every class: 437/437 field offsets agree

At scale: 386 classes with a Write, 1,682 member fields.
  verified 87 (542 fields) | clean 77 (328) | unnamed 176 (471)
  partial 31 (341) | empty 15
  58 classes with a sizeof corroborated by a second line of evidence
  (45 container stride, 13 enumeration meeting the embedding bound); the rest
  report a lower bound and say so.

Four things each worth 10-170 classes: the RTTI class hierarchy descriptor as
the only honest "is this an IStreamable" test (a 3-slot vftable also matches
TacAISquadRule_* and the row parsers); mod=0 memory operands, which x86disp.py
cannot index and which hide every field at offset 0; the member->id pointer
idiom behind every handle field; and sub-writers, both base-class and private
(StrategyServer's six id lists live in FUN_00794cd0).

Failure classes are enumerated in the finding -- 176 anonymous-tag classes are
a hard limit on names but not on layout, and the other 64 are bounded
mechanical fixes. Two fields lost to a value assembled across a branch were
left unrecovered rather than patched with an unverifiable heuristic.

Write-back: 288 structures + 328 labels into Ghidra (0 failures), +201
addresses.json entries, header regenerated with tools/gen_addresses.py.

Note: ghidra/addresses.json also carries lane V's already-written live
confirmation text on ObservedTech_sizeof and ServerPlayer_off_ObservedTechs --
their edit, swept in only because we share the file.
2026-09-08 05:51:37 -04:00

248 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Automated struct recovery from the `Mars::IStreamable` serializers
Lane D, 2026-09-08. Static/host only — VM140 was held by lane V and the game was never run.
The campaign had ~1,600 classes mapped by name and a few dozen by layout. This round makes the
layout step mechanical: every serializable class in the exe carries an **enumeration** of its own
fields — its `Write(Stream&)`, which walks the members in order with a 4-char name tag — and a
decoder for that idiom turns "read one class in an afternoon" into "read them all in 0.35 s".
Tools: `tools/rtti_map.py` (RTTI → vftables), `tools/serializers.py` (the recovery),
`tools/serializers_golden.py` (the regression set), `tools/serializers_ghidra.py` (write-back).
Outputs: `objects/layouts.json`, `objects/layouts.md`, `objects/layouts.h`.
---
## 1. Validation first
`uv run python3 tools/serializers.py validate` runs three checks, all against answers this
campaign already had **before** the tool existed. Nothing below §1 is claimed until this passes.
### A — field offsets and kinds, vs `struct-recovery.md` §1–§4 and `observedtech-append.md`
```
[OK ] Game::DiplomacyStats 14/14 exact
[OK ] Game::FlightPlan 6/6 exact
[OK ] Game::FlightPlan::Waypoint 3/3 exact
[OK ] Game::IndependenceInfo 5/5 exact
[OK ] Game::MoraleEvent 6/6 exact
[OK ] Game::NodeRoute 3/3 exact
[OK ] Game::ObservedTech 5/5 exact
[OK ] Game::ObservedWeapon 5/5 exact
[OK ] Game::PlayerReport 11/11 exact
[OK ] Game::PopulationGroup 3/3 exact
[MISS] Game::ServerPlayer 101/103 exact 2 missing
[OK ] Game::ServerSystem 79/79 exact
[OK ] Game::ShipBuildOrder 5/5 exact
[OK ] Game::StarFleet 16/16 exact
[OK ] Game::StarShip 23/23 exact
[OK ] Game::StarSystem::OutputRates 7/7 exact
[OK ] Game::StarSystem::PlayerView 13/13 exact
fields: 305/307 exact, 2 not recovered, 0 WRONG
```
**Zero wrong.** Every offset, every width, every tag the tool emits for these 17 classes matches the
hand-recovered tables — including the ones that were traps: `ObservedTech` `+0x24` is *not* reported
as a field (it is the string's trailing `_Alval`), `Bats2`/`rcex` come out as int64 and not int,
`TShn`/`ETS`/the whole `DiplomacyStats` block come out int16, `ltis` comes out int, `pswd` comes out
string, `TRM`/`CstR`/`CstE`/`CstT`/`shrm`/`RefCap`/`RepCap`/`PlayerView.Infra` come out float.
The two misses are one failure class, described in §4: a value assembled **across a branch**
(`ResTNm` writes `tech ? tech->name : ""`; `tnc` writes `max(member, 1)`). A linear pass sees only
the last branch's expression. Neither was papered over with a heuristic.
### B — `sizeof`, from the container-stride divides
```
[OK ] Game::DiplomacyStats got 0x24 [OK ] Game::ObservedWeapon got 0x2c
[OK ] Game::MoraleEvent got 0x50 [OK ] Game::PlayerReport got 0x30
[OK ] Game::ObservedTech got 0x2c
```
`sizeof(ObservedTech) = 0x2c` — the value lane X pinned three ways — falls out mechanically, from a
completely different place: `VectorHelper<ObservedTech>::Write` divides the vector's byte span by
the element stride, and MSVC's magic-number sequence for that divide is invertible
(`M = ceil(2^(32+k)/s)`). `PopulationGroup` and `OutputRates` are `[--]` — they have no
`VectorHelper`, so this oracle is silent on them rather than guessing.
### C — on-disk tag order, vs `save_reader.py`
22 of `save_reader.py`'s shapes name the exact `::Write` that produces them. For every one, the
tool's recovered tag sequence contains the oracle's tag sequence **in the same relative order**:
```
22 shapes agree, 0 disagree
```
That includes `Sys` (78 tags), `Player` (104), `CreateParams` (25), `Ship` (22), `Slot` (18),
`DipStat` (14), `PlayerView` (14). `save_reader.py` parses the three real saves `--strict` clean
with 36 tests and lane C's `state_checksum.py` proves its coverage by re-serialising byte-for-byte,
so this is disassembly and the save oracle agreeing independently, at scale.
### D — Read/Write cross-check, run on every class
The campaign's own standard ("the Read functions agree on every member address"), automated: the
class's `Read` is extracted the same way and its member offsets compared to `Write`'s, matched by
tag name.
```
Read/Write cross-check : 437/437 fields agree (0 classes with any disagreement)
```
Two subtleties had to be handled or this check manufactures false alarms, and both are worth
carrying: a tag used **twice** in one class (`ServerPlayer` writes both an `int Team` at `0xac` and
a `PlayerAlliances Team` at `0x168`) cannot be matched by name, and an **anonymous** tag (`"."`,
the writer passed NULL) carries no identity at all, so positional matching turns one side's
under-recovery into a cascade of fake conflicts. Both are excluded, and the classes affected are
reported as not-cross-checkable rather than as agreeing.
---
## 2. How it works
Five things had to be got right; each of the last four was worth 10–170 classes on its own.
**The idiom.** Writer side, one shape under two calling conventions:
```
push 0xff ; default-value argument
<member expression> ; lea r,[this+D] | mov r,[this+D] | movzx r,word [this+D]
push r
push <tag> ; -> .rdata "otch", "pswd", ... (or 0 -> "." on disk)
push <stream> / mov ecx,<stream>
call WriteString/WriteBool/… | call [[stream]+slot]
```
The push **immediately before the tag push** is always the member. Stream vftable slots, read off
the wrappers rather than assumed: `+0x18` string, `+0x1c` bool, `+0x20` float, `+0x24` int,
`+0x28` nested, `+0x30` raw bytes (`n = 8` → int64).
**Sizes come from the kind, never from the offsets touched** — lane S's rule. `bool` 1, `int16` 2,
int/float/enum/handle 4, `int64` 8, `std::string` **0x1c**, `std::vector` **0x10**, both
*allocator-last* in this build's STL and therefore invisible to any touch-scan.
**(a) RTTI gives the owner and the this-adjustment.** `tools/rtti_map.py` walks
type-descriptor ← `COL.pTypeDescriptor` ← `vftable[-1]` for the whole image: 1,924 type
descriptors (exactly the inventory's count), 2,172 COLs, 2,172 vftables. It reproduces the known
anchors exactly — `ObservedTech` vftable `0x00a2439c` / COL `0x00a81c78`; `ServerSystem`'s
IStreamable vftable `0x00a2043c` at COL offset **+8** with Read `0x0075d4b0` / Write `0x00749630`.
`COL.offset` is what turns a decompiled offset into an absolute one, and for `ServerPlayer` that is
`+0x3a0`, so most of its members are at *negative* decompiled offsets.
**(b) The class hierarchy descriptor is the only honest membership test.** A 3-slot vftable is
*not* a serializer test: `TacAISquadRule_*`, the CSV row parsers and ~100 other classes have three
virtuals. Walking `COL → ClassHierarchyDescriptor → base class array` and requiring
`Mars::IStreamable` in the base list removes them all. This dropped a 116-class "empty" bucket to
11 — i.e. it removed 105 classes that were never serializers, instead of reporting them as failures.
**(c) `mod=0` memory operands.** `tools/x86disp.py` indexes operands that carry a *displacement*, so
`mov eax,[edi]` — a member at offset **0** — is invisible to it. For a plain struct with no vptr,
offset 0 is a real field: `StarSystem::OutputRates.SRt` is exactly that, and so is every
`handle`/element read through a bare iterator. Synthesising the mod=0 case recovered `SRt` and every
`Flt`/`GF`/`SnF`/`MnF` element in `ServerSystem`.
**(d) The pointer-member idiom.** `HomeSys`, `PlrID`, `DesID`, `FltID`, `SrnTo` are written as
`member->id`, so the pushed value is a load off a *pointer that came from* a member, three to six
instructions and a conditional branch earlier. Remembering where a register's pointer came from
(and reading the base's this-ness **before** retiring the destination — `mov edi,[edi+0x1c0]`
overwrites the this-register with the member it is reading) recovered those five plus
`ServerSystem::indi`.
**(e) Sub-writers.** Two kinds, both invisible without special handling. A **base-class Write**
(`StarMapNode::Write` inside `ServerSystem::Write`, giving `Pos`) and a **private sub-writer of the
same class** (`StrategyServer` writes its six id lists in `FUN_00794cd0`, so `PlayerIDs`,
`DesignIDs`, `SystemIDs`, `FleetIDs`, `ShipIDs`, `TradeIDs` are simply absent without it — that was
the last disagreement in check C). Both are spliced in at the call site, which preserves disk order.
A third kind, a **tag-forwarding** writer `f(stream, name, member)` that passes the caller's name
through to a stream primitive, is *discovered* (by spotting a tag push that is `[ebp+0xc]`) rather
than listed by hand, because a hand list is exactly what silently under-reports on 1,600 classes.
**Containers.** A count argument is never a member; it is `(_Mylast - _Myfirst)/stride`. Recovering
the subtraction gives the container's offset (`NumFlts` → the `vector` at `0x16c`, `NVO` → the map
`_Mysize` at `0x278`), and inverting the divide gives the element stride — which is `sizeof(T)`.
---
## 3. Results at scale
`uv run python3 tools/serializers.py all` — 0.35 s for the whole binary.
| tier | classes | member fields | meaning |
|---|---|---|---|
| **verified** | 87 | 542 | every field resolved and sized, and `Read` agrees on every comparable offset |
| **clean** | 77 | 328 | every field resolved and sized; no `Read`-side comparison available |
| **unnamed** | 176 | 471 | offsets and types resolved, but the writer passes a NULL name so the fields have no on-disk names (combat commands, network messages) |
| **partial** | 31 | 341 | see §4 |
| **empty** | 15 | 0 | the class's `Write` writes no tagged field |
| **total** | **386** | **1,682** | |
* **58 classes have a `sizeof` corroborated by a second, independent line of evidence** — 45 from a
`VectorHelper<T>` element-stride divide, 13 where the field enumeration's lower bound meets the
upper bound implied by every embedding of that type in another class. For the rest the tool
reports a **lower bound** and says so: a serializer enumerates *serialised* members, so a
non-streamed cache or back-pointer at the tail is invisible to it. That is the honest limit of
this method and it is stated everywhere the number appears.
* Coverage of the population: 368 non-template classes have `Mars::IStreamable` in their RTTI base
list; 348 of them are covered here, plus 38 POD types (`Vector3`, `OutputRates`, …) that have no
vftable of their own and are reachable only through a specialised `StreamableHelper` thunk
(`push ebp; mov ebp,esp; mov ecx,[ecx+8]; pop ebp; jmp writer`).
* This is roughly a **10× increase** in classes with a known layout, and it is reproducible from the
exe in under a second.
---
## 4. Failure classes — what defeats the tool, and whether it scales
This is the part that says whether the remaining classes are reachable.
| # | failure | classes | why it happens | fixable? |
|---|---|---|---|---|
| 1 | **anonymous tags** | 176 | the writer passes a NULL name, so the field is `"."` on disk. Offsets and types are still recovered; only the *names* are missing, and the Read cross-check cannot run | not by this method — the names do not exist in the binary. These are combat commands and network messages, where the reader is positional |
| 2 | **nested member of unknown size** | 19 | an inline sub-object whose own type has neither a `VectorHelper` stride nor a tight embedding bound (`TechTree`, `ShipRecords`, `FleetLayout`) | yes — one constructor or `operator new` size each |
| 3 | **container elements only** | 9 | everything the serializer writes is a loop body over a `std::map`/`std::list` node, so the offsets are within the node, not the object (`BuildQueue`, `SpyReport`'s four lists) | partly — needs a node-layout model for the map/list |
| 4 | **untyped sub-writer** | 3 | a discovered tag-forwarding writer whose argument type could not be classified, so the member's offset is known but its size is not | yes, per sub-writer |
| 5 | **value built across a branch** | 2 fields | `tech ? tech->name : ""`, `max(member, 1)`. The pass is linear, so it sees the last branch only | yes, with a basic-block-aware pass — deliberately **not** patched with a backward-search heuristic, because there is no oracle for the 1,600 classes on which a heuristic would silently be wrong |
| 6 | **`std::map` / `std::list` members** | — | recovered as their `_Mysize` int, not as the container. `ServerSystem::NVO` comes out as `int @0x278`; the map itself starts at `0x274` | yes, and cheaply: this build's `std::map` is 0x10 bytes with head@0/size@4, from `NVO@0x274` → `NVE@0x284` |
| 7 | **runtime-built tag** | ~1 | `Mars::ParticleSystem::Write` makes six write-side calls and not one tag is a compile-time constant | no |
| 8 | **stub `Write`** | 15 | e.g. `Game::ServerTradeManager`'s Read and Write are both the inherited no-op `0x924fb0`; `Game::ShipDesign::Write` makes no stream call at all. These classes are not streamed through `IStreamable` and something else persists them | n/a — a finding, not a failure |
**Does it generalise?** Yes, with one caveat that matters. Categories 2, 3, 4, 5 and 6 are 64
classes and every one of them is a bounded, mechanical fix — none is a research problem. Category 1
(176 classes, 46% of the population) is a hard limit, but a *soft* one: those layouts are complete
in offsets and types, and only the names are missing, so a reimplementation can use them as-is.
Category 7 is genuinely out of reach and is one class.
The caveat: **this method covers only what the class serialises.** `ServerSystem` is `>= 0x2d8` by
enumeration and the campaign's hand table agrees, but neither can see a non-streamed member past the
last serialised one. Every `sizeof` in the output is therefore labelled with how it was obtained,
and the 58 corroborated ones are the only ones that should be treated as closed.
---
## 5. Write-back
* **`objects/layouts.json`** — the full machine-readable result (fields, offsets, kinds, tags,
nested types, container strides, grade, why, Read/Write agreement).
* **`objects/layouts.md`** — the same as tables, one section per class.
* **`objects/layouts.h`** — 288 C structs, gaps made explicit as padding, `Mars_string` (0x1c) and
`Mars_vector` (0x10) declared once with the allocator word in place.
* **Ghidra** — `tools/serializers_ghidra.py structs` pushes every struct through
`parse-c-structure` (dependencies first, resumable) — **288 pushed, 0 failed** — and `… labels`
names the `Write`/`Read` entry points (**328 labels, 0 failed**). `Game_ObservedTech` comes back from Ghidra as `size: 44`.
* **`ghidra/addresses.json`** — +201 entries: `<Class>_Write` / `<Class>_Read` for all 87
verified-tier classes, and `sizeof_<Class>` for every corroborated size, each carrying its
Read/Write agreement count in the prototype comment. Header regenerated with
`tools/gen_addresses.py` (never hand-edited).
## 6. Open items for the next lane
1. **`Game::ShipDesign::Write` (`0x8747a0`) makes no stream call.** `ShipDesign` is an important
class and its IStreamable slots are inert — worth knowing how designs actually persist
(`ShipDesignDef` is already verified separately, which may be the whole answer).
2. **`std::map`/`std::list` node layouts** would close failure classes 3 and 6 — ~15 classes.
3. **The 19 unsized nested types** need one size each; a constructor or `operator new` argument
scan would do it in bulk and would also give sizes for the non-streamed tail.
4. `Game::SVSOJewelsOfTheCrown` writes the tag `JEWELLOCATIONID` **twice**, at `+0x8` and `+0x10`.
Not an error in the tool (the Write really does emit it twice) but it means the on-disk record
has a duplicated name — a save reader matching that shape by name will get the wrong field.