objects: save-schema gaps resolved from serializers; StrategyServer complete

This commit is contained in:
alex 2026-09-07 16:24:54 -04:00
parent afbfdf5ce8
commit a1852bbf5b
3 changed files with 456 additions and 2 deletions

View file

@ -10,7 +10,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| `Mars::AppStartup` (entry) | control-flow | mapped | high | 100% | 2026-09-07 | entry 0x00925794 -> WinMain 0x0089dd30 -> DemoApp ctor 0x0089c950 -> Mars::Application::Initialize 0x008a0e50 (config/affinity, D3D9, window, sound thread, OnStartup -> net thread). findings/control-flow/turn-spine.md |
| main loop / tick dispatch | control-flow | mapped | high | 90% | 2026-09-07 | Application::Run 0x0089f5b0: FrameTimer, PanelManager, DemoApp::OnUpdate 0x00898800 / OnTick 0x0089a640 / OnRender 0x00899210. Turn pipeline: EndTurn 0x00783be0 -> BeginProcessTurn 0x007d98e0 -> StrategyServer::ProcessTurn 0x007dc6c0 -> RunCombatRound 0x007cbe80 -> SETurnResults -> ResumePlaying 0x007ddc90. Lockstep on every machine |
| `Game::ClientPlayer` / `AIPlayer` | object | verified | high | 85% | 2026-09-07 | ServerPlayer layout confirmed vs real saves (names, species, home, savings, designs coherent across turns 1-3). Gaps: none in player block; findings/objects/struct-recovery.md |
| `Game::StarSystem` | object | verified | med | 75% | 2026-09-07 | ServerSystem confirmed vs real saves EXCEPT a conditional layout in 7/28 systems: `VFlags` appears where `Name`/`vnh` expected; `halt[2].haltv` is a 31/35-byte struct not bool; optional `indi` before `NVE`/`PID`. Round-4 Ghidra target |
| `Game::StarSystem` | object | verified | high | 95% | 2026-09-07 | ServerSystem layout CONFIRMED: the 'conditional VFlags' gap was a reader bug (cp1252 in Name); haltv is a bool; NVO nodes write indi unconditionally. findings/objects/schema-gaps-resolved.md |
| `Game::Planet` / `DOPlanet` | object | mapped | high | 100% | 2026-09-07 | Planet : Actor is a RENDER actor, not streamed; all colony state is in ServerSystem (+PlayerView in NVs map) |
| `Game::ShipDesignDef` / `DesignList` | object | in-progress | low | 30% | 2026-09-07 | vft@0x009fef64 (3), leaf (0 bases); mostly data-defined via .shipsection. fields TBD |
| `Game::FleetLayout` | object | verified | high | 85% | 2026-09-07 | StarFleet/StarShip confirmed vs real saves; gap: PrisonerHold `prisoners` entries lack `PrNSp` (conditional/absent). Round-4 target |
@ -27,7 +27,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| networking (SNM/FNM + GameSpy) | subsystem | backlog | — | 0% | 2026-09-07 | ~60 SNM strategy msgs, FNM file-xfer/host-migration; lockstep sim |
| battle-load: thread contention | subsystem | in-progress | med | 50% | 2026-09-07 | game now runs on VM140 -> dynamic profiling unblocked (round 4) |
| UI screen & flow map | meta | mapped | high | 100% | 2026-09-07 | findings/subsystems/ui-screen-map.md - 36 screens; screens are C++ on Mars controls (NOT data); turn state machine recovered |
| `Game::StrategyServer` (sim block) | object | mapped | med | 60% | 2026-09-07 | 41 fields; real save shows missing optional `zdsi`/`zdst` at end, `turnstats.hist.stats.tch` is int not string, CreateParameters `key` is int. Round-4 completion target |
| `Game::StrategyServer` (sim block) | object | mapped | high | 95% | 2026-09-07 | member table complete (47 rows) incl. ID-list sub-writer FUN_00794cd0, GOWinPly/sprjs/RNG/creps/turnstats/invasions; PlayerTurnStats + Invasion structs in Ghidra. Verify: strict pass pending reader patch |
| stream primitive API | subsystem | mapped | high | 100% | 2026-09-07 | IStreamable vft: +0x18 string, +0x1c bool, +0x20 float, +0x24 int, +0x28 nested, +0x30 raw; FUN_00816490 = NetworkObject handle id |
| real save for verification | verify | verified | high | 100% | 2026-09-07 | 6 saves, 3 distinct turn states in verify/results/saves/ (byte-deterministic per state). Game runs on VM140 via DXVK 3.1 + lavapipe; recipe findings/subsystems/running-the-game.md |
| save_reader.py | verify | verified | high | 90% | 2026-09-07 | parses all 3 real saves end-to-end (padding=joint confirmed); --strict exit 2 with a precise gap list: verify/results/saves/turn2-strict-issues.txt |

View file

@ -24,3 +24,4 @@ Each links to the finding that raised it. Promoted to backlog or closed by **re-
- **Spine leftovers** — `StrategyServer` struct partial (41 fields); static-initialiser region 0x009be000–0x009c1400 undisassembled; `Mars::Stream` vftable not located; several small ProcessTurn phase fns unnamed. (from [[turn-spine]])
- **Save framing ambiguities (settle on first real save)** — padding joint `[len][name][value][pad]` vs split; bool vs int for names with len%4==0 (no type byte); on-disk tags for Summary/CreateParameters and count/element tags inside framed arrays unknown (positional for now). A real 3-char bool tag (`NPC`,`Dep`,`hsp`) settles padding. (from [[SAVE_FORMAT]])
- **Strict-parse gaps vs real save (verifier output, round 3)** — (1) `ServerSystem`: 7/28 systems carry `VFlags` where `Name`/`vnh` were expected — a conditional layout (uncolonized/special systems?); (2) `halt[].haltv` is a 31/35-byte struct, not a bool; (3) optional `indi` (IndependenceInfo?) precedes `NVE`/`PID` in some systems; (4) `PrisonerHold.prisoners[]` entries lack `PrNSp`; (5) `/createParams/key` is int; (6) `turnstats.hist.stats.tch` is int; (7) optional trailing `zdsi`/`zdst`. Padding = **joint** (settled). 7 resyncs / 2728 raw bytes ≈ RNG blob. (from [[turn2-strict-issues]])
- **RESOLVED: strict-parse gaps** — 5/8 were reader bugs (ASCII plausibility test applied to string values; empty string == int 0), 3 real: `PrNSp` only when `PrMax>0`; `indi` unconditional in `NVO` nodes (system-level gated by `hindi`); `zdsi/zdst` pairs under `zdsc`. `Key` is an empty string; `tch` int. Summary/CreateParams tag lists confirmed. Reader patch in flight. (from [[schema-gaps-resolved]])

View file

@ -0,0 +1,453 @@
# 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.