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)