#!/usr/bin/env python3 """Turn a live `aiorders` shim dump into a `.tcb` turn-command capture. The shim's dump is a memory view: for each submitted block it prints the six prologue gates, the twenty-seven list lengths, and a fixed 48-byte window of every element. That is the right thing for an instrument to emit -- it commits to nothing -- but it is not a turn record, because the fields are in MEMORY order (and one list's writer runs them backwards), the floats are still integers, and a payload behind a pointer is simply absent. This is the mechanical step in between. It applies the per-list field mapping, reinterprets the words the element record says are floats, converts a vector's begin/end pair into a length, and writes `?` wherever the dump window could not reach. The output is what `sots_turn --turn-commands` reads. tools/aiorders_to_tcb.py LOG [-o OUT] [--batch SEQ] [--name PID=TECHNAME]... [--seed ID=HEX]... Two things this tool deliberately will not do. * It will not invent a payload. A route whose hops live behind a pointer becomes `vN` -- a vector of known length and unknown contents -- and never `v1:0`. The replayer counts such a command and refuses to apply it, which is the whole point: a command that was issued and a command whose effect we can reproduce are different facts. * It will not pick a batch for you when the choice is ambiguous. A process applies a command batch at LOAD as well as at End Turn; replaying the load-time one against a save that was written after it would double-count. The default is the LAST batch in the log, which is the End-Turn one, and `--batch` overrides it. `--name PID=TECHNAME` records the tech NAME an instrument observed a research-target gate resolve to. The wire carries an integer id and the save carries a name; the client resolves one to the other off the command and that map is unread, so the name can only be recorded, never computed. `--seed NETID=VALUE` records an AI client's construction seed. """ import argparse import re import struct import sys # Per list: how the 48-byte memory window maps onto the element's wire fields. # # `order` is the sequence of word indices to emit; `kind` says how to read each one. The build # list is the only one that reverses -- its writer emits +0x14, +0x10, +0x0c, +0x08, descending -- # and that is per-list, not a rule. Every other observed list writes ascending. # # i an int word # f a word to reinterpret as a float # b a word whose low byte is a bool # v a std::vector: this word and the next hold begin/end, and the length is their difference # over four. The CONTENTS are behind the pointer and the dump does not follow it, so the # field is emitted with a length and no values. # ? a word the element record says is part of an object this window cannot read LIST_MAP = { 1: [("?", 0)], # a ShipDesignDef object 3: [("i", 3), ("i", 2), ("i", 1), ("i", 0)], # DESCENDING: ordinal, design, system, w # List 5 carries a system id and then the seven planetary-budget sliders. The id is certain; # THE SLIDERS ARE NOT, and this used to map them straight onto the wire order and was wrong. # A replay built on that mapping wrote the AI's one non-zero slider into the wrong member and # regressed two leaves on the canonical pair -- the command re-issues the rates the save # already holds, so a correct applier is a no-op and an incorrect one is immediately visible. # # What is known: the only non-zero word in every dumped element of this list is at index 2, # and the same command on the WIRE (where the frame is named) puts its only non-zero in # `SRsc`, the THIRD member. So memory member 1 is wire member `SRsc` and the frame's memory # order is not its wire order -- one correspondence pinned, six unread. The `?` below is # that ignorance, and the replayer counts such a command and refuses to apply it. # # The experiment that settles it is one UI run: push two DIFFERENT sliders to two different # values, capture the block, and read the permutation straight off. Cheaper still, a save # taken after issuing rates carries the same command on the wire with every field NAMED, so a # capture converted from a save's own TurnCommands block needs no memory mapping at all. 5: [("i", 0)] + [("?", k) for k in range(1, 8)], 7: [("i", 0), ("i", 1)], 8: [("i", 0), ("v", 1)], 10: [("i", 0), ("i", 1), ("v", 2)], 14: [("i", 0), ("i", 1), ("b", 2)], 23: [("i", 0), ("?", 1)], # a Population object } BLK_RE = re.compile( r"^aiblk seq=(\d+) blk=(\d+)/(\d+) at=\S+ pid=(-?\d+) " r"rate=(\d):(\S+) target=(\d):(-?\d+) boost=(\d):(-?\d+),(\S+) g4=(\d):(-?\d+),(-?\d+) " r"f3=(\d):(\S+),(\S+),(\S+) civ=(\d)") LISTS_RE = re.compile(r"^ailists seq=(\d+) blk=(\d+) pid=(-?\d+) nonEmpty=\d+ sizes\(1\.\.27\)=\[([^\]]*)\]") ELEM_RE = re.compile(r"^aielem blk=(\d+) pid=(-?\d+) list=(\d+) idx=(\d+) .*? hex=\[([^\]]*)\]") def f32(word): """A dumped word, as the float32 it is, printed so it round-trips exactly.""" return "%.9g" % struct.unpack(" {seq: {"n": int, "blocks": {idx: block}}}""" batches = {} def batch(seq): return batches.setdefault(seq, {"n": 0, "blocks": {}}) with open(path, encoding="utf-8", errors="replace") as f: for line in f: m = BLK_RE.match(line) if m: seq, idx, n = int(m.group(1)), int(m.group(2)), int(m.group(3)) b = batch(seq) b["n"] = max(b["n"], n) b["blocks"][idx] = { "pid": int(m.group(4)), "rate": (m.group(5) == "1", m.group(6)), "target": (m.group(7) == "1", int(m.group(8))), "boost": (m.group(9) == "1", int(m.group(10)), m.group(11)), "g4": (m.group(12) == "1", int(m.group(13)), int(m.group(14))), "f3": (m.group(15) == "1", m.group(16), m.group(17), m.group(18)), "civ": m.group(19) == "1", "sizes": [0] * 27, "elems": {}, } continue m = LISTS_RE.match(line) if m: seq, idx = int(m.group(1)), int(m.group(2)) blk = batch(seq)["blocks"].get(idx) if blk is not None: blk["sizes"] = [int(x) for x in m.group(4).split()] continue m = ELEM_RE.match(line) if m: # An `aielem` line carries no seq, so it belongs to the batch whose block header # it followed -- which is the most recent one seen. seq = max(batches) if batches else 0 idx, listno, elemidx = int(m.group(1)), int(m.group(3)), int(m.group(4)) words = [int(x, 16) for x in m.group(5).split()] blk = batch(seq)["blocks"].get(idx) if blk is not None: blk["elems"].setdefault(listno, {})[elemidx] = words return batches def element_fields(listno, words): """The wire fields of one element, as `.tcb` tokens.""" spec = LIST_MAP.get(listno) if spec is None: # An unmapped list: record that the element exists and nothing about it. The command # still costs whatever its list costs; the replayer will decline to apply it. return ["?"] out = [] for kind, k in spec: if k >= len(words): out.append("?") continue w = words[k] if kind == "i": out.append("i%d" % struct.unpack("= len(words): out.append("?") else: out.append("v%d" % ((words[k + 1] - w) // 4)) else: out.append("?") return out def emit(batch, seq, source, names, seeds): lines = ["tcb 1", "meta source %s" % source, "meta batch seq=%d n=%d" % (seq, batch["n"]), "meta note the load-time batch is excluded; this is the End-Turn submission"] for netid, value in seeds: lines.append("seed %s %s" % (netid, value)) for idx in sorted(batch["blocks"]): b = batch["blocks"][idx] lines.append("block %d %d" % (idx, b["pid"])) if b["rate"][0]: lines.append("gate %d rate %s" % (idx, b["rate"][1])) if b["target"][0]: name = names.get(b["pid"]) lines.append("gate %d target %d%s" % (idx, b["target"][1], (" name %s" % name) if name else "")) if b["boost"][0]: lines.append("gate %d boost %d %s" % (idx, b["boost"][1], b["boost"][2])) if b["g4"][0]: lines.append("gate %d group4 %d %d" % (idx, b["g4"][1], b["g4"][2])) if b["f3"][0]: lines.append("gate %d group5 %s %s %s" % (idx, b["f3"][1], b["f3"][2], b["f3"][3])) if b["civ"]: lines.append("gate %d civilian" % idx) for listno in range(1, 28): n = b["sizes"][listno - 1] if not n: continue lines.append("list %d %d %d" % (idx, listno, n)) have = b["elems"].get(listno, {}) for e in range(n): words = have.get(e) fields = element_fields(listno, words) if words is not None else ["?"] lines.append("elem %d %d %d %s" % (idx, listno, e, " ".join(fields))) return "\n".join(lines) + "\n" def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("log") ap.add_argument("-o", "--out") ap.add_argument("--batch", type=int, help="which seq to convert (default: the last)") ap.add_argument("--name", action="append", default=[], metavar="PID=TECHNAME", help="the tech name a research-target gate was observed to resolve to") ap.add_argument("--seed", action="append", default=[], metavar="NETID=VALUE", help="an AI client's construction seed") a = ap.parse_args(argv) batches = parse_log(a.log) if not batches: print("no aiblk records in %s" % a.log, file=sys.stderr) return 2 seq = a.batch if a.batch is not None else max(batches) if seq not in batches: print("no batch seq=%d; the log holds %s" % (seq, sorted(batches)), file=sys.stderr) return 2 names = {} for n in a.name: pid, _, tech = n.partition("=") if not tech: print("--name wants PID=TECHNAME", file=sys.stderr) return 2 names[int(pid)] = tech seeds = [] for s in a.seed: netid, _, value = s.partition("=") if not value: print("--seed wants NETID=VALUE", file=sys.stderr) return 2 seeds.append((netid, value)) text = emit(batches[seq], seq, a.log.split("/")[-1], names, seeds) if a.out: with open(a.out, "w") as f: f.write(text) print("wrote %s (batch seq=%d, %d blocks)" % (a.out, seq, len(batches[seq]["blocks"]))) else: sys.stdout.write(text) return 0 if __name__ == "__main__": raise SystemExit(main())