Compare commits
No commits in common. "94e8530a4a1c515f5ede6b18960e9fb7da832b77" and "49f1af466eab3067f32eeed008bcbd996e32c194" have entirely different histories.
94e8530a4a
...
49f1af466e
4 changed files with 19 additions and 415 deletions
|
|
@ -1,315 +0,0 @@
|
|||
# `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.
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||
// Source: sots-re ghidra/addresses.json @ f9b744e, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ 612bfe6, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
|
@ -1351,18 +1351,6 @@ constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0;
|
|||
constexpr uint32_t SNMAllCombatDone_layout = 0x00624758;
|
||||
// data const float 0.5f -- the progress-ratio threshold in ServerPlayer::ProcessTurn's `ResT != NULL && ResErrRoll != 0 && CONST < progressRatio` gate (events.md §3, window 0x008915ec-0x00891624). Read out of dumps/sots.exe: .rdata bytes at 0x00a2c788 are 00 00 00 3f (float 0.5); the following word 0x00a2c78c is float 100.0, so this is a float32, NOT the double an 8-byte read would suggest (that reads as 5.28e13). CONSEQUENCE, measured on the live game: ResErrRoll survives into ProcessResearch only while progress/cost <= 0.5 at the START of the turn, so the OnTechResearched draw (0x0088df20) can fire only when a single turn supplies more than half the target tech's remaining cost. See the board's `research_roll_pending save` row [verified]
|
||||
constexpr uint32_t ResearchRollProgressThreshold = 0x0062c788;
|
||||
// thiscall 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 [verified]
|
||||
constexpr uint32_t TurnCommands_Write = 0x00442540;
|
||||
// thiscall 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 [mapped]
|
||||
constexpr uint32_t TurnCommands_Read = 0x00492ae0;
|
||||
// cdecl 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]} [verified]
|
||||
constexpr uint32_t TurnCommands_WriteFleetMoveList = 0x0043e550;
|
||||
// cdecl 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) [verified]
|
||||
constexpr uint32_t TurnCommands_WriteBuildOrderList = 0x00422870;
|
||||
// cdecl 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 [verified]
|
||||
constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0;
|
||||
// cdecl 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 [verified]
|
||||
constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960;
|
||||
// thiscall void (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets [verified]
|
||||
constexpr uint32_t ServerPlayer_ProcessTurn = 0x00491340;
|
||||
// site site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64 [verified]
|
||||
|
|
|
|||
|
|
@ -285,45 +285,33 @@ 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).
|
||||
|
||||
> **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`.
|
||||
so this is a positional record, not a named one):
|
||||
|
||||
```
|
||||
int playerId (16 = the human player's PID; matches CDT's "Player.00000016.…")
|
||||
bool hasResearchRateCmd ; if set, float researchRate follows
|
||||
(True in every sample; 0.25 default -> 0.97 after the slider)
|
||||
bool ? (True in every sample, orders or not)
|
||||
float researchRate (0.25 default -> 0.97 after the empire Savings/Research slider)
|
||||
bool hasResearchTargetCmd ; if set, int techId follows (191 = WEP_GrnLas)
|
||||
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 }
|
||||
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 }
|
||||
(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)
|
||||
list 5 system rates { int systemId; frame { float SRs; float SRt; float SRsc;
|
||||
float SRtf; float SRi; float SRoh; int SRnr } }
|
||||
int 0
|
||||
int nSystemRateCmds ; n x { 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)
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
|
@ -331,10 +319,5 @@ 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 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`).
|
||||
Not observed (the flags/lists that stayed zero in every sample): the three unset command-present
|
||||
bools, and every list beyond the five above.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue