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.
7.8 KiB
techId -> tech name: an offline lookup, not a capture to transcribe
Lane ID, 2026-09-08. Host work only.
Closes row 8 of turn-command-replay.md §4 and the open value that kept Rung C-set's "we can name
all k" from closing.
282 is
XNC_TrnsHum2.
And it was already in our own notes: lane L4 observed it live in run R2
(ai-order-capture.md, the two-capture table — "player 512 target: XNC_TrnsHum2, techId 282").
Nobody connected it, and lane CB then produced 282 as an unmapped value. Earned rule 18's corollary,
paid for again: search the notes before the binary.
1. The rule
techIdis the 0-based index of the tech's name in the master tech list sorted by_stricmp— case-insensitively.
293 nodes in stock TechTree/MasterTechList.tech, so techId runs 0..292. That is a complete,
offline, data-derived mapping: no capture, no transcription, no VM. The replayer can stop
carrying the observed name in its own column.
Every observed point, including the two the rule was not fitted to:
| techId | name | how it was observed |
|---|---|---|
| 90 | DRV_PlsFiss |
RB / L4 capture, player 496 |
| 144 | IND_Waldo |
RB / L4 capture, player 32 |
| 282 | XNC_TrnsHum2 |
L4 ai-order-capture.md run R2, player 512 — independent of the fit |
| 288 | XNC_TrnsMorr2 |
RB / L4 capture, player 512 |
The rule was fitted on the three RB reported and then hit 282 on the nose against a live
observation taken days earlier by a different lane on a different run. It also reproduces L4's
structural claim about the family: the six-member XNC_Trns<Species>2 set is exactly
{282 Hum2, 284 Hvr2, 286 Lir2, 288 Morr2, 290 Trk2, 292 Zuul2}, interleaved with the …3
variants — which is why L4 saw two picks "six apart".
2. Read from the instruction stream, not fitted
MasterTechTree::MasterTechTree 0x0058b870, immediately after the LoadTechFile loop:
0058b9df lea eax,[ebx+0x14] ; the PARSE-ORDER list
0058b9e3 lea ecx,[ebx+0x24]
0058b9e6 call 0x8b3210 ; vector::operator= -- +0x24 is a COPY of +0x14
0058b9eb..0058b9ff
push pred / push count=(last-first)/4 / push last / push first
call 0x583b10 ; MSVC std::sort::_Sort
0058ba19 xor eax,eax
0058ba23 mov edx,[ebx+0x24]
0058ba26 mov ecx,[edx+eax*4] ; sortedList[i]
0058ba29 mov [ecx],eax ; **def->techId = i**
0058ba31 inc eax
0058ba37 jl 0x58ba23
Three things this settles that the fit could not.
It is a sorted COPY. +0x14 keeps parse order; +0x24 is the sorted one and the id is its
index. That is why the parse-order pointer-table dump in strategic-turn-internals.md
(turn3/techtable.txt, which starts IND_Waldo, IND_RefCoat, …) does not match the ids, and why
RB correctly concluded the ids "are not indices into anything we hold" — we held the wrong list.
The comparator is _stricmp, so the order is case-insensitive. It is inlined into
_Unguarded_partition FUN_00581130 at 0x00581190 and 0x005811b8 as
_stricmp(a->name, b->name) < 0, reading a std::string at TechDef+0x04 (SSO-tested at +0x14
against 0x10); the import at 0x009dd328 resolves to MSVCR100.dll _stricmp.
This matters. 48 of the 293 ids differ between byte-wise ASCII order and case-insensitive order
— the CCC_AI*/CCC_Adv* run at 21–28, DRN_COL/DRN_Cmbt, DRV_NodFoc/DRV_Node,
DRV_ROOT/DRV_Rad…, IND_DSCon/IND_Decon, IND_ROOT/IND_RefCoat, WEP_APRtech/WEP_Ac…,
WEP_KKMsl/WEP_KelTrp, WEP_MWMsl/WEP_Mas…, WEP_Nukes/WEP_NukMine, WEP_Pls*. None of
the four observed points discriminates, so this half is disassembly-only and the corpus cannot
confirm it. Flagged as such (earned rule 6): if a future capture ever reports a techId in one of
those 48 slots, it is a real test and it should be reported as one. No two names collide under
case-folding (checked over all 293), so std::sort's instability cannot bite.
Why sorted at all. The same +0x24 vector is the binary-search index: FUN_0057f120
(std::lower_bound) resolves every requires / allows name reference against it at 0x0058baed.
The id is the index into a lookup table that had to be sorted anyway.
3. The two tech id spaces, which is what made this look hard
| space | base | size | where | |
|---|---|---|---|---|
| wire / command | index into the _stricmp-sorted master list |
0 | 293 | TechDef+0x00; the research-target gate payload |
TechID enum |
index into MasterTechTree->resolved[] |
10000 | 196 | a {name, id} table in .rdata, CCC_AdvSens = 10001 |
MasterTechTree::GetTechDef 0x0057d610 and IsTech 0x0057d5d0 take the enum id:
if (id == 0xc5) return 0; id -= 10000; if ((unsigned)id > 0xc3) return 0; return resolved[id];
(0xc5 = 197 is the "no tech" sentinel.)
TechTree::HasResearched 0x0057d810 is the bridge and shows both in four instructions:
eax = enumId - 10000; eax = master->resolved[eax]; ; ENUM space -> TechDef*
eax = *eax; ; TechDef+0x00 = the WIRE techId
eax = this->+0x10[eax]; ; per-player node vector, indexed by WIRE id
return eax && eax->+0x14 == 4;
The enum space exists so that C++ code can name a specific tech; the wire space is a dense index
over all techs. TechTree::IsResearchable 0x0057e820 — called by the research-target gate applier
at 0x0088ff49 with the payload word straight off the command — bounds-checks against the length of
the 293-entry vector, which is what proves the payload is in the wire space.
4. The table, and how to regenerate it
tools/techid_table.py derives the whole mapping from the data file (or from the checked-in parse
oracle) and self-checks the four observed points:
uv run python3 tools/techid_table.py # prints all 293
uv run python3 tools/techid_table.py 282 288 144 90 # or just some
It is deliberately short: the rule is one sorted(key=str.lower) and a tool that obscured that
would be worse than no tool. It reads $SOTS_GOB_DIR/TechTree/MasterTechList.tech if that is set
(the same variable the engine's realdata_test uses) and otherwise the checked-in parse oracle, so
it needs no data tree. It refuses to print a table whose four observed points do not agree —
a short parse shifts every id after the gap, and the observed points are the only check it has.
Honest bound: the raw-.tech regex path is exercised here only against a synthetic three-block
fixture (both tech { and tech + newline + {); the numbers in this note all come through the
parse-oracle path.
5. What this lane did NOT do
- No live confirmation of its own. The 282 point is lane L4's observation, re-read. Nothing new ran. The strongest available further test is free: any future capture reporting a techId in the 48 sort-ambiguous slots.
- Only stock data. A mod adding a tech file renumbers every id after the insertion point, because the id is a sorted index over the union. That is a property of the original and a real hazard for anyone replaying a modded game; untested here.
- The second
LoadTechFileloop in the ctor (0x0058ba7e) was not read. If it can add nodes after 0x0058ba37, ids would be assigned before those nodes exist. Stock data has exactly one tech file (data-model.md) and the 293-entry fit holds, so it does not bite here — but it is unread, and it is the one way the rule could be incomplete. - The 196-entry
.rdataenum table was not dumped in full. It is at the address behindMasterTechTree::ResolveTechIds0x00581c10;scratchpad/techfx/techtable196.txthas an old dump of it (CCC_AdvSens = 10001,IND_Waldo = 10002, …). Note thatnvo-tshn-visible-owner.mdcallsCCC_AdvSens"tech id 10000"; the table says 10001. One of the two is off by one and this lane did not resolve which.