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

453 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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<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):
```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<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)
```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<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 `"."`):
```c
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"`
```c
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`):
```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<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`):
```c
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:
```c
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.
```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<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.