453 lines
28 KiB
Markdown
453 lines
28 KiB
Markdown
# 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.
|