## Regression guard for the j141 "bot does not fire" regression. ## ## ROOT CAUSE (2026-09-26, j141): commit e9302bc added rack id 17 (the ADE+SBC ## BITBRAIN gun) but the LIVE per-gun real-shot accounting in ## `ModularBot_garage/src/ModularBot.nim` was still hard-wired to ## `array[17, int]`. The instant the selector picked gun 17, the accounting ## indexed one past the end: ## ## * debug build -> IndexDefect out of `run()`: the bot stops dead, 0 shots; ## * `-d:release` -> a silent out-of-bounds write that lands on the adjacent ## `lastPowerLogKey: string` header, so the bot kept moving ## but never fired and never reported a shot. ## ## The fix derives every gun-indexed width from `NumRackGuns` (== `len(RackGunNames)`). ## This test is the guard that would have caught it: ## ## A. rack table — `NumRackGuns` is 17, 17 DISTINCT names, and the ## membership table and the legacy alias target are in range; ## B. source shape — `ModularBot.nim` contains NO literal width/loop bound ## narrower than `NumRackGuns` for the gun-indexed ## accounting, and sizes them with `NumRackGuns`; ## C. accounting — an in-process replay of the real shape (`array[NumRackGuns]` ## indexed by every admitted id) never goes out of range, ## and the newest id IS accounted, even when the rack ## grows by one more gun; ## D. live (opt-in) — with a rack that admits ONLY the newest id, the bot ## selects it, fires and lands hits (skipped without JARs). ## ## The ADE+SBC gun (rack id 17) was later RETIRED, so the rack is back to 17 and ## the newest id is LEADGAIN (16). The guard is unchanged in shape and in ## strength: C3 now proves the fix survives a rack that grows again, which is ## the general form of the bug that shipped. ## ## Run: nim c -r common_libs/tests/test_rack_stat_width.nim ## Live: TR_SERVER_JAR=... TR_BATTLE_RUNNER=... nim c -r common_libs/tests/test_rack_stat_width.nim import std/[os, strutils, json, tables] import gun_harness/selector import gun_harness/virtual_bullets import test_framework/bot_compiler import test_framework/server_manager import test_framework/runner_process const repoRoot = currentSourcePath().parentDir.parentDir.parentDir botSource = repoRoot / "ModularBot_garage" / "src" / "ModularBot.nim" adversaryDir = repoRoot / "common_libs" / "test_framework" / "adversaries" / "SittingDuck" statsPath = "/tmp/rack_stat_width_stats.jsonl" PatternId = 5 NewestId = NumRackGuns - 1 ## 16 (LEADGAIN), the newest rack id LegacyLeadGainId = 16 var failures = 0 proc check(name: string, ok: bool) = if ok: echo "PASS: ", name else: echo "FAIL: ", name; inc failures # ── A. rack table ──────────────────────────────────────────────────────────── proc testRackTable() = check "A1: NumRackGuns is derived from RackGunNames", NumRackGuns == RackGunNames.len and NumRackGuns == 17 check "A2: rack membership table has one entry per gun", DefaultRackMembership.len == NumRackGuns var seen = initTable[string, bool]() var allDistinct = true for n in RackGunNames: if n in seen: allDistinct = false seen[n] = true check "A3: all rack gun names are distinct", allDistinct check "A4: the newest id is in range for every per-gun table", NewestId < NumRackGuns and LegacyLeadGainId < NumRackGuns check "A5: the legacy alias target is a real gun id", RackLegacyAlias[0][1] < NumRackGuns # ── B. the live bot's source is not width-locked to a literal ─────────────── proc testSourceShape() = check "B0: ModularBot.nim exists", fileExists(botSource) if not fileExists(botSource): return let src = readFile(botSource) var lines = src.splitLines() # Every array declared as a per-gun accounting array, with its literal width. let gunArrays = ["gunRealShots", "gunRealHits", "gunRealShotsByMode", "gunRealHitsByMode", "gunSelectionCount"] for field in gunArrays: var found = false for line in lines: let st = line.strip() if not (st.startsWith(field & ":")): continue found = true # outer width for a per-mode array, else the single array width var inner = st[(st.find("array[") + "array[".len) .. ^1] if st.find("array[") != st.rfind("array["): inner = inner[(inner.find("array[") + "array[".len) .. ^1] let width = inner[0 ..< inner.find(',')].strip() check "B1: " & field & " is sized with NumRackGuns (got '" & width & "')", width == "NumRackGuns" break check "B2: " & field & " is still declared", found # No hard-coded per-gun width/loop bound narrower than the rack may survive. # Scoped to the gun-indexed accounting only: other arrays in the file (e.g. the # 12-bucket covering-arc histogram) are legitimately narrower. var offenders: seq[string] for i, line in lines: let code = line.split('#')[0] var guarded = false for field in gunArrays: if field in code: guarded = true if not guarded: continue for token in ["array[", "0..<", "..<", "initTracker("]: let at = code.find(token) if at < 0: continue var rest = code[at + token.len .. ^1].strip(chars = {' ', '('}) var digits = newStringOfCap(4) for ch in rest: if ch in {'0' .. '9'}: digits.add ch else: break if digits.len == 0: continue if digits.len < rest.len and rest[digits.len] in {',', ')', '.'}: continue if parseInt(digits) < NumRackGuns: offenders.add "line " & $(i + 1) & ": " & line.strip() check "B3: no per-gun width or loop bound is hard-coded below the rack size", offenders.len == 0 for o in offenders: echo " offender: ", o var literal17: seq[string] = @[] for i, line in lines: if line.split('#')[0].find("array[17,") >= 0 or line.split('#')[0].find("0..<17") >= 0: literal17.add "line " & $(i + 1) & ": " & line.strip() check "B4: the old literal 17 is gone from the accounting (comments exempt)", literal17.len == 0 for o in literal17: echo " offender: ", o check "B5: the accounting arrays are adjacent to a documented width note", "NumRackGuns" in src # ── C. the accounting shape, in process ────────────────────────────────────── proc testAccounting() = # Exactly the declaration the live bot now uses. var gunSelectionCount: array[NumRackGuns, int] var gunRealShots: array[NumRackGuns, int] var gunRealHits: array[NumRackGuns, int] # The rack that regressed: ONLY the newest gun admitted, in both modes. var membership = DefaultRackMembership for i in 0../out/.env`, so the live check has to write there. # It backs the owner's file up to `/.env.j141-backup` FIRST and restores # it in a `finally`, so even a hard crash leaves a recoverable copy. let dotenv = repoRoot / "ModularBot_garage" / "out" / ".env" let backup = repoRoot / "ModularBot_garage" / "out" / ".env.j141-backup" let hadDotenv = fileExists(dotenv) if hadDotenv: copyFile(dotenv, backup) defer: if hadDotenv: copyFile(backup, dotenv) else: removeFile(dotenv) removeFile(backup) var linesOut: seq[string] for l in readFile(backup).splitLines(): if l.strip().startsWith("GUN_STATS_PATH"): linesOut.add "GUN_STATS_PATH=" & statsPath elif l.strip().startsWith("TR_RACK_BITBRAIN"): linesOut.add "TR_RACK_LEADGAIN=both" else: linesOut.add l writeFile(dotenv, linesOut.join("\n")) echo "NOTE: live section wrote a temporary out/.env (backup at out/.env.j141-backup)." echo "Running one live round (rack: only rack id ", NewestId, ")..." let compiled = compileBots(@[repoRoot / "ModularBot_garage", adversaryDir]) echo "compiled: ", compiled ensureServer() discard runBattleRunner(getServerUrl(), @[repoRoot / "ModularBot_garage", adversaryDir], 1, 300_000, true) removeFile(statsPath) if not fileExists(statsPath): check "D1: the bot wrote its per-round gun stats", false return let r = lastRound(statsPath) check "D1: the bot wrote its per-round gun stats", r != nil if r == nil: return let guns = r["guns"] check "D2: the stats dump has one row per rack gun", guns.len == NumRackGuns var selected = 0 var shots = 0 var hits = 0 for g in guns: selected += g["selected"].getInt() shots += g["realShots"].getInt() hits += g["realHits"].getInt() echo " selected=", selected, " realShots=", shots, " realHits=", hits check "D3: a gun IS selected (the selector reaches the newest id)", selected > 0 check "D4: shots ARE fired", shots > 0 check "D5: hits ARE landed", hits > 0 when isMainModule: testRackTable() testSourceShape() testAccounting() if paramCount() > 0 and paramStr(1) == "--live": live() else: echo "SKIP: live section (pass --live with the JAR env vars)" echo "" if failures == 0: echo "ALL CHECKS PASSED" else: echo failures, " CHECK(S) FAILED" quit(1)