The client-side allocator is not a second allocator. StrategyServer and StrategyClient are both StrategySim, which owns an IDMap at +0x80; each sim allocates from its own map on its own local node index. StrategyServer::InitGameForPlayer sets that index to PlyrIdx + 1 (node 0 is the server's) and seeds the client from the server's counter for that node. IDMap::Initialize names the save's NM* tags: NMSz nodes, NMLc local node, NMnx that node's counter -- confirming B5's labelled hypothesis and adding the other two. Cross-checked on 20 saves: nodes 1, 2 and 3 all occur, counters run from 1 per node, and 2,600 ids collide zero times. Corrects turn-command-replay.md row 2: design 18 IS in turn2-state.sav, so the canonical pair needs one minted id, not two. One open item, with the one-hook probe named: a reloaded save produced fleet 34 rather than 18, and nothing I read restores a client counter. techId is the 0-based index into the master tech list sorted by _stricmp -- read out of MasterTechTree's ctor, which sorts a copy of the parse-order list and then writes def->techId = i. 282 is XNC_TrnsHum2, which lane L4 had already observed live and nobody connected. tools/techid_table.py derives all 293 offline and refuses to print unless the four observed points agree.
309 lines
16 KiB
Markdown
309 lines
16 KiB
Markdown
# Object id allocation: one allocator, sixteen node counters, and why the client's ids are free
|
|
|
|
Lane ID, 2026-09-08. **Host work only — no VM run was needed and none was taken.** VM140 was held
|
|
and released untouched; its `SavedGames` set is byte-identical to the oracle (§8).
|
|
|
|
Closes row 2 of `turn-command-replay.md` §4 — "the client's id allocator" — which lane RB flagged as
|
|
"a watchpoint, not a week of reading". It turned out to be neither: it is arithmetic on ids the
|
|
corpus already held, confirmed against the instruction stream and cross-checked on twenty saves.
|
|
|
|
Predictions were written before any of the work below and are quoted verbatim where they were
|
|
falsified (§3.2). Consumes: `combat-retreat-pipeline.md` (B5, `IDMap::AllocateID`),
|
|
`turn-command-replay.md` (RB), `ai-order-emission.md` (AI4), `struct-recovery.md`.
|
|
|
|
---
|
|
|
|
## 0. Lead: there is no second allocator
|
|
|
|
RB's gap list says "**two id spaces**, and the small one is part of the wire protocol". There are
|
|
two id *spaces*, and RB is right that a reimplementation which allocates on apply gets every
|
|
AI-created id wrong. But there is **one allocator, one implementation, and one formula** — B5's
|
|
`IDMap::AllocateID` 0x008b8ae0, `id = (counter << 4) | (node & 0xF)`.
|
|
|
|
What differs is **which node's counter** is used, and that is a property of the *sim object*, not of
|
|
the code:
|
|
|
|
> `Game::StrategyServer` and `Game::StrategyClient` are **both** `Game::StrategySim`
|
|
> (RTTI: `StrategyClient` bases `[StrategyClient, StrategySim]` at offset 0; `StrategyServer` bases
|
|
> `[StrategyServer, IStreamable, StrategySim]` with vftables at offset 0 **and** offset 4 — that is
|
|
> the "two bases 4 bytes apart" of earned rule 1, and the +4 one is the `StrategySim` subobject).
|
|
>
|
|
> `StrategySim` owns an `IDMap` at **`StrategySim + 0x80`** — i.e. `StrategyServer + 0x84`,
|
|
> `StrategyClient + 0x80`. Every sim allocates from **its own** map, on **its own** local node
|
|
> index at `IDMap + 0x18`.
|
|
|
|
So the object-creation code is base-class code that runs unchanged on both sides. The whole
|
|
client-allocates/server-honours protocol is one branch in `StrategySim::CreateDesign` 0x008827e0:
|
|
|
|
```
|
|
008828b3 mov eax,[ebp+0x10] ; explicitId, the third argument
|
|
008828b6 test eax,eax
|
|
008828b8 jne 0x8828c5 ; non-zero -> use the id the command carried
|
|
008828ba lea ecx,[edi+0x80] ; else: this sim's own IDMap
|
|
008828c0 call 0x8b8b70 ; IDMap::AllocateOnLocalNode
|
|
008828c5 ... call 0x8b9350 ; IDMap::Insert(map, design+0xa0, id)
|
|
```
|
|
|
|
The client calls it with `explicitId = 0` and mints; the server calls it with the id off the wire
|
|
and mints nothing.
|
|
|
|
## 1. The arithmetic, which is the whole result
|
|
|
|
B5's formula, run backwards on RB's numbers:
|
|
|
|
| id | | counter | node |
|
|
|---:|---|---:|---:|
|
|
| **18** design | *client-allocated* | **1** | **2** |
|
|
| **34** fleet | *client-allocated* | **2** | **2** |
|
|
| 1712 | from the master counter | 107 | 0 |
|
|
| 1728 | ship in the input save | 108 | 0 |
|
|
| 1776 | | 111 | 0 |
|
|
| 272, 288 systems; 32, 496, 512 players | | 17, 18, 2, 31, 32 | 0 |
|
|
|
|
Every id that comes out of a save carries node nibble **0**. Both client-allocated ids carry node
|
|
nibble **2**, with counters **1** and **2** — the first two ids that node ever issued.
|
|
|
|
### 1.1 On twenty saves, four games, three distinct client nodes
|
|
|
|
Nibble histogram over `FltID`/`DesID`/`ShipID`/`SysID`/`PlayerID` in the whole corpus
|
|
(`verify/results/saves/*.sav`, read with `verify/save-reader/save_reader.py --dump`):
|
|
|
|
| save | ids | nibbles | the non-zero ones |
|
|
|---|---:|---|---|
|
|
| `turn1-state` | 98 | `{0: 98}` | — |
|
|
| `turn2-state` | 102 | `{0: 101, 2: 1}` | 18 |
|
|
| `turn3-state` | 104 | `{0: 102, 2: 2}` | 18, 34 |
|
|
| `cb-turn2to3-autosave` | 104 | `{0: 102, 2: 2}` | 18, 34 |
|
|
| `human-turn3-noderoute` | 90 | `{0: 86, 2: 4}` | 18, 34, 50, 66 |
|
|
| `human-turn8-traderoutes` | 151 | `{0: 141, 2: 10}` | 18 … 242 |
|
|
| `human-turn15-spyprogram` | 295 | `{0: 252, **1: 1**, 2: 42}` | **17**, 18 … 818 |
|
|
| `zuul-turn15-orders` | 150 | `{0: 139, 2: 10, **3: 1**}` | 18 … 162, **19** |
|
|
| `zuul-turn17-orders2` | 167 | `{0: 151, 2: 14, **3: 2**}` | 18 … 226, **19, 35** |
|
|
| `zuul-turn23-fleet23` | 222 | `{0: 198, 2: 22, 3: 2}` | … |
|
|
|
|
Three facts fall straight out, and none of them needed a hook.
|
|
|
|
1. **Nodes 1, 2 and 3 all occur.** "The client" is not singular. Each node's counters run
|
|
**1, 2, 3, …** contiguously from the start of the game — node 2's first id is `18` in *every*
|
|
save in the corpus, node 3's first is `19`, node 1's first is `17`.
|
|
2. **The partition is absolute.** ~2,600 ids across four games, zero collisions between spaces.
|
|
That is the entire purpose of the nibble and of `IDMap::Initialize`'s `numNodes <= 0x10` cap.
|
|
3. **Node = owning player's index + 1.** Every node-nibble object belongs to exactly one player:
|
|
|
|
| save | id | owner `PID` | that player's `PlyrIdx` | node |
|
|
|---|---:|---:|---:|---:|
|
|
| `turn3-state` | design 18, fleet 34 | 32 | 1 | 2 |
|
|
| `human-turn15` | design 17 | 16 | 0 | 1 |
|
|
| `human-turn15` | design 18 | 32 | 1 | 2 |
|
|
| `zuul-turn17` | designs 18, 34, … | 32 | 1 | 2 |
|
|
| `zuul-turn17` | designs 19, 35 | 496 | 2 | 3 |
|
|
|
|
## 2. The node index, read from the instruction stream
|
|
|
|
`StrategyServer::InitGameForPlayer` 0x007c8d90 builds the CreateGame message for one player's
|
|
client. Four instructions do the assignment:
|
|
|
|
```
|
|
007c8ddf mov esi,[eax+0x28] ; player->PlyrIdx
|
|
...
|
|
007c8ecb cmp esi,0xffffffff
|
|
007c8ece je 0x7c8ed3
|
|
007c8ed0 inc esi ; localNode = PlyrIdx + 1
|
|
007c8ed1 jmp 0x7c8ed5
|
|
007c8ed3 xor esi,esi ; PlyrIdx == -1 -> localNode = 0
|
|
007c8ed5 ... [ebx+0x90] - [ebx+0x8c], /20 ; numNodes = the server map's node count
|
|
007c8ef2 mov [ebp-0x134],ecx ; msg->numNodes
|
|
007c8ef8 push esi
|
|
007c8ef9 lea ecx,[ebx+0x84] ; the SERVER's IDMap
|
|
007c8eff mov [ebp-0x130],esi ; msg->localNode
|
|
007c8f05 call 0x8b8b80 ; IDMap::GetNodeCounter(server map, localNode)
|
|
007c8f1a mov [ebp-0x12c],eax ; msg->startId
|
|
```
|
|
|
|
The message is `{numNodes, localNode = PlyrIdx + 1, startId = the server's counter for that node}`.
|
|
The client receives it as **case 0** of `StrategyClient::RaiseEvent` 0x00783ee0 (jump table
|
|
0x00784200, handler 0x00783f05) and `StrategySim::OnCreateGame` 0x00776f20 calls
|
|
`IDMap::Initialize` on its own map at `+0x80` with exactly those three words.
|
|
|
|
So `PlyrIdx + 1` is not an inference from the saves any more. The saves and the disassembly agree,
|
|
which is the only reason either is worth reporting.
|
|
|
|
## 3. Seeding: what a save carries, and what it cannot
|
|
|
|
`IDMap::Initialize(numNodes, localNode, startId)` 0x008b9ad0, `ret 0xc`:
|
|
|
|
* refuses `numNodes > 0x10` — **sixteen nodes, and that is the four-bit nibble**;
|
|
* refuses `localNode` outside `[0, numNodes)`;
|
|
* resizes the 0x14-stride node vector, then **zeroes every node's counter** (0x008b9b20);
|
|
* writes `startId` into **`nodes[localNode].counter` alone** (0x008b9b38);
|
|
* stores `localNode` at `this->+0x18` (0x008b9b3d).
|
|
|
|
### 3.1 The save's `NM*` tags are now named
|
|
|
|
`StrategyServer::Read` 0x007d27a0 reads three ints and passes them straight in:
|
|
|
|
```
|
|
NMSz -> numNodes NMLc -> localNode NMnx -> startId
|
|
```
|
|
|
|
That **confirms and extends B5's labelled hypothesis** ("the counter is almost certainly NMnx").
|
|
It is NMnx, it is the counter *for node NMLc only*, and the two tags beside it are the node count
|
|
and the local node index. Corpus:
|
|
|
|
| save | NMSz | NMLc | NMnx | ModCount | Frame |
|
|
|---|---:|---:|---:|---:|---:|
|
|
| `turn1-state` | 16 | 0 | 106 | 0 | 1 |
|
|
| `turn2-state` | 16 | 0 | 109 | 12 | 2 |
|
|
| `turn3-state` | 16 | 0 | **111** | 24 | 3 |
|
|
|
|
`StrategyServer::LoadGame` 0x007dd530 sets a fresh game up with `Initialize(16, 0, 0)`, which is
|
|
where `NMSz 16` / `NMLc 0` come from and why the host's ids all carry nibble 0.
|
|
|
|
**A correction to `turn-command-replay.md` §4 row 2** (earned rule 11). It says the server "issued
|
|
1712 and 1776 the same turn", and that design 18 and fleet 34 are "objects that do not exist in the
|
|
input save". Neither is quite right on the canonical pair:
|
|
|
|
* 1712 is counter **107**, issued during turn **1→2** (NMnx 106 → 109 covers counters 107, 108, 109
|
|
= ids 1712, 1728, 1744). 1776 is counter 111, issued during turn **2→3** (109 → 111).
|
|
* **Design 18 is present in `turn2-state.sav`.** It is in the master `DesignIDs` list and in player
|
|
32's block, and it was allocated during turn 1→2. Only **fleet 34** is new on the canonical pair.
|
|
So the turn-2→3 build order names an id the input save already holds, and a faithful replay of
|
|
that pair has to mint exactly **one** id, not two. That is a materially smaller gap than the row
|
|
claims.
|
|
|
|
Everything else in the row stands, including the load-bearing part: allocate on apply and you get
|
|
`1792` where the original has `34`.
|
|
|
|
### 3.2 A falsified prediction, and the open item it leaves
|
|
|
|
Prediction ID-P3 said, in full:
|
|
|
|
> If NMLc = 0 holds, then the node-2 counter that issued 18 and 34 is NOT in the save, is NOT
|
|
> restored by Initialize (which zeroes it), and therefore **restarts at zero on every load**.
|
|
|
|
NMLc is 0, and the first two clauses are read directly out of the code: no save carries a per-node
|
|
array, `Initialize` zeroes the rest, `IDMap::Insert` 0x008b9350 **never touches a counter** (read to
|
|
the end: it logs a duplicate, refuses id 0, bounds-checks `id & 0xF`, writes the id into `obj->+4`
|
|
and inserts into `nodes[n]`'s map — no counter write), and `InitGameForPlayer` seeds a client from
|
|
`GetNodeCounter`, which therefore returns 0 for every client node in a freshly loaded game.
|
|
|
|
**The third clause is falsified by a save already in the corpus.** `cb-turn2to3-autosave.sav` is a
|
|
fresh process that *loaded* `turn2-state.sav` (which contains design 18 = node 2, counter 1) and
|
|
pressed End Turn. The fleet it created is **34 — counter 2**, not 18. If the client's counter had
|
|
started at 0 the fleet would have been 18, colliding with a design that already exists.
|
|
|
|
Two explanations survive, and this lane cannot separate them without a hook:
|
|
|
|
* **(A)** something restores the server's node-2 counter to 1 during the load, and the client is
|
|
seeded with it. I read `Initialize`, `Insert`, `AllocateID`, `FindNode`, `GetNodeCounter` and
|
|
`InitGameForPlayer` and found no such write. Absence in six functions is not absence.
|
|
* **(B)** the counter really does start at 0 and the client made **one earlier allocation that turn
|
|
which never reached the save** — a local design object, a task, an order — taking counter 1, so
|
|
the fleet got counter 2. This fits the read code exactly and requires nothing unread.
|
|
|
|
**The probe is one hook and it is cheap** (earned rule 18, and rule 20 — instrument the *entry*):
|
|
detour `IDMap::AllocateID` 0x008b8ae0 and log `(this, nodeIndex, resulting id, return address)` for
|
|
one `turn2-state → turn3-state` End Turn. (A) predicts the client's very first allocation that turn
|
|
returns 34; (B) predicts it returns 18 from a site that is not the fleet creator. **A count alone
|
|
cannot separate them** — that is exactly the shape rule 20 exists for.
|
|
|
|
Whichever it is, the consequence for a reimplementation is the same and is stated in §5.
|
|
|
|
### 3.3 The collision hazard, stated as a hypothesis
|
|
|
|
If (B) is right, then a client's counter is a pure function of **allocation order within the
|
|
process** and a save/load loses it. Load a save in which a client has already allocated *n* objects
|
|
and let it allocate again, and it re-issues `(1<<4)|k` — an id the map already holds. `IDMap::Insert`
|
|
logs `IDMap: Object already exists with id %d.` and **inserts anyway** (0x008b9367: the log is
|
|
followed by fall-through, not a return). That would be an original defect, not ours.
|
|
|
|
Labelled hypothesis. The same one-hook probe settles it, and `human-turn15-spyprogram.sav` — node 2
|
|
at counter 51 — is the workload that would make it loud.
|
|
|
|
## 4. Layout, for the record
|
|
|
|
```
|
|
IDMap (StrategySim + 0x80)
|
|
+0x08 / +0x0c / +0x10 vector<NodeEntry>, STRIDE 0x14 (allocator +0x10, rule 5)
|
|
+0x18 int localNodeIndex
|
|
NodeEntry 0x14 bytes
|
|
+0x00 .. +0x0f std::map<int, void*> id -> object, for THIS node
|
|
+0x10 int counter pre-incremented; 0 is never issued
|
|
```
|
|
|
|
`AllocateID(node)`: 0 if `node == -1` or the node does not exist; else `++nodes[node].counter`
|
|
(pre-increment, and on wrap to 0 it logs and increments again, so `INVALID_NETWORK_ID` is never
|
|
handed out); `id = (counter << 4) | (node & 0xF)`, with an overflow log if the counter no longer
|
|
round-trips.
|
|
|
|
## 5. What a faithful replay has to do
|
|
|
|
1. **A per-node counter array, not one counter.** Sixteen of them.
|
|
2. **Node 0 for the sim that owns the board; node `PlyrIdx + 1` for each player's client.**
|
|
3. **Restore only node `NMLc`'s counter from `NMnx`.** There is nothing else in the save to
|
|
restore, and inventing a per-node array in the save format would diverge from the original.
|
|
4. **Mint the id at command *emission*, not at apply**, and carry it in the command — the applier
|
|
must honour a non-zero id and mint only on zero (`StrategySim::CreateDesign`'s branch).
|
|
5. **Do not model the client counter as a function of the save.** It is a function of allocation
|
|
order in the process, and until §3.2 is settled a replay of the canonical pair should take the
|
|
client's ids from the capture rather than derive them.
|
|
|
|
For the canonical pair specifically, (4) plus "design 18 is already in the input save" means the
|
|
replay needs exactly one minted id, `34`, and it is the *second* thing node 2 allocates that turn.
|
|
|
|
## 6. What this lane did NOT do
|
|
|
|
1. **No hook, no VM run, no new instrument.** Every number is from the checked-in corpus and the
|
|
image. That is also the limit: §3.2 is open precisely because nothing ran.
|
|
2. **The second `LoadTechFile` loop and the message *send* were not read.** I read
|
|
`InitGameForPlayer` up to the point where the message is filled, and `OnCreateGame` from the
|
|
point where it is received; the transport between them is assumed, not read. It is a single
|
|
process, so nothing observable depends on it — but it is not read.
|
|
3. **`FUN_008a9030`'s counter at `+0x23c` is a different mechanism** (a Mars scene object with its
|
|
own `IDMap` at `+0x68`, `Initialize(1, 0, 0)`, and a separate per-object counter gated on
|
|
`obj->+0x20 & 0xF == 2`). It is not game state and it is not this. Recorded so the next lane
|
|
that greps for `& 0xF` does not chase it.
|
|
4. **Multiplayer is untested.** Every save in the corpus is `NMLc 0`. A real client machine would
|
|
save with `NMLc = k` and its own counter, and *the server's* copy of that counter is the thing
|
|
§3.2 is about. Nothing here has been exercised across two machines.
|
|
5. **`ShipID` allocation was not traced to its site.** Ships in the corpus are all node 0, so they
|
|
are minted server-side; I did not read which function does it.
|
|
|
|
## 7. Reproducing every number here
|
|
|
|
```sh
|
|
cd ~/sots-re
|
|
uv run python3 verify/save-reader/save_reader.py --dump verify/results/saves/turn3-state.sav \
|
|
| grep -E 'NMSz|NMLc|NMnx'
|
|
# nibble histogram: see the one-screen script in section 1.1's method note below
|
|
uv run python3 dumps/b6dis.py 0x008b9ad0 # IDMap::Initialize
|
|
uv run python3 dumps/b6dis.py 0x007c8d90 # InitGameForPlayer; the +1 is at 0x007c8ed0
|
|
```
|
|
|
|
The histogram is `re.match(r'\s*@[0-9a-f]+\s+(\S+)\s+int\??\s+(-?\d+)', line)` over the dump,
|
|
keeping the five id tags and counting `value & 15`. No tool was added for it — it is four lines and
|
|
a tool would only hide what it does.
|
|
|
|
## 8. VM140 released
|
|
|
|
The game was never launched and nothing was written. `C:\SOTS\SavedGames` verified over SSH before
|
|
release — **8 files**, and the three oracle hashes match:
|
|
|
|
| file | bytes | sha256[0:16] | |
|
|
|---|---:|---|---|
|
|
| `(Autosave).sav` | 67,219 | `978041acd168b56e` | ✅ oracle |
|
|
| `(Autosave EndTurn).sav` | 66,732 | `bb4fd9ac89f41e3b` | ✅ oracle |
|
|
| `(Autosave Backup).sav` | 67,219 | `978041acd168b56e` | ✅ oracle |
|
|
| `MyGameverify1rtD.sav` | 66,732 | `bb4fd9ac89f41e3b` | |
|
|
| `MyGameverify1rtE.sav` | 66,732 | `bb4fd9ac89f41e3b` | |
|
|
| `MyGameverify1verify1.sav` | 66,732 | `bb4fd9ac89f41e3b` | |
|
|
| `ref-turn2.sav` | 66,739 | `ab4ac2d7e2977260` | |
|
|
| `zuul-turn5.sav` | 59,131 | `48559ab5b719b332` | |
|
|
|
|
Note for the next VM lane: a filename with parentheses cannot be hashed through
|
|
`ssh … 'certutil -hashfile "…(Autosave).sav"'` — `cmd.exe` eats the parens and returns
|
|
"Check the spelling", which reads exactly like a missing file. Use
|
|
`powershell -NoProfile -c "(Get-FileHash -LiteralPath '…' -Algorithm SHA256).Hash"`. My first
|
|
attempt reported the three oracle files as absent and they were never absent.
|
|
|
|
**Please mark VM140 free.**
|