diff --git a/findings/objects/turncommands-block.md b/findings/objects/turncommands-block.md new file mode 100644 index 0000000..d4aa8e6 --- /dev/null +++ b/findings/objects/turncommands-block.md @@ -0,0 +1,315 @@ +# `TurnCommands_v5` — the pending order queue, and why the recovery could not be aligned to it + +Lane Q, 2026-09-08. Static + host only; VM140 was not needed. + +Closes the last opaque block of the save format. Named coverage on all eleven saves goes to +**100.0%**, with the two items of the MT19937 blob the only things left carried as Nodes. + +--- + +## 1. The reconciliation + +Lane W's negative result was real work and its arithmetic was right. Its conclusion — "a save with +issued orders is required" — was also right. But the *reason* the two sequences would not align is +not that the writer takes branches a no-orders turn does not (it does, but that is the smaller +half). It is that **three different things were being compared as if they were one**. + +`Game::TurnCommands::Write` is at `0x00842540` and is 764 bytes. Read as instructions, it has two +halves and no loops of its own: + +**(1) A prologue of six flag-gated groups.** Each group is a `WriteBool` on a member, followed — +only when that bool is set — by that command's payload: + +| gate | payload | meaning | +|---|---|---| +| — | `i32 @0x04` | player id, always written | +| `bool @0x0c` | `f32 @0x08` | research rate (the empire savings/research slider) | +| `bool @0x14` | `i32 @0x10` | research target tech id | +| `bool @0x20` | `i32 @0x18`, `f32 @0x1c` | research boost: savings spent, and a fraction | +| `bool @0x2c` | `bool @0x24`, `i32 @0x28` | never observed set | +| `bool @0x3c` | `f32 @0x30`, `f32 @0x34`, `f32 @0x38` | never observed set | +| `bool @0x6c` | `StreamableHelper` frame on `@0x40` | never observed set | + +**(2) Twenty-seven `std::list` members** at `0x70 … 0x1a8`, stride `0x0c` +(`{_Myhead, _Mysize, _Alval}` — allocator-last, as rule 5 warns). Each is handed to its own +free-function writer, `helper(stream, &list)`, and every helper has the same shape: + +``` +WriteInt(list._Mysize) ; the count -- ALWAYS written, even for an empty list +for each node: +``` + +That is the whole class. Now the three ways the comparison went wrong: + +### 1a. The sequence lane W aligned against was offset-sorted, not write-ordered + +`objects/layouts.md` sorts fields by `off_abs`. `Game::TurnCommands` is one of the **89 of 386 +classes** where offset order is not write order — the writer emits `0x04, 0x0c, 0x08, 0x14, 0x10, +0x20, 0x18, 0x1c, …` because each gate bool precedes its payload in the stream while sitting after +it in the struct. In the sorted view, item 4 is the `i32 @0x10`; in the writer, item 4 is the +`bool @0x14`. Lane W's "the save's item 4 is 8 bytes, which can only be a bool where the recovery +says `i32`" is therefore an artifact of the sorted view, not a contradiction. The **write-ordered** +table (`objects/streams.json`, which is what the engine's wire-schema channel consumes) already has +a `bool` in that slot, and matches the save. + +This is the same trap the wire-schema channel was built to avoid, hit from the other side. It is +worth restating as an operational rule: **never align a save against `layouts.md`; align against +`streams.json`.** + +### 1b. The tail is 27 container *call sites*, not 27 scalars + +The recovery is a linear pass. It cannot express a loop, so for each of the 27 list writers it +emits **one item**, guessing a kind from an element field it could resolve, and **drops the count +word entirely**. That is where the recovered 44 comes from: + +``` +44 = 17 member writes (all branches, as the recovery always presents them) + + 27 container call sites (one item each, element kind guessed, count dropped) +``` + +`44 − 17 = 27`, and there are exactly 27 list members. Lane W's second objection — "27 consecutive +ints on disk but only 22 `i32` slots after index 15" — is that same 27 seen from both sides. The +five slots that are not `i32` in the table (a `Game::StarSystem::OutputRates` frame, a `narr`, two +`bool`s, a `Game::Population` frame, a `Game::RaidTargets` frame) are the recovery describing an +**element**, while the wire at that position always shows a **count**. It was a kind mismatch in +the table, not a structural impossibility in the save. + +Two of those five are exactly right about which list they name, which is the tell: the table's +5th tail item is `OutputRates` and list 5 *is* the planetary-budget list; its 8th tail item is +`narr` and list 8 *is* the only one whose element ends in a nested counted vector. + +### 1c. The empty block is 8 + 27, and that is why every old save was bit-identical + +A no-orders turn sets only the research-rate gate. So the block is + +``` +i32 playerId, bool(true), f32 rate, bool×5 (all false) = 8 items +27 × WriteInt(0) = 27 items + -- 35 items, 122 B +``` + +which is `CD[0]` digest `3df7d93164fb1d7d` in `turn1/2/3-state.sav`, `zuul-turn5-species5.sav`, +`human-turn3-noderoute.sav`, `zuul-turn17-rollpending.sav` and `zuul-turn23-fleet23.sav`. Nothing +could be learned from those saves because 27 of their 35 items are the *absence* of every command +list. + +### 1d. The arithmetic checks on every save + +| save | prologue | list contents | total | observed | +|---|---|---|---|---| +| any no-orders save | 8 | 27 counts | 35 | 35 | +| `zuul-turn16-noderoute` | 11 | 27 counts | 38 | 38 | +| `human-turn2-orders` | 8 | 27 + 3 (move) + 3 (list 14) | 41 | 41 | +| `zuul-turn15-orders` | 11 | 27 + 5×4 (build) + 3 (move) | 61 | 61 | +| `zuul-turn17-orders2` | 8 | 27 + 20×4 (build) + 2 (rates) + 3×2 (colonize) | 123 | 123 | + +(`state_checksum --tree` counts 129 *leaves* for the last one because the `OutputRates` sub-frame +contributes 7 leaves in place of 1 item.) + +--- + +## 2. Corrections to lane O's provisional layout + +Lane O's §11 was derived from bytes and UI correlation and is right about every command it +observed. Two things are corrected from the writer: + +**The fleet-move element is not `{fleetId, 1, destSystemId, 0}`.** The helper at `0x0083e550` +writes `WriteInt(fleetId)` and then a **nested counted int vector**: `n = (v._Mylast − +v._Myfirst) >> 2`, then `n` ints. So the wire is `{fleetId, hopCount, hopCount × systemId}` — three +items for a one-hop order, and *longer for a multi-hop route*. Lane O read the `1` as a constant +and the next list's zero count as a trailing `0`. Only the three-item reading makes the item totals +close: with a four-item element `human-turn2-orders` needs 26 lists and `zuul-turn15-orders` needs +28, and the writer has 27. + +This also means a route order is directly visible in the block, which is what the `noderoute` saves +were built to catch — and worth recording, neither of them carries one (both decode to zero fleet +moves), so the node-route UI does not queue through this list. + +**There are 27 lists, not "five plus trailing zeros".** Lane O's "int x2 = 0" before the build +count and its "int 0" separators are lists 1, 2, 4, 6 and 9…27 with count 0. The five observed are +list 3 (build), 5 (system rates), 7 (colonize), 8 (fleet move) and 14. + +`verify/save-reader/SAVE_FORMAT.md` §11 has been corrected in place. + +--- + +## 3. The 27 lists + +Element records read off each helper. RTTI resolves every nested frame's helper vftable to a class, +which is where the names come from. + +| # | member | helper | element record | status | +|---|---|---|---|---| +| 1 | `+0x70` | `0x0082e310` | `StreamableHelper` frame, `i32` | hypothesis | +| 2 | `+0x7c` | `0x00822ca0` | `i32` | hypothesis | +| 3 | `+0x88` | `0x00822870` | `i32 ordinal, i32 designId, i32 systemId, i32 w` | **observed** | +| 4 | `+0x94` | `0x008228f0` | `i32 ×3` | hypothesis | +| 5 | `+0xa0` | `0x0082e3d0` | `i32 systemId`, `StarSystem::OutputRates` frame | **observed** | +| 6 | `+0xac` | `0x0082e490` | `PlayerNotes` frame | hypothesis | +| 7 | `+0xb8` | `0x00822960` | `i32 shipId, i32 w` | **observed** | +| 8 | `+0xc4` | `0x0083e550` | `i32 fleetId`, counted `i32` route | **observed** | +| 9 | `+0xd0` | `0x008229c0` | `i32, bool, i32, i32, f32` | hypothesis | +| 10 | `+0xdc` | `0x0083e5f0` | `i32, i32`, counted `i32` | hypothesis | +| 11 | `+0xe8` | `0x00822a50` | `i32, bool` | hypothesis | +| 12 | `+0xf4` | `0x0082e530` | `i32`, `FleetLayout` frame | hypothesis | +| 13 | `+0x100` | `0x00822ab0` | `i32`, `string` | hypothesis | +| 14 | `+0x10c` | `0x00822b10` | `i32, i32, bool` | **observed** (see below) | +| 15 | `+0x118` | `0x00822a50` | `i32, bool` | hypothesis | +| 16 | `+0x124` | `0x00822a50` | `i32, bool` | hypothesis | +| 17 | `+0x130` | `0x00822b80` | `i32 ×3` | hypothesis | +| 18 | `+0x13c` | `0x00822b80` | `i32 ×3` | hypothesis | +| 19 | `+0x148` | `0x00822bf0` | `i32` | hypothesis | +| 20 | `+0x154` | `0x00822bf0` | `i32` | hypothesis | +| 21 | `+0x160` | `0x0082e5f0` | `i32, i32`, `VectorHelper>` | hypothesis | +| 22 | `+0x16c` | `0x0082e6c0` | `i32`, `WeaponGroups` frame | hypothesis | +| 23 | `+0x178` | `0x0082e780` | `i32`, `Population` frame | hypothesis | +| 24 | `+0x184` | `0x00822c40` | `i32`, `f32` | hypothesis | +| 25 | `+0x190` | `0x0082e840` | `i32`, `DefenceLayout` frame | hypothesis | +| 26 | `+0x19c` | `0x0082e900` | `RaidTargets` frame | hypothesis | +| 27 | `+0x1a8` | `0x00822ca0` | `i32` | hypothesis | + +Six member slots share a helper with an earlier slot (15/16 with 11, 18 with 17, 20 with 19, 27 +with 2), so there are 22 distinct helper functions for 27 call sites. + +**Rule 6 applies to twenty-two of these rows.** The scalar sequences are read directly out of the +instruction stream, which is stronger than a guess, but no save exercises them and no element value +has ever been observed. Where an element holds a nested object whose body is not otherwise modelled +by the engine's shapes (lists 1, 12, 22, 25, 26), the frame is **carried as an opaque Node** rather +than decoded — a wrong body cannot then desynchronise a reader if one of those lists ever turns up +populated. + +**List 14 is observed but not understood.** `human-turn2-orders.sav` — a turn whose only UI action +was one fleet move — carries one list-14 element `{1456, 0, true}`, and `1456` is the same fleet id +list 8 carries. So issuing a move queues a second, separate command against the same fleet. What it +is remains open; it is named `list14` in the engine rather than guessed at. + +**What lane O's saves could not produce**, and therefore what stays a hypothesis: the three unset +prologue gates (`@0x2c`, `@0x3c`, `@0x6c` — the last being a whole `CivilianRatios` frame, i.e. the +empire civilian-settings command), and lists 1, 2, 4, 6, 9–13 and 15–27. + +--- + +## 4. Is the block version-tagged or otherwise variable? + +No version word is written. The id string carries the version instead +(`Player..TurnCommands_v5`), and the shape is fixed: 27 lists always, six gates always. All the +variability is the gates and the list lengths, so a single static shape is correct — with one +guard. The engine's `CustomDataBlock::select()` matches the **exact** suffix `.TurnCommands_v5`; +anything else, including a hypothetical `_v6`, falls back to the carried Node rather than being +decoded with a stale layout. There is a unit test for that. + +--- + +## 5. Conformance, and why the generic check could not be used + +Every item in the class is written with a NULL name, so on disk every tag is `.`. The wire-schema +test's LCS degenerates on such a sequence: with all tags equal, the gap branches are unreachable and +the walk becomes a strict positional comparison in which any primitive disagreement is a fatal +`MISMATCH`. That is correct behaviour when the two sequences describe the same thing — and here they +do not, for the reason in §1b. Binding the shape with the generic `check()` would have failed the +build, and "make it pass" would have meant deforming the shape to match a table that is wrong. + +So `test_wire_schema.cpp` gets a dedicated check that states what is actually checkable: + +* **the 17-item prologue, item for item and primitive for primitive** — `17/17`, zero mismatches. + `SchemaProbe` takes every branch, which is the same all-branches view the recovery has, so the + conditional structure read out of the instruction stream must reproduce the recovered sequence + exactly. It does. That is the independent evidence for §1a, and it is not a weak test: get any + gate/payload pairing wrong and the primitives desynchronise immediately. +* **the tail count** — the shape has exactly 27 top-level counted lists and the table has exactly + 27 tail items. `27 == 27` is the entire content of that half of the table. + +The 27 tail items are reported as `wire-only`, never claimed as matched. + +Totals: **87 shapes bound, 856 items matched, 0 MISMATCH** (was 86 / 838 / 0). + +--- + +## 6. A second defect this work turned up: `NVs` element tags + +Not `TurnCommands`, but found by running the whole corpus and worth reporting loudly. + +`zuul-turn23-fleet23.sav` is the **first save in the campaign with a non-empty `NVs` list** on a +`ServerSystem`. Its element's leading id had been typed positionally as `R("pid")` — disk tag `.` — +on the assumption every anonymous element carries `.`. The real tag is `PID`, so the engine's typed +round trip on that save differed at offset `0x89c14`, at the very first element. This was already +broken at engine `main` (`c883a32`), before any of lane Q's changes. + +Rule 6 exactly: a path no save exercised was a hypothesis flying as a fact, and the first workload +that touched it falsified it. Fixed to `A("PID")`. The fix is independently corroborated — the +recovered wire table for `Game::ServerSystem` names that item `PID`, and the conformance row moves +from `102 matched / 1 wire-only / 3 shape-only` to `103 / 0 / 2`. + +--- + +## 7. Coverage, before and after + +`CoverageArchive`'s typed-vs-carried split (not round-trip success), all eleven saves: + +| save | before | after | opaque before → after | +|---|---|---|---| +| `turn1-state` | 99.9% | **100.0%** | 37 → 2 | +| `turn2-state` | 99.9% | **100.0%** | 37 → 2 | +| `turn3-state` | 99.9% | **100.0%** | 37 → 2 | +| `zuul-turn5-species5` | 99.9% | **100.0%** | 37 → 2 | +| `human-turn2-orders` | 99.9% | **100.0%** | 43 → 2 | +| `human-turn3-noderoute` | 99.9% | **100.0%** | 37 → 2 | +| `zuul-turn15-orders` | 99.8% | **100.0%** | 63 → 2 | +| `zuul-turn16-noderoute` | 99.9% | **100.0%** | 40 → 2 | +| `zuul-turn17-orders2` | 99.7% | **100.0%** | 132 → 2 | +| `zuul-turn17-rollpending` | 99.9% | **100.0%** | 37 → 2 | +| `zuul-turn23-fleet23` | 99.9% | **100.0%** | 37 → 2 | + +The two remaining opaque items on every save are the `RNG` MT19937 block, which is deliberately +carried. **Nothing else in any save we hold is untyped.** The ratchet moves 99.8 → 99.99. + +Note `zuul-turn17-orders2` was *below* the old 99.8 ratchet: lane O's largest order save had already +pushed the opaque count high enough to fail the coverage gate, which is the gate working. + +Round trip stayed byte-identical on all eleven saves before and after (and became byte-identical on +`zuul-turn23-fleet23`, where it was not). + +--- + +## 8. `save_reader.py` was deliberately left alone, and why + +The Python oracle reads `CD` frames as `Repeat(A("CD", "any"))` — it types **neither** custom-data +body, `AIAgent` included. Lane A judged that an absence rather than a defect and did not touch it. +That judgement still holds, and the case for mirroring is weaker than it looks: + +* the block is already **fully accounted for** by the oracle's generic walk — + `state_checksum --strict` reports `coverage: PROVED`, `0 error 0 warn`, on all eleven saves, + before and after; +* `state_checksum`'s digest tree is built from the **generic** node tree, not the typed dict, so + typing `CD` would not put a single named leaf into a diff; +* nothing consumes the typed dict for `CD`; +* the dispatch would need new machinery in the oracle (selection by `CDT` ordinal, which the schema + DSL has no construct for) — new risk in the instrument the campaign trusts most; +* and rule 8 is pointed at exactly this move. A layout mirrored into both readers from one reading + does not become two checks; its failure mode is silent agreement. + +So the independent evidence for this layout is **not** "two readers agree". It is the disassembly, +the 17/17 prologue conformance against a table produced by a different tool, and the item arithmetic +in §1d closing to the unit on five distinct workloads. + +What was added instead, where real saves are actually available: `test_save.cpp` now asserts, on +every save, that each `TurnCommands_v5` block is consumed by the prologue plus the 27 lists with +**nothing left over** (`extra.empty()`). That is the item-granular statement, and a wrong list count +or a wrong element width breaks it first. + +--- + +## 9. Gates + +Run as separate commands, as rule 13 requires. + +* `tools/clean_room_check.sh` — **OK** +* host `ctest` — **36/36** +* `test_save` with `SOTS_SAVES_DIR` — **11 saves, 0 failures**, round trip byte-identical on all +* `state_checksum.py --strict` — `coverage: PROVED`, `0 error 0 warn`, on all eleven saves +* `test_wire_schema` — 87 shapes, 856 items matched, **0 MISMATCH** + +`src/shim/` was not touched, so no CT111 cross-build was needed. + +New addresses are in `ghidra/addresses.d/lane-q.json` (6 entries). The fragment was validated by +generating to a scratch path, never over the tracked header. diff --git a/ghidra/addresses.d/lane-q.json b/ghidra/addresses.d/lane-q.json new file mode 100644 index 0000000..ec7cb62 --- /dev/null +++ b/ghidra/addresses.d/lane-q.json @@ -0,0 +1,52 @@ +{ + "entries": [ + { + "name": "TurnCommands_Write", + "addr": "0x00842540", + "convention": "thiscall", + "prototype": "void __thiscall Game::TurnCommands::Write(Mars::IStream* s) -- the writer for the `Player..TurnCommands_v5` custom-data block (CDT id -> one CD frame). 764 bytes, no loops of its own. Two halves. (1) A PROLOGUE of six flag-gated groups, each a WriteBool on a member followed, only when that bool is set, by the group's payload; write order is NOT offset order, which is why the offset-sorted layout view cannot be aligned to the wire: bool@0x0c gates f32@0x08 (research rate); bool@0x14 gates i32@0x10 (research target tech); bool@0x20 gates i32@0x18 + f32@0x1c (research boost spend + fraction); bool@0x2c gates bool@0x24 + i32@0x28; bool@0x3c gates f32@0x30,0x34,0x38; bool@0x6c gates a StreamableHelper frame on the member at 0x40. i32@0x04 (player id) is written first and unconditionally. (2) TWENTY-SEVEN std::list members at 0x70..0x1a8, stride 0x0c ({_Myhead, _Mysize, _Alval}), each passed to its own free-function writer as helper(stream, &list). Every one is written unconditionally, so an empty list still costs one zero int: 8 prologue items + 27 zero counts = the 35-item block every no-orders save carries", + "status": "verified", + "source": "lane Q 2026-09-08, instruction-stream read of dumps/sots.exe; item counts reproduce all 11 saves in verify/results/saves exactly (35/38/41/61/123 items)" + }, + { + "name": "TurnCommands_Read", + "addr": "0x00892ae0", + "convention": "thiscall", + "prototype": "void __thiscall Game::TurnCommands::Read(Mars::IStream* s) -- the reader paired with TurnCommands_Write (0x00842540). 1543 bytes; not decompiled by lane Q, listed so the pair is on the record", + "status": "mapped", + "source": "lane Q 2026-09-08, IStreamable vftable 0x00a25e00 slot pair" + }, + { + "name": "TurnCommands_WriteFleetMoveList", + "addr": "0x0083e550", + "convention": "cdecl", + "prototype": "void __cdecl (Mars::IStream* s, std::list* moves) -- writer for TurnCommands member +0xc4, the FLEET MOVE list. Per element: WriteInt(fleetId) then a NESTED counted int vector -- n = (v._Mylast - v._Myfirst) >> 2 written with WriteInt, then n ints. So a fleet move on the wire is {fleetId, hopCount, hopCount x systemId}, NOT a fixed {fleetId, 1, destSystemId, 0} quadruple: a multi-hop route is longer. Observed single-hop in human-turn2-orders.sav {1456, 1, [128]} and zuul-turn15-orders.sav {688, 1, [432]}", + "status": "verified", + "source": "lane Q 2026-09-08, instruction-stream read; corrects SAVE_FORMAT.md section 11's fleet-move element" + }, + { + "name": "TurnCommands_WriteBuildOrderList", + "addr": "0x00822870", + "convention": "cdecl", + "prototype": "void __cdecl (Mars::IStream* s, std::list* orders) -- writer for TurnCommands member +0x88, the BUILD ORDER list. Per element four WriteInts taken in DESCENDING member order (node+0x14, +0x10, +0xc, +0x8), so the wire order is {ordinal, designId, systemId, w}. Observed in zuul-turn15-orders.sav (5 orders, designs 608/576, system 384) and zuul-turn17-orders2.sav (20 orders, ordinals 6..25)", + "status": "verified", + "source": "lane Q 2026-09-08, instruction-stream read + UI cross-check from lane O's saves" + }, + { + "name": "TurnCommands_WriteSystemRatesList", + "addr": "0x0082e3d0", + "convention": "cdecl", + "prototype": "void __cdecl (Mars::IStream* s, std::list* cmds) -- writer for TurnCommands member +0xa0, the PLANETARY-BUDGET list. Per element WriteInt(systemId) then a StreamableHelper frame (helper vftable 0x00a1f884), which is the only NAMED sub-frame anywhere in the block (SRs SRt SRsc SRtf SRi SRoh SRnr). Observed once, in zuul-turn17-orders2.sav: system 384 with SRsc = 1.0", + "status": "verified", + "source": "lane Q 2026-09-08, instruction-stream read + RTTI vftable resolution" + }, + { + "name": "TurnCommands_WriteColonizeList", + "addr": "0x00822960", + "convention": "cdecl", + "prototype": "void __cdecl (Mars::IStream* s, std::list* orders) -- writer for TurnCommands member +0xb8, the COLONIZE list. Per element two WriteInts (node+0x8 then node+0xc): {shipId, w}. Observed once, in zuul-turn17-orders2.sav: three ships 2512/2544/2624, each with w = 1", + "status": "verified", + "source": "lane Q 2026-09-08, instruction-stream read + UI cross-check" + } + ] +} diff --git a/verify/save-reader/SAVE_FORMAT.md b/verify/save-reader/SAVE_FORMAT.md index d3e3218..7971dad 100644 --- a/verify/save-reader/SAVE_FORMAT.md +++ b/verify/save-reader/SAVE_FORMAT.md @@ -285,33 +285,45 @@ before End Turn** grow it (all five below are `--strict` clean and `coverage: PR | `zuul-turn17-orders2.sav` | 129 / 498 B | 20 build + 1 system-rates + 1 colonize (3 ships) | Body, as read off `zuul-turn15-orders.sav` / `zuul-turn17-orders2.sav` (all items `"."`-tagged, -so this is a positional record, not a named one): +so this is a positional record, not a named one). + +> **Corrected by lane Q, 2026-09-08**, from the writer's own instruction stream +> (`0x00842540`). The shape below is right about every command it observed, and wrong about two +> structural things: the fleet-move element ends in a **counted route vector**, not a fixed +> `{fleetId, 1, destSystemId, 0}` quadruple; and the "trailing zero words" are not padding but +> **twenty-seven command lists**, of which five have ever been non-empty. Full derivation and the +> per-list element records are in `findings/objects/turncommands-block.md`. ``` int playerId (16 = the human player's PID; matches CDT's "Player.00000016.…") -bool ? (True in every sample, orders or not) -float researchRate (0.25 default -> 0.97 after the empire Savings/Research slider) +bool hasResearchRateCmd ; if set, float researchRate follows + (True in every sample; 0.25 default -> 0.97 after the slider) bool hasResearchTargetCmd ; if set, int techId follows (191 = WEP_GrnLas) -bool hasResearchBoostCmd ; if set, int money follows (216383 -- exactly the - Imperial Savings delta the boost produced) -float ? (0.9992… when a research command is present, absent otherwise) -bool x3 = False (three further command-present flags, never seen set) -int x2 = 0 -int nBuildOrders ; nBuildOrders x { int ordinal; int designId; int systemId; int 0 } +bool hasResearchBoostCmd ; if set, int money then float follow (216383 -- exactly the + Imperial Savings delta the boost produced; float 0.9992…) +bool gate4 ; if set, bool + int follow (never seen set) +bool gate5 ; if set, three floats follow (never seen set) +bool gate6 ; if set, a CivilianRatios frame follows (never seen set) + +27 x { int count ; count x } -- ALL 27 always written, + so an empty list costs one zero int + + list 3 build orders { int ordinal; int designId; int systemId; int w } (ordinal is the running build-queue index, not 0-based per turn; designId 608 = DE Colonizer, 576 = DE Armor; systemId 384 = the home system that owns the queue) -int 0 -int nSystemRateCmds ; n x { int systemId; frame { float SRs; float SRt; float SRsc; - float SRtf; float SRi; float SRoh; int SRnr } } + list 5 system rates { int systemId; frame { float SRs; float SRt; float SRsc; + float SRtf; float SRi; float SRoh; int SRnr } } (SRsc = 1.0 is the Planetary Budget slider pushed fully to Construction; this is the only NAMED sub-frame in the block) -int 0 -int nColonizeOrders ; n x { int shipId; int 1 } -int nFleetMoveOrders ; n x { int fleetId; int 1; int destSystemId; int 0 } - … trailing zero words (the unused command lists) to the frame END + list 7 colonize { int shipId; int w } + list 8 fleet moves { int fleetId; int nHops; nHops x int systemId } + list 14 ? { int fleetId; int 0; bool True } -- observed once, alongside a move ``` +So the empty block is `1 + 1 + 1 + 5` (playerId, the rate gate and its float, five clear gates) +`+ 27` zero counts = **35 items**, which is why every no-orders save is bit-identical here. + Cross-checks that pin the decode: `384`/`432` resolve to `Sys` **Gallandro**/**Octans** in the same file, `688` to the `Flt` the UI showed as *Alpha Fleet* with `Dest: Octans`, and `216383` is the exact `Imperial Savings` drop the Boost Research panel caused. **The commands are pending, not @@ -319,5 +331,10 @@ applied**: in `zuul-turn15-orders.sav` the `ServerPlayer` still reads `ResRate 0 `Sav 2,902,722` while the block already carries rate 0.97 and the boost — the block is the client→server queue that `ProcessTurn` drains. -Not observed (the flags/lists that stayed zero in every sample): the three unset command-present -bools, and every list beyond the five above. +Not observed: the three unset gates (4, 5, 6 — the last being the empire civilian-settings +command), and the twenty-two lists other than 3, 5, 7, 8 and 14. Neither `noderoute` save carries +a fleet move, so the node-route UI does **not** queue through list 8. + +`save_reader.py` still reads `CD` frames generically (`any`), as it does for the `AIAgent` bodies — +see §8 of `findings/objects/turncommands-block.md` for why that was left alone. The typed decode +lives in the engine (`src/mars/stream/shapes.h`, `shapes::TurnCommands`).