28 KiB
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):
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:
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):
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)
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 "."):
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"
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):
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 (ReadFUN_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; ReadFUN_00885450):vft+0x24("ply", *(this+4)); then for every map nodenested("stats", …)with no count — the reader (FUN_00885450) loopsdo { read "stats" } while (found), i.e. until the enclosing frame's END marker.PlayerTurnStats::Write=FUN_0082c290@ 0x0082c290 (vftable 0x00a3145c; ReadFUN_00833360):
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:
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:
haltv12 B,vnh8 B,Name4+4+4+len rounded to 4). "."convention: any write with a NULL name is emitted with tag"."(len 1). AllVectorHelper<T>writers (FUN_00830920for SystemEvent, same template for the others) emit"."=count then n×"."elements (frames for streamable T, plain values for POD T).StarMapParams::WriteandStrategyPlayerGameSettingsuse 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 inMapPplanets, 12-B items). Same writer (FUN_008a60d0) servesPos/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{}—CDTis a tagged frame (reader assumed "unframed"); eachCDframe is opaque custom data (Player.<id>.TurnCommands_v5,Player.<id>.AIAgent…), written only when present. Summary.IncMod/ResModandSim.IncMod/ResModare floats;Session.TMRS.*four floats;Slot.PwdandCreateParams.MapFstrings;CreateParams.SDist/SSize/SRes/SSuitfloats.
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.
# ---- 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: addedGOWinPly(+0x18,std::vector),sprjs(+0x160),RNG(+0x16c),creps(+0x1fc,std::list*),turnstats(+0x200),invasions(+0x2c8,std::vector); comments onzds,SvSctOb,ISsu,NMLc; struct description = full disk order.- New
/Game/StrategyServer::Invasion(16 B) and/Game/PlayerTurnStats(0x70 B, full layout). /SOTS/PrisonerHolddescription documents thePrMax>0gate.- Placeholder (empty) structs
/Game/SpecialProjectNameGen,/Game/GameTurnHistory,/Mars/RNGfor the pointer targets. No function or existing-field changes; nothing committed to git.reva-serverrestarted.