objects: save-schema gaps resolved from serializers; StrategyServer complete
This commit is contained in:
parent
afbfdf5ce8
commit
a1852bbf5b
3 changed files with 456 additions and 2 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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]])
|
||||
|
|
|
|||
453
findings/objects/schema-gaps-resolved.md
Normal file
453
findings/objects/schema-gaps-resolved.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue