sots-re/tools/techid_table.py
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

92 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""techId -> tech name, derived offline from the tech tree data.
The wire techId is the 0-based index of the tech's name in the master tech list sorted with
_stricmp -- case-insensitively. Read from MasterTechTree's constructor: it copies the parse-order
list to a second vector, std::sort()s that copy with an inlined `_stricmp(a->name, b->name) < 0`,
then walks it writing `def->techId = i`. See findings/subsystems/techid-name-map.md.
This is NOT the 10000-based TechID enum, which is a separate 196-entry .rdata table.
Usage:
tools/techid_table.py # all of them
tools/techid_table.py 282 288 144 90 # just these
"""
import glob
import json
import os
import re
import sys
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
# Four (techId, name) pairs observed live, by two lanes on different runs. 282 is the one the
# rule was NOT fitted to -- it comes from lane L4's run R2 (findings/subsystems/ai-order-capture.md)
# and is the check that this file is right rather than merely self-consistent.
OBSERVED = {90: "DRV_PlsFiss", 144: "IND_Waldo", 282: "XNC_TrnsHum2", 288: "XNC_TrnsMorr2"}
# Where the 293 names can come from, in order of preference: the raw game file under
# $SOTS_GOB_DIR (the same variable the engine's realdata_test uses), then the checked-in parse
# oracle in sots-engine's tests, which needs no data tree at all.
SOURCES = [p for p in [
os.path.join(os.environ["SOTS_GOB_DIR"], "TechTree", "MasterTechList.tech")
if os.environ.get("SOTS_GOB_DIR") else None,
os.path.join(ROOT, "..", "sots-engine", "tests", "mars_parse", "build", "oracle",
"TechTree", "MasterTechList.tech.json"),
] if p]
def names_from_tech_file(path):
"""Pull every `tech { name "X" ... }` block name out of the raw .tech file."""
text = open(path, encoding="latin-1").read()
return re.findall(r'^\s*tech\b[^\n]*\n(?:[^\n]*\n)*?\s*name\s+"([^"]+)"', text, re.M)
def names_from_oracle(path):
return [t["name"] for t in json.load(open(path))["tech"]]
def load_names():
for pat in SOURCES:
for path in sorted(glob.glob(pat)):
if os.path.exists(path):
return (names_from_oracle(path) if path.endswith(".json")
else names_from_tech_file(path)), path
for path in sorted(glob.glob(os.path.join(ROOT, "**", "MasterTechList.tech"), recursive=True)):
return names_from_tech_file(path), path
sys.exit("no MasterTechList.tech (or its parse oracle) found; see SOURCES in this file")
def table():
names, src = load_names()
if len(set(n.lower() for n in names)) != len(names):
sys.exit("case-folded duplicate tech names: the sorted order is not total, stop")
# _stricmp order. str.lower reproduces it exactly for these pure-ASCII names.
return sorted(names, key=str.lower), src
def main(argv):
ids, src = table()
bad = [(i, want, ids[i] if i < len(ids) else "<out of range>")
for i, want in OBSERVED.items() if i >= len(ids) or ids[i] != want]
if bad:
print(f"# {len(ids)} names parsed from {src}", file=sys.stderr)
for i, want, got in bad:
print(f"MISMATCH techId {i}: observed {want!r}, derived {got!r}", file=sys.stderr)
print("The observed points are the only check this file has. Do not trust the table "
"until they agree -- a short parse (a missed block) shifts EVERY id after it.",
file=sys.stderr)
return 1
print(f"# {len(ids)} techs from {os.path.relpath(src, ROOT)}; "
f"{len(OBSERVED)}/{len(OBSERVED)} observed points agree", file=sys.stderr)
wanted = [int(a) for a in argv] if argv else range(len(ids))
for i in wanted:
if 0 <= i < len(ids):
print(f"{i:4d} {ids[i]}{' <- observed' if i in OBSERVED else ''}")
else:
print(f"{i:4d} <out of range 0..{len(ids) - 1}>")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))