sots-re/findings/subsystems/id-allocation.md
alex c60ee36a0e lane ID: zero-residual id account for the canonical turn, and the collision story
Diffing the four master id lists in the Sim block accounts for every object created on
turn 2->3: ship 1760 (server counter 110), fleet 1776 (111), fleet 34 (client node 2,
counter 2), and DesignIDs unchanged at 43 -- so no design was created that turn and
1776 is a fleet, not a design. Sharpens the correction to turn-command-replay.md row 2.
Also works out what explanation (B) implies for CB's reloaded run: the client re-issues
18, collides in its own map, and the autosave still matches because only the server's
state is serialized.
2026-09-08 20:26:44 -04:00

334 lines
18 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 right on the canonical pair, and the master id lists say so exactly.
`turn2-state → turn3-state` moves NMnx **109 → 111**, so the server issued counters **110 and 111**
and nothing else. Diffing the four master lists in the `Sim` block gives a **zero-residual account
of every object created on the reference turn** — the id-level counterpart of RB's `ModCount = 24`:
| id | counter | node | list | what |
|---:|---:|---:|---|---|
| **1760** | 110 | 0 | `ShipID` 16 → 17 | the ship the build order built |
| **1776** | 111 | 0 | `FleetIDs` 7 → 8 | a server-created fleet |
| **34** | 2 | **2** | `FleetIDs` 7 → 8 | **the client-created fleet** |
| — | — | — | `DesignIDs` **43 → 43** | **no design was created that turn** |
| — | — | — | `PlayerIDs` 8, `SystemIDs` 28 | unchanged |
Three objects, three ids, and the counters on both nodes are contiguous with no gaps. So:
* 1712 is counter **107** and was issued during turn **1→2**, not 2→3 (NMnx 106 → 109 covers
counters 107, 108, 109 = ids 1712, 1728, 1744). 1776 is a **fleet**, not a design.
* **Design 18 is already in `turn2-state.sav`** — in the master `DesignIDs` list (43 entries, and
still 43 at turn 3) and in player 32's block. It was allocated during turn 1→2. The turn-2→3
build order therefore names a design the input save already holds, and **only fleet 34 is new**.
A faithful replay of the canonical pair mints exactly **one** client 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.
**(B) has a specific form that fits everything, including the master-list diff above.** In the
*continuous* reference session (turn 1 → 3 in one process, which is where `turn2-state` and
`turn3-state` come from) the client allocated 18 for its design in turn 1→2 and 34 for its fleet in
turn 2→3 — counters 1 then 2, no gap, nothing extra needed. In CB's *reloaded* run the client's
counter started at 0 again, so its first allocation of the turn would be counter 1 = **18**, an id
the loaded board already holds; `IDMap::Insert` logs and inserts anyway; the fleet then takes
counter 2 = 34. On that reading **the collision has already happened, in a run whose autosave
matched the oracle** — which is exactly what you would expect, because the colliding object lives in
the *client's* map and only the server's state is serialized. It also predicts what the earlier
allocation was: whatever the AI client makes each turn before it makes a fleet.
That is a story, not a measurement. **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.**