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.
315 lines
17 KiB
Markdown
315 lines
17 KiB
Markdown
# `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.
|