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:
parent
49f1af466e
commit
f9b744e2ed
3 changed files with 402 additions and 18 deletions
315
findings/objects/turncommands-block.md
Normal file
315
findings/objects/turncommands-block.md
Normal 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.
|
||||
52
ghidra/addresses.d/lane-q.json
Normal file
52
ghidra/addresses.d/lane-q.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue