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

146 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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