#!/usr/bin/env python3 """Convert a lane-CB JSON turn-command capture into the `.tcb` file `sots_turn` reads. TWO FORMATS ON PURPOSE, AND THIS IS THE JOIN. `tools/turncommands_capture.py` (lane CB) produces the **capture of record**: raw element words as ground truth, heap vectors and strings the deep dump followed, the input save's hash bound to the output autosaves' hashes, the per-client AI seeds, and the container self-check. That is what an experiment should leave behind and none of it belongs in an engine's input file. `.tcb` is the **engine's input**: line-oriented, no parser, nothing but the commands and their provenance. `sots_turn --turn-commands` reads it and nothing else, so the engine never grows a JSON reader and never has an opinion about how a capture was taken. This script is the only thing that has to know both, and it is deliberately the narrow part: it takes `decoded.wire` where lane CB's decoder produced one, falls back to raw words where it did not, and writes `?` for every field neither could reach. tools/tcb_from_json.py CAPTURE.json [-o OUT.tcb] [--batch SEQ] ONE FIELD MAPPING IS KNOWN WRONG AND IS OVERRIDDEN HERE, WITH ITS REASON. List 5 (system rates) is decoded by lane CB as `{systemId, ship, terraform, sciences, ...}` -- the element's memory words mapped straight onto the frame's WIRE order. Lane RB measured that and it is wrong: replaying it wrote the AI's one non-zero slider into `SRt` and regressed two leaves on the canonical pair, where the oracle holds `SRsc = 1.0`. The only non-zero word of every list-5 element ever dumped is at memory 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. Rather than carry a mapping that is known to be off by at least one, this converter emits the system id and seven `?`. The replayer then counts the command -- the count is right either way -- and refuses to apply it, which is the correct behaviour for a payload nobody has read. Settling it is one run: push two DIFFERENT sliders to two DIFFERENT values and read the permutation off the element. Cheaper, a save taken after issuing rates carries the same command on the wire with every field named, and needs no memory mapping at all. """ import argparse import json import struct import sys # Lists whose decoded `wire` this converter trusts. List 5 is deliberately absent (see above); # lists with no entry are carried as a single `?`, which counts the command and applies nothing. TRUSTED = { 3: "iiii", # ordinal, designId, systemId, trailing 7: "ii", # shipId, trailing 14: "iib", # fleetId, mode, flag } # Lists whose element leads with scalars and then a counted vector the deep dump may have read. VECTOR_TAIL = { 8: ("i", 1), # fleetId, then the route 10: ("ii", 2), # systemId, fleetId, then a counted vector } UNMAPPED_HEAD = { 5: (1, 7), # one trusted leading int (the system id) and seven unread fields 23: (1, 1), # the system id and a Population body behind a vftable } def as_int(v): if isinstance(v, bool): return 1 if v else 0 if isinstance(v, float): return int(v) return int(v) def tok_i(v): return "i%d" % as_int(v) def fields_for(list_no, elem): """The `.tcb` field tokens for one element.""" decoded = elem.get("decoded") or {} wire = decoded.get("wire") raw = elem.get("raw_words") or [] vectors = {v["at_word"]: v for v in (elem.get("vectors") or [])} if list_no in UNMAPPED_HEAD: lead, unread = UNMAPPED_HEAD[list_no] head = [tok_i(raw[i]) for i in range(min(lead, len(raw)))] return head + ["?"] * unread if head else ["?"] if list_no in TRUSTED: spec = TRUSTED[list_no] if not wire or len(wire) < len(spec): return ["?"] out = [] for i, kind in enumerate(spec): v = wire[i] out.append("b%d" % (1 if v else 0) if kind == "b" else tok_i(v)) return out if list_no in VECTOR_TAIL: spec, vec_word = VECTOR_TAIL[list_no] if len(raw) < len(spec): return ["?"] out = [tok_i(struct.unpack(" vec_word + 1: out.append("v%d" % max(0, (raw[vec_word + 1] - raw[vec_word]) // 4)) else: out.append("?") elif v.get("truncated"): out.append("v%d" % v["count"]) else: vals = [struct.unpack("