From a1852bbf5bd624b6acb6aa78a9a1e844c86614f3 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 7 Sep 2026 16:24:54 -0400 Subject: [PATCH] objects: save-schema gaps resolved from serializers; StrategyServer complete --- campaign/board.md | 4 +- campaign/open-questions.md | 1 + findings/objects/schema-gaps-resolved.md | 453 +++++++++++++++++++++++ 3 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 findings/objects/schema-gaps-resolved.md diff --git a/campaign/board.md b/campaign/board.md index 6a388bc..ce9bd3c 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -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 | diff --git a/campaign/open-questions.md b/campaign/open-questions.md index d9dae22..3fb30f9 100644 --- a/campaign/open-questions.md +++ b/campaign/open-questions.md @@ -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]]) diff --git a/findings/objects/schema-gaps-resolved.md b/findings/objects/schema-gaps-resolved.md new file mode 100644 index 0000000..b0563e5 --- /dev/null +++ b/findings/objects/schema-gaps-resolved.md @@ -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::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.