sots-re/findings/objects/schema-gaps-resolved.md

28 KiB
Raw Blame History

SOTS1 save schema — gaps resolved from the serializer code (round 3)

Program sots / "Sword of the Stars.exe" (ImageBase 0x00400000). Sources: Ghidra decompiles with string-literal substitution (CT111 /tmp/gapdump/*.c, /tmp/gapdump2/*.c, /tmp/gapdump3/*.c; scripts /root/GapDump.java, /root/VtWrite.java, /root/GapFix.java) cross-checked byte-for-byte against the inflated real save verify/results/saves/turn2-state.sav (inflated copy /tmp/turn2.inflated, 603,360 B). Offsets quoted as @0x… are inflated-stream offsets in that file.

Verdict in one line: the on-disk layouts we had were right in every case but three; five of the eight "gaps" are reader-side plausibility/typing bugs, three are real schema errors (indi unconditional in NVO, PrNSp gated on PrMax>0, zdsi/zdst are per-entry pairs under zdsc) plus one incomplete struct (StrategyServer) and two blocks whose tags were only guessed (Summary, CreateParams).

# gap resolution kind
1 VFlags where Name/vnh expected (7 systems) no conditional exists; consequence of #2 reader bug
2 haltv "31/35-byte value" haltv is a 1-byte bool; reader rejected it because the next string value (Name) contains cp1252 0x92 reader bug
3 indi before NVE / before PID NVO entries always carry indi after isind (not gated) schema fix
4 PrNSp missing PrNSp + pairs written only when PrMax > 0 schema fix
5 CreateParameters.key int vs string it is a string (Mars::Stream::WriteString), empty in this save → 4 zero bytes; reader must accept len==0 strings. Full tag lists for Summary/CreateParams below reader bug + tags
6 turnstats…tch int vs string int (int16 in memory, widened); full turnstats layout below schema typo
7 trailing zdsi/zdst not trailing scalars: zdsc = map size, then n × (zdsi, zdst). SvSctOb is conditional (ptr≠NULL) schema fix
8 StrategyServer partial member table completed from Write/Read (+ ID-list sub-writer); 6 members written back to Ghidra done

1+2. Game::ServerSystem — haltv, vnh, Name, VFlags

Code: Game::ServerSystem::Write @ 0x00749630 (excerpt, this = obj+8):

  local_18 = (int *)0x3;
  FUN_008b9d50(piVar3,"haltc",&local_18);            // WriteInt  haltc = 3 (constant)
  do {
    (**(code **)(*piVar3 + 0x24))("haltt",iVar6);     // int, by value: index 0..2
    FUN_008b9c20(piVar3,"haltv",param_1 + 0x70 + iVar6); // WriteBool: bool haltv[3] @ this+0x70 (abs 0x78)
    iVar6 = iVar6 + 1;
  } while (iVar6 < 3);
  FUN_008b9c20(piVar3,"vnh",(char *)(param_1 + 0xbe));
  if (*(char *)(param_1 + 0xbe) != '\0') {            // ONLY conditional in this region
    FUN_008b9c20(piVar3,"vnd",param_1 + 0xbf);
    FUN_008b9c20(piVar3,"vnex3",param_1 + 0xc0);
    FUN_008b9c20(piVar3,"vnpex3",param_1 + 0xc1);
  }
  FUN_008b9d70(piVar3,"Name",param_1 + 0xa0);          // WriteString, unconditional
  (**(code **)(*piVar3 + 0x24))("VFlags",*(undefined4 *)(param_1 + 0xc4));
  (**(code **)(*piVar3 + 0x24))("EFlags", …); … "AFlags" "FFlags" "GFlags"
  (**(code **)(*piVar3 + 0x30))("Bats2",param_1 + 0xe8,8);   // raw 8 bytes
  (**(code **)(*piVar3 + 0x30))("rcex",param_1 + 0xf0,8);

There is no variant that omits Name/vnh; Read @ 0x0075d4b0 mirrors it (only legacy-tag tolerance). Every one of the 28 systems in the real save has haltv → vnh → Name → VFlags in this exact order.

Bytes (system 8, "Kor’Voth", one of the 7 that failed) @0x83100:

00083100  05 00 00 00 68 61 6c 74 76 00 00 00   "haltv"  value=00  pad 2   (12 B, joint padding)
0008310c  03 00 00 00 76 6e 68 00               "vnh"    value=00          ( 8 B, no pad)
00083114  04 00 00 00 4e 61 6d 65 08 00 00 00 4b 6f 72 92 56 6f 74 68   "Name" len=8 "Kor\x92Voth"  (20 B)
00083128  06 00 00 00 56 46 6c 61 67 73 00 00 00 00 00 00   "VFlags" int 0 (pad 2)

Skip accounting from the strict log: 3 (haltv value+pad) + 8 (vnh) + 20 (Name item) = 31 for the five systems with 8-/6-char names, 3 + 8 + 24 = 35 for the two 9-char names. All seven failing systems (idx 8, 10, 14, 16, 18, 24, 25) and only those have a byte ≥ 0x80 in Name (Kor’Voth Koa’Tuun Keu’Loko Koa’Vo Kaa’Vaalu Kao’Lolto Kea’Pono, cp1252 0x92 = ’). The eighth non-ASCII name (Kor’Hakaan, idx 20, 10 chars → 24-B item) happened to pass.

Fix (reader): the lookahead that validates a candidate bool layout must not require string values to be ASCII — only tag bytes are constrained. Decode string values as cp1252, never reject on them. No schema change: haltc:int(=3), 3×(haltt:int, haltv:bool), vnh:bool, If(vnh) vnd vnex3 vnpex3 :bool, Name:string, VFlags…GFlags:int, Bats2 rcex:int64.

3. indi inside NVO entries is unconditional

Code: ServerSystem::Write @ 0x00749630, NVO loop:

  local_18 = *(int **)(param_1 + 0x270);                     // map size
  FUN_008b9d50(piVar3,"NVO",&local_18);
  … for each map node piVar8:
      FUN_00816490(piVar3,"PID", owner->+0x50[piVar8[3]]);   // handle id of player (map key → player table)
      FUN_008b9d00(piVar3,"TShn",(int)piVar8 + 0x12);         // int16 → int32
      (**(code **)(*piVar3 + 0x24))("OID",piVar8[5]);          // int by value
      FUN_008b9c20(piVar3,"isind",piVar8 + 6);                 // bool
      local_1c = piVar8 + 7;  local_24 = StreamableHelper<IndependenceInfo>::vftable;
      (**(code **)(*piVar3 + 0x28))("indi",&local_24);         // ALWAYS written (inline IndependenceInfo @ node+0x1c)

The isind flag does not gate the frame; indi is an inline member of the map value, so it is serialized for every colony. That is why the reader saw indi "before NVE" (last colony) and "before PID" (second colony). The other indi — the system-level one after NVs — is gated by hindi (param_2._3_1_ = *(int *)(param_1 + 0x1c0) != 0; … if (param_2._3_1_) …"indi"); unchanged.

Fix (schema): NVO entry = Seq([PID:int, TShn:int, OID:int, isind:bool, indi:IndependenceInfo]).

4. PrisonerHold — PrNSp only when PrMax > 0

Code: Game::PrisonerHold::Write @ 0x0056ec00 (this+0x14 → int* counts; counts[0]=max, counts[2..8] = per-species prisoners):

  local_8 = counts ? counts[0] : 0;
  FUN_008b9d50(param_2,"PrMax",&local_8);
  if (0 < local_8) {
    n = (counts[2]>0)+(counts[3]>0)+…+(counts[8]>0);
    FUN_008b9d50(piVar1,"PrNSp",&n);
    for (i = 0, p = 8; p < 0x24; p += 4, i++)      // species 0..6
      if (0 < counts[p/4]) { write_int_by_value("PrSp", i); FUN_008b9d50(piVar1,"PrNum",&count); }
  }

Bytes @0x8d6a4: "PrisH" BEEFBEEF "PrMax" 00000000 41104110 — frame closes right after PrMax.

Fix (schema): PrisH = [PrMax:int, If(PrMax > 0) PrNSp:NArr(Seq([PrSp:int, PrNum:int]))]. Read side PrisonerHold::Read @ 0x0056eb00 is a 20-byte thunk into the same logic.

5. CreateParams and Summary — real tags and types

The file root is written by FUN_00877070 (@0x00877070, the save routine): three nested frames "Summary" (StreamableHelper<Game::StrategyGameInfo>), "CreateParams" (StreamableHelper<Game::StrategyGameCreateParams>), "Sim" (StreamableHelper<Game::StrategyServer>), then "CDT" (StrategyGameLoader::CustomDataTable) followed by one "CD" frame per custom-data id (written only if that entry's blob pointer ≠ NULL). Loader counterpart FUN_0086abb0; the summary-only reader FUN_008773c0.

5.1 Game::StrategyGameCreateParams::Write @ 0x0082ae40 (vftable 0x00a25574 slot 2; Read @ 0x00832cc0)

  WriteString(stream,"Name",  this+0x4);      // std::string
  vft+0x24     ("ID",    *(this+0x20));       // int by value
  WriteInt   (stream,"RSeed", this+0x24);
  vft+0x24     ("AID",   *(this+0xd8));
  WriteString(stream,"Key",   this+0x28);     // std::string  <-- gap 5: string, empty in the real save
  nested       ("MapP",  StreamableHelper<Game::StarMapParams>, this+0x44);
  vft+0x24     ("MapS",  *(this+0x7c));
  WriteString(stream,"MapF",  this+0x80);     // string (R1 said int; empty here)
  WriteInt   ("NSys",+0x9c); WriteFloat("REnc",+0xa0); WriteFloat("SDist",+0xa4); WriteFloat("SSize",+0xa8);
  WriteFloat ("SRes",+0xac); WriteFloat("SSuit",+0xb0); WriteInt("MaxP",+0xb4); vft+0x24("ASpec",*(+0xb8));
  WriteBool  ("bAlly",+0xbc); WriteInt("NTeam",+0xc0); WriteBool("tmgrp",+0xc4);
  WriteInt   ("PSav",+0xcc); WriteInt("PCol",+0xd0); WriteInt("PTech",+0xd4);
  WriteFloat ("IncM",+0xdc); WriteFloat("ResM",+0xe0);
  nested     ("scrp", StreamableHelper<Game::StrategyScriptParams>, this+0xe4);

Bytes @0x488: 03 00 00 00 "Key" 00 00 00 00 00 = tag(7) + string len 0 (4) + pad 1. Identical to an int 0 — the reader's "expected string, read int" is its own refusal of a zero-length string. MapF @0x1200 likewise is an empty string.

Game::StarMapParams::Write @ 0x00727a10 writes every item with a NULL tag (emitted as "."):

  vft+0x24(0, *(this+4));                                  // "." int            (map type/seed; 0 here)
  nested (0, VectorHelper<Game::SystemParams>, this+8);    // "." frame: "."=count, n × "." frame(planet)
  WriteInt(0, &nPlayers);                                  // "." int = (+0x1c - +0x18) / 16
  for each player: nested(0, VectorHelper<int>, entry);    // "." frame: "."=count, n × "." int
  nested (0, VectorHelper<Game::SimpleNodePath>, this+0x28); // "." frame (empty in this save)

Planet element (SystemParams, from bytes @0x4cc, writer not decompiled): "." frame { "." frame{ "." float ×3 } (Vector3), "." int, "." int, "." int, "." 4-byte (0x7f7fffff = FLT_MAX → float) }.

Game::StrategyScriptParams::Write @ 0x0082ad40: WriteInt("spc", size), then per list node WriteString("spsn"), WriteString("sppn"), WriteString("sppv").

5.2 Game::StrategyGameInfo::Write @ 0x00829960 (vftable 0x00a07ba0 slot 2; Read @ 0x00875cb0) = "Summary"

  WriteString("GameName",+0x20); WriteInt("Turn",+0x3c); WriteInt("NumSys",+0x40); WriteInt("Checksum",+0x44);
  nested("Players", VectorHelper<Game::StrategyPlayerInfo>, +0x4c);   // "." count, n × "." frame
  nested("Session", StreamableHelper<Game::StrategySessionParams>, +0x5c);
  vft+0x24("MapShape",*(+0x48));
  WriteFloat("IncMod",+0x74); WriteFloat("ResMod",+0x78);             // floats (reader had int)
  WriteBool("Alliances",+0x7c); WriteBool("Teams",+0x7d); WriteBool("Encounters",+0x7e);
  WriteString("Scenario",+0x80);

StrategyPlayerInfo element (writer FUN_008298d0 @ 0x008298d0, reader FUN_00829820): nested("Slot", StreamableHelper<Game::SlotDef>, +4), WriteInt("Rank", +0xc0).

Game::SlotDef::Write = FUN_008276d0 @ 0x008276d0 (reader FUN_00832790):

  WriteBool("IsPlay",+0); WriteBool("IsDead",+1); WriteBool("IsReq",+2); WriteBool("IsRec",+3);
  WriteBool("IsFxNm",+4); WriteString("FxNm",+8);
  WriteBool("IsFxSp",+0x24); vft+0x24("FxSp",*(+0x28));
  WriteBool("IsFxCr",+0x2c); nested("FxCrID", StreamableHelper<Game::PlayerColorID>, +0x30);  // "." int idx; iff -1: r,g,b
  WriteBool("IsFxBd",+0x44); WriteString("FxBd",+0x48);
  WriteBool("IsFxAv",+0x64); WriteString("FxAv",+0x68);
  WriteInt("Tag",+0x88); WriteString("Pwd",+0x8c); WriteInt("Team",+0x84);          // Pwd is a STRING (empty here)
  nested("Settings", StreamableHelper<Game::StrategyPlayerGameSettings>, +0xa8);    // 4 × "." int (50000,1,0,1 observed)

StrategySessionParams::Write @ 0x0082b360: single nested "TMRS" (StrategyTimerParams::Write FUN_008173e0 @ 0x008173e0): WriteFloat "TSTL" +4, "TCTL" +8, "TQTL" +0xc, "TQTLE" +0x10 — all four floats.

6. turnstats — tch is an int; full record

StrategyServer::Write emits nested("turnstats", StreamableHelper<Game::GameTurnHistory>, *(this+0x200)).

  • GameTurnHistory::Write = FUN_0082c5a0 @ 0x0082c5a0 (Read FUN_00894180): WriteInt("nply", this+0xc); per map node: WriteInt("ply", node+0xc), nested("hist", StreamableHelper<Game::PlayerTurnHistory>, node+0x10).
  • PlayerTurnHistory::Write = FUN_0082c4a0 @ 0x0082c4a0 (vftable 0x00a32c90; Read FUN_00885450): vft+0x24("ply", *(this+4)); then for every map node nested("stats", …) with no count — the reader (FUN_00885450) loops do { read "stats" } while (found), i.e. until the enclosing frame's END marker.
  • PlayerTurnStats::Write = FUN_0082c290 @ 0x0082c290 (vftable 0x00a3145c; Read FUN_00833360):
  vft+0x30("pop", this+0x20, 8);                       // raw 8 bytes = int64
  nested("sacq",  VectorHelper<Game::SystemEvent>, this+0x50);
  nested("slost", VectorHelper<Game::SystemEvent>, this+0x60);
  vft+0x24("trn",  *(int16*)(this+4));  "almem" +8; "inc" +0xc; "tdinc" +0x10; "sav" +0x14;   // ints
  vft+0x24("col",  *(int16*)(this+0x18)); "bat" (int16)+0x1a; "tch" (int16)+0x28;             // int16 → int32
  WriteInt("ncls", 3);
  for i in 0..2: vft+0x24("cls", i); "shpt" int16[+0x2a+2i]; "shpl" [+0x36+2i]; "shpk" [+0x3c+2i];
                 "satt" [+0x30+2i]; "satl" [+0x42+2i]; "satk" [+0x48+2i];

SystemEvent::Write @ 0x008189a0: "set"(+8) "ses"(+4) "seop"(+0xc) "senp"(+0x10) ints by value, "seno2" = count, n × "seot2" int. Element stride 0x24 in the vector.

Bytes @0x4260: 03 00 00 00 "tch" 16 00 00 00 00 = 22 techs (int, 1 pad byte). Struct /Game/PlayerTurnStats (0x70) created in Ghidra with this layout.

7. zdsc / zdsi / zdst, and SvSctOb

Code: Game::StrategyServer::Write @ 0x0079fa70, tail:

  if (this->SvSctOb != NULL) nested("SvSctOb", StreamableHelper<Game::SVScriptObject>, this->SvSctOb); // conditional
  local_68 = (this->zds)._Mysize;                       // std::map @ +0x318
  WriteInt(stream,"zdsc",&local_68);
  for each node piVar9 of zds:
      vft+0x24("zdsi", piVar9[3]);                      // key   (node+0xc), int by value
      WriteInt(stream,"zdst", piVar9 + 4);              // value (node+0x10), int

Read @ 0x007d27a0 mirrors (zdsc count loop of zdsi/zdst). Bytes @0x8ffe0: "zdsc" 00000000 then 41104110 (Sim END) — count 0, so nothing follows; SvSctOb absent because the pointer is NULL in a normal game.

Fix (schema): Opt("SvSctOb", any), zdsc: NArr(Seq([zdsi:int, zdst:int])) — no standalone zdsi/zdst.

8. Game::StrategyServer — completed member table

Write @ 0x0079fa70 (2613 B), Read @ 0x007d27a0 (5420 B); IStreamable vftable 0x00a26084 at COL offset 0, so decompiled offsets are absolute. Struct /Game/StrategyServer (0x320 B) in the Ghidra project now carries every row marked ✎ (added this round) — 47 defined members.

off type save tag(s) notes
0x00 vptr — IStreamable vftable
0x04 vptr — primary
0x08 int ModCount
0x0c int Frame
0x10 int GOTurn
0x14 int GameID written by value
0x18 ✎ vector<StreamableEnum<uint>> GOWinPly VectorHelper frame: "."=count, n × "." int (reader had NestedInt)
0x28 std::string GameName
0x44 vector<ServerSystem*> NumSys + n×(SysID handle, Sys frame)
0x54 vector<ServerPlayer*> NumPlrs + n×(PlayerID handle, Player frame)
0x64 vector<StarFleet*> NumFlts + n×(FltID, Flt)
0x74 vector<obj*> NumActs + n×Act handle
0x8c vector<NodeMapLine> (stride 0x14) NMSz = size
0x9c int NMLc NMnx = FUN_008b8b80(NMLc) (derived, not stored)
0xbc int Map by value
0xc0 float IncMod float (reader had int)
0xc4 float ResMod float
0xc8 bool EnAl
0xc9 bool EnTm
0xfc float[7] 7 × (ISsp string, ISsu float) species name from FUN_0053b030(i); reader loops while ISsp present
0x134 std::string KeyPath first tag of the block
0x154 ServerNodeGraph* NdGr2 frame
0x158 ServerTradeManager* trdmgr frame (impl Write @ 0x0082cb60)
0x15c IServerSpyManager* spymgr frame
0x160 ✎ SpecialProjectNameGen* sprjs frame
0x164 AttribMap* Attrib frame
0x16c ✎ Mars::RNG* RNG frame, opaque MT state
0x1a0 float RandEncAdj
0x1b4 SVScriptObject* SvSctOb only if ≠ NULL
0x1b8..0x1c8 int×5 NPCm NPCo NPCi NPCv NPCa
0x1cc..0x1d4 float×3 szadj rsadj suadj
0x1f8 int cmbtid by value
0x1fc ✎ std::list<CombatReport>* numcreps = list size, n × crep frame
0x200 ✎ GameTurnHistory* turnstats §6
0x2c8 ✎ vector<Invasion> (16-B {obj* invs; int inve, invt, invtb}) ninv + n×(invs handle, inve by value, invt, invtb) struct /Game/StrategyServer::Invasion created
0x2b8 std::list — StrategyEvents (not streamed)
0x318 std::map<int,int> zdsc + n×(zdsi, zdst) §7

Not members but in the block: the ID lists written by FUN_00794cd0 @ 0x00794cd0 right after NMnx (reader FUN_0078a290): PlayerIDs=count, n×"." int (player +4); DesignIDs (each player's +0xe4 and +0x178 design vectors, id = design+0xa4); SystemIDs; FleetIDs; ShipIDs (fleet +0xa4 ships); TradeIDs (global FUN_00819810/20). The exclusion tables come from globals (FUN_0081b0c0(cat) count / FUN_0081b100(i,cat) pair): AllExc=n, n×(AllExc,AllExc) for cat 3; again for cat 2; AllExcCF=n, n×(AllExcCFp,AllExcCFp) for cat 1.

Legacy read-only tags in StrategyServer::Read (never written by 1.8): AIDifficultyID (int, after GameID), Rand (int, before RNG), RandEnc (bool, before EnAl), NPC (int, after NPCa; fans out to NPCm/o/i/v).

Full disk order of the Sim frame: KeyPath NMSz NMLc NMnx | PlayerIDs DesignIDs SystemIDs FleetIDs ShipIDs TradeIDs (each: count, n×".") | ModCount Frame GameID Attrib{} RNG{} GameName Map IncMod ResMod EnAl EnTm GOTurn GOWinPly{} NPCm NPCo NPCi NPCv NPCa szadj rsadj suadj sprjs{} RandEncAdj cmbtid turnstats{} numcreps n×crep{} ninv n×(invs inve invt invtb) AllExc n×(AllExc AllExc) AllExc n×(AllExc AllExc) AllExcCF n×(AllExcCFp AllExcCFp) NumPlrs n×(PlayerID Player{}) 7×(ISsp ISsu) NumSys n×(SysID Sys{}) NdGr2{} trdmgr{} spymgr{} NumFlts n×(FltID Flt{}) NumActs n×Act [SvSctOb{}] zdsc n×(zdsi zdst).


9. Other facts settled on the way (answers to SAVE_FORMAT.md VERIFY items)

  • Padding = joint (confirmed again: haltv 12 B, vnh 8 B, Name 4+4+4+len rounded to 4).
  • "." convention: any write with a NULL name is emitted with tag "." (len 1). All VectorHelper<T> writers (FUN_00830920 for SystemEvent, same template for the others) emit "."=count then n×"." elements (frames for streamable T, plain values for POD T). StarMapParams::Write and StrategyPlayerGameSettings use NULL names for scalars too. CArr = "frame whose first child is the count" is therefore exact; the count tag is always ".".
  • Vector3 bodies are tagged: 3 × "." float items inside the frame (seen in MapP planets, 12-B items). Same writer (FUN_008a60d0) serves Pos/PrvPos/FtOrig/FPogn2/FPdpos; treat 3 × "." float as the canonical form, keep the 12-raw-byte fallback.
  • Empty strings are legal (Key, MapF, Pwd, Scenario): 4 zero bytes, indistinguishable from int 0.
  • Root layout: Summary{} CreateParams{} Sim{} CDT{NumIDs, n×ID string} n×CD{} — CDT is a tagged frame (reader assumed "unframed"); each CD frame is opaque custom data (Player.<id>.TurnCommands_v5, Player.<id>.AIAgent …), written only when present.
  • Summary.IncMod/ResMod and Sim.IncMod/ResMod are floats; Session.TMRS.* four floats; Slot.Pwd and CreateParams.MapF strings; CreateParams.SDist/SSize/SRes/SSuit floats.

10. Schema patch for verify/save-reader/save_reader.py

Notation as in the reader (A confirmed tag, NArr inline count+elements, CArr framed "."-counted array, Seq, If, Opt, Rest). Only changed shapes are listed; everything else stands.

# ---- primitives / reader behaviour ------------------------------------------
# 1. string values may be EMPTY (len 0) and may contain any byte (cp1252); never let a string VALUE
#    fail the plausibility test — only tag bytes must be printable ASCII.           (gaps 1, 2, 5)
# 2. count tag inside every framed array (CArr) is "."; element tag is ".".
# 3. "vec3" = frame { ".":float, ".":float, ".":float }  (12 raw bytes as fallback).
# 4. Sim.GOWinPly is CArr("int"), not NestedInt.

# ---- Summary ------------------------------------------------------------------
PlayerColor  = Shape(None, [R("idx","int"), If("idx", Seq([R("r","int"),R("g","int"),R("b","int")]), equals=-1)])  # tags "."
PlayerSettings = Shape("Settings", [R("treasury","int"), R("colonies","int"), R("techs","int"), R("difficulty","int")])  # 4 x "."
Slot = Shape("Slot", [
    A("IsPlay","bool"), A("IsDead","bool"), A("IsReq","bool"), A("IsRec","bool"),
    A("IsFxNm","bool"), A("FxNm","string"), A("IsFxSp","bool"), A("FxSp","int"),
    A("IsFxCr","bool"), A("FxCrID", PlayerColor), A("IsFxBd","bool"), A("FxBd","string"),
    A("IsFxAv","bool"), A("FxAv","string"), A("Tag","int"), A("Pwd","string"), A("Team","int"),
    A("Settings", PlayerSettings), Rest()])
PlayerInfo = Shape(None, [A("Slot", Slot), A("Rank","int"), Rest()])          # element tag "."
Tmrs    = Shape("TMRS", [A("TSTL","float"), A("TCTL","float"), A("TQTL","float"), A("TQTLE","float"), Rest()])
Session = Shape("Session", [A("TMRS", Tmrs), Rest()])
Summary = Shape("Summary", [
    A("GameName","string"), A("Turn","int"), A("NumSys","int"), A("Checksum","int"),
    A("Players", CArr(PlayerInfo)), A("Session", Session), A("MapShape","int"),
    A("IncMod","float"), A("ResMod","float"), A("Alliances","bool"), A("Teams","bool"),
    A("Encounters","bool"), A("Scenario","string"), Rest()])

# ---- CreateParams -------------------------------------------------------------
Planet = Shape(None, [R("pos","vec3"), R("p1","int"), R("p2","int"), R("p3","int"), R("p4","float"), Rest()])  # all "."
MapP = Shape("MapP", [
    R("mapType","int"),                      # "."
    R("planets", CArr(Planet)),              # "." frame
    R("players", NArr(CArr("int"))),         # "." count, n x "." frame{ "." count, n x "." int }
    R("nodePaths", CArr("any")),             # "." frame (VectorHelper<SimpleNodePath>), empty here
    Rest()])
Scrp = Shape("scrp", [A("spc", NArr(Seq([A("spsn","string"), A("sppn","string"), A("sppv","string")])), key="params"), Rest()])
CreateParams = Shape("CreateParams", [
    A("Name","string"), A("ID","int"), A("RSeed","int"), A("AID","int"), A("Key","string"),   # Key may be ""
    A("MapP", MapP), A("MapS","int"), A("MapF","string"), A("NSys","int"), A("REnc","float"),
    A("SDist","float"), A("SSize","float"), A("SRes","float"), A("SSuit","float"), A("MaxP","int"),
    A("ASpec","int"), A("bAlly","bool"), A("NTeam","int"), A("tmgrp","bool"),
    A("PSav","int"), A("PCol","int"), A("PTech","int"), A("IncM","float"), A("ResM","float"),
    A("scrp", Scrp), Rest()])

# ---- turnstats ------------------------------------------------------------------
SystemEvent = Shape(None, [A("set","int"), A("ses","int"), A("seop","int"), A("senp","int"),
                           A("seno2", NArr(A("seot2","int")), key="others"), Rest()])
ClassStats = Seq([A("cls","int"), A("shpt","int"), A("shpl","int"), A("shpk","int"),
                  A("satt","int"), A("satl","int"), A("satk","int")])
PlayerTurnStats = Shape("stats", [
    A("pop","int64"), A("sacq", CArr(SystemEvent)), A("slost", CArr(SystemEvent)),
    A("trn","int"), A("almem","int"), A("inc","int"), A("tdinc","int"), A("sav","int"),
    A("col","int"), A("bat","int"), A("tch","int"),                      # tch: INT
    A("ncls", NArr(ClassStats), key="classes"), Rest()])
PlayerTurnHistory = Shape("hist", [A("ply","int"), Repeat(A("stats", PlayerTurnStats))])   # repeat until frame END (no count)
TurnStats = Shape("turnstats", [A("nply", NArr(Seq([A("ply","int"), A("hist", PlayerTurnHistory)])), key="players"), Rest()])

# ---- ServerSystem (only the changed rows) ----------------------------------------
#   halt block / vnh / Name / VFlags: UNCHANGED (haltv is bool) — fix is reader-side, see primitives.
    A("NVO", NArr(Seq([A("PID","int"), A("TShn","int"), A("OID","int"), A("isind","bool"),
                       A("indi", IndependenceInfo)])), key="colonies"),        # indi unconditional
#   system-level: A("hindi","bool"), If("hindi", A("indi", IndependenceInfo))  — unchanged

# ---- PrisonerHold ----------------------------------------------------------------
PrisonerHold = Shape("PrisH", [
    A("PrMax","int"),
    If("PrMax", A("PrNSp", NArr(Seq([A("PrSp","int"), A("PrNum","int")])), key="prisoners"), greater_than=0),
    Rest()])

# ---- Sim block ---------------------------------------------------------------------
Invasion = Seq([A("invs","int"), A("inve","int"), A("invt","int"), A("invtb","int")])
Sim = Shape("Sim", [
    A("KeyPath","string"), A("NMSz","int"), A("NMLc","int"), A("NMnx","int"),
    A("PlayerIDs", NArr(R(".","int")), key="playerIds"), A("DesignIDs", NArr(R(".","int")), key="designIds"),
    A("SystemIDs", NArr(R(".","int")), key="systemIds"), A("FleetIDs", NArr(R(".","int")), key="fleetIds"),
    A("ShipIDs",   NArr(R(".","int")), key="shipIds"),   A("TradeIDs", NArr(R(".","int")), key="tradeIds"),
    A("ModCount","int"), A("Frame","int"), A("GameID","int"), Opt("AIDifficultyID","int"),
    A("Attrib","any"), Opt("Rand","int"), A("RNG","any"), A("GameName","string"),
    A("Map","int"), A("IncMod","float"), A("ResMod","float"), Opt("RandEnc","bool"), A("EnAl","bool"), A("EnTm","bool"),
    A("GOTurn","int"), A("GOWinPly", CArr("int")),
    A("NPCm","int"), A("NPCo","int"), A("NPCi","int"), A("NPCv","int"), A("NPCa","int"), Opt("NPC","int"),
    A("szadj","float"), A("rsadj","float"), A("suadj","float"),
    A("sprjs","any"), A("RandEncAdj","float"), A("cmbtid","int"), A("turnstats", TurnStats),
    A("numcreps", NArr(A("crep", Crep)), key="combatReports"),
    A("ninv", NArr(Invasion), key="invasions"),
    A("AllExc", NArr(Seq([A("AllExc","int"), A("AllExc","int")])), key="exclusions3"),
    A("AllExc", NArr(Seq([A("AllExc","int"), A("AllExc","int")])), key="exclusions2"),
    A("AllExcCF", NArr(Seq([A("AllExcCFp","int"), A("AllExcCFp","int")])), key="exclusionsCF"),
    A("NumPlrs", NArr(Seq([A("PlayerID","int"), A("Player", Player)])), key="players"),
    Repeat(Seq([A("ISsp","string"), A("ISsu","float")]), key="species"),     # 7 pairs; loop while next tag is ISsp
    A("NumSys", NArr(Seq([A("SysID","int"), A("Sys", Sys)])), key="systems"),
    A("NdGr2", NodeGrid), A("trdmgr","any"), A("spymgr","any"),
    A("NumFlts", NArr(Seq([A("FltID","int"), A("Flt", Fleet)])), key="fleets"),
    A("NumActs", NArr(A("Act","int")), key="acts"),
    Opt("SvSctOb","any"),
    A("zdsc", NArr(Seq([A("zdsi","int"), A("zdst","int")])), key="zoneDefence"),
    Rest()])

# ---- root -----------------------------------------------------------------------------
CdTable = Shape("CDT", [A("NumIDs", NArr(A("ID","string")), key="ids"), Rest()])
ROOT = Seq([A("Summary", Summary), A("CreateParams", CreateParams), A("Sim", Sim),
            A("CDT", CdTable), Repeat(A("CD","any"))])      # one CD frame per id that has data

Expected effect on turn2-state.sav --strict: all 53 listed issues clear (16 tch warns, 7 haltv warns + 21 derived Name/haltv/vnh errors, 1 key, 16 PrNSp, 2 zdsi/zdst, 9 indi), provided the empty-string and non-ASCII-string-value reader fixes are made; no further resyncs expected in the Sim spine.

11. Ghidra project changes (saved)

  • /Game/StrategyServer: added GOWinPly (+0x18, std::vector), sprjs (+0x160), RNG (+0x16c), creps (+0x1fc, std::list*), turnstats (+0x200), invasions (+0x2c8, std::vector); comments on zds, SvSctOb, ISsu, NMLc; struct description = full disk order.
  • New /Game/StrategyServer::Invasion (16 B) and /Game/PlayerTurnStats (0x70 B, full layout).
  • /SOTS/PrisonerHold description documents the PrMax>0 gate.
  • Placeholder (empty) structs /Game/SpecialProjectNameGen, /Game/GameTurnHistory, /Mars/RNG for the pointer targets. No function or existing-field changes; nothing committed to git. reva-server restarted.