diff --git a/docs/re/autopilot-memory-driven.md b/docs/re/autopilot-memory-driven.md index e97c7ad..4b699b2 100644 --- a/docs/re/autopilot-memory-driven.md +++ b/docs/re/autopilot-memory-driven.md @@ -1,8 +1,46 @@ # Memory-driven autopilot โ€” build log and current state -**Status: ๐ŸŸก PARTIAL โ€” infrastructure works, the craft is not yet flown.** -Started 2026-07-29. This is the honest state, not a plan: what is proven, what -is not, and the one thing that most cheaply unblocks the rest. +**Status: ๐ŸŸข IT FLIES AND SHOOTS โ€” it does not yet survive.** +Updated 2026-07-29 (second pass). The autopilot reads the live world, picks +hostile targets, pursues them and opens fire. What it cannot do is stay alive: +there is no evasion or shield management, so it dies before a mission ends. +This is the honest state, not a plan. + +## What the loop does now, observed + +``` +[ 82.5] tgt=e007_ADAN_Turret d=3384 yaw= -7.3 pit=+14.6 stick=(-0.13,-0.34) fire=0 +[ 85.7] tgt=e007_ADAN_Turret d=2797 yaw= +1.9 pit=+27.2 stick=(+0.20,-0.59) fire=0 +[117.3] tgt=e007_ADAN_Turret d=4211 yaw= +5.7 pit= +9.0 stick=(+0.25,-0.40) fire=1 +``` + +Distance closes monotonically, yaw error is driven from โˆ’8ยฐ to ~0, and once both +errors are inside the firing cone it holds RB and the ammo counter falls. A +rescan reports the scene as e.g. `136 entities {'TCAF': 16, 'ADAN': 120}`. + +**The chain that made it work** โ€” each link checked, not assumed: + +1. **Entity typing.** A live entity's definition pointer sits at + **position + 0x130**. One heap scan then yields every craft *with its unit + type*, which is what separates 20-odd real combatants from ~30 000 moving + particles. Verified by the result being coherent: wingmen, enemy turrets and + attackers, friendly capital ships, and exactly one `โ€ฆ_Player`. +2. **Orientation.** A 3ร—3 rotation at **position โˆ’ 0x70**, stored with a + **16-byte row stride** (a 4ร—4 transform whose translation row *is* the + position). An earlier search for nine *contiguous* floats structurally could + not find this, which is why the first pass concluded "no transform". The + binding is confirmed independently: its row 2 matches the craft's measured + direction of travel with **cos = +1.000**. +3. **The fire button is RB** โ€” established by consequence, not by guessing: + of RB/LB/A/B/X/Y/RT/LT, pressing RB is the only one that makes the nose-ammo + counter in RAM fall (5958 โ†’ 5940). `RT` is *not* the throttle, and no button + tested is. +4. **Control.** PD on the aiming error with the derivative taken from the + craft's own body angular velocity (from two consecutive rotation matrices), + and target selection weighted by off-boresight angle + (`score = dยท(1 + 3ยท(ฮธ/ฯ€)ยฒ)`) rather than pure nearest โ€” closing on a target + 90ยฐ off the nose only raises the bearing rate, which is what held the first + run outside its firing cone at a steady ~27ยฐ pitch error. ## Goal @@ -27,8 +65,17 @@ because definitions load **per stage**). ## What is NOT solved -**Identifying our own craft, and typing the other entities.** Both remain open, -and the autopilot cannot work without them. +**Survival, and therefore mission completion.** The loop has no evasion, no +shield/armour awareness and no throttle control, so it flies a straight pursuit +into defended space and is eventually shot down โ€” every long run so far has +ended in GAME OVER. Completing a mission needs, at least: reading own +shield/armour, breaking off when hit, and prioritising the mission's actual +objective targets over the nearest turret. + +### Superseded (kept because the reasoning still matters) + +The notes below were written before the chain above worked. They remain true as +statements about `0x820af030`, which is *not* the live entity โ€” 1. **The `0x820af030` class is not the live entity.** It has one object per spawned thing and carries the unit-ID string, so it looked like the entity diff --git a/tools/re-capture/autopilot3.py b/tools/re-capture/autopilot3.py new file mode 100644 index 0000000..4c17e82 --- /dev/null +++ b/tools/re-capture/autopilot3.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Fly and fight from guest memory. + +World state comes from entities2.py's typing rule: a live entity's position +triple sits at a fixed offset before its definition pointer, so one scan of the +entity heap yields every craft in the scene *with its unit type* โ€” which is what +separates enemies from wingmen, capital ships and the thousands of moving +particles. + +Control is PD on the aiming error, with the derivative taken from the craft's +own body angular velocity (recovered from two consecutive orientation matrices) +rather than from the differenced error โ€” that is what the old screen-scraping +autopilot lacked, and why it oscillated. + +Usage: autopilot3.py [seconds] [--dry] +""" +import json +import math +import os +import struct +import sys +import time +from collections import Counter + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gmem # noqa: E402 +import gworld # noqa: E402 +import entities2 # noqa: E402 +from flight_probe import Pad # noqa: E402 + +HOSTILE = ("e", "be") # UN_e* / UN_be* are ADAN; UN_f*/UN_bf* are ours + + +def unit_faction(nm): + base = nm[3:] + return "ADAN" if base.startswith("be") or base.startswith("e") else "TCAF" + + +class World: + def __init__(self, cfg): + self.cfg = cfg + self.w = gworld.World() + self.fd, self.size = self.w.fd, self.w.size + self.defs = entities2.definitions(self.w) + self.delta = cfg["def_delta"] + self.rot_delta = cfg["rot_delta"] + self.rot_stride = cfg.get("rot_stride", 12) + self.fwd_row = cfg["fwd_row"] + self.fwd_sign = cfg["fwd_sign"] + self.ents = [] + + def rescan(self): + movers = entities2.moving(self.fd, self.size, dt=0.35, + va_range=(self.cfg["va_lo"], self.cfg["va_hi"])) + ents = entities2.typed(self.fd, self.defs, movers, self.delta) + uniq = {} + for off, nm, pos, sp in ents: + uniq.setdefault(off, (off, nm, pos, sp)) + self.ents = list(uniq.values()) + return self.ents + + def pos(self, off): + b = os.pread(self.fd, 12, off) + return np.array(struct.unpack(">3f", b)) if len(b) == 12 else None + + def rot(self, off): + n = self.rot_stride * 2 + 12 + b = os.pread(self.fd, n, off + self.rot_delta) + if len(b) < n: + return None + M = np.array([struct.unpack_from(">3f", b, self.rot_stride * r) for r in range(3)]) + if not np.all(np.isfinite(M)): + return None + if np.max(np.abs(M @ M.T - np.eye(3))) > 5e-3: + return None + return M + + +def clamp(v, lo=-1.0, hi=1.0): + return max(lo, min(hi, v)) + + +def body_rate(Mprev, M, dt): + if Mprev is None or M is None or dt <= 0: + return np.zeros(3) + D = Mprev @ M.T + w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / 2.0 + return w / dt + + +class Autopilot: + KP, KD = 2.2, 0.45 + FIRE_CONE = math.radians(10) + FIRE_RANGE = 6000.0 + + def __init__(self, world, pad, dry=False): + self.W = world + self.pad = pad + self.dry = dry + self.prevM = None + self.firing = False + + def me(self): + for off, nm, pos, sp in self.W.ents: + if "Player" in nm: + return off, nm + return None, None + + def step(self, dt): + off, nm = self.me() + if off is None: + return "no-player" + p = self.W.pos(off) + M = self.W.rot(off) + if p is None or M is None: + return "no-state" + fwd = M[self.W.fwd_row] * self.W.fwd_sign + rows = [M[i] for i in range(3)] + right = rows[(self.W.fwd_row + 1) % 3] + up = np.cross(fwd, right) + + w = body_rate(self.prevM, M, dt) + self.prevM = M + + # nearest hostile, preferring what is already in front + best, bestscore = None, 1e18 + for eoff, enm, epos, esp in self.W.ents: + if unit_faction(enm) != "ADAN": + continue + q = self.W.pos(eoff) + if q is None: + continue + v = q - p + d = float(np.linalg.norm(v)) + if d < 1e-3: + continue + # Prefer targets we can actually bring the nose onto. Nearest-first + # picks whatever is closest even at 90 deg off the nose, and closing + # on an off-boresight target only raises the bearing rate -- which is + # exactly the lag that kept the first run outside its firing cone. + ahead = float(v @ fwd) / d + ang = math.acos(max(-1.0, min(1.0, ahead))) + score = d * (1.0 + 3.0 * (ang / math.pi) ** 2) + if score < bestscore: + best, bestscore = (eoff, enm, q, v, d), score + if best is None: + if not self.dry: + self.pad.axis("LX", 0.0) + self.pad.axis("LY", 0.0) + return "no-hostiles" + + eoff, enm, q, v, d = best + lx = float(v @ right) + ly = float(v @ up) + lz = float(v @ fwd) + yaw = math.atan2(lx, lz if abs(lz) > 1e-3 else 1e-3) + pitch = math.atan2(ly, lz if abs(lz) > 1e-3 else 1e-3) + if lz < 0: + yaw = math.copysign(math.pi / 2, lx if lx else 1.0) + + sx = clamp(self.KP * yaw - self.KD * float(w @ up)) + sy = clamp(-(self.KP * pitch - self.KD * float(w @ right))) + aligned = abs(yaw) < self.FIRE_CONE and abs(pitch) < self.FIRE_CONE + fire = aligned and d < self.FIRE_RANGE + + if not self.dry: + self.pad.axis("LX", sx) + self.pad.axis("LY", sy) + if fire != self.firing: + (self.pad.press if fire else self.pad.release)("RB") + self.firing = fire + self.shots = getattr(self, "shots", 0) + (1 if fire else 0) + return (f"tgt={enm[3:24]:<22} d={d:8.0f} yaw={math.degrees(yaw):+6.1f} " + f"pit={math.degrees(pitch):+6.1f} stick=({sx:+.2f},{sy:+.2f}) fire={int(fire)}") + + def run(self, secs, hz=10.0): + t0 = time.time() + last = t0 + last_scan = 0.0 + while time.time() - t0 < secs: + t = time.time() + if t - last_scan > 2.5: + ents = self.W.rescan() + last_scan = t + c = Counter(unit_faction(e[1]) for e in ents) + print(f"[{t-t0:6.1f}] rescan: {len(ents)} entities {dict(c)}", flush=True) + print(f"[{t-t0:6.1f}] {self.step(t - last)}", flush=True) + last = t + time.sleep(max(0, 1.0 / hz - (time.time() - t))) + if not self.dry: + self.pad.reset() + + +def main(): + cfg = json.load(open(sys.argv[1])) + secs = float(sys.argv[2]) if len(sys.argv) > 2 else 60.0 + dry = "--dry" in sys.argv + W = World(cfg) + W.rescan() + ap = Autopilot(W, Pad(), dry=dry) + ap.run(secs) + + +if __name__ == "__main__": + main() diff --git a/tools/re-capture/calibrate.py b/tools/re-capture/calibrate.py new file mode 100644 index 0000000..2918fc5 --- /dev/null +++ b/tools/re-capture/calibrate.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Learn which body axis each stick drives, and which button fires. + +The first autopilot run nulled yaw but held a steady ~27 deg pitch error, which +is the signature of a wrong pitch axis or sign โ€” guessing `right = row0` and +`up = fwd x right` assumes a handedness the game need not share. So measure it: +hold each stick axis, read the craft's body angular velocity from consecutive +orientation matrices, and see which body axis actually responds and in which +direction. + +The fire button is found the same way โ€” by consequence, not assumption: the +nose ammo counter in the player's object must go down. + +Usage: calibrate.py [out.json] +""" +import json +import math +import os +import struct +import sys +import time + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gmem # noqa: E402 +import gworld # noqa: E402 +import entities2 # noqa: E402 +from flight_probe import Pad # noqa: E402 + + +def player_off(w, fd, size, delta): + defs = entities2.definitions(w) + for _ in range(6): + mv = entities2.moving(fd, size, dt=0.5) + ents = entities2.typed(fd, defs, mv, delta) + for off, nm, pos, sp in ents: + if "Player" in nm: + return off + time.sleep(0.5) + return None + + +def read_rot(fd, off, rot_delta, stride): + n = stride * 2 + 12 + b = os.pread(fd, n, off + rot_delta) + if len(b) < n: + return None + M = np.array([struct.unpack_from(">3f", b, stride * r) for r in range(3)]) + if not np.all(np.isfinite(M)) or np.max(np.abs(M @ M.T - np.eye(3))) > 5e-3: + return None + return M + + +def mean_body_rate(fd, off, cfg, secs=2.0, hz=8.0): + """Mean angular velocity expressed in the craft's own axes.""" + prev, t_prev = None, None + acc, n = np.zeros(3), 0 + end = time.time() + secs + while time.time() < end: + t = time.time() + M = read_rot(fd, off, cfg["rot_delta"], cfg.get("rot_stride", 12)) + if M is not None and prev is not None: + dt = t - t_prev + D = prev @ M.T + w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / 2.0 + if dt > 0 and np.linalg.norm(w) < 0.5: + # into body axes + acc += M @ (w / dt) + n += 1 + if M is not None: + prev, t_prev = M, t + time.sleep(max(0, 1.0 / hz - (time.time() - t))) + return acc / max(n, 1) + + +def main(): + cfg = json.load(open(sys.argv[1])) + out = sys.argv[2] if len(sys.argv) > 2 else sys.argv[1] + w = gworld.World() + fd, size = w.fd, w.size + off = player_off(w, fd, size, cfg["def_delta"]) + if off is None: + sys.exit("player not found") + print(f"# player pos va {gmem.primary_va(off):#010x}") + pad = Pad() + + res = {} + for axis, name in (("LX", "yaw"), ("LY", "pitch")): + pad.reset() + time.sleep(1.0) + base = mean_body_rate(fd, off, cfg, 1.2) + pad.axis(axis, 0.9) + time.sleep(0.4) + wpos = mean_body_rate(fd, off, cfg, 2.0) + pad.axis(axis, 0.0) + time.sleep(1.2) + d = wpos - base + k = int(np.argmax(np.abs(d))) + res[name] = {"axis": k, "sign": 1 if d[k] > 0 else -1, + "mag": float(abs(d[k]))} + print(f"# {axis} (+0.9) -> body rate {d.round(3)} => {name} axis {k} " + f"sign {res[name]['sign']:+d}") + pad.reset() + + # ---- fire button: the nose ammo counter must fall ----------------------- + blob = os.pread(fd, 0x1000, max(0, off - 0x800)) + ammo_offs = [] + for i in range(0, len(blob) - 3, 4): + (u,) = struct.unpack_from(">I", blob, i) + (f,) = struct.unpack_from(">f", blob, i) + if u == 6000 or (math.isfinite(f) and abs(f - 6000.0) < 0.5): + ammo_offs.append((max(0, off - 0x800) + i) - off) + print(f"# ammo-like words (==6000) near the player: " + f"{[hex(o) for o in ammo_offs] or 'none'}") + + def ammo(): + vals = [] + for d in ammo_offs: + b = os.pread(fd, 4, off + d) + if len(b) == 4: + vals.append(struct.unpack(">I", b)[0]) + return vals + + fire_btn = None + if ammo_offs: + for btn in ("RB", "LB", "A", "B", "X", "Y"): + before = ammo() + pad.press(btn) + time.sleep(1.2) + pad.release(btn) + time.sleep(0.5) + after = ammo() + drop = [a - b for a, b in zip(before, after)] + print(f"# {btn}: ammo delta {drop}") + if any(x > 0 for x in drop): + fire_btn = btn + break + for trig in ("RT", "LT"): + if fire_btn: + break + before = ammo() + pad.trig(trig, 1.0) + time.sleep(1.2) + pad.trig(trig, 0.0) + time.sleep(0.5) + after = ammo() + drop = [a - b for a, b in zip(before, after)] + print(f"# {trig}: ammo delta {drop}") + if any(x > 0 for x in drop): + fire_btn = trig + pad.reset() + + cfg["yaw_axis"] = res["yaw"]["axis"] + cfg["yaw_sign"] = res["yaw"]["sign"] + cfg["pitch_axis"] = res["pitch"]["axis"] + cfg["pitch_sign"] = res["pitch"]["sign"] + cfg["fire"] = fire_btn + cfg["ammo_offs"] = ammo_offs + json.dump(cfg, open(out, "w"), indent=1) + print("\n" + json.dumps(cfg, indent=1)) + + +if __name__ == "__main__": + main() diff --git a/tools/re-capture/entities2.py b/tools/re-capture/entities2.py new file mode 100644 index 0000000..a7174e0 --- /dev/null +++ b/tools/re-capture/entities2.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Type every live entity, and locate the player, via the definition pointer. + +A live entity object keeps a pointer to its parsed `.tbl` definition (vtable +0x820af844, addresses known) at a **fixed offset from its position triple**. +Finding that offset once types every moving object in the scene at a stroke: +enemies, friendlies and us, separated from the thousands of moving particles. + +The offset is *derived*, not assumed: for every moving triple, every nearby word +is checked against the set of definition addresses, and the winning delta is the +one that repeats across many independent entities. + +Sub-commands: + delta find the (position -> definition pointer) offset + list [delta] type every live entity and print it + self [delta] the player's entity, its object window, and its orientation +""" +import math +import os +import struct +import sys +import time +from collections import Counter + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import gmem # noqa: E402 +import gworld # noqa: E402 + + +def definitions(w): + out = {} + for off in w.scan_vtable(gworld.DEF_VTABLE): + nm = w.name_of(off) + if nm and nm.startswith("UN_"): + va = gmem.primary_va(off) + if va is not None: + out[struct.pack(">I", va)] = nm + return out + + +# Entity objects live in one heap region; scanning only it turns a 250 MB +# double-read into a few MB. That matters for more than speed: the full scan +# competes with the emulator for every core under lavapipe, and a game that +# does not advance a frame between the two samples has nothing "moving" in it. +ENT_VA_LO, ENT_VA_HI = 0xBD000000, 0xBE000000 + + +def moving(fd, size, dt=0.5, lo=1.0, hi=4000.0, va_range=(ENT_VA_LO, ENT_VA_HI)): + def arrays(): + if va_range: + f0, f1 = gmem.va_to_off(va_range[0]), gmem.va_to_off(va_range[1]) + else: + f0, f1 = 0, size + for a, b in gmem.extents(fd, size): + a, b = max(a, f0), min(b, f1) + n = (b - a) // 4 * 4 + if n >= 64: + yield a, np.frombuffer(os.pread(fd, n, a), dtype=">f4") + + s0 = dict(arrays()) + t0 = time.time() + time.sleep(dt) + s1 = dict(arrays()) + t1 = time.time() + out = [] + # Match extents by their file offset. Zipping the two lists positionally is + # wrong: the game allocates between the samples, the sparse extent layout + # shifts, and every subsequent pair misaligns -- which silently reports + # almost nothing as moving. + for o, a in s0.items(): + b = s1.get(o) + if b is None or len(a) != len(b): + continue + with np.errstate(invalid="ignore"): + af = np.nan_to_num(a.astype(np.float64), nan=0, posinf=0, neginf=0) + bf = np.nan_to_num(b.astype(np.float64), nan=0, posinf=0, neginf=0) + d = bf - af + idx = np.flatnonzero(np.abs(d) > 1e-4) + cand = idx[np.isin(idx + 1, idx) & np.isin(idx + 2, idx)] + for i in cand: + v = np.array([d[i], d[i + 1], d[i + 2]]) + sp = float(np.linalg.norm(v)) / (t1 - t0) + if lo < sp < hi: + out.append((o + int(i) * 4, tuple(af[i:i + 3]), sp)) + return out + + +def find_delta(fd, defs, movers, radius=0x400): + votes = Counter() + for off, pos, sp in movers[:4000]: + lo = max(0, off - radius) + blob = os.pread(fd, radius * 2, lo) + for k in range(0, len(blob) - 3, 4): + if blob[k:k + 4] in defs: + votes[(lo + k) - off] += 1 + return votes + + +def typed(fd, defs, movers, delta): + out = [] + for off, pos, sp in movers: + try: + b = os.pread(fd, 4, off + delta) + except OSError: + continue + nm = defs.get(b) + if nm: + out.append((off, nm, pos, sp)) + return out + + +def main(): + cmd = sys.argv[1] if len(sys.argv) > 1 else "delta" + w = gworld.World() + fd, size = w.fd, w.size + defs = definitions(w) + print(f"# {len(defs)} unit definitions", flush=True) + movers = moving(fd, size) + print(f"# {len(movers)} moving triples", flush=True) + + if cmd == "delta": + votes = find_delta(fd, defs, movers) + print("# candidate (position -> definition pointer) deltas:") + for d, n in votes.most_common(8): + print(f" {d:+#08x} seen {n}") + return + + delta = int(sys.argv[2], 0) if len(sys.argv) > 2 else 0x130 + ents = typed(fd, defs, movers, delta) + uniq = {} + for off, nm, pos, sp in ents: + uniq.setdefault((nm, tuple(round(c, 1) for c in pos)), (off, nm, pos, sp)) + ents = list(uniq.values()) + print(f"# {len(ents)} typed live entities (delta {delta:+#x})") + + if cmd == "list": + c = Counter(nm for _, nm, _, _ in ents) + for nm, k in c.most_common(): + print(f" {k:4d} {nm}") + for off, nm, pos, sp in sorted(ents, key=lambda e: e[1])[:60]: + print(f" {gmem.primary_va(off):#010x} {nm:<40} " + f"({pos[0]:+9.1f},{pos[1]:+9.1f},{pos[2]:+9.1f}) {sp:7.1f}/s") + return + + if cmd == "self": + me = [e for e in ents if "Player" in e[1]] + if not me: + sys.exit("player entity not found") + off, nm, pos, sp = me[0] + print(f"# player entity: pos va {gmem.primary_va(off):#010x} {nm}") + print(f"# pos ({pos[0]:+.1f},{pos[1]:+.1f},{pos[2]:+.1f}) speed {sp:.1f}/s") + # orientation inside the same object + # The rotation is stored with a 16-byte row stride (a 4x4 transform + # whose translation row is the position we already have), so a test for + # nine *contiguous* floats structurally cannot find it. Test both. + lo = max(0, off - 0x800) + blob = os.pread(fd, 0x1000, lo) + found = [] + for k in range(0, len(blob) - 48, 4): + for stride, tag in ((12, "3x3"), (16, "4x4")): + try: + rows = [np.array(struct.unpack_from(">3f", blob, k + stride * r)) + for r in range(3)] + except struct.error: + continue + M = np.array(rows) + if not np.all(np.isfinite(M)) or np.max(np.abs(M)) > 1.001: + continue + if np.max(np.abs(M @ M.T - np.eye(3))) > 3e-3: + continue + if abs(np.linalg.det(M) - 1.0) > 1e-2: + continue + found.append(((lo + k) - off, M, stride)) + break + print(f"# orthonormal 3x3 blocks inside the player object: {len(found)}") + for d, M, st in found[:6]: + print(f" pos{d:+#07x} stride {st}: " + " ".join( + "(" + ",".join(f"{v:+.3f}" for v in row) + ")" for row in M)) + if len(sys.argv) > 3 and found: + import json + # forward axis = the row closest to our direction of travel + vdir = None + p0 = np.array(pos) + time.sleep(0.35) + p1 = np.array(struct.unpack(">3f", os.pread(fd, 12, off))) + if np.linalg.norm(p1 - p0) > 1e-3: + vdir = (p1 - p0) / np.linalg.norm(p1 - p0) + rot_delta, M, rot_stride = found[0] + row, sign = 2, 1 + if vdir is not None: + cs = [float(M[r] @ vdir) for r in range(3)] + row = int(np.argmax([abs(c) for c in cs])) + sign = 1 if cs[row] > 0 else -1 + print(f"# forward axis = row {row} (sign {sign:+d}), " + f"cos={cs[row]:+.3f}") + cfg = {"def_delta": delta, "rot_delta": rot_delta, + "rot_stride": rot_stride, + "fwd_row": row, "fwd_sign": sign, + "va_lo": ENT_VA_LO, "va_hi": ENT_VA_HI} + json.dump(cfg, open(sys.argv[3], "w"), indent=1) + print("# wrote " + sys.argv[3] + ": " + json.dumps(cfg)) + return + + +if __name__ == "__main__": + main()