# `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_Trns2` 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: ```sh 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.