sots-re/findings/objects/struct-recovery.md
alex f965c8c769 lane S: std::string is 0x1c binary-wide; ObservedTech element fully mapped
Settles the 0x18-vs-0x1c contradiction lane X raised. 0x1c is right, everywhere,
and there is exactly one std::string instantiation in this binary:
_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18.

ObservedTech+0x24 is that string's trailing empty-allocator word, not the
unaccounted data field it was read as. Three complete enumerations of the element
each skip it: ObservedTech::Write 0x00817cf0, the ctor 0x008562a0, and the copy
ctor inlined at 0x0079a184. Generalised with a new scanner, tools/strfootprint.py,
which recovers every (base, disp, tag) handed to the Mars::Stream string helpers:
65 std::string members off a non-stack base across every serializer in the exe,
ZERO with a sibling member inside the 0x1c span, and 51 of the 52 measurable
inter-member gaps exactly 0x1c. Corroborated by the vector<string> walk stride
(add esi,0x1c @0x00699c29), PostEvent's by-value strings at [ebp+8]/[ebp+0x24]
with RET 0x4c, and MoraleEvent 0x50 = name@0x34 + 0x1c.

Blast radius: zero recovered struct tables were wrong. Every string-bearing layout
already used 0x1c spans and 0x1c gaps -- ServerPlayer::pswd @0x2dc..0x2f7, the row
flagged for re-checking, included. Only prose carried the 0x18 number: the
loader-prototypes conventions line, the GlobalConst_ParseString prototype, and the
ObservedTech element table. struct-recovery S0 additionally had _Mysize/_Myres
transposed (size@0x14, res@0x18) while every table in the same file used the
correct offsets; fixed.

ObservedTech's four on-disk fields are now mapped rather than guessed, by reading
the serializer as lane X suggested: +0x04 uint16 otnF, +0x06 uint16 otnL, +0x08
bool odet (ONE BYTE, WriteBool), +0x0c std::string otch (0x1c), +0x28 int owith
= 0x2c exactly. That matches save_reader.py's on-disk order already. Game::
ObservedWeapon (0x00817bc0/0x00817b10) is the same element with tag owep.

Oracles unaffected and re-run: save_reader 36/36 and --strict exit 0 on all three
real saves; state_checksum 38 tests OK, coverage PROVED byte-for-byte on turn1 and
turn3. sots-engine wip/strings 32d3e36 syncs the header and corrects two stale
"unpinned" comments: clean_room_check OK, host ctest 33/33.

Standing rule this produced: never size a struct member from the offsets the code
touches. This build's STL puts the empty allocator LAST in both string (0x1c) and
vector (0x10), and an empty allocator is never loaded or stored, so a touch-scan
undercounts by exactly 4 every time. Size from an enumeration instead.
2026-09-08 05:09:33 -04:00

31 KiB
Raw Blame History

Struct recovery via save-field-name xrefs — Sword of the Stars (2006)

Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, 32-bit MSVC. All addresses are VAs. Method: every on-disk field is tagged with its name string; the name strings live in .rdata; the functions that reference dozens of a struct's names in sequence are its IStreamable::Read/Write. Decompiled those, mapped this+offset → name → type. Scripts (on CT111 /root/): SerFind.java (string→xref→function ranking), SerDump.java/SerDump2.java (decompile with DAT_ → literal substitution), VtOwner.java/VtOwner2.java (find the owning vftable + RTTI Complete-Object-Locator offset), SubWrite.java (sub-struct Read/Write by class name). Raw decompiles: CT111 /tmp/serdump/*.c.

Reference cross-checked: save-editor-structs.md (R1 = Bardez editor, R2 = SOTSedit).


0. Serialization runtime facts (needed to read the tables)

IStreamable vftable shape

Every streamable class has a 3-slot vftable { [0] scalar-deleting dtor, [1] Read(Stream&), [2] Write(Stream&) } (Mars::IStreamable::vftable @ 0x009e22bc = {0x4f7230, 0x924fb0, 0x924fb0}). Mars::StreamableHelper<T> / Mars::VectorHelper<T> are thin adaptors: slot [1]/[2] call the object's virtual Read/Write (or, for POD types like Vector3, a free function).

this-adjustment (IMPORTANT for offsets)

The serializer is called through the class's IStreamable sub-vftable, whose RTTI COL offset field gives the sub-object offset. The vftable slots point straight at the functions (no adjustor thunks; prologues verified in the exe: 55 8b ec 6a ff 68 …), so inside each function this = object + COL offset. Absolute member offset = decompiled offset + COL offset. Tables below give both.

Class IStreamable vftable (COL offset) Read Write Primary vftable
Game::ServerSystem (: StarSystem : StarMapNode) 0x00a2043c (+8) FUN_0075d4b0 FUN_00749630 (StarSystem primary 0x00a200e4)
Game::StarSystem / ClientSystem (StarMapNode part) 0x00a200d4 / 0x00a20144 (+8) FUN_00727790 FUN_00727820 0x00a200e4 / 0x00a20154
Game::StarMapNode 0x00a1e620 (+8) FUN_00727790 FUN_00727820 0x00a1e630
Game::ServerPlayer (: StrategyPlayer) 0x00a32794 (+0x3a0 = 928) FUN_008804d0 FUN_008563e0 0x00a327a4 (COL 0, 8 slots)
Game::StarShip 0x00a31408 (+8) FUN_00853fa0 FUN_008291f0 0x00a31418 (2 slots)
Game::StarFleet (: StarMapNode) 0x00a1d5f8 (+8) FUN_00702470 FUN_00701070 0x00a1d608
Game::StrategyServer (whole sim block) 0x00a26084 (+0) FUN_007d27a0 FUN_0079fa70 0x00a26034 (COL 4)
Game::StarSystem::PlayerView 0x00a201ac (+0) FUN_00752af0 FUN_007492d0 —
Game::StarSystem::OutputRates (POD, via helper) helper 0x00a1f884 FUN_007472a0 FUN_00745190 —
Game::Population 0x009f90f0 (+0) FUN_005390c0 FUN_00537ef0 —
Game::PopulationGroup 0x009f8d50 (+0) FUN_00536a80 FUN_00536af0 —
Game::IndependenceInfo 0x00a2005c (+0) FUN_00748df0 FUN_00748ee0 —
Game::Morale / MoraleEvent 0x00a1f7c8 / 0x00a2003c FUN_00744dd0 / FUN_007490b0 FUN_00744ea0 / FUN_007491b0 —
Game::ShipBuildOrder(Def) 0x00a0c160 / 0x00a0ad08 FUN_00813770 FUN_00813800 —
Game::PlayerNotes 0x00a21948 FUN_00813250 FUN_008132b0 —
Game::SpyReport 0x00a32b2c FUN_008843d0 FUN_00828ec0 —
Game::PlayerReport (preps) 0x00a21440 FUN_008200a0 FUN_00817480 —
Game::DiplomacyStats 0x00a21430 — FUN_00818cb0 —
Game::FlightPlan / ::Waypoint / NodeRoute 0x00a1d50c / 0x00a1d39c / 0x00a1cbdc FUN_00704c70 / FUN_00701860 / FUN_006e2260 FUN_00700f60 / FUN_00700ed0 / FUN_006e22e0 —
Game::PrisonerHold 0x009fe130 FUN_0056eb00 FUN_0056ec00 —
Game::EventStorage / PlayerAlliances / ShipHealth / PlayerColorID / Mars::Vector3 — — FUN_00825cc0 / FUN_006d2e10 / FUN_00813e50 / FUN_0053c080 / FUN_008a60d0 —

Stream primitive API (writer side; Stream object vftable, this = stream)

call meaning wrapper used by serializers
vft+0x18 (name, std::string*) write string FUN_008b9d70(stream,name,std::string*)
vft+0x1c (name, byte) write bool FUN_008b9c20(stream,name,bool*)
vft+0x20 (name, float) write float FUN_008b9be0(stream,name,float*)
vft+0x24 (name, int, default=-1) write int32 FUN_008b9d50(stream,name,int*); FUN_008b9d00(stream,name,int16*) (widens short→int)
vft+0x28 (name, IStreamable-helper*) write nested object (BEEFBEEF frame) inline StreamableHelper<T>{vft, 0, T*}
vft+0x30 (name, ptr, nbytes) write raw bytes used for 8-byte Int64s
FUN_00816490(stream,name,obj*) write handle id = obj ? obj->id(+4) : 0 NetworkObject id at +4
FUN_008b9c60(stream,name,int64*) write int64 (PopC)
Reader side mirrors: FUN_008b9bc0 float, FUN_008b9d20 int, FUN_008b9c00 bool, FUN_008b9d90 string,
FUN_008b9c40 int64, FUN_008b9cd0 short, FUN_008164d0(stream,name) handle→object* lookup,
stream vft+0x10 int-by-ref (returns found flag), vft+0x14 nested object (NULL helper = skip/legacy).
Readers accept legacy tags (ISuit, Income, HPop, Bats, Builds, Clr, SensMod, ExPopSys,
NShps, SysID, TrdID, Caps, GtTrf, FtSens, FtInc, Pris, NumPlgs, lcid, morev, cme)
by reading them into scratch/NULL — these are pre-1.8 fields, NOT members.

Common Mars/MSVC layouts seen: std::string = 0x1c bytes — _Bx union@0 (16-byte SSO buffer, or a char* when _Myres >= 16), _Mysize@0x10, _Myres@0x14, _Alval@0x18 (empty allocator, occupies a word, never read or written). FUN_008b9d70 does the res>=16 ? heap : sso check. (2026-09-08: this line previously transposed the two to size@0x14, res@0x18; the 0x10/0x14 offsets are the verified ones — Stream::WriteString cmp [str+0x14],0x10, basic_string::assign 0x00425550.) The allocator is trailing in this build's STL, in strings and vectors alike, so it is invisible to any scan of "which offsets does the code touch" and costs exactly 4 bytes if you size a member that way. See findings/subsystems/observedtech-append.md §9 for the whole-binary audit (tools/strfootprint.py): 65 std::string members across every serializer, zero with a sibling field inside the 0x1c span, 51 of 52 measurable inter-member gaps exactly 0x1c. There is one string layout in this binary. std::vector<T> = {begin@0, end@4, cap@8, _Alval@0xc — 0x10 bytes}; std::map/set node = {left@0, parent@4, right@8, key@0xc, value@0x10, …, color/isnil bytes at tail}; std::list = {head*@0, size@4}. Mars::NetworkObject = {vptr@0, int id@4}.


1. Game::ServerSystem (= live star system + colony record; R1 SimSystemDetailsSaveStruct)

Serializers: Write FUN_00749630 @ 0x00749630 (3453 B), Read FUN_0075d4b0 @ 0x0075d4b0 (8320 B, has legacy branches). Both begin with StarMapNode::Write/Read (FUN_00727820/FUN_00727790) which emits Pos. this = obj+8. Base layout: +0 primary vptr (StarSystem 0x00a200e4), +4 NetworkObject id, +8 IStreamable vptr, +0xc HandleObject vptr, +0x10 owner pointer (*(+0x10)->+0x50 = player-object table indexed by map key), +0x18 Pos.

abs off rel(this+8) type save name notes
0x18 0x10 Mars::Vector3 (3 floats) Pos via StarMapNode; on disk 3 unnamed floats
0x4c 0x44 float R starColor.r
0x50 0x48 float G
0x54 0x4c float B
0x58 0x50 float A
0x5c 0x54 int Idx system index
0x60 0x58 int Size 1–10
0x64 0x5c float Suit climate hazard (legacy ISuit discarded)
0x68 0x60 int Res
0x6c 0x64 int ARes2 (legacy ARes read then overwritten)
0x70 0x68 int MRes
0x74 0x6c int TRes
0x78..0x7a 0x70..0x72 bool[3] haltv written as haltc=3, then 3×(haltt=i, haltv=v[i])
0x7c 0x74 float OutMod
0x80 0x78 int TAcq
0x84 0x7c int TFAcq
0x88..0xa3 0x80 StarSystem::OutputRates (0x1c) Rts see §1.1; nested object
0xa4 0x9c BuildQueue* BQ written only if owner (PID) non-null
0xa8..0xc3 0xa0 std::string Name
0xc4 0xbc bool Abdn bool in memory (R2 "short" is just the value byte; R1 Int32 is the framing)
0xc5 0xbd bool Dstyd
0xc6 0xbe bool vnh gate: if true → vnd,vnex3,vnpex3
0xc7 0xbf bool vnd
0xc8 0xc0 bool vnex3
0xc9 0xc1 bool vnpex3
0xcc 0xc4 int VFlags written by value (int)
0xd0 0xc8 int EFlags
0xd4 0xcc int AFlags
0xd8 0xd0 int FFlags
0xdc 0xd4 int GFlags
0xe0 0xd8 int MnRFlags
0xe4 0xdc int RfRFlags
0xe8 0xe0 int ClkFlags
0xf0 0xe8 int64 Bats2 raw 8 bytes (R1 correct; R2 Int32 reads low half). Legacy Bats int
0xf8 0xf0 int64 rcex raw 8 bytes
0x100 0xf8 ServerPlayer* PID owner; written as handle id (->+4)
0x104..0x117 0xfc Population (0x14) dcs nested
0x118 0x110 float dsu
0x11c..0x13b 0x114 Morale (0x20: vptr + int[7]) cm nested
0x13c..0x147 0x134 vector<MoraleEvent> cme2 VectorHelper
0x14c..0x16b 0x144 Morale PvCM
0x16c..0x177 0x164 vector<StarFleet*> NumFlts + n×Flt ids via handle
0x17c 0x174 float RepCur
0x180 0x178 float RepMax
0x184 0x17c int EggScio
0x188 0x180 bool NoRebAI
0x189 0x181 bool PvNoRebAI
0x18c 0x184 int Pop imperial pop
0x190 0x188 float Infra
0x194 0x18c int pbon
0x198 0x190 float ibon
0x19c 0x194 int TerrFl
0x1a0..0x1b3 0x198 Population Pop2 civilian pop groups (R1 popG)
0x1b4..0x1c7 0x1ac Population pbon2
0x1c8 0x1c0 IndependenceInfo* hindi + indi hindi = ptr!=NULL
0x1cc..0x1d7 0x1c4 vector<int> spies2 VectorHelper
0x1dc 0x1d4 int rbfl written by value
0x1e0 0x1d8 bool hsrg
0x1e4..0x1ff 0x1dc int[7] nadct,(ads=i,adt=v) addiction table: count of non-zero entries then sparse (index,value) pairs
0x200 0x1f8 int PvPop previous-turn snapshot block
0x204 0x1fc float PvInfra
0x208 0x200 float PvSuit
0x20c 0x204 int PvRes
0x210 0x208 int PvARes2
0x214 0x20c int PvMRes
0x218..0x22b 0x210 Population PvPop2
0x238 0x230 StarFleet* DefF handle id
0x23c 0x234 StarFleet* DefSF handle id
0x240..0x24b 0x238 vector<obj*> NumGFs + n×GF gates
0x250..0x25b 0x248 vector<obj*> NumSnF + n×SnF stations
0x260..0x26b 0x258 vector<obj*> NumMnF + n×MnF monitors
0x274 / 0x278 0x26c / 0x270 std::map head / size NVO + entries colonies, see §1.2
0x284 / 0x288 0x27c / 0x280 std::map head / size NVE + entries §1.2
0x294 / 0x298 0x28c / 0x290 std::map head / size NVs + entries per-player pview, §1.3
0x2a8..0x2b3 0x2a0 vector<Plague*> NumPlgs2 + n×(PlgT=plg->+4, Plg obj)
0x2b8 0x2b0 int TnsOH
0x2bc 0x2b4 int TDst
0x2c4 0x2bc int ntdev
0x2c8 0x2c0 int ltis int (R2 "short" wrong width)
0x2cc 0x2c4 int rbtn
0x2d0 0x2c8 int rbfr by value
0x2d4 0x2cc int rbwn

Object size ≥ 0x2d8. On-disk order = R1 §8 exactly (Pos, RGBA, Idx, Size, Suit, Res, ARes2, MRes, NoRebAI, TRes, Pop, Pop2, Infra, PvPop, PvPop2, PvInfra, PvSuit, PvRes, PvARes2, PvMRes, PvNoRebAI, Rts, Abdn, Dstyd, TnsOH, OutMod, RepCur, RepMax, ntdev, pbon, pbon2, ibon, ltis, rbfl, rbtn, rbfr, rbwn, hsrg, halt*, vn*, Name, *Flags, Bats2, rcex, Mn/Rf/ClkFlags, EggScio, TerrFl, TAcq, TFAcq, TDst, dcs, dsu, cm, PvCM, cme2, spies2, PID, DefF, DefSF, BQ, nadct/ads/adt, NumPlgs2…, NumFlts/GFs/SnF/MnF, NVO, NVE, NVs, hindi/indi).

Members that are only ever read with a NULL/scratch target (not stored): ISuit, Income, HPop, Builds (+Con,Sav,ConLeft,OrID,DesID — old inline build queue), Slvs, dct, cme, Bats, NumPlgs.

1.1 Game::StarSystem::OutputRates (POD, 0x1c) — Write FUN_00745190

Memory order ≠ disk order: +0x00 float SRt, +0x04 SRsc, +0x08 SRtf, +0x0c SRi, +0x10 SRoh, +0x14 SRs, +0x18 int SRnr. Disk order: SRs, SRt, SRsc, SRtf, SRi, SRoh, SRnr. Reader: if SRs tag is absent, reads 5 unnamed floats (legacy).

1.2 Colony maps NVO / NVE (std::map keyed by player-table index)

Write emits NVO=size, then per node: PID = handle id of owner->+0x50[key] (player object table), TShn = int16 at node+0x12 (value+2), OID = int at node+0x14 (value+4) by value, isind bool at node+0x18 (value+8), indi = inline IndependenceInfo at node+0x1c (value+0xc, 0x70 bytes). Node isnil byte at +0x8d ⇒ value size 0x7c. NVE nodes: EPid handle (key→player), ETS int16 @ node+0x12, Eid int @ node+0x14 (isnil @ +0x19 ⇒ value 8 bytes). OID is a stored int, distinct from PID; the R2 claim "OID = PID×16" is an id-allocation pattern, not a derivation in this code (open question — check the HandleObject id allocator).

1.3 Game::StarSystem::PlayerView (per-player seen snapshot) — Write FUN_007492d0, Read FUN_00752af0

Stored inline as map value at node+0x10 (NVs; node isnil @ +0xad ⇒ value ≈ 0x9c). +0 vptr (0x00a201ac), +8 int VTrn, +0xc int Pop, +0x10 Population Pop2 (0x14), +0x24 float Infra, +0x28 float Suit, +0x2c int Res, +0x30 int ARes2, +0x34 int MRes, +0x38 bool NoRebAI, +0x3c int pbon, +0x40 Population pbon2, +0x54 float ibon, +0x58 int TerrFl; trailer bool footer=1. Reader also accepts legacy ARes, PvPop/PvInfra/PvSuit/PvRes/PvARes/PvARes2/PvMRes/PvNoRebAI into the same slots. (R1 lists Int32 infra — it is a float.)

1.4 Game::Population (0x14) / Game::PopulationGroup (0x18 stride)

Population: +0 vptr, +4/+8/+0xc vector<PopulationGroup>; Write emits PopNG = count of groups with PopC>0 (or ≥0 with low word ≠0), then each as nested PopG. PopulationGroup: +4 int PopT, +8 int PopS, +0x10 int64 PopC (R1 popT,popS,popC ✓).

1.5 Game::IndependenceInfo (0x70) — Write FUN_00748ee0

+4 int indsp, +8 PlayerColorID indcl (nested), +0x1c string indnm, +0x38 string indav, +0x54 string indba.

1.6 Game::Morale / Game::MoraleEvent

Morale (0x20): +0 vptr, +4 int[7]; disk: mnsp=n then n×(msp=index, mv=value) (reader tolerates missing mnsp → 7 fixed entries, skipping index 4). MoraleEvent: +4 mid, +8 mtr, +0xc mn, +0x10 mtp (ints), +0x14 Morale mfx, +0x34 string mdsc.

1.7 Game::ShipBuildOrder (build-queue entry) — Write FUN_00813800

+4 int desID, +8 int con, +0xc int sav, +0x10 int conleft, +0x14 int ordID; disk order desID, con, conleft, sav, ordID (R1 ✓).

Where is "Planet"?

Game::Planet : Actor (vft 0x009ef144) is a render/scene actor and is not streamed. The colony/planet state the save calls "planet" (R2's Idx/Name/Size/Suit/Res/Infra/Pop/OID…) is entirely in ServerSystem above plus PlayerView. The CreateParameters PlanetSaveStruct (x,y,z + 4 ints) is map-gen input (StarMapParams), not touched here.


2. Game::ServerPlayer (empire; R1 SimPlayerDetailsSaveStruct)

Serializers: Write FUN_008563e0 @ 0x008563e0 (4040 B), Read FUN_008804d0 @ 0x008804d0 (7647 B). IStreamable sub-object at +0x3a0 (COL offset 928); this = obj+0x3a0, so decompiled offsets are negative for most members. Primary vptr @+0 (0x00a327a4, StrategyPlayer shape), NetworkObject id @+4.

abs off rel(this+0x3a0) type save name notes
0x28 -0x378 int PlyrIdx
0x2c -0x374 ServerSystem* HomeSys handle id
0x30/0x34 -0x370/-0x36c vector<ServerPlayer*> NumOwn + n×OwnId handle ids (R1 ownerIds)
0x40..0x5b -0x360 std::string PlryName
0x5c -0x344 int Species 0 Human … 6 Morrigi
0x60 -0x340 PlayerColorID (4 B) ClrID nested; §2.1 (legacy Clr int skipped)
0x74..0x8f -0x32c std::string Bdg badge
0x90..0xab -0x310 std::string Avt avatar
0xac -0x2f4 int Team
0xb0 -0x2f0 float IdealSuit
0xb4 -0x2ec float SuitTol
0xb8 -0x2e8 float MaxOH
0xbc -0x2e4 float ResRate
0xc0 -0x2e0 float ResMod
0xc4 -0x2dc float ResScl
0xd0 -0x2d0 float TRM R1 says Int32 — it is float
0xd4 -0x2cc int TRA note memory order TRA before TRP
0xd8 -0x2c8 int TRP
0xe4/0xe8 -0x2bc/-0x2b8 vector<ShipDesign*> NumDes + n×(DesID=d->+0xa4, Des obj) current designs
0xf4 -0x2ac TechTree* TechTree first field on disk
0xf8 -0x2a8 bool Elim
0xfb -0x2a5 bool NPC
0xfc -0x2a4 bool RebAI
0xfd -0x2a3 bool ReqCL
0xfe -0x2a2 bool AIBn
0xff -0x2a1 bool CnTrd
0x100 -0x2a0 bool CnRad
0x101 -0x29f bool CnVItl
0x102 -0x29e bool hgs
0x103 -0x29d bool hadvs
0x104 -0x29c bool harcc
0x108 -0x298 float pddm
0x10c..0x117 -0x294 float[3] ConMod ×3 interleaved on disk as 3×(ConMod[i], SavMod[i])
0x118..0x123 -0x288 float[3] SavMod ×3
0x124 -0x27c float OutMod
0x128 -0x278 float RebOutMod
0x12c -0x274 float ScOutMod
0x130 -0x270 float PopMod (legacy SensMod float, ExPopSys int skipped between IncMod/PopMod/TerraMod)
0x134 -0x26c float TerraMod
0x138 -0x268 bool AMine
0x13c -0x264 float MinPure
0x140 -0x260 float MinRate
0x144 -0x25c int NGts
0x148 -0x258 int PrGtTrf
0x14c -0x254 int GTraf
0x150 -0x250 float CstR R1 Int32 → float
0x154 -0x24c float CstE
0x158 -0x248 float CstT
0x15c -0x244 int Maint (legacy NShps skipped)
0x160 -0x240 float shrm
0x164 -0x23c int Status by value
0x168..0x177 -0x238 PlayerAlliances {int ALid, AL, NA, CF} Team (2nd) nested (R1 teamStruct)
0x178/0x17c -0x228/-0x224 vector<ShipDesign*> NumLeg + n×(DesID,Des) legacy/drone designs (R1 droneDesigns)
0x188 -0x218 int PvSav
0x18c -0x214 bool PvMA
0x19c -0x204 int HasDisc by value
0x1a0 -0x200 int HasDiscSp
0x1a4 -0x1fc int HasDiscCl
0x1a8 -0x1f8 int HasEnc
0x1ac -0x1f4 int HasEng
0x1b0 -0x1f0 ShipRecords (inline) ShipRecs Write FUN_008176a0
0x1f4 -0x1ac vector<Objective> Ojvs VectorHelper
0x204/0x208 -0x19c/-0x198 vector<{int xid,xmin,xmax; float xper}> (16 B) Nexp + n×(xid,xmin,xmax,xper) R1 misses the per-entry body
0x214/0x218 -0x18c/-0x188 vector<int> NWeapXcl + n×WeapXcl
0x230 -0x170 vector<DiplomacyStats> dipstats §2.2
0x240 -0x160 CommMessageContainer* comms
0x244 -0x15c vector<PlayerReport> preps §2.3
0x254 -0x14c vector<ObservedDesign> odes
0x264 -0x13c vector<ObservedWeapon> owep
0x274 -0x12c vector<ObservedTech> otch
0x284 -0x11c int Sav savings
0x288 -0x118 int HasImm by value
0x28c -0x114 int HasVac
0x290 -0x110 int NPTrk
0x294 -0x10c Tech* (current research) ResTNm writes tech ? tech->name(+4) : ""
0x298 -0x108 FleetNameGenerator* FNG
0x29c -0x104 EventStorage (inline) Events {EvNxID@+0x14, Events vector@+4}
0x2b8/0x2bc -0xe8/-0xe4 std::list<PlayerNotes> head/size NumNotes + n×Nts node value at +0x10: NtSys@+4,NtTxt str@+8,NtTrn@+0x24
0x2c4 -0xdc int BnkWrn by value
0x2c8 -0xd8 int BnkTrn
0x2cc -0xd4 int BnkEl
0x2d0 -0xd0 int BnkPr
0x2d8 -0xc8 int plcy by value
0x2dc..0x2f7 -0xc4 std::string pswd R1 says Int32 — it is a string
0x2f8 -0xa8 bool Srn
0x2fc -0xa4 obj* SrnTo handle id (R1 srcTo)
0x300 -0xa0 int lboid
0x304 -0x9c int lcid2 by value (legacy lcid)
0x30c -0x94 float IncMod
0x310 -0x90 vector<PlayerAid> aid
0x320/0x324 -0x80/-0x7c vector<DefenceLayout*> ndeflay + n×deflay
0x330 -0x70 bool cdp
0x334 -0x6c SpyReport* spy2 §2.4
0x338/0x33c -0x68/-0x64 vector<RaidTargets> (0x20 stride) rdtc + n×rdt
0x368 -0x38 int aidf by value
0x370 -0x30 CivilianRatios (inline) civr Write FUN_0082c740
0x39c -4 int tnc written as max(v,1)
0x3a0 0 vptr — IStreamable sub-vftable 0x00a32794
0x3a4/0x3a8 +4/+8 vector<{float PRm; int PRBt}> NumPR + n×(PRm,PRBt)
0x3b4 +0x14 bool ResErrRoll
0x3b5 +0x15 bool cta
0x3b8 +0x18 AIRebellion* HasAIR + AIR gate = ptr!=NULL
0x3bc +0x1c AIEncounterFlags* AIEnf
0x3c0/0x3c4 +0x20/+0x24 vector<SpecialProjectImpl*> NSprj + n×(SprjT=p->+0x3c, Sprj)
0x3d0 +0x30 int NextPrjID
0x3d4 +0x34 int lret
0x3dc +0x3c int nmeid

Object size ≥ 0x3e0. Disk order = R1 §6 (TechTree, HomeSys, PlyrIdx, PlryName, Species, ClrID, Bdg, Avt, Team, Sav, IdealSuit, SuitTol, MaxOH, ResRate, ResMod, ResScl, TRM, TRP, TRA, OutMod, RebOutMod, ScOutMod, IncMod, PopMod, TerraMod, AMine, MinPure, MinRate, NGts, PrGtTrf, GTraf, CstR/E/T, Maint, shrm, Status, Elim, NPC, RebAI, ReqCL, Team{ALid,AL,NA,CF}, HasVac, HasImm, NPTrk, HasDisc, HasDiscSp, HasDiscCl, HasEnc, HasEng, Events, FNG, PvSav, PvMA, AIBn, CnTrd, CnRad, hgs, hadvs, harcc, CnVItl, pddm, BnkWrn/Trn/Pr/El, ShipRecs, NextPrjID, plcy, pswd, lret, nmeid, cdp, spy2, civr, aidf, Srn, SrnTo, lboid, lcid2, ResTNm, ResErrRoll, 3×(ConMod,SavMod), NumOwn/OwnId, NumDes/DesID/Des, NumLeg/DesID/Des, NumNotes/Nts, NumPR/PRm/PRBt, HasAIR/AIR, cta, AIEnf, NSprj/SprjT/Sprj, Nexp/xid/xmin/xmax/xper, NWeapXcl/WeapXcl, Ojvs, dipstats, comms, preps, odes, owep, otch, aid, ndeflay/deflay, rdtc/rdt, tnc). Note: on-disk Sav comes right after Team though it lives at 0x284 in memory.

2.1 Game::PlayerColorID (4 bytes) — Write FUN_0053c080

+0 int8 index; +1,+2,+3 uint8 r,g,b; writer emits index via FUN_008b9cb0 (char→int on disk), and iff index == -1 the three r,g,b bytes (matches R1 §1.6). Same struct used by IndependenceInfo.indcl.

2.2 Game::DiplomacyStats (0x24) — Write FUN_00818cb0

+4 int other; then int16 fields at +8 lastnap, +0xa lastnapbty, +0xc bknnap, +0xe btynap, +0x10 lastally, +0x12 lastallybty, +0x14 bknally, +0x16 btyally, +0x18 lastcf, +0x1a lastcfbty, +0x1c bkncf, +0x1e btycf, +0x20 deadhome (all widened to int32 on disk; R1 nap/ally/cf{last_,last_bty,bkn_,bty_} ✓).

2.3 Game::PlayerReport (preps, 0x30) — Write FUN_00817480

ints +4 oid, +8 pid, +0xc flds, +0x10 sav, +0x14 home, +0x18 ncol, +0x1c mpwr, +0x20 mcls, +0x24 mmsl, +0x28 nshp, +0x2c nsat (R1 ✓).

2.4 Game::SpyReport — Write FUN_00828ec0

Four std::lists: +4 list<SpyReportDefences> (count defc2@+8, items def), +0x10 list<SpyReportTrade> (rtc@+0x14, strd), +0x1c list<SpyReportEvents> (evc@+0x20, evs), +0x28 list<SpyReportTechTree> (ttc@+0x2c, tt). R1 only kept the four counts.

2.5 Game::TechTree — Write FUN_005890a0 (tags NumTechs, TNm, NumBrs; per-tech body in a

sub-writer not decompiled here; logs "TechTree: Tech %d not found saving tech tree").

2.6 Game::ObservedTech (0x2c) — Write 0x00817cf0, Read 0x00817c40

Elements of ServerPlayer::otch, the vector<ObservedTech> at ServerPlayer+0x274. Polymorphic: vftable 0x00a2439c, RTTI .?AVObservedTech@Game@@, slots {[0] 0x00793610 dtor, [1] Read, [2] Write}.

off type save name notes
0x00 vptr — 0x00a2439c
0x04 uint16 otnF turn first observed; widened to int32 on disk
0x06 uint16 otnL turn last observed; widened to int32 on disk
0x08 bool odet one byte (+3 pad); WriteBool/ReadBool
0x0c..0x27 std::string otch tech name; _Mysize@0x1c, _Myres@0x20, _Alval@0x24
0x28 int owith last member; 0x28 + 4 = 0x2c = sizeof, no slack

sizeof = 0x2c (44), pinned three ways by lane X (magic divide 0x2e8ba2e9 sar 3 at 0x0087239f, imul reg,reg,0x2c, search stride add edi,0x2c); the member map is the serializer's own order. Game::ObservedWeapon (Write 0x00817bc0, Read 0x00817b10) is the identical element with tag owep in place of otch; odes elements are the smaller otnF otnL odid opid record.

Appended by RecordObservedTech 0x007ba1a0, which de-duplicates by tech name — a reimplementation that just push_backs will diverge on re-observation. It writes otnF and otnL from the same source word on first sighting, so first-seen == last-seen initially.

Do not read +0x24 as a field. It is the name string's trailing _Alval. See §0 and findings/subsystems/observedtech-append.md §9 — this is the class that produced the 0x18-vs-0x1c std::string scare, and the resolution is that 0x1c was right everywhere.


3. Game::StarFleet (R1 SimFleetDetails) — Write FUN_00701070, Read FUN_00702470; this = obj+8

abs rel type save name notes
0x18 0x10 Vector3 Pos via StarMapNode
0x4c 0x44 Vector3 PrvPos
0x58 0x50 ServerPlayer* PID handle
0x5c..0x77 0x54 string FtName
0x78 0x70 bool Perm
0x7c 0x74 FleetLayout (inline, ~0x24) HLay + Lay gate = either of its two vectors (+4/+8, +0x14/+0x18) non-empty
0xa0 0x98 StarSystem* LocID handle (legacy SysID,TrdID ints skipped)
0xa4/0xa8 0x9c/0xa0 vector<StarShip*> NShips + n×(ShipID handle, Ship obj)
0xc4..0xfb 0xbc FlightPlan (inline, 0x38) HFPlan + FPlan gate = wpts non-empty; §3.1
0xfc 0xf4 int FtTrans by value (legacy Caps int, GtTrf short, FtSens float, FtInc int skipped)
0x100 0xf8 Vector3 FtOrig (R1 "3 ints" → 3 floats)
0x10c 0x104 int FtFlg
0x110 0x108 int Ftae
0x114 0x10c int Ftpae
0x118 0x110 int FtEnc
0x11c 0x114 int FtMS

3.1 Game::FlightPlan (0x38) / Waypoint / NodeRoute

FlightPlan: +0 vptr, +4 vector<Waypoint> wpts, +0x14 float FPsp2, +0x18 int FPeta2, +0x1c Vector3 FPogn2, +0x28 Vector3 FPdpos, +0x34 int pnd. Waypoint: +4 int Wpt, +8 int Tp, +0xc NodeRoute nrt. NodeRoute: +4 nrp, +8 nrf, +0xc nrt (ints). Reader also handles legacy NumWpt/path/FPognid.

4. Game::StarShip (R1 SimFleetShipDetails) — Write FUN_008291f0, Read FUN_00853fa0; this = obj+8

abs rel type save name notes
0x10 0x8 ServerPlayer* PlrID handle
0x14 0xc ShipDesign* DesID writes design->+0xa4 (design id)
0x20 0x18 float Range
0x24..0x33 0x1c ShipHealth {vptr; float[3]} Health 3 unnamed floats (R1 guess command/mission/drive)
0x34 0x2c int MineCap
0x38/0x3c 0x30/0x34 vector<{float th, thm}> NTH + n×(TH,THM)
0x48 0x40 int Plg
0x4c 0x44 int Act
0x50 0x48 bool Dep
0x51 0x49 bool Atq
0x5c 0x54 int LCT
0x60 0x58 int tsd
0x64 0x5c StarFleet* FltID handle
0x68 0x60 int ConCap
0x6c 0x64 float RefCap R1 Int32 → float
0x70 0x68 float RepCap R1 Int32 → float
0x7c 0x74 int EncID
0x80 0x78 PrisonerHold (inline) PrisH +0x14 int* → [0]=PrMax, [2..8] per-species counts; disk PrMax,PrNSp,(PrSp=idx,PrNum)
0x98 0x90 BuildQueue* hbq + BQ2 gate = ptr!=NULL
0x9c 0x94 Population* hsp + pop gate = ptr!=NULL
0xa0 0x98 Population* ppop
0xa8 0xa0 int atsp by value
0xac 0xa4 int tblt

5. Top-level sim block — Game::StrategyServer Write FUN_0079fa70 / Read FUN_007d27a0

Tag order confirms R1 §5: KeyPath, NMSz, NMLc, NMnx, ModCount, Frame, GameID, [AIDifficultyID legacy], Attrib, RNG, GameName, Map, IncMod, ResMod, EnAl, EnTm, GOTurn, GOWinPly, NPCm/o/i/v/a, szadj, rsadj, suadj, sprjs, RandEncAdj, cmbtid, turnstats, numcreps/crep, ninv/invs/inve/invt/invtb, AllExc×6, AllExcCF, AllExcCFp×2, NumPlrs/PlayerID/Player, ISsp, ISsu, NumSys/SysID/Sys, NdGr2, trdmgr, spymgr, NumFlts/FltID/Flt, NumActs/Act, SvSctOb, zdsc, zdsi, zdst. (Offsets not tabulated — out of scope this round.)


6. Corrections to the community reference (R1/R2)

  • Types: TRM, CstR/E/T, shrm, ship RefCap/RepCap, PlayerView Infra are floats; pswd is a string; PvMA, AIBn are bools; FtOrig is a Vector3 (3 floats).
  • Widths: Bats2/rcex are true int64 (R2 int is wrong); Abdn/Dstyd are bools, ltis an int (R2 "short" is an artefact of reading value bytes); TShn, ETS, all DiplomacyStats counters are int16 in memory but int32 on disk.
  • Missing in R1: Nexp entries carry (xid,xmin,xmax,xper); SpyReport lists have bodies; Morale is a sparse (msp,mv) table; nadct is followed by sparse (ads,adt) pairs over a 7-int table.
  • Plg/Act/EncID/OID/flags are written by value → plain ints (not handles).

7. Confidence & open questions

  • High: all offsets/types in §1–§4 (direct from Write functions; Read functions agree on every member address; COL offsets from RTTI; no adjustor thunks).
  • Medium: nested struct sizes inferred from neighbouring offsets (Population 0x14, Morale 0x20, FlightPlan 0x38, PlayerView ≈0x9c, IndependenceInfo 0x70); which members belong to StarSystem vs ServerSystem (serializer is ServerSystem's; ClientSystem shares the StarSystem vftables and only streams Pos).
  • Open: FUN_008b9cb0 exact on-disk width for PlayerColorID (R1 says int32 — plausible); ServerSystem+0x10 owner type (StrategyServer? its +0x50 is a player-object table); OID allocation (R2's ×16); per-tech body of TechTree::Write; CdPlayer block not attempted (names are R1's unknownN, nothing to xref). No types were written back into Ghidra (notes only).