# 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): ```c 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: ```c 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::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): ```c 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`), `"CreateParams"` (`StreamableHelper`), `"Sim"` (`StreamableHelper`), 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) ```c 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, 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, 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 `"."`): ```c vft+0x24(0, *(this+4)); // "." int (map type/seed; 0 here) nested (0, VectorHelper, this+8); // "." frame: "."=count, n × "." frame(planet) WriteInt(0, &nPlayers); // "." int = (+0x1c - +0x18) / 16 for each player: nested(0, VectorHelper, entry); // "." frame: "."=count, n × "." int nested (0, VectorHelper, 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"` ```c WriteString("GameName",+0x20); WriteInt("Turn",+0x3c); WriteInt("NumSys",+0x40); WriteInt("Checksum",+0x44); nested("Players", VectorHelper, +0x4c); // "." count, n × "." frame nested("Session", StreamableHelper, +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, +4)`, `WriteInt("Rank", +0xc0)`. `Game::SlotDef::Write` = `FUN_008276d0` @ 0x008276d0 (reader `FUN_00832790`): ```c 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, +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, +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, *(this+0x200))`. * `GameTurnHistory::Write` = `FUN_0082c5a0` @ 0x0082c5a0 (Read `FUN_00894180`): `WriteInt("nply", this+0xc)`; per map node: `WriteInt("ply", node+0xc)`, `nested("hist", StreamableHelper, 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`): ```c vft+0x30("pop", this+0x20, 8); // raw 8 bytes = int64 nested("sacq", VectorHelper, this+0x50); nested("slost", VectorHelper, 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: ```c if (this->SvSctOb != NULL) nested("SvSctOb", StreamableHelper, 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>` | `GOWinPly` | VectorHelper frame: `"."`=count, n × `"."` int (reader had `NestedInt`) | | 0x28 | `std::string` | `GameName` | | | 0x44 | `vector` | `NumSys` + n×(`SysID` handle, `Sys` frame) | | | 0x54 | `vector` | `NumPlrs` + n×(`PlayerID` handle, `Player` frame) | | | 0x64 | `vector` | `NumFlts` + n×(`FltID`, `Flt`) | | | 0x74 | `vector` | `NumActs` + n×`Act` handle | | | 0x8c | `vector` (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*` | `numcreps` = list size, n × `crep` frame | | | 0x200 ✎ | `GameTurnHistory*` | `turnstats` | §6 | | 0x2c8 ✎ | `vector` (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` | `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` 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..TurnCommands_v5`, `Player..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. ```python # ---- 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), 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.