sots-re/findings/subsystems/id-allocation.md
alex 975c19a57d lane ID: one id allocator on sixteen node counters, and techId is a sorted index
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.
2026-09-08 20:22:24 -04:00

16 KiB

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

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.