From 208f4a9092d0befc28c24fbee221d866ee718ed5 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 27 Sep 2026 18:44:24 +0200 Subject: [PATCH] j177: aim capture - log what the model BELIEVED, not what it did j176 could not attribute the 11.9 deg aim error at 450+ px: the corpus had no gun id and no bot-side belief, so staleness was an inverse (unidentifiable) problem and a good gun was indistinguishable from a bad one. Both are cheap to log and impossible to recover later. New default-off knob TR_CAPTURE_AIM (presence-only). It appends TWO record kinds to the EXISTING TR_RECORD_WORLDSTATE file: aim_scan - one per onScannedBot, written BEFORE the tracker update, so it is the pre-update belief by construction: tick, raw scanned values (ex,ey,eh,es,ee), our own state (sx,sy,sh,ss), the gun in force, the PREVIOUS belief (bx,by,bh,bs,blst), the scan parity age = tick - blst, and the radar-lock context (rlock, rdir, lbear, boff). aim_fire - one per real shot: gun, power, the aim angle handed to setFire, the turret angle and the signed turret error, gunHeat, the predicted intercept (ax,ay) and implied TOF, and the exact WorldState the predictor consumed (ex,ey,eh,es,ee,sx,sy) with the tick it came from (lst). Row builders live in a new pure module src/aim_capture.nim - no bot API, no env reads - so the offline guard test and the live bot go through the SAME builders and a field the test proves present is a field the bot writes. Per-tick world-state rows also gain a `gun` id. offline_range.nim skips aim_* lines (they carry no `ex`), so the annotations are inert to the replay. No aim model changed. Knob registered in env_report.nim (context field, effective-value emit) and knownEnvNames(); documented in .env.example. Defaults OFF, diagnostic only, never live-tested. Verification (no battle, no Java, no server, no GUI): - default parity: per-gun shots/hits over 20026 ticks of tr_drussgt_vs_modularbot.jsonl byte-for-byte identical to the golden generated from the PRE-CHANGE tree (git archive 55e92bc); the golden was regenerated from that pre-change tree and re-diffed, so it is not self-referential. Boot [env] block of the pre- and post-change binaries is identical except pid/cmdline/build line and the new knob's own line. - test_aim_capture: ALL PASS (every aim_scan/aim_fire key present, plus the annotation-inertness replay). - guards: test_tfil_commit_env 159/0, test_env_report 25/0, test_tfil_ring_weights 24/0, test_vbullet_draw 30/0. - .env.example round-trip (env_report via the j172 harness): 210 effective values + 70 [x]/[modules] lines, 0 diffs, 0 dropped keys, 0 warnings. - clean `git archive HEAD` + nim c -d:release: [SuccessX]. --- ModularBot_garage/.env.example | 19 +++ ModularBot_garage/src/ModularBot.nim | 45 ++++++ ModularBot_garage/src/aim_capture.nim | 129 +++++++++++++++++ ModularBot_garage/src/env_report.nim | 5 +- common_libs/gun_harness/offline_range.nim | 6 + .../tests/fixtures/aim_capture_gunpath.golden | 18 +++ common_libs/tests/test_aim_capture.nim | 132 ++++++++++++++++++ 7 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 ModularBot_garage/src/aim_capture.nim create mode 100644 common_libs/tests/fixtures/aim_capture_gunpath.golden create mode 100644 common_libs/tests/test_aim_capture.nim diff --git a/ModularBot_garage/.env.example b/ModularBot_garage/.env.example index b08e337..bc34206 100644 --- a/ModularBot_garage/.env.example +++ b/ModularBot_garage/.env.example @@ -153,6 +153,7 @@ # TR_SURF_LOG one line per wave-surfing decision # TR_FIRE_DIAG per-reading fire-detection tick/raw/correction # TR_RECORD_WORLDSTATE dump every observed world state to JSONL +# TR_CAPTURE_AIM aim_scan/aim_fire records (gun id + bot belief) # TR_RADAR_SCANLOG log every radar scan tick # TR_RADAR_FORCE_SPIN force the old full-360 spin radar # TR_TRACKER_PROBE dump the enemy-tracker internals @@ -737,6 +738,24 @@ GUN_STATS_PATH=/tmp/gun_stats.jsonl # where the per-round gun stats are writte GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl # where the per-shot log is written # ── measurement helpers (leave off unless you are measuring) ───────────────── +# WHAT: aim capture. Adds TWO record kinds to the TR_RECORD_WORLDSTATE file: +# aim_scan — one per radar scan: the raw reading, our own state, the gun +# that fired, the PREVIOUS tracker belief, and the scan parity +# (age = tick - previous lastSeenTick); +# aim_fire — one per real shot: gun, power, the aim angle, the turret error, +# gun heat, the predicted intercept/TOF, and the exact WorldState the +# predictor consumed (with the tick it came from). It also adds the `gun` +# id to the per-tick world-state rows. +# VALUES: presence-only, like the other keys in this block. Unset = off. +# STATUS: default-off, diagnostic only, never live-tested. j176 could not +# attribute the 11.9 deg aim error because the corpus had no gun id and no +# bot-side belief; this knob makes both a lookup instead of an inverse +# problem. No aim model changed with it. +# GOTCHA: it only writes when TR_RECORD_WORLDSTATE is on as well, and it makes +# the capture file bigger, not different: the extra lines are annotations and +# the offline replay skips them. +# TRY: TR_RECORD_WORLDSTATE=1 TR_CAPTURE_AIM=1 ./out/ModularBot +#TR_CAPTURE_AIM=1 # PRESENCE-only: aim_scan / aim_fire records (needs TR_RECORD_WORLDSTATE) #TR_RECORD_WORLDSTATE=1 # PRESENCE-only: dump every observed world state #TR_RADAR_SCANLOG=1 # PRESENCE-only: log every radar scan tick #TR_TRACKER_PROBE=1 # PRESENCE-only: dump the enemy-tracker's internal state diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 676fcb6..47c06cb 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -44,6 +44,7 @@ import movement_harness/bullet_shadows import targeting/enemy_tracker import targeting/target_selector import env_report +import aim_capture import vbullet_draw import geo_overlay @@ -69,6 +70,13 @@ const ShotLog = true ## can enable recording for just the battle it spawns by exporting the env var. let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE") const WorldStateRecordPath = "/tmp/worldstate_record.jsonl" +## j177 aim capture: with TR_CAPTURE_AIM set, append two extra record kinds to +## the SAME world-state file — `aim_scan` (one per radar scan: the raw reading, +## our own state, the GUN, the previous belief and the scan parity) and +## `aim_fire` (one per firing decision: the gun, the power, the aim angle, the +## turret error, and the exact WorldState the predictor consumed). Off by +## default, so a normal run writes byte-for-byte what it wrote before. +let CaptureAim* = existsEnv("TR_CAPTURE_AIM") ## Radar measurement switches (all RUNTIME, read once at process start): ## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full ## spin (always 45 deg/tick). This reproduces the @@ -477,6 +485,9 @@ proc recordWorldState(bot: ModularBot, ws: WorldState) = "eid": tid, } if lst >= 0: row["lst"] = %lst + # j177: the gun in force when this state was built. Absent before j177, + # which made a per-gun decomposition of the aim error impossible. + if CaptureAim: row["gun"] = %bot.currentGun try: let f = open(WorldStateRecordPath, fmAppend) f.writeLine($row) @@ -616,6 +627,23 @@ proc recordRadarStats(bot: ModularBot) = inc bot.arcWidthHist[min(11, int(width / 30.0))] method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) = + # j177 aim capture: the belief we are about to REPLACE, and the fire site's + # state, recorded BEFORE the update. Written first so the record is the + # pre-update belief by construction, not by argument. + if CaptureAim: + var rec = AimScan(tick: bot.tick, eid: e.scannedBotId, + ex: e.x, ey: e.y, eh: e.direction, es: e.speed, ee: e.energy, + sx: getX(), sy: getY(), sh: getDirection(), ss: getSpeed(), + gun: bot.currentGun, + rlock: bot.radarMode == 0, rdir: getRadarDirection(), + bx: 0.0, by: 0.0, blst: -1) + if bot.enemyTracker.enemies.contains(e.scannedBotId): + let prev = bot.enemyTracker.enemies[e.scannedBotId] + rec.bx = prev.x; rec.by = prev.y; rec.bh = prev.heading + rec.bs = prev.speed; rec.blst = prev.lastSeenTick + rec.lbear = bearing(rec.bx, rec.by, rec.sx, rec.sy) + rec.boff = (bearing(rec.ex, rec.ey, rec.sx, rec.sy) - rec.rdir) mod 360.0 + appendLine(WorldStateRecordPath, scanRow(rec)) bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick) bot.hasContact = true if RadarScanLog and bot.radarMeleeActive: @@ -1467,6 +1495,22 @@ method run*(bot: ModularBot) = # Enqueue the selected gun so onBulletFired can stamp the server's bulletId. # getEnergy() > power mirrors the server's "bot.energy <= firepower" reject. if not floorBlocks and setFire(power) and getEnergy() > power: + # j177 aim capture: one `aim_fire` line per REAL shot, with the gun + # that fired and the exact WorldState the predictor consumed. This + # is the only place where `lst` is knowable, so it is the only place + # the scan parity of a firing decision can be recorded. + if CaptureAim: + let bspd = bulletSpeed(power) + appendLine(WorldStateRecordPath, fireRow(AimFire( + tick: bot.tick, eid: tid, gun: selectedGun, power: power, + aim: aimTarget, turret: gunDir, terr: normDelta, heat: gunHeat, + ax: pred.x, ay: pred.y, tof: (if bspd > 0: distPx / bspd else: 0.0), + ex: bot.lastState.enemyX, ey: bot.lastState.enemyY, + eh: bot.lastState.enemyHeading, es: bot.lastState.enemySpeed, + ee: bot.lastState.enemyEnergy, + sx: bot.lastState.selfX, sy: bot.lastState.selfY, + lst: (if tid >= 0 and bot.enemyTracker.enemies.contains(tid): + bot.enemyTracker.enemies[tid].lastSeenTick else: -1)))) bot.pendingFires.add(PendingShot( gunId: selectedGun, angleErr: abs(normDelta), @@ -1558,6 +1602,7 @@ when isMainModule: vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""), vBulletDebugMax: VBulletDebugMax, recordWorldState: RecordWorldState, + captureAim: CaptureAim, geoDebug: GeoDebugOn, debugDraw: DebugDrawOn, radarForceSpin: RadarForceSpin, diff --git a/ModularBot_garage/src/aim_capture.nim b/ModularBot_garage/src/aim_capture.nim new file mode 100644 index 0000000..ec78b85 --- /dev/null +++ b/ModularBot_garage/src/aim_capture.nim @@ -0,0 +1,129 @@ +## j177 aim capture — the two records that make the aim error ATTRIBUTABLE. +## +## j176 could not answer "why is the aim 11.9 deg off at 450+ px" because the +## corpus has neither the bot's own belief (staleness was an INVERSE problem, +## unidentifiable) nor the gun id (a good gun's contribution was +## indistinguishable from a bad one's). Both are cheap to log and impossible +## to recover later. This module builds the two JSON records; ModularBot.nim +## calls it and the lines go into the EXISTING world-state capture +## (`TR_RECORD_WORLDSTATE` file), so the offline tooling sees one stream. +## +## It is deliberately PURE (no bot API, no env reads): the bot passes plain +## floats, the offline guard test passes the recorded fixture, and both go +## through the SAME row builders — so a field that the test proves present is +## a field the live bot writes. +## +## Key convention: `e*` = the enemy, `s*` = us, `b*` = the enemy's PREVIOUS +## belief in the tracker (before this scan's update), `lst` = the tick that +## state came from. `age = tick - blst` is the scan PARITY, recorded rather +## than inferred. + +import std/[json, os, math] + +const + ScanRecordKey* = "aim_scan" ## wrapper key, sibling of "meta"/"end" + FireRecordKey* = "aim_fire" + +type + AimScan* = object + ## One `onScannedBot` event, as the bot saw it BEFORE the update. + tick*: int + eid*: int + ex*, ey*: float ## raw scanned values + eh*, es*: float ## scanned heading (= direction) and speed + ee*: float + sx*, sy*: float ## our state at the scan (the FIRE SITE) + sh*, ss*: float + gun*: int ## bot.currentGun — the gun that fired, not the rack slot + bx*, by*: float ## previous belief, BEFORE this scan's update + bh*, bs*: float + blst*: int ## previous lastSeenTick; -1 = never scanned before + rlock*: bool ## radar lock engaged (false = the melee radar) + rdir*: float ## 36 deg scan window centre (radar heading) + lbear*: float ## bearing the lock is chasing (believed target) + boff*: float ## scanned bearing - rdir: where in the window it landed + + AimFire* = object + ## One firing decision, as the model computed it. + tick*: int + eid*: int + gun*: int ## bot.currentGun = the gun that fired + power*: float + aim*: float ## the raw angle handed to setFire/turret + turret*: float ## getGunDirection() at the command + terr*: float ## signed turret error (aim - turret) + heat*: float ## getGunHeat() BEFORE firing + ax*, ay*: float ## the intercept the gun predicted + tof*: float ## implied time of flight, ticks + ex*, ey*: float ## the WorldState the predictor CONSUMED + eh*, es*: float + ee*: float + sx*, sy*: float + lst*: int ## which tick that enemy state came from (scan parity) + +proc bearing*(x, y, fx, fy: float): float = + arctan2(y - fy, x - fx).radToDeg + +proc scanRow*(s: AimScan): JsonNode = + ## The `aim_scan` line. Every field is unconditional: a capture that + ## silently omits a field is worse than no capture. + result = newJObject() + result[ScanRecordKey] = newJObject() + let b = result[ScanRecordKey] + b["tick"] = %s.tick + b["eid"] = %s.eid + b["ex"] = %s.ex + b["ey"] = %s.ey + b["eh"] = %s.eh + b["es"] = %s.es + b["ee"] = %s.ee + b["sx"] = %s.sx + b["sy"] = %s.sy + b["sh"] = %s.sh + b["ss"] = %s.ss + b["gun"] = %s.gun + b["bx"] = %s.bx + b["by"] = %s.by + b["bh"] = %s.bh + b["bs"] = %s.bs + b["blst"] = %s.blst + b["age"] = %(if s.blst >= 0: s.tick - s.blst else: -1) + b["rlock"] = %s.rlock + b["rdir"] = %s.rdir + b["lbear"] = %s.lbear + b["boff"] = %s.boff + +proc fireRow*(f: AimFire): JsonNode = + ## The `aim_fire` line. + result = newJObject() + result[FireRecordKey] = newJObject() + let b = result[FireRecordKey] + b["tick"] = %f.tick + b["eid"] = %f.eid + b["gun"] = %f.gun + b["power"] = %f.power + b["aim"] = %f.aim + b["turret"] = %f.turret + b["terr"] = %f.terr + b["heat"] = %f.heat + b["ax"] = %f.ax + b["ay"] = %f.ay + b["tof"] = %f.tof + b["ex"] = %f.ex + b["ey"] = %f.ey + b["eh"] = %f.eh + b["es"] = %f.es + b["ee"] = %f.ee + b["sx"] = %f.sx + b["sy"] = %f.sy + b["lst"] = %f.lst + +proc appendLine*(path: string, row: JsonNode) = + ## Append one JSONL line, fully guarded: a full disk or a bad path must + ## never take the bot down (same contract as the existing recorders). + try: + let f = open(path, fmAppend) + f.writeLine($row) + f.close() + except CatchableError: + discard diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 9bc48dd..4631b54 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -52,6 +52,7 @@ type vBulletDebugGun*: string vBulletDebugMax*: int recordWorldState*: bool + captureAim*: bool ## j177: aim_scan / aim_fire records, default off geoDebug*: bool debugDraw*: bool radarForceSpin*: bool @@ -245,6 +246,8 @@ proc printEffectiveValues(ctx: EnvReportContext) = emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG")) emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState), sourceOfPresence("TR_RECORD_WORLDSTATE")) + emit("TR_CAPTURE_AIM", onOff(ctx.captureAim), + sourceOfPresence("TR_CAPTURE_AIM")) emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin), sourceOfPresence("TR_RADAR_FORCE_SPIN")) emit("TR_RADAR_SCANLOG", onOff(ctx.radarScanLog), @@ -657,7 +660,7 @@ proc knownEnvNames*(): seq[string] = "GUN_SELECTOR_SHRINK", "GUN_SELECTOR_DWELL", "GUN_SELECTOR_MARGIN", "GUN_SELECTOR_POINT_TIE", "GUN_SELECTOR_SEED", "GUN_RACK_DISABLE", "GUN_STATS_PATH", "GUN_SHOTLOG_PATH", - "TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE", + "TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE", "TR_CAPTURE_AIM", "TR_RADAR_FORCE_SPIN", "TR_RADAR_SCANLOG", "TR_RADAR_SCAN_LOG_PATH", "TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY", VBulletDebugEnv, VBulletDebugGunEnv, VBulletDebugMaxEnv, diff --git a/common_libs/gun_harness/offline_range.nim b/common_libs/gun_harness/offline_range.nim index 97e882b..8adf2b3 100644 --- a/common_libs/gun_harness/offline_range.nim +++ b/common_libs/gun_harness/offline_range.nim @@ -23,6 +23,8 @@ ## A trailing live end marker is also optional: ## {"end":{"enemy_died":,"ticks":}} ## It lets the replay reproduce the live resolver's final-tick behaviour. +## `aim_scan` / `aim_fire` annotation lines (written only when +## TR_CAPTURE_AIM is set) carry no `ex` and are skipped. ## ## The replay never calls the gun selector, so it is RNG-free for every ## deterministic gun. Tsetlin is stochastic and is expected to differ. @@ -196,6 +198,10 @@ proc loadFixture*(path: string): Fixture = if node["end"].hasKey("enemy_died"): result.enemyDied = node["end"]["enemy_died"].getBool() continue + # j177: the recorder can also write `aim_scan` / `aim_fire` lines into the + # same file when TR_CAPTURE_AIM is set. They are annotations on ticks, not + # ticks, so they carry no `ex` and are skipped here. + if not node.hasKey("ex"): continue result.states.add stateFromJson(node, arenaW, arenaH, enemyId) result.lastSeen.add (if node.hasKey("lst"): node["lst"].getInt() else: -1) result.enemyId = enemyId diff --git a/common_libs/tests/fixtures/aim_capture_gunpath.golden b/common_libs/tests/fixtures/aim_capture_gunpath.golden new file mode 100644 index 0000000..7f53d64 --- /dev/null +++ b/common_libs/tests/fixtures/aim_capture_gunpath.golden @@ -0,0 +1,18 @@ +# j177 gun-path default-parity golden. +# Generated from the PRE-CHANGE tree (`git archive 55e92bc`) with +# TR_CAPTURE_AIM unset, over the whole tr_drussgt_vs_modularbot.jsonl. +# Format: shots= hits=, one line per rack gun +HeadOn shots=400 hits=59 +Linear shots=400 hits=47 +Tsetlin shots=400 hits=74 +Circular shots=400 hits=51 +GuessFactor shots=400 hits=44 +Pattern shots=400 hits=46 +WallBounce shots=400 hits=55 +Accel shots=400 hits=59 +StopShot shots=400 hits=84 +Displace shots=400 hits=46 +AvgLead shots=400 hits=51 +DecayGF shots=400 hits=47 +KNN shots=400 hits=29 +TMSelect shots=0 hits=0 diff --git a/common_libs/tests/test_aim_capture.nim b/common_libs/tests/test_aim_capture.nim new file mode 100644 index 0000000..7a482a1 --- /dev/null +++ b/common_libs/tests/test_aim_capture.nim @@ -0,0 +1,132 @@ +## j177 guard: the aim capture actually contains every field it promises, and +## the default gun path is byte-for-byte unchanged. +## +## NO battle, NO Java, NO server, NO GUI. +## nim c -r --path:common_libs --path:ModularBot_garage/src \ +## --nimcache:/tmp/nc_j177 common_libs/tests/test_aim_capture.nim +## +## Part 1 (the guard): with the capture ON, build one `aim_scan` and one +## `aim_fire` row from a real recorded tick of +## tools/fixtures/tr_drussgt_vs_modularbot.jsonl, through the SAME +## `aim_capture` row builders ModularBot.nim calls, and assert every required +## key is present and carries the value that was passed in. A capture that +## silently omits a field is worse than none. +## +## Part 2 (default parity): replay the whole recorded fixture through the real +## VirtualTracker + the guns with the capture OFF and compare the per-gun +## fitness report against `fixtures/aim_capture_gunpath.golden`. That golden +## was generated from the PRE-CHANGE tree (`git archive 55e92bc`) with the +## knob unset — regenerating it from this code would defeat the check. +## Part 2b: the same replay over a fixture that ALSO carries aim_scan/aim_fire +## lines must give the identical report, i.e. the annotations are inert. + +import std/[json, os, strutils, sequtils, strformat] +import gun_harness/offline_range +import range_guns +import ../../ModularBot_garage/src/aim_capture + +const + repoRoot = currentSourcePath().parentDir.parentDir.parentDir + fixtureRel = "tr_drussgt_vs_modularbot.jsonl" + fixture = repoRoot / "tools" / "fixtures" / fixtureRel + goldenPath = currentSourcePath().parentDir / "fixtures" / "aim_capture_gunpath.golden" + annotated = "/tmp/j177_annotated_fixture.jsonl" + ScanKeys = ["tick", "eid", "ex", "ey", "eh", "es", "ee", "sx", "sy", "sh", "ss", + "gun", "bx", "by", "bh", "bs", "blst", "age", "rlock", "rdir", + "lbear", "boff"] + FireKeys = ["tick", "eid", "gun", "power", "aim", "turret", "terr", "heat", + "ax", "ay", "tof", "ex", "ey", "eh", "es", "ee", "sx", "sy", "lst"] + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: echo "PASS: ", name + else: + echo "FAIL: ", name + inc failures + +proc replayReport(path: string): string = + ## Per-gun shots/hits over the whole fixture, through the REAL tracker and + ## the REAL guns, with the capture OFF. + let res = replayFixture(loadFixture(path), buildAllGunDrivers(seed = 1)) + for r in res: + result.add r.name & " shots=" & $r.shots & " hits=" & $r.hits & "\n" + +# ── Part 1: the records carry every field ──────────────────────────────────── +proc fieldCheck() = + let states = loadFixture(fixture).states + check("fixture loaded", states.len > 1000) + let ws = states[900] + let scan = scanRow(AimScan( + tick: 900, eid: 7, + ex: ws.enemyX, ey: ws.enemyY, eh: ws.enemyHeading, es: ws.enemySpeed, + ee: ws.enemyEnergy, sx: ws.selfX, sy: ws.selfY, sh: ws.selfHeading, + ss: ws.selfSpeed, gun: 5, + bx: ws.enemyX - 8.0, by: ws.enemyY - 8.0, bh: ws.enemyHeading, + bs: ws.enemySpeed, blst: 896, + rlock: true, rdir: 42.5, lbear: bearing(ws.enemyX - 8, ws.enemyY - 8, ws.selfX, ws.selfY), + boff: -12.25)) + let fire = fireRow(AimFire( + tick: 900, eid: 7, gun: 5, power: 1.6, aim: 88.25, turret: 74.0, + terr: 14.25, heat: 0.31, ax: ws.enemyX + 40.0, ay: ws.enemyY - 15.0, + tof: 12.5, ex: ws.enemyX, ey: ws.enemyY, eh: ws.enemyHeading, + es: ws.enemySpeed, ee: ws.enemyEnergy, sx: ws.selfX, sy: ws.selfY, lst: 897)) + + for k in ScanKeys: + check("aim_scan has " & k, scan[ScanRecordKey].hasKey(k)) + for k in FireKeys: + check("aim_fire has " & k, fire[FireRecordKey].hasKey(k)) + + check("aim_scan carries the gun id", scan[ScanRecordKey]["gun"].getInt() == 5) + check("aim_fire carries the gun id", fire[FireRecordKey]["gun"].getInt() == 5) + check("aim_scan records the scan parity age", + scan[ScanRecordKey]["age"].getInt() == 4) + check("aim_fire records the source tick (parity)", + fire[FireRecordKey]["lst"].getInt() == 897) + check("aim_fire carries the aim angle", fire[FireRecordKey]["aim"].getFloat() == 88.25) + check("aim_fire carries the turret error", fire[FireRecordKey]["terr"].getFloat() == 14.25) + check("aim_fire carries the WorldState the model consumed", + fire[FireRecordKey]["ex"].getFloat() == ws.enemyX and + fire[FireRecordKey]["ey"].getFloat() == ws.enemyY) + + echo "\n--- aim_scan record ---" + echo $scan + echo "--- aim_fire record ---" + echo $fire + echo "" + +# ── Part 2: default gun-path parity ────────────────────────────────────────── +proc parityCheck() = + let clean = replayReport(fixture) + let ticks = loadFixture(fixture).states.len + if defined(aimCapGenGolden): + var g = "# j177 gun-path default-parity golden.\n" + g.add "# Generated from the PRE-CHANGE tree (`git archive 55e92bc`) with\n" + g.add "# TR_CAPTURE_AIM unset, over the whole " & fixtureRel & ".\n" + g.add "# Format: shots= hits=, one line per rack gun\n" + g.add clean + createDir(goldenPath.parentDir) + writeFile(goldenPath, g) + echo "wrote ", goldenPath, " (", ticks, " ticks)" + return + check("golden exists", fileExists(goldenPath)) + if not fileExists(goldenPath): return + let g = lines(goldenPath).toSeq().filterIt(not it.startsWith("#")).join("\n").strip() + check("gun path byte-for-byte identical over " & $ticks & " ticks", g == clean.strip()) + + # 2b: the annotation lines must be inert for the replay. + let extra = @[ + $scanRow(AimScan(tick: 0, eid: 1, ex: 1.0, ey: 2.0, eh: 3.0, es: 4.0, ee: 5.0, + sx: 6.0, sy: 7.0, sh: 8.0, ss: 9.0, gun: 3, + bx: 0.0, by: 0.0, blst: -1, rlock: true, rdir: 1.0, lbear: 2.0)), + $fireRow(AimFire(tick: 1, eid: 1, gun: 3, power: 1.5, aim: 1.0, turret: 2.0, + terr: 3.0, heat: 0.0, ax: 1.0, ay: 1.0, tof: 1.0, + ex: 1.0, ey: 1.0, eh: 1.0, es: 1.0, ee: 1.0, + sx: 1.0, sy: 1.0, lst: 0))] + writeFile(annotated, (lines(fixture).toSeq() & extra).join("\n")) + check("aim records do not perturb the replay", replayReport(annotated) == clean) + removeFile(annotated) + +fieldCheck() +parityCheck() +echo (if failures == 0: "\nALL PASS" else: "\n" & $failures & " FAILURE(S)") +quit(if failures == 0: 0 else: 1)