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.
92 lines
3.9 KiB
Python
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:]))
|