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.
146 lines
7.8 KiB
Markdown
146 lines
7.8 KiB
Markdown
# `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.
|