sots-re/findings/subsystems/techid-name-map.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

7.8 KiB
Raw Blame History

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

techId is 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

  1. 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.
  2. 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.
  3. The second LoadTechFile loop 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.
  4. The 196-entry .rdata enum table was not dumped in full. It is at the address behind MasterTechTree::ResolveTechIds 0x00581c10; scratchpad/techfx/techtable196.txt has an old dump of it (CCC_AdvSens = 10001, IND_Waldo = 10002, …). Note that nvo-tshn-visible-owner.md calls CCC_AdvSens "tech id 10000"; the table says 10001. One of the two is off by one and this lane did not resolve which.