lane Q: TurnCommands_v5 reconciled and typed; SAVE_FORMAT section 11 corrected

findings/objects/turncommands-block.md -- why lane W's 44-item recovery and the
save's 35/38/41/61/123 items disagreed, in mechanism terms, plus the per-list
element records read off all 22 distinct helper writers.

Three separate causes, only one of which was a branch:
 * layouts.md is OFFSET-SORTED and Game::TurnCommands is one of the 89 classes
   whose offset order is not write order, because each gate bool sits after the
   payload it gates. Align saves against streams.json, never layouts.md.
 * the writer's tail is 27 std::list members, each written by its own helper as
   WriteInt(size) then size element records. The linear recovery keeps one item
   per CALL SITE, guesses its kind from an element field and drops the count.
   44 - 17 members = 27 = the list count.
 * 8 prologue items + 27 zero counts = the 35-item empty block, which is why
   every earlier save was bit-identical here.

Corrects lane O's section 11 in place: the fleet-move element is
{fleetId, nHops, nHops x systemId} -- a counted route vector, not a fixed
{fleetId, 1, destSystemId, 0}. Only the three-item reading makes the item totals
close (a four-item element needs 26 lists on human-turn2 and 28 on zuul-turn15;
the writer has 27). Also records that neither noderoute save carries a fleet
move, so the node-route UI does not queue through that list.

22 of the 27 lists are labelled hypotheses: read off the instruction stream, but
no save exercises them and no element value has ever been observed. List 14 is
observed but not understood -- issuing one fleet move also queues
{sameFleetId, 0, true} there.

save_reader.py deliberately left alone, with the reasoning written down: it reads
CD generically for the AIAgent bodies too, state_checksum's digest tree is built
from the generic tree so typing CD would add no named leaf to any diff, dispatch
by CDT ordinal would need new machinery in the oracle, and rule 8 is pointed at
exactly this move -- a layout mirrored into both readers from one reading is not
two checks. coverage: PROVED, 0 error 0 warn on all eleven saves, unchanged.

ghidra/addresses.d/lane-q.json: 6 entries (the writer, its Read pair, and the
four observed list helpers). Validated by generating to a scratch path; the
tracked header was not regenerated.
This commit is contained in:
alex 2026-09-08 09:03:26 -04:00
parent 49f1af466e
commit f9b744e2ed
3 changed files with 402 additions and 18 deletions

View file

@ -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<Game::CivilianRatios>` frame on `@0x40` | never observed set |
**(2) Twenty-seven `std::list<T>` 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 list's element record>
```
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<ShipDesignDef>` 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<StreamableEnum<uint>>` | 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.<id>.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<T>()` 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.

View file

@ -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.<id>.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<Game::CivilianRatios> frame on the member at 0x40. i32@0x04 (player id) is written first and unconditionally. (2) TWENTY-SEVEN std::list<T> 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<T>* 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<T>* 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<T>* cmds) -- writer for TurnCommands member +0xa0, the PLANETARY-BUDGET list. Per element WriteInt(systemId) then a StreamableHelper<Game::StarSystem::OutputRates> 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<T>* 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"
}
]
}

View file

@ -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 <that list's element record> } -- 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`).