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

16 KiB
Raw Permalink Blame History

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.