From 752d3a3829bdb112dbaa597ab3dfef94ce2ec555 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sat, 26 Sep 2026 11:02:39 +0200 Subject: [PATCH] j129: virtual-bullet debug overlay (TR_VBULLET_DEBUG, default off) - travelled path, predicted aim ring, hit/miss vector; shared turret colour table --- ModularBot_garage/src/ModularBot.nim | 54 ++-- ModularBot_garage/src/env_report.nim | 13 + ModularBot_garage/src/vbullet_draw.nim | 244 ++++++++++++++++++ ModularBot_garage/tests/test_vbullet_draw.nim | 172 ++++++++++++ docs/env_reference.md | 16 ++ 5 files changed, 480 insertions(+), 19 deletions(-) create mode 100644 ModularBot_garage/src/vbullet_draw.nim create mode 100644 ModularBot_garage/tests/test_vbullet_draw.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 9b59825..674a2e5 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -43,6 +43,7 @@ import movement_harness/bullet_shadows import targeting/enemy_tracker import targeting/target_selector import env_report +import vbullet_draw const botJsonPath = currentSourcePath().parentDir / "ModularBot.json" const DebugVBullets = false @@ -160,6 +161,17 @@ const TmHorizonId = 15 ## head — hence every other gun's learning order — is byte-for-byte unchanged. const BitBrainId = 16 +## ── virtual-bullet debug overlay (TR_VBULLET_DEBUG*) ─────────────────────── +## OFF by default: the shipped bot draws nothing. When on, the tracker's +## virtual bullets are overlaid so the training signal the selector learns +## from is visible (travelled path, predicted aim point, miss vector). The +## colour table is shared with the turret (see `vbullet_draw.gunColors`). +## Default filter: ONLY the currently selected gun, to stay legible; set +## `TR_VBULLET_DEBUG_GUN=all` or name a gun (e.g. `Pattern`). +let VBulletDebugOn = vBulletDebugEnabled() +let VBulletDebugMax = vBulletDebugMaxBullets() +let VBulletDebugGun = resolveGunFilter(getEnv(VBulletDebugGunEnv, ""), GunNames) + const CLR_GUN = "\e[33m" # yellow CLR_MOVE = "\e[36m" # cyan @@ -1267,6 +1279,11 @@ method run*(bot: ModularBot) = for id, es in bot.enemyTracker.enemies: enemyPositions[id] = (x: es.x, y: es.y, lastSeenTick: es.lastSeenTick, alive: es.alive) + # Debug-only: bullets that resolve during `tickBullets` below are cleared + # from the tracker in the same tick, so capture their outcome here for the + # overlay's miss vector. Empty (and unused) when the overlay is off. + var resolvedMarks: seq[VbResolved] + let st = bot.lastState bot.tracker.tickBullets(st, enemyPositions, proc(gunId: GunId, binIdx: int, fe: FeedbackEvent) = case gunId @@ -1294,6 +1311,10 @@ method run*(bot: ModularBot) = of BitBrainId: bot.bitbrain.onResult(fe) else: discard if fe.hit: inc bot.virtualHits else: inc bot.virtualMiss + if VBulletDebugOn: + resolvedMarks.add VbResolved( + gunId: gunId, aimX: fe.prediction.x, aimY: fe.prediction.y, + actualX: fe.actualX, actualY: fe.actualY, hit: fe.hit) when DebugVBullets: let total = bot.virtualHits + bot.virtualMiss let pct = if total > 0: bot.virtualHits.float / total.float * 100.0 else: 0.0 @@ -1320,25 +1341,11 @@ method run*(bot: ModularBot) = if selectedGun != bot.currentGun: bot.currentGun = selectedGun bot.cfgDirty = true # gun switch: emit the config line at tick end - case selectedGun - of 0: setTurretColor("#FFFFFF"); setBulletColor("#FFFFFF") # HEADON: pure white (was #FF3333 red) - of 1: setTurretColor("#3366FF"); setBulletColor("#6699FF") - of 2: setTurretColor("#9933FF"); setBulletColor("#CC66FF") - of 3: setTurretColor("#33CC33"); setBulletColor("#66FF66") - of 4: setTurretColor("#FFCC00"); setBulletColor("#FFE066") - of 5: setTurretColor("#00CC44"); setBulletColor("#66FF99") # PATTERN: green (was #FF6600 orange, too close to BitBrain's pink) - of 6: setTurretColor("#CCCCCC"); setBulletColor("#EEEEEE") - of 7: setTurretColor("#FF00FF"); setBulletColor("#FF66FF") - of 8: setTurretColor("#990000"); setBulletColor("#CC3333") - of 9: setTurretColor("#006600"); setBulletColor("#009900") - of 10: setTurretColor("#996633"); setBulletColor("#CC9966") - of 11: setTurretColor("#008888"); setBulletColor("#00AAAA") - of 12: setTurretColor("#CC00CC"); setBulletColor("#FF44FF") - of 13: setTurretColor("#00FFCC"); setBulletColor("#66FFDD") - of TmPatternId: setTurretColor("#AAFF00"); setBulletColor("#CCFF66") - of TmHorizonId: setTurretColor("#00AAFF"); setBulletColor("#66CCFF") - of BitBrainId: setTurretColor("#FF1493"); setBulletColor("#FF69B4") - else: discard + # Per-gun colours live in `vbullet_draw.gunColors`, shared with the + # virtual-bullet overlay so turret and overlay can never drift apart. + let (turretColor, bulletColor) = gunColors(selectedGun) + setTurretColor(turretColor) + setBulletColor(bulletColor) let pred = case selectedGun of 1: bot.linear.predict(bot.lastState, bulletSpeed(power)) @@ -1387,6 +1394,12 @@ method run*(bot: ModularBot) = setGunTurnRate(normDelta) + # Virtual-bullet overlay (default off). Drawn AFTER selection so the + # default "selected gun only" filter uses this tick's choice. + if VBulletDebugOn: + discard drawVirtualBullets(bot.tracker, resolvedMarks, selectedGun, + VBulletDebugGun, VBulletDebugMax, GunNames) + # Emit exactly one config line per tick, at the END of the tick, so the gun # field reflects the selection made above rather than the previous tick's. if bot.cfgDirty: @@ -1449,6 +1462,9 @@ when isMainModule: printEnvReport(EnvReportContext( movementName: MovementName, vBulletAdmitOnly: VBulletAdmitOnly, + vBulletDebug: VBulletDebugOn, + vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""), + vBulletDebugMax: VBulletDebugMax, recordWorldState: RecordWorldState, radarForceSpin: RadarForceSpin, radarScanLog: RadarScanLog, diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 53952a9..56cfacb 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -32,6 +32,7 @@ import movements/learned_surfer import guns/tm_horizon import guns/bitbrain_gun import guns/pattern_matcher +import vbullet_draw const EnvReportEnableEnvVar* = "TR_ENV_REPORT" @@ -44,6 +45,9 @@ type ## diverge from what the bot will actually use. movementName*: string vBulletAdmitOnly*: bool + vBulletDebug*: bool + vBulletDebugGun*: string + vBulletDebugMax*: int recordWorldState*: bool radarForceSpin*: bool radarScanLog*: bool @@ -197,6 +201,14 @@ proc printEffectiveValues(ctx: EnvReportContext) = emit("TR_MOVEMENT_LOG", onOff(MovementLog), sourceOfPresence("TR_MOVEMENT_LOG")) emit("TR_VBULLET_ADMIT_ONLY", onOff(ctx.vBulletAdmitOnly), sourceOf("TR_VBULLET_ADMIT_ONLY")) + emit("TR_VBULLET_DEBUG", onOff(ctx.vBulletDebug), + sourceOfPresence(VBulletDebugEnv)) + emit("TR_VBULLET_DEBUG_GUN", + (if ctx.vBulletDebugGun.len == 0: "(unset -> selected gun)" + else: ctx.vBulletDebugGun), + sourceOf(VBulletDebugGunEnv)) + emit("TR_VBULLET_DEBUG_MAX", $ctx.vBulletDebugMax, + sourceOf(VBulletDebugMaxEnv)) emit("TR_POWER_LOG", onOff(ctx.powerLog), sourceOfPresence("TR_POWER_LOG")) emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG")) emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState), @@ -469,6 +481,7 @@ proc knownEnvNames*(): seq[string] = "TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE", "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, "TR_POWER_LOG", "TR_RESULT_LOG", # the .env loader's own environment hook (TR_ENV_FILE names the file) diff --git a/ModularBot_garage/src/vbullet_draw.nim b/ModularBot_garage/src/vbullet_draw.nim new file mode 100644 index 0000000..551473d --- /dev/null +++ b/ModularBot_garage/src/vbullet_draw.nim @@ -0,0 +1,244 @@ +## Debug overlay for the virtual-bullet tracker (the training signal the gun +## selector learns from). DEFAULT OFF: when the env knobs are unset the shipped +## bot draws exactly what it drew before. +## +## Why this lives in `ModularBot_garage/src` and not next to the tracker: the +## tracker is shared with the offline range, while the overlay is a GUI-only +## concern of the live bot. It imports the bot API's debug-graphics module to +## emit SVG, and the tracker to read the bullets. +## +## Visual vocabulary (one colour per gun, the SAME table as the turret): +## +## path thin line from the fire point along the fire direction, as far as +## the bullet has actually travelled (`travelDist`). +## aim small hollow circle at the PREDICTED target position (`aimX/Y`). +## miss short line from the aim point to the closest-approach point, plus +## a HIT dot (filled) / MISS ring (hollow) there. This is the score. +## +## The colour table is `gunColors`, shared with `ModularBot.run`'s turret +## colouring, so the overlay and the turret can never drift apart. +## +## Env knobs (all read once at boot by ModularBot and registered in +## `env_report.nim`): +## TR_VBULLET_DEBUG presence/1 = on. default off. +## TR_VBULLET_DEBUG_GUN "" = selected gun only (default); "all"/"*" = every +## gun; otherwise a gun name (e.g. `Pattern`). +## TR_VBULLET_DEBUG_MAX cap on bullets drawn per tick. default 32. + +import std/[math, os, strutils, algorithm] +import robocode_tankroyale_botapi +# The bot API also exports a case-insensitively identical `BOT_RADIUS`, so bring +# the harness module in qualified only and reference `gun_interface.BotRadius`. +from gun_harness/gun_interface import nil +import gun_harness/virtual_bullets as vb + +const + VBulletDebugEnv* = "TR_VBULLET_DEBUG" + VBulletDebugGunEnv* = "TR_VBULLET_DEBUG_GUN" + VBulletDebugMaxEnv* = "TR_VBULLET_DEBUG_MAX" + VBulletDebugDefaultMax* = 32 + ## Bullets drawn per tick when `TR_VBULLET_DEBUG_MAX` is unset. With the + ## default SELECTED-gun filter a gun spawns at most one flight per power bin + ## per tick (~3-4 in flight), so 32 is generous while still bounding the + ## worst case: at most 32 paths + 32 aim rings + miss vectors. A wall of + ## 200 overlapping lines is a failed feature, so the cap is enforced. + + VbFilterSelected* = -1 + VbFilterAll* = -2 + + VbPathWidth* = 1.0 + VbAimRadius* = 5.0 + VbAimWidth* = 1.5 + VbMarkRadius* = 3.0 + +# ── env resolution (pure, so a test can pin it) ────────────────────────────── + +proc vBulletDebugEnabled*(): bool = + ## Presence/1 = on; explicit 0/false/no/off = off; unset = off (shipped). + let v = getEnv(VBulletDebugEnv, "").strip().toLowerAscii() + v.len > 0 and v notin ["0", "false", "no", "off"] + +proc vBulletDebugMaxBullets*(): int = + ## `TR_VBULLET_DEBUG_MAX`, clamped to at least 1. Bad values fall back. + try: + result = max(1, parseInt(getEnv(VBulletDebugMaxEnv, "").strip())) + except ValueError: + result = VBulletDebugDefaultMax + +proc resolveGunFilter*(spec: string, names: openArray[string]): int = + ## "" -> selected gun only; "all"/"*" -> every gun; a gun name + ## (case-insensitive) -> that gun; unknown -> selected (safe fallback). + let s = spec.strip() + if s.len == 0: return VbFilterSelected + let low = s.toLowerAscii() + if low == "all" or low == "*": return VbFilterAll + for i, n in names: + if cmpIgnoreCase(n, s) == 0: return i + VbFilterSelected + +# ── the shared per-gun colour table ────────────────────────────────────────── + +proc gunColors*(gunId: int): tuple[turret, bullet: string] = + ## SINGLE source of truth for the per-gun colours, used by BOTH the live + ## turret colouring in `ModularBot.run` and this overlay. Keep gun ids in + ## step with `GunNames`. + case gunId + of 0: ("#FFFFFF", "#FFFFFF") # HeadOn — white + of 1: ("#3366FF", "#6699FF") # Linear — blue + of 2: ("#9933FF", "#CC66FF") # Tsetlin — violet + of 3: ("#33CC33", "#66FF66") # Circular — green + of 4: ("#FFCC00", "#FFE066") # GuessFactor — amber + of 5: ("#00CC44", "#66FF99") # Pattern — green + of 6: ("#CCCCCC", "#EEEEEE") # WallBounce — grey + of 7: ("#FF00FF", "#FF66FF") # Accel — magenta + of 8: ("#990000", "#CC3333") # StopShot — dark red + of 9: ("#006600", "#009900") # Displace — dark green + of 10: ("#996633", "#CC9966") # AvgLead — brown + of 11: ("#008888", "#00AAAA") # DecayGF — teal + of 12: ("#CC00CC", "#FF44FF") # KNN — purple + of 13: ("#00FFCC", "#66FFDD") # TMSelect — cyan + of 14: ("#AAFF00", "#CCFF66") # TMPattern — lime + of 15: ("#00AAFF", "#66CCFF") # TMHorizon — sky + of 16: ("#FF1493", "#FF69B4") # BitBrain — deep pink + else: ("#FFFFFF", "#FFFFFF") + +proc gunTurretColor*(gunId: int): string = gunColors(gunId).turret +proc gunBulletColor*(gunId: int): string = gunColors(gunId).bullet + +# ── primitives ─────────────────────────────────────────────────────────────── + +type + VbPrimKind* = enum + vpPath ## travelled path (line) + vpAim ## predicted aim point (hollow circle) + vpMiss ## aim -> closest-approach vector (line) + vpHit ## hit at closest approach (filled circle) + vpMissMark ## miss at closest approach (hollow circle) + + VbPrim* = object + kind*: VbPrimKind + x1*, y1*: float ## line start / circle centre + x2*, y2*: float ## line end (circles ignore) + r*: float ## circle radius + gunId*: int + color*: string + + VbResolved* = object + ## One bullet resolution captured from `onResolved`, so the miss vector of + ## a bullet that DISAPPEARED this tick is still visible (the tracker clears + ## `active` the moment it resolves). + gunId*: int + aimX*, aimY*: float + actualX*, actualY*: float + hit*: bool + +proc matchesFilter(gunId, selectedGun, filterGun: int): bool {.inline.} = + if filterGun == VbFilterAll: return true + if filterGun == VbFilterSelected: return gunId == selectedGun + gunId == filterGun + +proc collectVirtualBulletPrims*(t: VirtualTracker, selectedGun, filterGun, + maxBullets: int): seq[VbPrim] = + ## Pure: the draw primitives for the tracker's ACTIVE bullets, newest first, + ## capped at `maxBullets` bullets (a bullet may add up to 4 primitives). + var drawn = 0 + for k in 1 .. MaxBullets: + if drawn >= maxBullets: break + let i = (t.head - k + MaxBullets) mod MaxBullets + let b = t.bullets[i] + if not b.active: continue + if not matchesFilter(b.gunId, selectedGun, filterGun): continue + inc drawn + + let dx = b.aimX - b.fireX + let dy = b.aimY - b.fireY + let dist = hypot(dx, dy) + var ux, uy: float + if dist > 1e-6: + ux = dx / dist + uy = dy / dist + let curX = b.fireX + ux * b.travelDist + let curY = b.fireY + uy * b.travelDist + let col = gunTurretColor(b.gunId) + + result.add VbPrim(kind: vpPath, x1: b.fireX, y1: b.fireY, x2: curX, y2: curY, + gunId: b.gunId, color: col) + result.add VbPrim(kind: vpAim, x1: b.aimX, y1: b.aimY, r: VbAimRadius, + gunId: b.gunId, color: col) + if b.bestMissDist < Inf: + result.add VbPrim(kind: vpMiss, x1: b.aimX, y1: b.aimY, + x2: b.bestMissX, y2: b.bestMissY, + gunId: b.gunId, color: col) + result.add VbPrim( + kind: (if b.bestMissDist < gun_interface.BotRadius: vpHit else: vpMissMark), + x1: b.bestMissX, y1: b.bestMissY, r: VbMarkRadius, + gunId: b.gunId, color: col) + +proc collectResolvedPrims*(resolved: seq[VbResolved], selectedGun, filterGun, + maxBullets: int): seq[VbPrim] = + ## Pure: miss-vector + hit/miss marker for bullets that resolved this tick. + ## Works for BOTH metrics (the point metric records no running best-miss). + var drawn = 0 + for r in resolved: + if drawn >= maxBullets: break + if not matchesFilter(r.gunId, selectedGun, filterGun): continue + inc drawn + let col = gunTurretColor(r.gunId) + result.add VbPrim(kind: vpMiss, x1: r.aimX, y1: r.aimY, + x2: r.actualX, y2: r.actualY, gunId: r.gunId, color: col) + result.add VbPrim(kind: (if r.hit: vpHit else: vpMissMark), + x1: r.actualX, y1: r.actualY, r: VbMarkRadius, + gunId: r.gunId, color: col) + +# ── emit to the GUI debug-graphics buffer ──────────────────────────────────── + +proc drawVbLegend(prims: seq[VbPrim], names: openArray[string]) = + ## One colour swatch + gun name per gun that contributed a primitive, top-left + ## corner. Cheap and it makes the colours self-explanatory. + var seen: seq[int] + for p in prims: + if p.gunId notin seen: seen.add p.gunId + seen.sort() + var y = 24.0 + for g in seen: + if g < 0 or g >= names.len: continue + setFillColor(gunTurretColor(g)) + fillRectangle(8.0, y - 10.0, 10.0, 10.0) + setFillColor("#FFFFFF") + setFont("Arial", 12.0) + drawText(names[g], 22.0, y) + y += 16.0 + +proc emitVbPrims*(prims: seq[VbPrim], names: openArray[string] = []): int = + ## Emit primitives into the current tick's SVG buffer and return the count. + ## Thin lines so a busy sky stays readable. + for p in prims: + case p.kind + of vpPath, vpMiss: + setStrokeColor(p.color) + setStrokeWidth(VbPathWidth) + drawLine(p.x1, p.y1, p.x2, p.y2) + of vpAim: + setStrokeColor(p.color) + setStrokeWidth(VbAimWidth) + drawCircle(p.x1, p.y1, p.r) + of vpHit: + setFillColor(p.color) + fillCircle(p.x1, p.y1, p.r) + of vpMissMark: + setStrokeColor(p.color) + setStrokeWidth(VbAimWidth) + drawCircle(p.x1, p.y1, p.r) + if names.len > 0: + drawVbLegend(prims, names) + result = prims.len + +proc drawVirtualBullets*(t: VirtualTracker, resolved: seq[VbResolved], + selectedGun, filterGun, maxBullets: int, + names: openArray[string]): int = + ## Entry point called once per tick by ModularBot. Returns primitives drawn. + let active = collectVirtualBulletPrims(t, selectedGun, filterGun, maxBullets) + let rest = collectResolvedPrims(resolved, selectedGun, filterGun, maxBullets) + var prims = active + prims.add rest + emitVbPrims(prims, names) diff --git a/ModularBot_garage/tests/test_vbullet_draw.nim b/ModularBot_garage/tests/test_vbullet_draw.nim new file mode 100644 index 0000000..7b5d220 --- /dev/null +++ b/ModularBot_garage/tests/test_vbullet_draw.nim @@ -0,0 +1,172 @@ +## Deterministic, offline check of the virtual-bullet overlay (`vbullet_draw`). +## +## NO battle, NO Java, NO server: it fabricates a tracker state, calls the same +## `collectVirtualBulletPrims` / `emitVbPrims` the live bot calls, and asserts +## the primitive counts, the geometry (path and aim point are collinear), the +## filter and cap behaviour, and the SVG emission. It also pins the shared +## turret/overlay colour table. +## +## Run: nim c -r ModularBot_garage/tests/test_vbullet_draw.nim + +import std/[math, strutils, tables, sequtils] +import robocode_tankroyale_botapi +import gun_harness/gun_interface +import gun_harness/virtual_bullets as vb +import vbullet_draw + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: echo "PASS: ", name + else: echo "FAIL: ", name; inc failures + +# ── a fixed tracker: gun 1 (Linear) and gun 5 (Pattern), 4 power bins each ──── +const SelfX = 100.0 +const SelfY = 100.0 +const AimX = 300.0 +const AimY = 300.0 +const TargetId = 7 + +proc makeTracker(): VirtualTracker = + result = vb.initTracker(17) + let s0 = WorldState(selfX: SelfX, selfY: SelfY, + enemyX: AimX, enemyY: AimY, enemySpeed: 0.0, + enemyHeading: 0.0, arenaWidth: 1000.0, arenaHeight: 1000.0, + tick: 0) + var preds: array[len(PowerBins), GunPrediction] + for i in 0.. 0 and sel.allIt(it.gunId == 1) + check "named filter (Pattern id 5) yields only gun 5", + p5.len > 0 and p5.allIt(it.gunId == 5) + check "all filter yields both guns", + all.len == sel.len + p5.len + +proc testCap() = + var t = makeTracker() + let one = collectVirtualBulletPrims(t, 1, VbFilterAll, 1) + var bullets = 0 + for p in one: + if p.kind == vpPath: inc bullets + check "cap=1 draws exactly one bullet", bullets == 1 + +proc testResolved() = + let resolved = @[ + VbResolved(gunId: 1, aimX: AimX, aimY: AimY, + actualX: AimX + 25.0, actualY: AimY, hit: false), + VbResolved(gunId: 5, aimX: AimX, aimY: AimY, + actualX: AimX + 3.0, actualY: AimY, hit: true), + ] + let miss = collectResolvedPrims(resolved, 1, VbFilterAll, 100) + check "one resolved bullet -> miss vector + marker (2 prims): " & $miss.len, + miss.len == 4 + check "first resolved is a MISS marker", + miss[0].kind == vpMiss and miss[1].kind == vpMissMark + check "second resolved is a HIT marker", + miss[2].kind == vpMiss and miss[3].kind == vpHit + +proc testEmit() = + clearGraphics() + var t = makeTracker() + let prims = collectVirtualBulletPrims(t, 1, VbFilterSelected, 100) + let n = emitVbPrims(prims, ["HeadOn", "Linear", "Tsetlin", "Circular", + "GuessFactor", "Pattern"]) + let svg = svgOutput() + check "emit returns the primitive count: " & $n, n == prims.len + check "SVG carries one per path primitive", + svg.count(" 0: + echo "\n", failures, " check(s) FAILED" + quit(1) +echo "\nAll vbullet-draw checks passed." diff --git a/docs/env_reference.md b/docs/env_reference.md index e62501d..4bb2243 100644 --- a/docs/env_reference.md +++ b/docs/env_reference.md @@ -449,6 +449,22 @@ Measured byte-identical on `bmPath`. Kept for experiments; leave at defaults. | `TR_RADAR_SCAN_LOG_PATH` | `/tmp/radar_scan_log.jsonl` | where that goes | | `TR_TRACKER_PROBE` | off | presence-based; per-tick enemy tracker vs server enemy count | | `TR_TRACKER_PROBE_PATH` | `/tmp/tracker_probe.jsonl` | where that goes | +| `TR_VBULLET_DEBUG` | off | presence-based; overlay the virtual bullets in the GUI debug graphics (see below) | +| `TR_VBULLET_DEBUG_GUN` | selected gun | `all`/`*` for every gun, or a gun name (e.g. `Pattern`); unset = only the currently selected gun | +| `TR_VBULLET_DEBUG_MAX` | `32` | cap on bullets drawn per tick | + +The **virtual-bullet overlay** (`TR_VBULLET_DEBUG`, off by default) makes the +selector's training signal visible. Turn it on with: + + TR_VBULLET_DEBUG=1 TR_VBULLET_DEBUG_GUN=all ./out/ModularBot + +- **travelled path** — thin line from the fire point along the fire direction, + as far as the bullet has actually flown. +- **aim ring** — small hollow circle at the predicted target position. +- **miss vector** — line from the aim ring to the closest-approach point; a + filled dot there is a HIT, a hollow ring a MISS (the score the selector sees). +- colour per gun is the SAME table as the turret (`vbullet_draw.gunColors`), with + a one-line legend in the top-left corner. The **adaptive-melee radar** has no env knobs. Its tuning lives in compile-time constants in `radars/adaptive_melee_radar.nim:36-50`: `MaxRadarTurnRate=45`,