8 Commits

Author SHA1 Message Date
SirStone de0dd50d6e Purge AGENTS.md of code explanation; keep inventory and diagrams
AGENTS.md is now an inventory of what exists plus the mermaid behaviour
diagrams — not a narration of the code. The source and its doc comments are
the carrier of truth for how the bot works; a prose retelling in AGENTS.md
only goes stale against them.

Removed: blocks that duplicated what the source already says, a stale line
that contradicted DevControlBot.sh's actual no-argument behaviour, and the
prior bug-fix history (which is a record of the past, not guidance for the
next change). 384 -> 238 lines.

Kept byte-identical: the behaviour diagrams, the rules (git, version/tag,
colour convention), the build and run commands, and the physics and
coordinates references.

Two constraints stay as one-liners, because they are the two ways this bot
has actually been broken: command the radar BEFORE go(), and keep all radar
logic in modules/radar.nim.
2026-10-04 16:55:05 +02:00
SirStone caa096d6c9 Add radar lock and melee sweep to DevControlBot
The bot now drives its radar every tick. It still never moves and never
fires: body, turret and gun stay white ("not programmed"), while the
radar paints itself black at init to mark itself as working.

- modules/radar.nim replaces the earlier multi-module radar design with a
  single module holding the whole of it: target memory, mode selection
  and both commands
- two commands, picked from the only fact the bot is sure of (the API's
  getEnemyCount()): one enemy plus a target in hand -> LOCK, servo onto
  its bearing; anything else -> SWEEP at 45 deg/tick, the radar's physics
  cap, a full revolution every 8 ticks
- the lock turns by the delta to the target's bearing plus 5 deg PAST it,
  so the radar CROSSES the bearing every tick instead of settling on it.
  That crossing is what produces a fresh ScannedBotEvent every tick: a
  scan per tick, with no rescan to wait for. Turning by the bare error
  parks the radar on the bearing and the scans stop
- commandRadar() is called before go() on every path, so there is no idle
  branch to fall into and switching modes costs zero turns
- idea derived from ModularBot's radar (Apache-2.0) via common_libs/;
  common_libs/ is not edited in place
- AGENTS.md gains mermaid behaviour diagrams plus pointers to the physics
  and coordinate references
- version bumped to 1.1.0 in both DevControlBot.nimble and
  DevControlBot.json so the two agree

Compiled against robocode_tankroyale_botapi 1.0.7.
2026-10-04 16:22:42 +02:00
SirStone 305c3977a6 Add PLAN.md rule, garage .gitignore, remove hardcoded server port hint
- AGENTS.md: mark PLAN.md as user-owned (never modify; it stays tracked)
- move the .env ignore rule to the garage root so it covers any depth
- drop the hardcoded "port 7654" hint from the no-server message; the target
  is now reported from the resolved SERVER_URL. The 7654 fallback remains
  only as the upstream TR default.
2026-10-03 16:39:02 +02:00
SirStone 59fad00b5c Add DevControlBot garage: skeleton bot with release/debug build script
A minimal Tank Royale bot that boots and stands still. Body, gun and radar
are set to white once at init (team convention: white = not programmed yet);
the tick loop only calls go().

- DevControlBot/ holds the single bot module, its metadata JSON, the
  BotLauncher entry script and the nimble file
- DevControlBot.sh is a thin wrapper around the runBot nimble task:
  release build by default, --debug for the classic diagnostic build
- dependencies resolve at run time from the global nimble store via
  `nimble path`; optional .env (gitignored) supplies env vars such as
  SERVER_URL, with existing environment variables taking precedence
- build happens in a throwaway mktemp dir removed on exit; no artifacts
2026-10-03 16:32:27 +02:00
SirStone 7632aaba06 docs: record the aim-capture blind spot - aim_fire only logs shots that passed setFire
j178 flagged it and it is real: the capture cannot show WHY a shot did not
happen (gun still hot, turret not aligned). A missing aim_fire record is
ambiguous, not a refusal. Docs only, no code change. Adds the TR_CAPTURE_AIM
row + a 'Known limitations' note to docs/env_reference.md section 8, and a
two-line pointer next to the knob in .env.example.
2026-09-27 18:53:59 +02:00
SirStone 208f4a9092 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].
2026-09-27 18:44:24 +02:00
pi 55e92bc5e0 docs: the range-vs-approach ceiling - melee unavailable vs DrussGT, the 16 deg is a lead-model number, pre-multiply premise dead 2026-09-27 15:05:14 +02:00
SirStone 486e2a69c6 docs(env): 6-block env reference + .env.example, every claim traceable
Rewrites docs/env_reference.md and ModularBot_garage/.env.example so a reader
can act on the file without re-deriving anything, and so every claim in it
can be checked.

WHAT
  177 knobs documented across a 6-block format:
  WHAT / VALUES / STATUS / GOTCHA / TRY. Values are the built-in defaults,
  so `.env.example` is behaviourally identical to a clean run. No default
  value changed anywhere; the only added key is TR_FIRE_LAG=0, which is real
  (fire_tracker.nim:164).

WHY (traceability)
  Every STATUS line now cites the job or commit behind the claim it makes.
  A documented default is only useful if you can tell whether it was
  verified or copied by hand; the citation makes that decidable without
  re-running the experiment.

  The presence-gated list was wrong: it claimed 7 knobs, the true number is
  10. Three knobs were also wrongly labelled presence-gated; they are
  value-based and are now documented as such.

  All 31 `# TRY:` example values were checked against the code that parses
  them, so no example is rejected when copied.

VERIFICATION
  Round-trip (j172 probe: printEnvReport clean vs .env.example applied
  through the repo's own env_dotenv loader, reports diffed): 0 mismatches,
  0 warnings, 0 dropped keys (177 in file, 177 seen). Re-run after this
  commit's comment edit, unchanged.
  test_tfil_commit_env 159 PASS / 0 FAIL
  test_env_report        25 PASS / 0 FAIL
  test_tfil_ring_weights 24 PASS / 0 FAIL (earlier in the series)

Also drops the stale "snapshot of commit 5e32ec1" pin from .env.example: a
pinned hash goes stale the moment the next commit lands, which makes the
"regenerate when a default changes" instruction worse than none. The line
now just says the values mirror current defaults.
2026-09-27 14:52:16 +02:00
18 changed files with 2326 additions and 65 deletions
+2
View File
@@ -0,0 +1,2 @@
# User-authored environment variables (e.g. SERVER_URL) — never committed
.env
+239
View File
@@ -0,0 +1,239 @@
# DevControlBot — Garage Agent Guide
> ## ⚠️ READ THIS FIRST — THIS GARAGE IS *INTENTIONALLY* NOT THE ROOT TEMPLATE
>
> **The layout and format described in the repo-root [`../../AGENTS.md`](../../AGENTS.md)
> template do NOT apply to `DevControlBot_garage/`.** The root template documents a
> `src/<bot>.nim` / `tests/` / `out/` convention for garages. **This garage deviates
> from it on purpose and must stay that way.**
>
> **Do NOT, even to "fix" a perceived inconsistency:**
> - create `src/` or `out/`, or put anything inside `DevControlBot/` for testing
> (the one exception is the intended `DevControlBot_garage/tests/`, see
> "Layout of this folder" — it does not exist yet, create it only when the
> first test is actually written)
> - add a `config.nims`, `nimble.paths`, `--path` flags, or a vendor/ directory
> - restructure `DevControlBot/` to match the root template
>
> Builds go to a `mktemp -d` throwaway dir that is removed on exit — nothing is
> ever written here. The root `AGENTS.md` and its folder conventions are for the
> **other** garages; here it is context only (issue tracker, Nim conventions), and
> is **NOT a layout contract for this folder**. If the root template and this file
> disagree about layout, **this file wins.**
> ## ⚠️ DO NOT TOUCH `PLAN.md`
>
> **[`PLAN.md`](PLAN.md) is the user's personal working notes file.** The user
> maintains it and edits it exclusively.
>
> - Agents must **NEVER** modify, rewrite, reformat, move, delete or commit
> changes to `PLAN.md` — unless the user explicitly asks in that conversation.
> - Reading it is **not** assumed or expected; do not open it by default.
> - It **IS** tracked in git and is intended to be committed (deliberately added
> in commit `59fad00b`). Do **NOT** gitignore it and do **NOT**
> `git rm --cached` it.
## Start here
The code is the source of truth; this file is an inventory of what exists and how
the pieces interact — not a description of what the code does. If the two
disagree, the code wins and this file is stale.
The human-facing description lives in [README.md](README.md). Repo-wide rules live
in [`../../AGENTS.md`](../../AGENTS.md) (issue tracker, Nim conventions, test
framework) — read it for that context, but note again: **its folder-layout
template does not govern this garage.**
## Conventions
### Git rule for this garage
- **EVERYTHING** under `DevControlBot_garage/` gets committed — sources, modules,
docs, `PLAN.md` — **unless it is explicitly listed in a `.gitignore`**.
- This includes files that look like scratch or generated output, and files an agent
did not create: if not gitignored, they belong in the commit.
- Do not silently leave a file untracked, and do **not** add entries to
`.gitignore` to avoid committing something. The only things that stay out are
those the user has deliberately gitignored (e.g. `.env`).
### Version bumps and tags — the user's decision
Agents **MUST NOT** bump the version in `DevControlBot.nimble` or
`DevControlBot.json`, and **MUST NOT** create git tags. A version bump and its tag
are honoured **only** when the user explicitly asks in that conversation; a bump is
never a side effect of ordinary work or of a commit. Never amend, rewrite or retag
an existing commit or tag.
### Scope and colour
- This bot's code lives **only** inside `DevControlBot_garage/`. Do not edit other
garages to make this bot work, and do not reuse another bot's game logic
without explicit permission; shared, bot-agnostic code belongs in
`common_libs/`, and only with permission.
- Team colour convention: **white = not programmed, black = radar active**.
- Provenance: ModularBot / common_libs (Davide Cappellini, Apache-2.0).
### Constraints worth remembering
Not explanations — real invariants that broke things when violated:
- **Command the radar BEFORE `go()`** — a real bug the other way round.
- All radar logic is `modules/radar.nim`, in one piece. Do not split it back into
harness/lock/melee modules.
## Layout of this folder
```
DevControlBot_garage/
├── AGENTS.md # this file
├── README.md # human-facing description
├── PLAN.md # the user's own notes — never edit (see header)
├── .gitignore # ignores .env
├── DevControlBot/
│ ├── DevControlBot.nim # bot type + entry point (isMainModule)
│ ├── DevControlBot.json # bot metadata
│ ├── DevControlBot.nimble # single task: runBot
│ ├── DevControlBot.sh # BotLauncher entry point (thin wrapper)
│ ├── .env # OPTIONAL local config; secrets, never commit
│ └── modules/
│ └── radar.nim # all radar logic
└── tests/ # INTENDED, does not exist yet — create it on
# the first real test
```
- `DevControlBot/` holds only what the bot needs to run.
- `tests/` is a sibling of `DevControlBot/`, never inside it, and holds **test
code worth keeping**: drivers, harnesses, reusable battle/probe scripts.
- **Throwaway output does NOT go here or anywhere in the repo — it goes to
`/tmp`**: logs, temporary binaries, dumps, telemetry, scratch output.
- No `out/`, no binaries, no `nimbledeps/` here. Builds are throwaway by design.
## Build and run
Use the folder task, never raw `nim c`:
```sh
cd DevControlBot_garage/DevControlBot
nimble runBot # quiet release build + run
```
Or via the wrapper, which works from any cwd:
```sh
./DevControlBot/DevControlBot.sh # bundled metadata
./DevControlBot/DevControlBot.sh my.json # first positional arg, must end in .json
./DevControlBot/DevControlBot.sh --debug # classic debug build (hints + diagnostics)
./DevControlBot/DevControlBot.sh --help # usage, exits 0 without building
```
- **No arguments means RUN, not usage.** Usage prints only for `-h` / `--help`.
There is no `--json` flag; a first positional ending in `.json` overrides the
metadata, anything else is forwarded to the bot verbatim.
- **Gotcha:** `nimble runBot` must run from `DevControlBot/` (where the
`.nimble` lives); from `DevControlBot_garage/` it fails with
`Could not find a file with a .nimble extension...`. `compile`/`build` are
reserved Nimble builtin names — hence `runBot`.
- **Config is environment-only.** The bot API's `start()` reads `SERVER_URL` /
`SERVER_SECRET` itself; **never add env-reading code to `DevControlBot.nim`.**
The optional `.env` (loaded by the `runBot` task, `$DEVCONTROLBOT_ENV_FILE`
overrides the path) is local-runs-only and **never overrides a variable already
present in the environment** — the BotLauncher's values always win. Never print
its values, never `set -x`, never commit it.
- Dependency setup is `nimble install robocode_tankroyale_botapi`; dependencies
come from the global nimble store. Do not vendor, do not add
`nimble.paths` / `nimble develop`.
- Without a game server at `SERVER_URL` the run fails with
`[start] Cannot connect ... Connection refused`. That is expected locally, not a
bug.
- Exit codes are distinct because BotLauncher branches on them: `0` clean,
`1` no server (expected), `2` dependency missing, `3` compile failure,
anything else = the bot's own code.
## Tests
There are **no tests yet** — no `tests/` directory and no `test` task; `nimble test`
does not exist. When the first test is written, follow the repo-wide convention in
[`../../AGENTS.md`](../../AGENTS.md) and
`common_libs/test_framework/README.md`: `tests/config.nims` with
`--path:"../../common_libs"`, one `test<concern>.nim` per concern, a `test` task
in the `.nimble`, and a guard-first block so tests pass with no Java present.
## Coordinates & angles (source: robocode.dev/articles/coordinates-and-angles.html)
Source: https://robocode.dev/articles/coordinates-and-angles.html (Tank Royale docs)
- Cartesian coordinate system; (0, 0) is the bottom-left corner of the arena.
- Y up is implied by the origin being bottom-left (page never states it explicitly); X east is confirmed via the angle rules below.
- Angles follow classic trig (unlike original Robocode, whose 0/360 was north and 90 east).
- 0°/360° = east; 90° = north; 180° = west; 270° = south.
- Positive angles go counterclockwise; turning right decreases the angle (clockwise).
- Full turn = 360°.
- NOT stated on this page: distance formula, bearing/angle-between-two-points formula, angle normalization/wrapping, worked example numbers.
## Behaviour diagrams
**CURRENT STATE, not a design sketch.** These describe the code as it is today.
**Diagram 1 — one tick. Owned by `DevControlBot/DevControlBot.nim` (`run`, lines
34-40) and `DevControlBot/modules/radar.nim` (`onScan`, `commandRadar`) — update
this diagram when either file changes.** The ordering is the whole point: the
command is issued BEFORE `go()` so it travels with this tick's intent, and
events arrive AFTER, so a scan can only ever steer the NEXT tick.
```mermaid
sequenceDiagram
participant Run as run loop
participant Radar as radar.nim
participant Bot as bot + API
participant Srv as server
Run->>Radar: commandRadar<br/># one command, every tick, no idle branch
Radar-->>Bot: setRadarTurnRate<br/>LOCK turn + 5deg overshoot,<br/>or +45deg sweep
Run->>Bot: go<br/># intent sent here, FIRST
Bot->>Srv: tick intent<br/>radar turn only,<br/>no move, no fire
Srv-->>Bot: tick result<br/>+ pending ScannedBotEvent
Bot->>Radar: onScannedBot -> onScan x, y<br/># AFTER go: remembers only
Radar-->>Radar: target position stored,<br/>tick counter reset
Note over Run,Radar: that remembered target is used<br/>by the NEXT commandRadar
```
**Diagram 2 — radar behaviour. Owned by `DevControlBot/modules/radar.nim`
(`commandRadar`, lines 70-81) — update this diagram when that file changes.**
SWEEP is not a separate lifecycle: it is the same single command the lock
replaces, so switching costs no ticks.
```mermaid
stateDiagram-v2
[*] --> SWEEP
state "SWEEP<br/>360 sweep at 45 deg/tick,<br/>full revolution every 8 ticks" as SWEEP
state "LOCKED<br/>servo onto target bearing<br/>PLUS 5 deg overshoot" as LOCKED
SWEEP --> LOCKED: "getEnemyCount is 1<br/>AND target seen within<br/>FreshScanTurns 1"
LOCKED --> SWEEP: "enemy count is not 1,<br/>or target stale beyond 10 ticks,<br/>or lost"
LOCKED --> LOCKED: "scan every tick<br/>because the radar crosses<br/>the bearing"
```
The 5 deg overshoot is the reason the lock works: the radar sweeps ACROSS the
bearing instead of parking on it, so a fresh `ScannedBotEvent` arrives every
tick. Turning by the bare error, or holding at rate 0 inside a deadzone, stops
the scans and loses the target until a sweep re-finds it. Never replace the
overshoot with a deadzone hold.
## Physics (source: robocode.dev/articles/physics.html)
Source: https://robocode.dev/articles/physics.html (Tank Royale docs)
- Turns/rounds 1-based; each round restarts at turn 1. Distance units: floating-point double.
- Acceleration: +1 unit/turn; deceleration: 2 units/turn (braking 2x faster than accelerating).
- Speed: v = a * t; max speed 8 units/turn. Velocity direction == bot heading.
- Distance: d = v * t.
- Rotation in degrees. Base max turn rate: 10 - 3/4*|v| deg/turn (10 deg/turn standing still; 4 deg/turn at max speed 8).
- Gun rotation max 20 deg/turn (added to bot's current rate).
- Radar rotation max 45 deg/turn (added to gun's current rate).
- Firepower: min 0.1, max 3; energy cost subtracted from bot energy.
- Bullet damage: 4 * firepower, plus 2 * (firepower - 1) if firepower > 1.
- Bullet speed: 20 - 3 * firepower units/turn (19.7 at fp 0.1; 11 at fp 3); constant per shot.
- Gun heat gain: 1 + firepower/5. Cannot fire while heat > 0. Gun starts at heat 3 each round.
- Energy gain on bullet hit: 3 * firepower.
- Collision with bot/wall stops the bot, except when moving away from the bot that hit it.
- Bot-vs-bot collision: 0.6 damage to each.
- Ramming (moving forward into another bot): both take damage; rammer gets ramming kill bonus. Wall damage: |v|/2 - 1, clamped at 0 if negative.
- NOT stated on this page: coordinate system/axes/units, Y up or down, angle origin & clockwise/CCW sense, distance/bearing formulas, angle normalization/wrapping, max energy, per-turn energy regain from inactivity.
@@ -0,0 +1,9 @@
{
"name": "DevControlBot",
"version": "1.1.0",
"authors": ["Davide Cappellini"],
"description": "Control skeleton bot — does nothing, bright colors",
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,52 @@
## DevControlBot — control skeleton: boots, stands still, and drives nothing but
## the radar. It never moves and it never fires.
##
## Team convention: white means "not programmed yet". At startup body, turret, gun
## and radar are all plain WHITE. The radar then claims its OWN colours when it
## starts working: initRadar() paints the radar and the scan arc BLACK, leaving
## body/turret/gun white (this bot never moves and never fires, so they stay
## "not programmed"). Colors are applied once, never per tick.
##
## This module is both the bot type and the program entry point (see isMainModule
## below). All radar logic lives in modules/radar.nim; the bot keeps no state.
##
## The run loop drives the radar: commandRadar() is called BEFORE go() every
## tick, so the command is part of the tick's intent and is sent by THIS go().
## The order matters — go() sends the intent first and dispatches the pending
## events last, so onScannedBot only feeds the NEXT tick.
##
## API: robocode_tankroyale_botapi 1.0.7 (the renamed tankroyale_botapi package).
import std/[os]
import robocode_tankroyale_botapi
import modules/radar
type DevControlBot* = ref object of Bot
proc newDevControlBot*(): DevControlBot =
result = DevControlBot()
setBodyColor(WHITE)
setTurretColor(WHITE)
setGunColor(WHITE)
setRadarColor(WHITE)
method onScannedBot*(bot: DevControlBot, e: ScannedBotEvent) =
onScan(e.x, e.y) # remembered; commandRadar() re-aims every tick
# (e.scannedBotId, schemas.nim:305, is the
# scanned bot's identity; unused here)
method run*(bot: DevControlBot) =
initRadar() # once: radar + scan turn BLACK = programmed
while isRunning():
# One radar command per tick, unconditionally, BEFORE go() — so the command
# is part of this tick's intent and no tick is ever left without one.
commandRadar()
go()
when isMainModule:
# argv[1] may override the metadata file (used by DevControlBot/DevControlBot.sh);
# otherwise the JSON next to this source, resolved at compile time so any cwd works.
let jsonPath =
if paramCount() >= 1: paramStr(1)
else: currentSourcePath().parentDir / "DevControlBot.json"
var bot = newDevControlBot()
start(bot, jsonPath)
@@ -0,0 +1,238 @@
# Package
version = "1.1.0"
author = "Davide Cappellini"
description = "DevControlBot — control-skeleton bot that does nothing but show up bright"
license = "MIT"
srcDir = "."
bin = @[]
# Dependencies
# Installed globally with `nimble install`; nothing is vendored locally and
# no nimble.paths / vendor/ is used. `runBot` resolves each package's real
# location at run time with `nimble path` and passes explicit --path: flags,
# so the build does not rely on $HOME or on the compiler's bundled nimblepath.
requires "nim >= 2.0.0"
requires "robocode_tankroyale_botapi >= 1.0.7"
requires "jsony >= 1.1.5"
# The build-and-run logic lives here, not in DevControlBot.sh.
#
# NimScript cannot install signal handlers, so the temp build dir is owned by a
# small POSIX shell program (below) that Nimble executes via `exec` (which goes
# through the shell). That program traps EXIT/INT/TERM and always removes the
# throwaway build dir, including when compilation fails or the run is
# interrupted. Nothing is ever written to a persistent out/ directory.
#
# `exec` turns ANY non-zero exit into a NimScript exception, which prints a
# stack trace plus an escaped copy of the whole script. That is nonsense for the
# expected "no game server at SERVER_URL" outcome. So the shell always exits 0 and
# hands the real code back through the file named by $DEVCONTROLBOT_STATUS_FILE,
# which DevControlBot.sh exports and then re-exits with. Direct `nimble runBot`
# (no such variable) keeps the old behaviour: the shell exits with the real code
# and Nimble raises.
#
# DevControlBot.sh is a thin wrapper: it locates itself, chdirs here so Nimble
# finds this file, forwards caller args, calls `runBot` and forwards signals.
import std/os
const
runScript = """
# Resolve the globally-installed dependencies at run time. Plain `nim c` only
# finds them when $HOME is set (the Nim distribution's nim.cfg carries
# `nimblepath="$home/.nimble/pkgs2/"`), which is NOT true in bare environments
# (env -i, nix build sandboxes, CI). `nimble path` is the reliable,
# cwd-independent way to ask for their real location.
# Distinct exit codes, so a caller can tell the failure modes apart.
EXIT_NO_SERVER=1 # bot could not connect to the game server (expected)
EXIT_NO_DEPS=2 # a dependency is not installed
EXIT_COMPILE=3 # compilation failed
STATUS_FILE="${DEVCONTROLBOT_STATUS_FILE:-}"
# report <code>: write the real exit code to the status file. Returns 1 when
# there is no status file, i.e. the caller wants it as the shell's own code.
report() {
[ -n "$STATUS_FILE" ] || return 1
printf '%s\n' "$1" > "$STATUS_FILE"
}
# die <code>: finish with <code> as the run's real result — quietly (exit 0,
# the status file carries the code) or, with no listener, by exiting with it.
die() {
if report "$1"; then exit 0; else exit "$1"; fi
}
DEPS=""
for pkg in robocode_tankroyale_botapi jsony; do
pkgdir="$(nimble path "$pkg" 2>/dev/null | head -n 1)"
if [ -z "$pkgdir" ] || [ ! -d "$pkgdir" ]; then
echo "[devcontrolbot] COMPILE BLOCKED: dependency '$pkg' is not installed." >&2
echo "[devcontrolbot] fix with: nimble install $pkg" >&2
die $EXIT_NO_DEPS
fi
echo "[devcontrolbot] dependency $pkg -> $pkgdir"
DEPS="$DEPS '--path:$pkgdir'"
done
# Build mode: $DEVCONTROLBOT_DEBUG is set to "debug" by DevControlBot.sh when
# the caller passed --debug; anything else (including an unset variable, i.e. a
# direct `nimble runBot`) means release. Release is the quiet default.
BUILD_MODE="${DEVCONTROLBOT_DEBUG:-release}"
BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/devcontrolbot.XXXXXX")"
BINARY="$BUILD_DIR/DevControlBot"
# ---- optional .env ------------------------------------------------------------
# Configuration reaches the bot through the ENVIRONMENT, not through any code
# of ours: robocode_tankroyale_botapi's start() reads SERVER_URL and
# SERVER_SECRET (robocode_tankroyale_botapi.nim:424-425, documented at :396-397)
# and loadBotInfo() reads BOT_NAME / BOT_VERSION / ... from the environment when
# the JSON is absent (bot_info.nim:117-130). That is the documented, supported
# mechanism the official BotLauncher uses, so the only thing we do here is make
# those variables PRESENT in the bot's process environment for local runs.
#
# Loaded HERE, in the runBot task, and not in DevControlBot.sh, so it applies both
# to `./DevControlBot.sh` and to a direct `nimble runBot` (one implementation, and
# the wrapper stays a thin wrapper).
# - optional: a missing file is not an error, nothing is printed but a note,
# the bot just uses the API defaults.
# - CRLF-tolerant: CRs are stripped so a Windows-edited file does not end up
# with the value "bar\r".
# - both `FOO=bar` and `export FOO=bar` styles.
# - never echoed, never `set -x`: only the file NAME is reported.
# - $DEVCONTROLBOT_ENV_FILE overrides the default path (".env" next to the
# .nimble, which is the cwd because DevControlBot.sh chdirs there).
#
# PRECEDENCE — CRITICAL. The .env file must NEVER override a variable that is
# already present in the environment: the official BotLauncher's values (and the
# caller's explicit `FOO=bar ./DevControlBot.sh`) must always win, otherwise this
# convenience file could silently break a BotLauncher run. Sourcing the file
# directly would do the opposite, because an assignment in a sourced file
# overwrites an already-exported variable of the same name. So the file is
# FILTERED line by line: a `NAME=value` line is dropped when NAME is already set
# in the environment (tested with `[ -n "${NAME+x}" ]`, i.e. set-but-maybe-empty,
# not non-empty), and only the remaining, still-unset names are sourced with
# `set -a`. The filtering is what implements the precedence, not the source order.
ENV_FILE="${DEVCONTROLBOT_ENV_FILE:-.env}"
if [ -f "$ENV_FILE" ]; then
ENV_RAW="$BUILD_DIR/env.raw"
ENV_KEEP="$BUILD_DIR/env.keep"
ENV_ADDED=""
# CRLF tolerance: drop the CRs, keep everything else verbatim.
tr -d '\r' < "$ENV_FILE" > "$ENV_RAW"
: > "$ENV_KEEP"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) continue ;; # blank / comment
export\ *|export=*) line="${line#export }" ;;
esac
name="${line%%=*}"
# Only plain NAME=value assignments; anything else is ignored, so the file
# can never smuggle in commands. `name` is validated as an identifier
# BEFORE being used in the eval below.
printf '%s' "$name" | grep -q '^[A-Za-z_][A-Za-z_0-9]*$' || continue
# PRECEDENCE: skip if the variable already exists in the environment.
if eval "[ -n \"\${$name+x}\" ]"; then
continue
fi
printf '%s\n' "$line" >> "$ENV_KEEP"
ENV_ADDED="$ENV_ADDED $name"
done < "$ENV_RAW"
if [ -n "$ENV_ADDED" ]; then
set -a
. "$ENV_KEEP"
set +a
fi
rm -f "$ENV_RAW" "$ENV_KEEP"
# Variable NAMES only, never values.
echo "[devcontrolbot] env file loaded: $ENV_FILE (names added:${ENV_ADDED:- none}; values never printed)"
echo "[devcontrolbot] variables already in the environment win and were left untouched"
else
echo "[devcontrolbot] no env file at $ENV_FILE (optional, using API defaults)"
fi
cleanup() {
rc=$?
trap - EXIT INT TERM
rm -rf "$BUILD_DIR"
if [ -d "$BUILD_DIR" ]; then
echo "[devcontrolbot] cleanup failed: $BUILD_DIR still present" >&2
exit 1
fi
exit $rc
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Release: hints and the progress/dot lines are noise, so the compiler output is
# captured to a log and only shown if the build fails (errors are ALWAYS shown).
# Debug: classic build, full compiler output (config-file hints, diagnostics).
LOG="$BUILD_DIR/build.log"
if [ "$BUILD_MODE" = "debug" ]; then
echo "[devcontrolbot] compiling DevControlBot.nim (DEBUG BUILD) -> $BINARY"
# `eval` re-parses $DEPS so the embedded quotes do the word splitting: the
# dependency paths survive spaces, without relying on glob or brace expansion.
# No pipe: POSIX sh has no PIPESTATUS, and `nim | tee` would report tee's
# status, not the compiler's. Buffer first, then replay the log verbatim.
eval nim c $DEPS --out:"$BINARY" DevControlBot.nim >"$LOG" 2>&1
compile_rc=$?
cat "$LOG"
else
echo "[devcontrolbot] compiling DevControlBot.nim -> $BINARY"
eval nim c -d:release --hints:off $DEPS --out:"$BINARY" DevControlBot.nim >"$LOG" 2>&1
compile_rc=$?
fi
if [ $compile_rc -ne 0 ]; then
if [ "$BUILD_MODE" != "debug" ]; then
echo "[devcontrolbot] --- compiler output ---" >&2
cat "$LOG" >&2
echo "[devcontrolbot] --- end compiler output ---" >&2
fi
echo "[devcontrolbot] COMPILE FAILED (nim c exited $compile_rc) — errors above." >&2
die $EXIT_COMPILE
fi
rm -f "$LOG"
if [ "$BUILD_MODE" = "debug" ]; then
echo "[devcontrolbot] build ok (debug)"
else
echo "[devcontrolbot] build ok (release)"
fi
# The bot reads argv[1] as its metadata JSON path. If the caller already
# supplied a JSON, use that instead of the bundled one.
if [ -n "${1:-}" ] && [ "${1##*.}" = "json" ]; then
echo "[devcontrolbot] using caller-supplied metadata: $1"
"$BINARY" "$@"
else
# NOTE: no `exec` here: the shell must survive so the EXIT trap can clean up.
echo "[devcontrolbot] running $BINARY DevControlBot.json $*"
"$BINARY" DevControlBot.json "$@"
fi
bot_rc=$?
if [ $bot_rc -ne 0 ]; then
echo "[devcontrolbot] bot exited with code $bot_rc." >&2
if [ $bot_rc -eq $EXIT_NO_SERVER ]; then
# The Tank Royale client answers a refused connection with this exact
# exit code, so it means "no server", not "broken bot".
echo "[devcontrolbot] No game server on ${SERVER_URL:-ws://localhost:7654} (SERVER_URL)." >&2
echo "[devcontrolbot] Start the Tank Royale server, or point SERVER_URL at a running one." >&2
else
echo "[devcontrolbot] The bot ran but failed at runtime (crash or bad metadata)." >&2
fi
fi
die $bot_rc
"""
task runBot, "Compile, run and clean up the bot":
# Nimble passes every CLI argument here (its own flags, then the task name,
# then the caller's arguments). Ours are the ones after the task name.
var args = ""
var seen = false
for a in commandLineParams():
if seen:
if args != "": args = args & " "
args = args & quoteShell(a)
elif a == "runBot":
seen = true
exec "sh -c " & quoteShell(runScript) & " devcontrolbot " & args
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# BotLauncher entry point for DevControlBot.
#
# Thin wrapper: all build/run/cleanup logic lives in the `runBot` nimble task
# of DevControlBot.nimble. This script only locates itself, moves next to the
# .nimble so Nimble finds it, forwards caller arguments, calls the task and
# propagates the real exit code. Signals are forwarded to Nimble so the task's
# own EXIT/INT/TERM cleanup always runs.
#
# Usage: DevControlBot.sh [--debug] [<metadata.json>] [extra args for the bot]
#
# (no args) RELEASE build + run with the bundled DevControlBot.json
# (no flag) RELEASE build (nim c -d:release), quiet output
# --debug classic DEBUG build (plain nim c): compiler hints + diagnostics
#
# --debug is a build-mode flag for this wrapper only: it is stripped here and
# never reaches the bot binary or nimble's task arguments. It travels to the
# runBot task as $DEVCONTROLBOT_DEBUG (exported), because an env var cannot be
# confused with a bot argument, cannot collide with the status file mechanism
# and keeps the task's argument parsing untouched. Repeated --debug is harmless.
#
# Configuration (SERVER_URL, SERVER_SECRET, ...) is NOT handled here at all: the
# API's start() reads those from the environment, and the optional .env file is
# loaded by the runBot task, so it also works with a direct `nimble runBot`.
set -uo pipefail
set -m # job control: the background job gets its own process group
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
usage() {
cat <<EOF
Usage: $(basename "$0") [--debug] [<metadata.json>] [extra args for the bot]
--debug classic debug build: shows compiler hints and diagnostics
(default is a quiet RELEASE build, nim c -d:release)
-h,--help show this help
Examples:
$(basename "$0") # release build + run, bundled DevControlBot.json
$(basename "$0") my.json # release build + run with custom metadata
$(basename "$0") -h # this help
$(basename "$0") --debug # debug build
$(basename "$0") --debug my.json # debug build with custom metadata
EOF
}
# Split caller args: everything except --debug is forwarded verbatim.
BUILD_MODE=release
FORWARDED=()
for a in "$@"; do
case "$a" in
--debug) BUILD_MODE=debug ;;
*) FORWARDED+=("$a") ;;
esac
done
set -- ${FORWARDED+"${FORWARDED[@]}"}
# No arguments is the normal case: build and run with the bundled metadata.
# Usage is printed ONLY for an explicit -h/--help.
case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
export DEVCONTROLBOT_DEBUG="$BUILD_MODE"
# The runBot task's shell always exits 0 (otherwise Nimble raises a NimScript
# exception with a stack trace and a dump of the whole script) and writes the
# bot's real exit code here instead. We read it back and exit with it, so
# BotLauncher still sees the faithful status: 1 = no game server at SERVER_URL,
# 2 = a dependency is missing, 3 = compile failure, anything else = the bot's
# own code, 0 = clean run.
STATUS_FILE="$(mktemp "${TMPDIR:-/tmp}/devcontrolbot.status.XXXXXX")"
export DEVCONTROLBOT_STATUS_FILE="$STATUS_FILE"
trap 'rm -f "$STATUS_FILE"' EXIT
# Forward to the whole job process group (nimble + the shell it spawned) so the
# task's own EXIT/INT/TERM cleanup runs. No cleanup logic here.
forward() { kill -s "$1" -- "-$NIMBLE_PID" 2>/dev/null; }
trap 'forward TERM' TERM
trap 'forward INT' INT
nimble runBot ${1+"$@"} &
NIMBLE_PID=$!
wait "$NIMBLE_PID"
rc=$?
trap - TERM INT
# Prefer the code the run actually produced; fall back to Nimble's own status if
# the task died before it could report (e.g. Nimble itself failed).
real_rc="$(head -n 1 "$STATUS_FILE" 2>/dev/null | tr -d '[:space:]')"
[ -n "$real_rc" ] || real_rc=$rc
exit "$real_rc"
@@ -0,0 +1,92 @@
## The radar — one module, the whole of it. No movement, no gun.
##
## Every tick `commandRadar()` issues EXACTLY ONE radar command, before `go()`,
## on every path. There is no "idle" branch: the 360 sweep is not a mode we
## fall back INTO, it is the command the other branch replaces. That is why
## switching costs zero turns.
##
## TWO commands, chosen by the only fact the bot knows for sure:
##
## exactly one enemy alive + a target in hand -> LOCK (servo onto its bearing)
## anything else -> SWEEP (45 deg/tick, the cap,
## a full revolution every 8 ticks)
##
## The sweep does double duty and needs no separate "melee" mode: it is both the
## search for a target we do not have yet and the right answer for 2+ enemies
## (a full revolution re-scans every enemy 8x sooner than any narrowed sweep,
## and locking one of several is worthless).
##
## Derived from ModularBot's radar (Davide Cappellini, Apache-2.0), i.e. from
## common_libs/radar_lock/radar_lock.nim (`doRadar`) and common_libs/radars/
## melee_scan.nim, which ModularBot picks between per tick in exactly this way
## (`let targetMode = if getEnemyCount() == 1: 0 else: 1` — ModularBot.nim, the
## "Auto-switch radar based on the SERVER's live enemy count" block). The one
## idea taken from it is the OVERSHOOT, and it is the whole reason the lock
## works: the rate is the delta to the target's bearing plus 5 deg PAST it, so
## the radar crosses the bearing every tick instead of settling on it — which
## is exactly what produces a fresh ScannedBotEvent every tick. Turning by the
## bare error (or holding inside a deadzone) parks the radar on the bearing and
## the scans stop; the target then has to be re-found by the sweep, which is the
## delay this replaces. common_libs/ is never edited in place.
##
## ANGLES: 0° = east, positive = counter-clockwise, so positive = a LEFT turn
## (proof in ../README.md; the API's own doc comment claiming "0 = North" for
## `directionTo` contradicts its code and is wrong).
import std/math
import robocode_tankroyale_botapi
const
MaxRadarTurn* = 45.0 # deg/tick, the radar's hard cap (the sweep)
OvershootDeg* = 5.0 # how far past the target's bearing the lock aims;
# guarantees the bearing is swept every tick
FreshScanTurns* = 1 # how many ticks a remembered target stays usable.
# The 5 deg overshoot makes the lock CROSS the
# target's bearing every tick, so a fresh
# ScannedBotEvent arrives every tick and this
# counter never gets past 1. Hence one tick without a
# scan means the target has genuinely left the radar
# cone: there is nothing left to wait for, sweeping
# re-finds it faster than a stale lock could.
# The target in hand: the position of the last bot we scanned. Absolute
# position, not a remembered bearing, so the delta is recomputed against the
# radar's CURRENT heading every tick and the lock keeps correcting a target
# that (and a radar that) has moved since the scan.
var
targetX, targetY: float
targetSeen: bool
scanlessTicks: int
proc onScan*(x, y: float) =
## Remember a scanned bot as the target. Issues no command: `onScannedBot`
## runs AFTER go() has already sent this tick's intent.
targetX = x
targetY = y
targetSeen = true
scanlessTicks = 0
proc initRadar*() =
## Called ONCE, before the first tick: the radar is now programmed and working,
## so it claims its own colours — radar and scan arc go BLACK.
## COLOUR CONVENTION: white = "not programmed yet", black = "programmed and
## running". The bot starts all WHITE (DevControlBot.nim); the radar paints
## ITSELF black the moment it takes control, and body/turret/gun stay white
## forever (they are still unprogrammed — this bot never moves, never fires).
## Set once here, never per tick: the colour never changes, so re-sending it
## every tick would only cost intent bandwidth.
setRadarColor(BLACK)
setScanColor(BLACK)
proc commandRadar*() =
## Command the radar for THIS tick. Called from run() before go().
if targetSeen: inc scanlessTicks
if getEnemyCount() == 1 and targetSeen and scanlessTicks <= FreshScanTurns:
var turn = normalizeRelativeAngle(
directionTo(getX(), getY(), targetX, targetY) - getRadarDirection())
if turn < 0.0: turn -= OvershootDeg
else: turn += OvershootDeg
setRadarTurnRate(turn.clamp(-MaxRadarTurn, MaxRadarTurn))
return
targetSeen = false # nothing usable to lock on: sweep
setRadarTurnRate(MaxRadarTurn)
+6
View File
@@ -0,0 +1,6 @@
1. Create a tidy Bot garage folder
2. Make inside a folder ready to be exported and shared with others
3. Create a basic bot with all white colors and does nothing else
4. make a nice runnable in bash script fro the RT official launcher
5. add nice radars: 1vs1 and melee
5. decide the gun to develop
+296
View File
@@ -0,0 +1,296 @@
# DevControlBot
Control skeleton bot: it boots, participates in the battle, and drives **no
movement and no gun**. The radar is the only driven part.
All radar logic is **one module**, `modules/radar.nim` (90 lines including its
comments; two procs, three variables, three constants). `run()` just calls
`commandRadar()`.
**Two commands, one decision.** Exactly one enemy alive *and* a target in hand ->
**lock** (servo onto the target's bearing). Anything else -> **sweep** at
`MaxRadarTurn` = 45 deg/tick, the radar's physical cap, i.e. a full revolution
every 8 turns. The sweep does double duty and needs no separate "melee" mode: it
is both the search when no target is in hand and the right answer for 2+ enemies
(a full revolution re-scans every enemy as fast as the physics allow, and locking
one of several is worthless). The choice is made from the API's
`getEnemyCount()` (bot.nim:166, from `tick.botState.enemyCount`, bot.nim:1295;
field at schemas.nim:134) every tick — the server's alive count, so it cannot
drift and it shrinks by itself when an enemy dies. No counter, no seen-id set, no
mode enum, no dispatch table.
**There is never a tick without a command.** `commandRadar()` ends in
`setRadarTurnRate(...)` on *every* path — the sweep is not a state the lock falls
back *into*, it is the single command the lock replaces. Switching costs zero
turns, in either direction, by construction.
**The lock works because of the 5° overshoot.** The commanded rate is the delta
from the radar's *current* heading to the target's bearing, plus 5° **past** it
in the direction of the turn, clamped to ±45. So the radar sweeps *across* the
bearing every tick instead of settling on it — which is what produces a fresh
`ScannedBotEvent` every tick. (Turning by the bare error, or holding inside a
deadzone, parks the radar on the bearing: the scans stop and the target has to be
re-found by the sweep, which is the delay this design removes. The `setRescan()`
trick this used to need is unnecessary here — the radar is never idle.) The
bearing is recomputed from the target's remembered **absolute position** each
tick, so the lock keeps correcting a target, and a radar, that have moved.
**Lost-target safety.** `scanlessTicks` counts the ticks since the target was last
scanned (reset by every scan) and `FreshScanTurns = 10` is how long a remembered
target stays usable — one sweep revolution plus slack. This can never throw a
good lock away: a working lock re-scans its target *every* tick, so the counter
never gets past ~1. It only bites on a target unseen for a while, where locking
would slew blindly at a stale point; the sweep re-finds it within one revolution
instead. The target is then dropped and the sweep resumes on the same tick.
`onScannedBot` is a one-liner — `onScan(e.x, e.y)` — and issues no command:
`go()` sends the tick's intent *before* dispatching the pending events, so
commanding first means the command is part of THIS tick's intent, while
`onScannedBot` only feeds the next one.
Angles are **0° = east, positive = counter-clockwise = turn LEFT**: proven from
the installed API — `directionTo` is `180 * arctan2(dy, dx) / PI`
(utils.nim:146-165, its "0 = North" doc comment contradicts its own code), and
`setTurnRadarLeft` sets a positive rate while `setTurnRadarRight` is
`setTurnRadarLeft(-degrees)` (bot.nim:1062-1070).
**Provenance.** The design is ModularBot's (Davide Cappellini, Apache-2.0), i.e.
`common_libs/radar_lock/radar_lock.nim` (`doRadar`, which is where the overshoot
comes from) and `common_libs/radars/melee_scan.nim`, picked per tick exactly this
way in `ModularBot.nim`: `let targetMode = if getEnemyCount() == 1: 0 else: 1`.
`common_libs/` is never edited in place.
**Measured** (1 round each, RadarSpy observer; scans/turn, idle = turns with no
radar command, max gap = longest run of turns without a scan):
| battle | version | scans/turn | idle turns | max scan gap |
|---|---|---|---|---|
| 1v1 Walls | before | 0.513 | **391 / 509** | **33 turns** |
| 1v1 Walls | now | **0.960** | **0** | **1 turn** |
| vs 2 adversaries | before | 0.256 | 0 | 8 |
| vs 2 adversaries | now | 0.278 | 0 | 8 |
| vs 2 adversaries (3 bots, mixed) | before | 0.462 | 233 | 32 |
| vs 2 adversaries (3 bots, mixed) | now | 0.548–0.644 | **0** | **8** |
The 2-enemy rows are identical by construction (both versions issue the same
45 deg/tick sweep). When the count drops to 1 mid-round the new radar is
acquiring again within 6 turns and never parks; the old one took 29 turns and
sat at rate 0 for 26 of them.
The radar keeps moving forever: sweeping at full rate until an enemy is
scanned, servoing onto it while it is the only one alive, spinning at the cap as
soon as a second one is alive, and dropping straight back to the lock when one of
them dies. All of it is in `DevControlBot/modules/radar.nim`.
Body, turret, gun and radar all start plain **white**, which by team convention
means "not programmed yet". The radar then claims its **own** colours when it
starts working: `initRadar()` (`DevControlBot/modules/radar.nim`) paints the radar
and the scan arc **black** — black = "programmed and running" — while
body/turret/gun stay white (this bot never moves and never fires). Colors are set
**once** (startup + radar init), never inside the tick loop.
## Layout
```
DevControlBot_garage/
├── AGENTS.md
├── README.md
└── DevControlBot/
├── DevControlBot.nim # bot type + entry point (isMainModule)
├── DevControlBot.json # bot metadata
├── DevControlBot.nimble # the single `runBot` task
├── DevControlBot.sh # BotLauncher entry point
└── .env # OPTIONAL local config (secrets; never commit)
```
There is no `src/`, no `tests/`, no `out/`, no `config.nim`, no `nimble.paths`
and no vendor directory. `DevControlBot.nim` is both the bot implementation and
the program entry point.
## Dependencies
Only two, both installed **globally** with Nimble and resolved from the global
package store — nothing vendored, no `nimble.paths`, no `nimble develop`:
- `robocode_tankroyale_botapi >= 1.0.7` (the renamed `tankroyale_botapi`)
- `jsony >= 1.1.5`
```sh
nimble install robocode_tankroyale_botapi
```
The `runBot` task asks Nimble where each of those packages actually lives
(`nimble path <pkg>`) and passes explicit `--path:` flags to `nim c`. This is
required because the compiler's own `nimblepath="$home/.nimble/pkgs2/"` only
works when `$HOME` is set — with an empty environment (`env -i`, Nix build
sandbox, CI) the packages are otherwise not on the search path. If a package is
not installed, the build stops with `run: nimble install <pkg>` instead of a
compiler error.
## Run
```sh
./DevControlBot/DevControlBot.sh
```
`DevControlBot.sh` is the **BotLauncher entry point**. It is a thin wrapper: it
locates itself, `chdir`s to the directory holding the `.nimble` file, forwards
arguments and signals to `nimble runBot`, and propagates the real exit code.
All build/run/cleanup logic lives in the `runBot` task of `DevControlBot.nimble`,
which creates a throwaway `mktemp -d` build dir, compiles there, runs, and always
removes the dir via an `EXIT`/`INT`/`TERM` trap. **Nothing is left behind** — this
bot never creates an `out/` directory.
`runBot` also accepts a metadata path: **as the first positional argument**. If it
ends in `.json` it is used instead of the bundled `DevControlBot.json`; there is
no `--json` flag (an argument that does not end in `.json` is forwarded to the bot
as-is).
```sh
./DevControlBot/DevControlBot.sh # bundled metadata (default)
./DevControlBot/DevControlBot.sh /path/to/my.json # alt metadata
./DevControlBot/DevControlBot.sh --debug /path/to/my.json # alt metadata, debug build
```
With **no arguments at all** the wrapper does *not* print usage: it performs a
quiet RELEASE build and runs the bot with the bundled `DevControlBot.json`,
exactly as if that path had been passed. Usage is printed **only** for an
explicit `-h` / `--help` (exit 0, nothing is built).
## Configuration comes from the environment — the API's own mechanism
No configuration code exists in `DevControlBot.nim`, and none is needed. The
bot API reads its own environment variables inside `start()`:
| variable | read at | default |
|---|---|---|
| `SERVER_URL` | `robocode_tankroyale_botapi.nim:424` (documented at `:396`) | `ws://localhost:7654` |
| `SERVER_SECRET` | `robocode_tankroyale_botapi.nim:425` | `""` |
| `BOT_NAME`, `BOT_VERSION`, `BOT_AUTHORS`, … | `bot_info.nim:117-130`, used only when no JSON is found | see that file |
This is the supported mechanism the official BotLauncher uses: it puts those
variables in the bot's process environment before starting the binary. So the
only thing the build/run wrapper does is make sure the variables are *present*
in the bot's environment for local runs.
## Optional `.env` (a convenience for local runs only)
`DevControlBot/.env` is **optional**. If it is absent nothing happens: no error,
no warning beyond one informational line, the run behaves exactly as before and
the API defaults apply. It is not required, not generated, and not committed.
When present it is loaded by the **`runBot` task**, not by `DevControlBot.sh`, so
it applies identically to `./DevControlBot.sh` and to a direct `nimble runBot`
and the wrapper stays a thin wrapper. Properties:
- **Format:** plain `NAME=value` and `export NAME=value` lines; blank lines and
`#` comments ignored; anything that is not a simple `NAME=value` identifier
assignment is ignored (a `.env` can never smuggle in a command).
- **CRLF tolerant:** `CR` characters are stripped, so a Windows-edited file does
not turn the value into `bar\r`.
- **Nothing is ever printed.** Only the *names* taken from the file are reported,
never a value, and `set -x` is not used.
- **Overridable path:** `$DEVCONTROLBOT_ENV_FILE` points the loader at a
different file (useful for testing).
### Precedence: the environment always wins
A variable **already present in the environment is never overridden** by the
`.env` file. This matters because the official BotLauncher's values must win:
if a `.env` sitting in the checkout could overwrite them, this convenience would
silently break a real BotLauncher run.
The `.env` file is therefore **not** simply sourced — sourcing it would do the
opposite, because an assignment in a sourced file overwrites an already-exported
variable of the same name. Instead the file is **filtered line by line**: a
`NAME=value` line is dropped when `NAME` is already set in the environment
(tested with `[ -n "${NAME+x}" ]`, i.e. *set but possibly empty*, not merely
non-empty), and only the still-unset names are then exported with `set -a` and
sourced. The filtering is what implements the precedence.
```sh
SERVER_URL=ws://host:1234 ./DevControlBot.sh # shell wins, .env ignored for SERVER_URL
./DevControlBot.sh # .env supplies SERVER_URL
```
`.env` contains secrets, so it must never be committed; see the note in
[AGENTS.md](AGENTS.md) about the root `.gitignore`.
## Build modes: release by default, `--debug` for the classic build
| invocation | build | output |
|---|---|---|
| `DevControlBot.sh …` (default) | `nim c -d:release --hints:off` | quiet: dependency lines, one `build ok (release)`, one `running` line |
| `DevControlBot.sh --debug …` | plain `nim c` | classic diagnostics: `Hint: used config file …`, dot-progress line, `DEBUG BUILD` hint |
`--debug` is stripped by the wrapper wherever it appears in the arguments (a
repeated `--debug` is harmless) and is handed to the `runBot` task via the
exported env var `$DEVCONTROLBOT_DEBUG` (`debug` / `release`) rather than as a
task argument: an env var can never be mistaken for a bot argument, cannot
collide with the `$DEVCONTROLBOT_STATUS_FILE` exit-code mechanism, and leaves the
task's argument parsing untouched. Every other argument is forwarded verbatim.
In release mode the compiler's stdout/stderr is captured to a log and replayed
**only if the build fails**, so a failed build is always loud in both modes while
a successful release build stays short (10 lines including the no-server report,
vs 16 with `--debug`). `--help` / `-h` prints usage and exits 0 **without
building**; no arguments does the opposite — it builds and runs.
The bot needs a running Tank Royale server at `SERVER_URL` (the API's documented
default is `ws://localhost:7654`); without one the run fails with
`[start] Cannot connect to <SERVER_URL>` and exits 1 (expected during local
verification).
## Failure reporting and exit codes
Nimble's `exec` raises a NimScript exception — stack trace, escaped copy of the
whole script — for *any* non-zero exit, which made "no game server" look like a
catastrophic build crash. The `runBot` shell therefore always exits 0 and writes
the real code to the status file named by `$DEVCONTROLBOT_STATUS_FILE`, which
`DevControlBot.sh` exports and then re-exits with. The real code is preserved,
not masked.
| exit | meaning |
|---|---|
| `0` | clean run |
| `1` | bot could not connect to the game server at `SERVER_URL` (expected locally) |
| `2` | a dependency is not installed — `nimble install <pkg>` |
| `3` | compilation failed — the compiler's own errors are printed above |
| other | the bot's own exit code (runtime crash, bad metadata) |
Each mode prints its own short line, e.g.
```
[start] Cannot connect to ws://localhost:4567: Connection refused
[devcontrolbot] bot exited with code 1.
[devcontrolbot] No game server on ws://localhost:4567 (SERVER_URL).
[devcontrolbot] Start the Tank Royale server, or point SERVER_URL at a running one.
```
No `Exception raised during nimble script execution`, no stack trace, no source
dump. `nimble runBot` run directly (no status file) keeps the old behaviour and
still raises.
Interrupt handling is unchanged: SIGINT/SIGTERM are forwarded, the build dir is
removed by the `EXIT` trap and the wrapper exits 143 (SIGTERM) / 130 (SIGINT).
## Gotcha: `nimble runBot` must be run from `DevControlBot/`
The `.nimble` file lives in `DevControlBot/`, so the task is only visible from
there:
```sh
cd DevControlBot && nimble runBot # works
cd DevControlBot_garage && nimble runBot
# Error: Could not find a file with a .nimble extension inside the specified
# directory: .../DevControlBot_garage
```
`DevControlBot.sh` handles this for you by `cd`-ing itself. Note also that
`compile`/`build` are reserved Nimble builtin names — hence the `runBot` name.
## Test
There are **no tests** in this garage. The `test` task and the `tests/` directory
were removed along with the old build layout; see
[AGENTS.md](AGENTS.md) for the convention to follow when tests are added.
+552 -59
View File
@@ -10,19 +10,187 @@
# Every value below IS the built-in default, so running the bot with this file
# is identical to a clean run with no file at all. Delete a line (or comment it
# out with #) and that knob falls back to the built-in default. An inline
# `# comment` after a value is fine — the loader strips it.
# `# comment` after a value is fine — the loader strips it (a `#` that follows
# a space starts the comment; a `#` glued to the value, like `x#y`, is data).
#
# A few switches are PRESENCE-only (`existsEnv`): for those, OFF means the line
# is absent, so they are shown commented out. Writing `=0` would still turn them
# ON.
# These values mirror the current defaults, not a frozen snapshot of one commit.
# Regenerate this file whenever a default changes, or it will start lying.
# ═════════════════════════════════════════════════════════════════════════════
# 1. ONE EXPERIMENT, END TO END
# ═════════════════════════════════════════════════════════════════════════════
#
# SNAPSHOT of the code at commit 5e32ec1. Regenerate this file whenever a default
# changes, or it will start lying.
# Pick ONE knob. Here the example is TR_TFIL_ARRIVE_TICKS, but the shape is the
# same for every knob in this file.
#
# # 1. write the arm. In ModularBot_garage/.env, change ONE line:
# # TR_TFIL_ARRIVE_TICKS=15.0
# # A per-run file is better than editing .env, because it is how you
# # GUARANTEE the arm: whatever else is in .env or in your shell, this
# # file is the one that is applied.
# cat > /tmp/arm_arrive15.env <<'EOF'
# TR_MOVEMENT=tfil
# TR_TFIL_ARRIVE_TICKS=15.0
# EOF
#
# # 2. RESTART THE BOT. Env is read ONCE, at boot (module init). Editing
# # .env while the bot runs changes nothing. There is no live reload.
# # (Two exceptions read lazily on first use: TR_PATTERN_RAD_* and a few
# # TR_TMHORIZON_* — do not rely on either.)
# cd ModularBot_garage && ./ModularBot.sh # or restart the GUI
#
# # 3. CONFIRM IT TOOK EFFECT, before you read a single result line.
# # `source: .env` = your file was applied. `source: default` = it was not.
# grep '^\[env\]' /tmp/modularbot_stdout.log | grep -E 'env file|ARRIVE_TICKS'
# # [env] env file: /tmp/arm_arrive15.env (source: TR_ENV_FILE)
# # [env] TR_TFIL_ARRIVE_TICKS = 15.0 (source: .env)
# # A value showing `(source: default)` means YOUR FILE NEVER REACHED THE BOT.
#
# # 4. point at the file instead of copying it into .env:
# TR_ENV_FILE=/tmp/arm_arrive15.env ./out/ModularBot
# ./out/ModularBot --env-file /tmp/arm_arrive15.env
# # This is what an A/B run does: one frozen binary, one env file per arm.
# # A file you ASKED for and that does not exist stops the bot with an error
# # (it never silently falls back); a missing default .env is silent.
#
# # 5. the module inventory, when you want to know what is on at all:
# grep '^\[modules\]' /tmp/modularbot_stdout.log
#
# ═════════════════════════════════════════════════════════════════════════════
# 2. SAFE TO EXPERIMENT WITH RIGHT NOW
# ═════════════════════════════════════════════════════════════════════════════
#
# The honest list is SHORT. After the recent campaign most experimental knobs
# are either never live-tested or already measured null/harmful, and this file
# says so on every one of them. These four are safe in the sense that they
# either cannot change a decision, or are the ones a measurement actually
# supports.
#
# TR_GEO_DEBUG=on Draw-only: the candidate-tile geometry overlay.
# Watch: the circle on the two tanks and each
# heading line. Good: you can SEE the tile the
# picker chose. Cannot change any decision.
# TR_VBULLET_DEBUG=1 Draw-only: each admitted gun's virtual bullets.
# TR_VBULLET_DEBUG_GUN=all
# Watch: travelled path, aim ring, miss vector.
# Good: you can see the signal the selector ranks
# on. Also draw-only. Needs a gun in the rack.
# TR_TFIL_DIAG=on Observability only, on the tfil mover. Fills the
# per-pick LOSS HISTOGRAM. Watch: the tfil pick log
# line. Good: the sReach/sCool/sSafe/sCand counts
# tell you where tiles are lost. Provably does not
# move a single command (guard-tested).
# TR_MOVEMENT=tfil The long-shipped mover, as an explicit override.
# Watch: nothing to compare against — it is the
# same engine you had before j119. Good: you are
# reproducing an older, documented behaviour. Only
# do this together with the tfil knobs below.
#
# Anything else on this list is a MEASUREMENT, not a free change: read its
# STATUS line before you type it.
#
# ═════════════════════════════════════════════════════════════════════════════
# 3. ALREADY REJECTED OR MEASURED NULL — WITH THE NUMBER
# ═════════════════════════════════════════════════════════════════════════════
#
# TR_TFIL_GEO_MODE=both-rej REJECTED live, 420 battles, 15-opponent panel.
# TR_TFIL_GEO_TAU=60 damage/run -8.83, p(sign-flip) = 0.0061,
# Wilcoxon p = 0.011. Round wins null.
# Docs: docs/tfil_geo_ab.md. DO NOT re-run it.
# TR_RAM_FLOOR_ENERGY=5 CLEAN NULL, 900 battles, 450 runs/arm.
# -0.018 wins/run, p(sign-flip) = 0.7676, under
# a 0.1420 wins/run MDE. Docs:
# docs/ram_floor_exhaustion_ab.md. DO NOT re-run
# it — the mechanism fired on 0.04% of ticks, so
# more runs buy resolution on an effect that is
# not there.
# TR_RAM_FLOOR_ENERGY=10/20 MEASURED COSTLY OFFLINE (24.7% of ticks blocked
# at 20) and the live zone they guard is almost
# empty: only 4.8% of shots are ever taken below
# 10 energy. Do not go above 5.
# TR_TMHORIZON_WINDOW=150 MEASURED HARMFUL live: 26.5% round wins vs 49.0%
# for the shipped rack, p = 0.036. Keep 0.
# TR_POWER_POLICY=0 MEASURED HARMFUL live: real hit rate 10.61% ->
# 7.88%, p = 0.0012. Keep it on.
# TR_TFIL_HEAT_TIME=1 MEASURED HARMFUL live at every tau tried
# (3/5/9/15): tau15 alone is -22 damage/run,
# p = 0.046. Keep it off.
# TR_MOVEMENT=tfil_ring MEASURED: round wins 16/49 -> 6/49, p = 0.012. A
# glass cannon — best live hit rate of anything
# measured, half the survival. Do not ship.
# TR_RACK_* (the full rack) MEASURED NEGATIVE VALUE: Pattern ALONE beats
# the full 13-gun rack, p = 0.0012. Adding guns
# costs rounds.
# TR_TFIL_TURN_BIAS=9 LIVE NULL: +0.15 wins/run, p(sign) = 0.244, under
# TR_TFIL_TURN_REF_DEG=0 a 0.30 MDE; 300 battles. Docs:
# docs/movement_campaign.md (j145).
# TR_RAM_OPPORTUNITY=on MEASURED not to convert: 0/59
# opportunity -> contact. The finisher ram is the
# only path that converts, and it is always on.
#
# READ THIS BEFORE YOU TRUST ANY "null" ABOVE. A null only excludes an
# effect at or above the MDE that run resolved. The 15-opponent panel at
# 14 runs/arm resolves ~0.17 wins/run and ~7.65 damage/run; the j163 run
# resolved 0.1420 wins/run. So "clean null" here means "no effect >= that
# size", NOT "no effect".
#
# ═════════════════════════════════════════════════════════════════════════════
# 4. PRESENCE-GATED KNOBS — FOR THESE, `NAME=0` TURNS THE FEATURE ON
# ═════════════════════════════════════════════════════════════════════════════
#
# grep -rn 'existsEnv' ModularBot_garage/src common_libs | grep -v /tests/
#
# The knobs below are read with `existsEnv`, not by value. OFF means THE LINE
# IS ABSENT. Writing `TR_POWER_LOG=0` does not disable the power log — it
# ENABLES it, because 0 is a perfectly good value for a knob nobody reads.
# That is why they are all shown COMMENTED OUT in this file: there is no
# "off" spelling for them, only absence. To disable one, DELETE its line.
#
# TR_POWER_LOG one line per power-decision CHANGE
# TR_RAM_LOG one line per ram start/stop, with the reason
# TR_MOVEMENT_LOG movement band / range-class changes
# TR_STRAFE_LOG one line per strafe tile pick
# 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).
# KNOWN LIMIT: only shots that PASSED setFire are
# recorded, so a gap is ambiguous - see docs/env_reference.md
# 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
#
# The VALUE-based switches are the opposite: 0 / false / no / off really
# disable them, and anything else enables them. Those are TR_RESULT_LOG,
# TR_TMHORIZON_LOG, TR_TMHORIZON_ACCURVE, TR_TMHORIZON_RESET_ON_TARGET,
# TR_LEADGAIN_LOG, TR_LEARNED_LOG, TR_LEARNED_GLOBAL, TR_LEARNED_REAL_EVENTS,
# TR_POWER_POLICY, TR_POWER_FINISH_KILL, TR_RAM_OPPORTUNITY, TR_RAM_PLAN,
# TR_FIRE_FIX, TR_STRAFE_FIRE_FIX, TR_STRAFE_ESCAPE, TR_STRAFE_HEAT_GRID,
# TR_TFIL_HEAT_TIME, TR_TFIL_PILLAR_ON, TR_TFIL_DIAG, TR_TFIL_NO_REV,
# TR_TFIL_HOLD_WHEN_TRAPPED, TR_TFIL_COMMIT_ARRIVAL,
# TR_TFIL_RING_COMMIT_ARRIVAL, GUN_SELECTOR_POOL, GUN_VBULLET_ADMIT_ONLY,
# TR_VBULLET_DEBUG, TR_GEO_DEBUG, TR_DEBUG_DRAW, TR_ENV_REPORT.
# (TR_TFIL_DIAG / _NO_REV / _HOLD_WHEN_TRAPPED / _COMMIT_ARRIVAL /
# _RING_COMMIT_ARRIVAL are read by value in the source; the boot report
# labels them by presence, which only affects the "(source: ...)" line, never
# the value.)
# ── movement ─────────────────────────────────────────────────────────────────
# Which dodging engine runs: tfil, tfil_ring, strafe, surf or learned.
# Which dodging engine runs. VALUE: strafe (default) | tfil | tfil_ring |
# surf | learned. Any unrecognised value silently runs `tfil`, with no warning.
# WHAT: the engine that picks the dodge tile every tick.
# STATUS: DEFAULT = strafe, the measured champion — 300 fresh battles,
# +0.30 wins/run over tfil, 95% CI [+0.02, +0.58], sign-flip p = 0.045
# (docs/movement_campaign.md, "Fresh-data confirmation (gate v2)").
# tfil_ring is measured harmful (round wins 16/49 -> 6/49, p = 0.012).
# surf and learned are wired and measured, and neither beats strafe.
# GOTCHA: a typo does not warn. `TR_MOVEMENT=straf` runs tfil.
# TRY: TR_MOVEMENT=tfil -> the long-shipped engine; the `[env]` block then
# reads `move.effective = tfil`. Pairs with the TR_TFIL_* knobs below.
TR_MOVEMENT=strafe
# Whole-engine on/off switches. 0 removes an engine from the TR_MOVEMENT choices.
# Whole-engine on/off switches. VALUE: on | 0/false/no/off. 0 removes an engine
# from the TR_MOVEMENT choices; the effective engine then falls back to the
# first still-enabled one, and tfil if all are off (there is no "no movement").
TR_MODULE_MOVE_TFIL=on # the long-shipped "floor is lava" engine
TR_MODULE_MOVE_TFIL_RING=on # the same, re-weighted toward a target range
TR_MODULE_MOVE_STRAFE=on # perpendicular strafe with sign-flip reversals
@@ -30,7 +198,17 @@ TR_MODULE_MOVE_SURF=on # wave surfing, steered by the GuessFactor
TR_MODULE_MOVE_LEARNED=on # learned per-state danger field
# ── gun rack: which guns the bot may choose (off | 1v1 | melee | both) ───────
# The shipped rack is Pattern only. Turn a gun on with `both`.
# WHAT: which guns the selector is allowed to fire. The shipped rack is
# PATTERN only; every other gun is `off`.
# VALUES: off | 1v1 | melee | both. Aliases: any/empty->both, single/lock->1v1,
# only1v1->1v1, multi/onlymelee->melee, none/disabled/disable->off.
# STATUS: MEASURED — the full 13-gun rack is WORSE than Pattern alone,
# p = 0.0012. Do not re-enable guns to "improve" the bot.
# GOTCHA: an UNRECOGNISED value warns on stderr and falls back to `both`, i.e.
# a typo ADDS the gun back into the rack. It never turns one off.
# TRY: TR_RACK_PATTERN=off -> nothing admitted in 1v1; the selector falls
# back to the full rack, so do not ship this. Real use is a PAIR:
# TR_RACK_PATTERN=off + TR_RACK_HEADON=both -> exactly one gun fires.
TR_RACK_PATTERN=both # the only admitted gun; both = usable in 1v1 and melee
TR_RACK_HEADON=off # aim straight at the target, no lead
TR_RACK_LINEAR=off # constant-angle linear aim
@@ -48,16 +226,24 @@ TR_RACK_TMSELECT=off # Tsetlin machine used as the shot selector
TR_RACK_TMPATTERN=off # Tsetlin machine used as a pattern matcher
TR_RACK_TMHORIZON=off # horizon Tsetlin automata gun
TR_RACK_LEADGAIN=off # per-range-band learned lead-gain corrector
# Give every admitted gun a fixed share of the turns instead of ranking them,
# e.g. TR_RACK_SHARE=PATTERN:60%,HEADON:40%. Empty = the ranking selector.
# Give every named gun a fixed share of the turns instead of ranking them.
# e.g. TR_RACK_SHARE=PATTERN:60%,HEADON:40% (GUN:weight, comma separated,
# the % sign is optional). Empty = the ranking selector. GOTCHA: every gun you
# name must ALSO be admitted by its own TR_RACK_<GUN> line, or the share is
# refused with a loud `[gun_harness] ERROR` and the ranking selector is used
# instead. In the shipped rack that means PATTERN and nothing else.
TR_RACK_SHARE=
# Drop whole guns by rack id (comma separated, e.g. 16). Empty = keep them all.
# Ids: 0 HEADON 1 LINEAR 2 TSETLIN 3 CIRCULAR 4 GUESSFACTOR 5 PATTERN
# 6 WALLBOUNCE 7 ACCEL 8 STOPSHOT 9 DISPLACE 10 AVGLEAD 11 DECAYGF 12 KNN
# 13 TMSELECT 14 TMPATTERN 15 TMHORIZON 16 LEADGAIN. A disabled gun never even
# spawns a virtual bullet, so its fitness stays empty and it cannot be picked.
GUN_RACK_DISABLE=
# ── gun selector (which admitted gun fires this tick) ───────────────────────
GUN_VBULLET_METRIC=path # fitness measure: path (time-to-collision) or point
GUN_SELECTOR_MODE=relative # rank guns against the incumbent (absolute = vs a fixed bar)
GUN_SELECTOR_WINDOW=100 # ticks of virtual-bullet history behind the fitness
GUN_SELECTOR_WINDOW=100 # ticks of virtual-bullet history behind the fitness (clamped 1..100)
GUN_SELECTOR_MINOBS=50 # observations a gun needs before it may compete
GUN_SELECTOR_TIE=0.2 # relative margin two guns must differ by to count as separated
GUN_SELECTOR_FLOOR=0.25 # fitness fraction of the peak below which a band is unsafe
@@ -73,6 +259,8 @@ GUN_SELECTOR_SEED=
# ── power / energy policy ────────────────────────────────────────────────────
# Every rule below only ever CAPS power; the gun's own preference is the ceiling.
# The measured case for keeping it on: TR_POWER_POLICY=0 drops the real hit rate
# from 10.61% to 7.88%, p = 0.0012.
TR_POWER_POLICY=on # 0 = no cap at all (the control arm)
TR_POWER_FAR_DIST=200.0 # px; past this the enemy is in the bad-chances zone
TR_POWER_FAR_CAP=1.0 # cap applied past TR_POWER_FAR_DIST
@@ -85,7 +273,7 @@ TR_POWER_ENERGY_MAX=3.0 # the cap at/above ENERGY_HI; 3.0 means effectively unc
TR_POWER_FINISH_KILL=on # cap to the smallest bullet that still kills a low-energy enemy
# ── radar ────────────────────────────────────────────────────────────────────
#TR_RADAR_FORCE_SPIN=1 # presence-only: force the old full 360 spin instead of 1v1 lock
#TR_RADAR_FORCE_SPIN=1 # PRESENCE-only: force the old full 360 spin instead of 1v1 lock
TR_RADAR_SCAN_LOG_PATH=/tmp/radar_scan_log.jsonl # where the per-tick scan log is written
# ── ram ──────────────────────────────────────────────────────────────────────
@@ -98,42 +286,274 @@ TR_RAM_PLAN=off # the change-of-plan trigger (enemy outguns us while
TR_RAM_PLAN_DIST=250.0 # px; max range at which the plan trigger may fire
TR_RAM_PLAN_MARGIN=20.0 # energy advantage the plan trigger needs
TR_RAM_PLAN_HITRATE=0.05 # pooled virtual hit rate below which the gun duel counts as failing
TR_RAM_FLOOR_ENERGY=0.0 # j160 firing floor: at/below this self energy stop firing (0 = off)
TR_RAM_ENEMY_ENERGY=0.0 # j160 exhaustion: last-scanned enemy energy <= this -> ram (0 = off)
# WHAT: at or below this much SELF energy we start no new shot, keeping a
# reserve for the ram. Units: energy points.
# VALUES: energy, 0.0 = off (today's behaviour). Any float parses.
# STATUS: =5 is a CLEAN NULL and must not be re-run: -0.018 wins/run,
# p(sign-flip) = 0.7676, under a 0.1420 wins/run MDE, 900 battles. The
# mechanism fired on 0.04% of ticks, ~200x less than the offline ruler
# predicted. Docs: docs/ram_floor_exhaustion_ab.md.
# GOTCHA: 0.0 genuinely disables it, but any POSITIVE value arms it, and the
# higher it goes the more of the low-energy zone it blocks (20 blocked 24.7%
# of all ticks offline).
# TRY: TR_RAM_FLOOR_ENERGY=5 -> already measured, do not re-run. If you
# want to see it at all, that is the only defensible value; anything
# higher is worse by the offline ruler and by the live null.
TR_RAM_FLOOR_ENERGY=0.0
# WHAT: the last-scanned enemy energy at or below this switches the ram decider
# into exhaustion mode (they are out of ammo, we are not). Units: energy.
# VALUES: energy, 0.0 = off. Any float parses.
# STATUS: DEFAULT-OFF, never live-tested. It is the second half of the j160 pair;
# the other half (FLOOR_ENERGY) measured null, so the pair is not a win.
# GOTCHA: 0.0 is the only safe "off". The always-on finisher (enemy < 20 energy
# and we are healthier, within 300 px) already covers most of this; setting
# this to 20 makes the two overlap.
# TRY: TR_RAM_ENEMY_ENERGY=10 -> ram once a scan shows them at <= 10.
# Watch: the `[ram] ON rrExhausted` line. Good: the reason field says
# `exhausted` rather than `finisher`. Needs TR_RAM_LOG=1 to see it.
TR_RAM_ENEMY_ENERGY=0.0
# ── movement internals: tfil (the floor-is-lava field) ──────────────────────
TR_TFIL_RANGE_LO=100.0 # px; lower edge of the range band the ring mover prefers
TR_TFIL_RANGE_HI=200.0 # px; upper edge of that band
TR_TFIL_RANGE_TEMP=0.4 # sharpness of the ring mover's weighted random draw
TR_TFIL_RANGE_K=60.0 # px; how fast the weight falls off outside the band
TR_TFIL_RING_COMMIT_ARRIVAL=off # tfil_ring only: on = hold the dodge tile until we are ON it (not a fixed dwell)
TR_TFIL_RING_NOREV_SPEED=0.0 # tfil_ring only; px/tick; below this, a mid-flight switch may not turn the bot around
# WHAT (tfil_ring only): hold the committed dodge tile until we are actually ON
# it, instead of the fixed dwell. VALUE: on | 0/off.
# STATUS: DEFAULT-OFF, never live-tested. Ported to the ring fork in j165; the
# tfil original (TR_TFIL_COMMIT_ARRIVAL) has a real but under-powered live
# result — see that line. Neither is a proven win.
# GOTCHA: the ring mover is NOT the default and is not shippable (round wins
# 16/49 -> 6/49, p = 0.012), so this only matters while you are measuring it.
# TRY: TR_MOVEMENT=tfil_ring + TR_TFIL_RING_COMMIT_ARRIVAL=1
# -> the `[env]` block reads TR_TFIL_RING_COMMIT_ARRIVAL = on.
TR_TFIL_RING_COMMIT_ARRIVAL=off
# WHAT (tfil_ring only): below this self speed (px/tick), a mid-flight switch
# may not turn the bot around. UNITS: px/tick (top speed is 8).
# STATUS: DEFAULT-OFF (0.0), never live-tested. The tfil original is in the
# j144 recommendation below.
# TRY: TR_TFIL_RING_NOREV_SPEED=4 -> 4 px/tick is half of top speed, the
# value j144 used on tfil. Accepts any float >= 0.
TR_TFIL_RING_NOREV_SPEED=0.0
TR_TFIL_CORRIDOR_HEAT=10.0 # lava painted per corridor-overlapping tile
TR_TFIL_CORRIDOR_TICKS=0.0 # corridor length in ticks: 0 = to the wall (shipped); N>0 = min(to wall, bullet speed * N)
# WHAT: cap the bullet-danger corridor at `bullet speed x this many ticks`,
# instead of running it all the way to the arena wall. UNITS: ticks.
# VALUES: ticks, 0.0 = off (corridor reaches the wall, today's behaviour).
# Any float parses; negatives are treated as 0.
# STATUS: DEFAULT-OFF, never live-tested, and NOT recommended. Commit 5e213df
# (j148) shipped it with no measurement at all: no battle, no offline ruler.
# The neighbouring j146 field-shape sweep, which is the closest evidence,
# says halving the corridor restores a safe tile set offline (filter-broken
# 63.5% -> 30.4%) and is a LIVE NULL on outcome.
# GOTCHA: value knob - 0.0 genuinely disables it. It is NOT a presence knob.
# TRY: TR_TFIL_CORRIDOR_TICKS=20 -> a 20-tick look-ahead. Bullet speed is
# 20 - 3*power, so over the shipped power bins 1.0..3.0 that is
# 17.0..11.0 px/tick = 340..220 px of corridor, instead of the whole
# wall. Watch: in the debug overlay the corridor stops short of the
# wall. Only bind it to tfil; strafe has its own knob below.
TR_TFIL_CORRIDOR_TICKS=0.0
TR_TFIL_WALL_HOTNESS=15.0 # peak heat painted on tiles next to a wall
TR_TFIL_WALL_RADIANCE=10.0 # how fast wall heat falls off with distance
TR_TFIL_BULLET_CORE=10.0 # tfil only: lava per bullet-overlapping tile (== PathDangerThreshold, so a bullet is never hot on its own)
TR_TFIL_BULLET_AURA=5.0 # tfil only: lava for the bullet's aura ring tiles
TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is cancelled
TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning
TR_TFIL_ARRIVE_TICKS=0.0 # hard bound: never pick a tile farther than this (ticks at 8px/tick); 0 = off = today's draw
TR_TFIL_HOLD_WHEN_TRAPPED=off # on = when NO safe tile exists, hold position one tick instead of taking the 2 least-hot blocked tiles; off = today's fallback
TR_TFIL_HOLD_MAX_TICKS=0 # BOUNDED version of the above: hold at most N ticks per empty-safe-set streak (default 0 = off = today's fallback). The budget is DERIVED from the enemy's rate of fire (2 x 3.0-power shots = 16 ticks); the hold is released the tick a safe tile exists and overridden outright if a tracked bullet reaches us within min(N, 16) ticks
TR_TFIL_DANGER_THRESHOLD=10.0 # hard heat filter on the path; lava is quantised to 5, so the effective steps are 10/15/20 and 10-14 admits exactly what 10 does
TR_TFIL_DIAG=off # on = fill the per-pick picker loss histogram (TfilLoss*); observability only
TR_TFIL_GEO_MODE=off # off | turn | dist | both, each with a `-soft` (default) / `-topk` / `-rej` suffix; shapes the DRAW over the heat-filtered pool
TR_TFIL_GEO_TAU=0.0 # deg; the geometric cost scale. 0 = off = today's uniform draw
TR_TFIL_NO_REV=off # on = never reverse direction inside a corridor
# WHAT: how hot a bullet paints the tile it is sitting on. UNITS: lava points
# on the picker's heat scale.
# VALUES: any float >= 0. 10.0 is exactly the danger threshold, so a bullet is
# never dangerous on its own; 20.0 puts one bullet's own tile over it.
# STATUS: live-tested ONLY as part of the j146 five-shape batch, 375 battles:
# core 10 -> 20 was a live NULL on both primaries, and the arm that combined
# it with no corridor/wall field LOST 13.45 damage/run, p(sign-flip) =
# 0.0095. The middle shape (corridor 10 / wall 15/5 / core 20 / aura 10) is
# also a null. Do not retune the shape on one axis.
# GOTCHA: changing CORE alone with the shipped corridor (10) and wall (15) is
# the one combination j146 did NOT isolate.
# TRY: TR_TFIL_BULLET_CORE=20 + TR_TFIL_BULLET_AURA=10 -> the j146
# "middle" bullet heat. Only meaningful as part of the whole middle
# shape; on its own it is a null at best.
TR_TFIL_BULLET_CORE=10.0
# WHAT: the heat painted on the bullet's AURA ring (the tiles around it), as
# opposed to the core tile. UNITS: lava points.
# VALUES: any float >= 0; 5.0 is half the core's 10.0.
# STATUS: live-tested only inside the j146 batch — null, see BULLET_CORE.
# TRY: TR_TFIL_BULLET_AURA=10 -> doubles the aura heat; pairs with
# TR_TFIL_BULLET_CORE=20 in the j146 "middle" shape.
TR_TFIL_BULLET_AURA=5.0
# WHAT: when a dodge commitment is cancelled, replan from whose state?
# self = today's behaviour. VALUE: self | enemy | off.
# STATUS: `self` is the shipped default; `off` is the pre-j144 behaviour and is
# exactly what an earlier A/B (cc11ede arm D) tried and could not measure.
# TRY: TR_TFIL_TILE_REPLAN=off -> the tile-crossing cancel stops firing.
# Accepts self, enemy, off, none, never, 0, false (case-insensitive);
# anything else falls back to self with no warning.
TR_TFIL_TILE_REPLAN=self
TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning (min 1)
# WHAT: hold the committed dodge tile until we are actually ON it, instead of
# letting our own tile-boundary crossing cancel it. VALUE: on | 0/off.
# STATUS: LIVE, 600 battles in two blocks (j144). Mechanism is real and
# confirmed: incoming hit rate 18.07% -> 14.92%, sign-flip p = 0.0013, damage
# taken -24.57/run. Outcome is NOT distinguishable: +0.28 wins/run,
# p(sign) = 0.0574 against a 0.31 MDE. The ledger's answer for your own .env
# is YES to this knob, and NO to COMMIT_MARGIN.
# GOTCHA: only meaningful with TR_MOVEMENT=tfil; the default strafe mover has
# no tfil commitment. With TR_TFIL_COMMIT_ARRIVAL on, COMMIT_TICKS becomes a
# MINIMUM dwell, not a maximum.
# TRY: TR_MOVEMENT=tfil + TR_TFIL_COMMIT_ARRIVAL=1
# -> `[env] TR_TFIL_COMMIT_ARRIVAL = on (source: .env)`.
TR_TFIL_COMMIT_ARRIVAL=off
# WHAT: leave the committed tile only if the best alternative is at least this
# much COOLER on the same pathMaxHeat scale the picker uses. UNITS: lava
# points; 10.0 is exactly one PathDangerThreshold level.
# VALUES: any float >= 0; 0.0 = off = today's behaviour (any improvement ends
# the commitment).
# STATUS: LIVE, j144, 600 battles, as the `arrive_hyst` arm. It is the WEAKEST
# of the three j144 arms on both primaries (+0.19 wins/run, p = 0.092) and
# the ledger's explicit answer is: YES to COMMIT_ARRIVAL, NO to this.
# GOTCHA: it is a hysteresis, so it makes the bot commit harder; a large value
# with a busy field means it holds a tile that is no longer the best one.
# TRY: TR_TFIL_COMMIT_MARGIN=10 -> the j144 value. Already measured, and
# the answer was no. Use it only to isolate COMMIT_ARRIVAL, not as an
# improvement.
TR_TFIL_COMMIT_MARGIN=0.0
# WHAT: refuse a candidate tile we cannot REACH inside this many ticks. The
# bot's top speed is 8 px/tick, so 1 tick = 8 px. UNITS: ticks.
# VALUES: ticks, 0.0 = off = today's uniform draw over every safe tile.
# STATUS: DEFAULT-OFF, never live-tested. Found by the j151 offline ruler: 65%
# of tfil picks outran the 15-tick commitment and the chosen tile was reached
# only 6.5% of the time.
# GOTCHA: it is a HARD bound, not a preference. If every safe tile is out of
# range the pool empties and the code falls back to today's full pool, so it
# can never starve the draw — it can also silently do nothing.
# TRY: TR_TFIL_ARRIVE_TICKS=15 -> refuse anything farther than 15*8 = 120
# px. That is deliberately the same length as TR_TFIL_COMMIT_TICKS,
# i.e. "only pick a tile you can still reach while you hold it".
TR_TFIL_ARRIVE_TICKS=0.0
# WHAT: when the safe-tile set is EMPTY (no tile under the heat threshold),
# hold position for one tick instead of promoting the 2 least-hot blocked
# tiles. VALUE: on | 0/off.
# STATUS: DEFAULT-OFF, never live-tested. j153 wrote the proposal and the A/B
# design; it was NOT run (docs/tfil_hold_when_trapped_ab.md is titled
# "NOT RUN"). Four mechanism-positive / outcome-null results preceded it, so
# a null was always the likely answer.
# GOTCHA: it can never latch — the pick site only runs on a replan tick, so the
# next tick re-reads the field from scratch. The gun is untouched: a held
# tick still fires exactly like every other tick.
# TRY: TR_TFIL_HOLD_WHEN_TRAPPED=on -> the tfil pick log shows `hold`
# instead of `promote` on a trapped tick. Needs TR_TFIL_COMMIT_LOG set.
TR_TFIL_HOLD_WHEN_TRAPPED=off
# WHAT: the BOUNDED version of the knob above: while the safe set stays empty,
# hold for at most this many ticks per empty streak. UNITS: ticks (integer).
# VALUES: integer >= 0; 0 = off = today's promote-the-2 fallback.
# STATUS: DEFAULT-OFF, never live-tested (j154, no battle).
# GOTCHA: the budget is DERIVED, not guessed: the enemy fires two 3.0-power
# shots 16 ticks apart, so 16 is the first window that admits its second
# shot. The hold is released the tick a safe tile exists, the counter resets
# when one is taken, and a tracked bullet reaching us within min(N,16) ticks
# overrides the hold outright (panic release).
# TRY: TR_TFIL_HOLD_MAX_TICKS=16 -> the derived budget. Any integer parses;
# a junk value degrades to 0 (off).
TR_TFIL_HOLD_MAX_TICKS=0
# WHAT: the hard heat filter on a candidate's path. UNITS: lava points.
# VALUES: any float >= 0. Lava is QUANTISED to 5, so the only values that
# change anything are 10, 15 and 20: 10-14 admits exactly what 10 admits.
# STATUS: 10.0 is the shipped const; j150 made it sweepable so the offline
# ruler could move it. Never live-tested as a knob.
# GOTCHA: raising it does not make the bot braver, it makes the safe set
# smaller and more of the picks forced. j146's `nofield` arm is the warning:
# -13.45 damage/run, p = 0.0095.
# TRY: TR_TFIL_DANGER_THRESHOLD=15 -> one quantisation step stricter. The
# picker log then reports fewer safe candidates per pick.
TR_TFIL_DANGER_THRESHOLD=10.0
# WHAT: fill the per-pick LOSS HISTOGRAM (TfilLoss*): how many tiles die at
# each picker stage. VALUE: on | 0/off (read by value, not by presence).
# STATUS: DIAGNOSTIC ONLY, j150. Provably does not move a single move command
# (byte-for-byte, guard-tested). Never measured on outcome, by design.
# GOTCHA: nothing. It is the safest tfil knob in this file.
# TRY: TR_TFIL_DIAG=on -> the tfil pick log gains the per-stage counts.
TR_TFIL_DIAG=off
# WHAT: shape the tile DRAW over the heat-filtered pool by geometry (how far
# the tile sits from where we are already going) as well as by heat.
# VALUES: off | turn | dist | both, each optionally suffixed -soft (default),
# -topk or -rej. `distance`=dist, `rejection`=rej are also accepted.
# Anything unrecognised, and `off`, means OFF — today's uniform draw.
# The form is NOT printed by the boot report, only the dim.
# STATUS: DEFAULT-OFF. THE ONE LIVE-TESTED ARM IS `both-rej` + TAU=60 AND IT
# WAS REJECTED: -8.83 damage/run, p(sign-flip) = 0.0061, Wilcoxon p = 0.011,
# 420 battles, 15 opponents. Round wins null. Offline it did exactly what was
# predicted (arrivals 4.5% -> 29.4%) and that is WHY it is bad: the bot ends
# up 26 px further out on 15/15 opponents and deals less. See
# docs/tfil_geo_ab.md. DO NOT re-run both-rej.
# GOTCHA: this knob ALONE is inert — TR_TFIL_GEO_TAU=0.0 means the weighting is
# off whatever the mode says. And it needs TR_MOVEMENT=tfil.
# TRY: TR_TFIL_GEO_MODE=both-soft + TR_TFIL_GEO_TAU=45
# -> the j152 offline headline (arrivals 4.5% -> 16.9%, top-tile share
# only 7.0% -> 8.6%). Still never live-tested, and the family has one
# measured loss, so treat it as a hypothesis.
TR_TFIL_GEO_MODE=off
# WHAT: the geometric cost scale, in degrees. 0.0 = off, i.e. exactly today's
# uniform draw. UNITS: degrees. VALUES: any float >= 0.
# STATUS: never live-tested with a mode other than off. The one live arm used
# TAU=60 and lost (see GEO_MODE).
# GOTCHA: TAU is IGNORED unless GEO_MODE is not off. A big TAU with mode=off
# looks like it is doing something and is not.
# TRY: TR_TFIL_GEO_TAU=45 -> 45 degrees of turn cost. Pairs with
# TR_TFIL_GEO_MODE=both-soft; on its own it changes nothing.
TR_TFIL_GEO_TAU=0.0
# WHAT: inside a corridor, never pick a tile in the reverse direction. VALUE:
# on | 0/false/no/off (read by value, so `=off` really disables it).
# STATUS: never live-tested as a standalone arm. The j144/j145 arms relied on
# the NOREV_SPEED knob instead, which is stricter.
# GOTCHA: soft only — every weight is floored, so the pool can never empty; and
# it overlaps TR_TFIL_NOREV_SPEED, which is the knob that was actually run.
# TRY: TR_TFIL_NO_REV=on -> a 3:1 forward:rearward draw weight inside a
# corridor. Accepts on/1/true/yes to enable, 0/false/no/off to disable.
TR_TFIL_NO_REV=off
TR_TFIL_COMMIT_LOG= # path for the per-commit log; empty = no log
TR_TFIL_COMMIT_ARRIVAL=off # on = hold the dodge tile until we are ON it (not a fixed dwell)
TR_TFIL_COMMIT_MARGIN=0.0 # lava an alternative tile must be cooler by before it wins the tile
TR_TFIL_NOREV_SPEED=0.0 # px/tick; below this, a mid-flight switch may not turn the bot around
TR_TFIL_TURN_BIAS=0.0 # turn TIEBREAK odds ratio among SAFE tiles; 0 = uniform draw as today
TR_TFIL_TURN_REF_DEG=45.0 # deg; turn below which the tiebreak applies no penalty
# WHAT: while our own speed is below this, a mid-flight target switch may not
# take a tile more than 90 degrees off the travel direction. UNITS: px/tick
# (top speed 8). VALUES: any float >= 0; 0.0 = off = shipped.
# STATUS: LIVE, j144/j145, 600 battles. Mechanism confirmed: slow opposite-way
# mid-flight switches fell 394 -> 64 offline. Outcome not distinguishable on
# its own (+0.12 wins/run, p = 0.39); the ledger recommends 4 in your .env
# together with COMMIT_ARRIVAL, not alone.
# GOTCHA: the pool can never be emptied — with every candidate behind us it
# takes the least-bad turn. 0.0 genuinely disables it.
# TRY: TR_TFIL_NOREV_SPEED=4 -> 4 px/tick = half of top speed, the j144
# value. Watch: fewer >90 deg switches in the tfil pick log.
TR_TFIL_NOREV_SPEED=0.0
# WHAT: turn TIEBREAK odds ratio among the SAFE tiles: a straight-ahead safe
# tile is drawn `1 + bias` times as often as a 180-degree one.
# w = max(1, round(1 + BIAS * (1 - max(0,|turn| - REF)/180)))
# UNITS: dimensionless. VALUES: any float >= 0; 0.0 = uniform draw as today.
# STATUS: LIVE NULL, j145, 300 battles: +0.15 wins/run, p(sign) = 0.244, under
# a 0.30 MDE. Docs: docs/movement_campaign.md. A real but small mechanism
# with an under-powered outcome.
# GOTCHA: the turn cost is NEVER folded into the heat score — the filter stays
# hard, and every weight is floored at 1, so the pool can never empty.
# TRY: TR_TFIL_TURN_BIAS=9 + TR_TFIL_TURN_REF_DEG=0
# -> the j145 arm value (the knee of the offline bias curve: mean
# |turn| -11%, opposite picks -22%). Already measured null.
TR_TFIL_TURN_BIAS=0.0
# WHAT: the turn below which the tiebreak above applies NO penalty. UNITS:
# degrees. VALUES: any float >= 0; 45.0 = today's default.
# STATUS: inert unless TR_TFIL_TURN_BIAS > 0. j145 used 0 with bias 9.
# GOTCHA: on its own this knob does nothing at all.
# TRY: TR_TFIL_TURN_REF_DEG=0 -> penalise every turn, not just the sharp
# ones. Pairs with TR_TFIL_TURN_BIAS=9; alone it is a no-op.
TR_TFIL_TURN_REF_DEG=45.0
# WHAT: paint heat on the virtual centre pillar. VALUE: on | 0/off. The shipped
# field has NO pillar: PillarHotness/PillarRadiance are 0.0.
# STATUS: live-tested, and the OWNER OVERRULED the recommendation to turn it
# back on: `old` (pillar on) was best on damage/run (287) and round wins
# (35/70) but the contrast is INSIDE the MDE (33 damage/run, 1.22 wins/run
# at n=10) and damage taken was 30.8/run higher with the pillar off
# (p = 0.040, not corrected for multiple arms). Decision: pillar stays
# removed. Docs: docs/tfil_heat_pillar_ab.md.
# GOTCHA: this restores an INVENTED hazard with no physical object behind it.
# Reversing that decision needs its own pre-registered A/B.
# TRY: TR_TFIL_PILLAR_ON=1 -> the pre-change field (30/10), for a fair
# A/B against the shipped one. Needs TR_MOVEMENT=tfil.
TR_TFIL_PILLAR_ON=off
TR_TFIL_HEAT_TIME=off # on = index bullet heat by time (flat field when off)
TR_TFIL_HEAT_TAU=9.0 # ticks a tracked bullet's heat lives for
TR_TFIL_HEAT_POWER_GAIN=1.0 # scale of the heat a bullet paints, per firepower
TR_TFIL_PILLAR_ON=off # on = paint heat on the arena centre, which has no pillar
# ── movement internals: strafe ───────────────────────────────────────────────
TR_STRAFE_BAND=20.0 # degrees the heading may sit off the perpendicular
@@ -152,14 +572,42 @@ TR_STRAFE_WALL_BIAS=0.35 # how strongly a tile farther from the wall is preferr
TR_STRAFE_WALL_SAFE=24.0 # px; a wing point never lands nearer than this to a wall
TR_STRAFE_ESCAPE=on # the guaranteed wall escape when every candidate is hot
TR_STRAFE_FIRE_FIX=on # strafe's share of the shared TR_FIRE_FIX switch
TR_FIRE_FIX=on # 0 = the shipped previous-energy bullet detector
#TR_FIRE_DIAG=1 # presence-only: per-reading tick/raw/correction trace
#TR_FIRE_LAG=0 # ticks to back-date each detected fire at spawn (0=shipped; 1=the measured live detection lag)
# WHAT: the shared enemy-fire detector. VALUE: on | 0/off. One switch, read by
# every mover; each mover may AND it with its own (TR_STRAFE_FIRE_FIX).
# STATUS: j134 propagated it to all five movers; the per-mover catch table went
# 98.888% -> 100% of enemy fires on a 70-battle corpus.
# GOTCHA: TR_STRAFE_FIRE_FIX is an AND, so turning TR_FIRE_FIX off is enough;
# turning only TR_STRAFE_FIRE_FIX off does not disable strafe's detector.
# TRY: TR_FIRE_FIX=0 -> the shipped previous-energy detector everywhere
# (the control arm for any fire-detector A/B).
TR_FIRE_FIX=on
#TR_FIRE_DIAG=1 # PRESENCE-only: per-reading tick/raw/correction trace
# WHAT: back-date every detected enemy fire by this many ticks when the ghost
# is spawned. UNITS: ticks (integer, clamped at 0). 0 = shipped.
# STATUS: LIVE, j147, 180 battles. The 1-tick detection lag is OURS and was
# measured on 1777 matched ghost spawns (displacement 19.1 px mean on tfil,
# 16.1 on strafe; the arrival deadline the mover reads was 0.99 / 0.77 ticks
# late). With =1 it falls to 5.4 / 9.0 px and 0.06 ticks. The live OUTCOME is
# null on both movers (+2.08 / +3.77 damage/run, every p > 0.6), so it stays 0.
# GOTCHA: a junk or negative value degrades to 0, never a negative back-date.
# TRY: TR_FIRE_LAG=1 -> one bullet step at power 1.0 is 17 px; the ghost is
# born that far downrange. Watch the tfil/strafe pick log's arrival
# deadline, which is what the knob actually corrects.
TR_FIRE_LAG=0
TR_STRAFE_HEAT_GRID=on # draw the whole heat grid; 0 leaves only the chosen tile
TR_STRAFE_BULLET_CORE=20.0 # strafe's own retune: lava per bullet-overlapping tile
TR_STRAFE_BULLET_AURA=10.0 # strafe's own retune: lava for the bullet aura ring
TR_STRAFE_CORRIDOR_HEAT=10.0 # strafe's own retune: lava per corridor tile
TR_STRAFE_CORRIDOR_TICKS=0.0 # same length bound for strafe: 0 = to the wall (shipped)
# WHAT: the same corridor LENGTH bound as TR_TFIL_CORRIDOR_TICKS, for the
# strafe mover. UNITS: ticks. VALUES: ticks, 0.0 = to the wall (shipped).
# STATUS: DEFAULT-OFF, never live-tested (commit 5e213df, j148). Same standing
# as the tfil one: no battle, no offline ruler, not recommended.
# GOTCHA: this is the DEFAULT engine's knob. Setting only the tfil one does
# nothing at all, because the shipped movement is strafe.
# TRY: TR_STRAFE_CORRIDOR_TICKS=20 -> 20-tick look-ahead, 220..340 px over
# the shipped power bins, instead of the whole wall. Watch: the strafe
# heat grid's corridor stops before the wall.
TR_STRAFE_CORRIDOR_TICKS=0.0
TR_STRAFE_WALL_HOTNESS=15.0 # strafe's own retune: peak wall heat
TR_STRAFE_WALL_RADIANCE=5.0 # strafe's own retune: wall heat falloff
@@ -168,7 +616,7 @@ TR_SURF_PREF_DIST=400.0 # px; the wave distance the mover tries to sit at
TR_SURF_DIST_BAND=50.0 # px dead band around it
TR_SURF_WALL_MARGIN=48.0 # px kept from the wall when picking a wave point
TR_SURF_RADIAL_FRAC=0.35 # how much of the remaining weight goes to the radial blend
#TR_SURF_LOG=1 # presence-only: one line per wave-surfing decision
#TR_SURF_LOG=1 # PRESENCE-only: one line per wave-surfing decision
# ── movement internals: learned (per-state learned danger) ──────────────────
TR_LEARNED_DECAY_EVERY=128 # learns between forgetting passes; 0 never forgets
@@ -183,18 +631,20 @@ TR_LEARNED_WALL_MARGIN=48.0 # px kept from the wall
TR_LEARNED_GLOBAL=off # on = ignore the learned state (ablation arm)
TR_LEARNED_LABEL=histogram # histogram (default) or outcome: what a wave is labelled with
TR_LEARNED_REAL_EVENTS=off # on = resolve a wave on the real bullet event, not on energy
#TR_LEARNED_LOG=1 # presence-only: one line per learned decision
#TR_LEARNED_LOG=1 # VALUE-based (1/on/yes to enable, 0/off to disable)
# ── guns ─────────────────────────────────────────────────────────────────────
# Virtual bullets: the prediction the whole gun selector is built on.
TR_MODULE_VBULLETS=on # 0 = no gun predicts or spawns; the selector falls back to its floor gun
# TMH — the horizon Tsetlin automata gun.
# TMH — the horizon Tsetlin automata gun. Inert unless TR_RACK_TMHORIZON=both.
TR_TMHORIZON_SHIFT=2.0 # degrees added to the aim; 0 disables the correction arm
TR_TMHORIZON_BIG_MULT=1.5 # extra scale applied when the error magnitude is big
TR_TMHORIZON_RESET_ON_TARGET=on # wipe the automata when the target changes
TR_TMHORIZON_NSTATES=64 # automata state count (the inertia it can hold)
TR_TMHORIZON_WINDOW=0 # samples kept in the sliding window; 0 keeps everything
# e.g. TR_TMHORIZON_WINDOW=30 -> retrain on the 30 most recent samples only.
# MEASURED HARMFUL LIVE at 150: 26.5% round wins vs 49.0%, p = 0.036. Keep 0.
TR_TMHORIZON_RESET_DROP=0.0 # rolling accuracy drop, in points, that forces a retrain
TR_TMHORIZON_ACCURVE=off # log the accuracy curve even without the thinking log
TR_TMHORIZON_RETRAIN_EVERY=50 # samples between full retrains in sliding mode
@@ -207,7 +657,7 @@ TR_LEADGAIN_MIN_OBS=8 # samples a band needs before its gain is trusted
TR_LEADGAIN_DECAY=250 # samples between count-decay passes
TR_LEADGAIN_DECAY_FRAC=0.02 # fraction each decay pass takes off every count
TR_LEADGAIN_RESET_ON_TARGET=on # wipe the learned gains when the target changes
TR_LEADGAIN_LOG=off # on = one line per gain change
TR_LEADGAIN_LOG=off # on = one line per gain change (value-based: 0 disables)
# Kept only so a pre-rename .env does not warn. The gun does not read them.
TR_LEADGAIN_N=32 # NO-OP: the old SBC geometry, no longer used
TR_LEADGAIN_NADE=256 # NO-OP: the old ADE count, no longer used
@@ -221,6 +671,10 @@ TR_LEADGAIN_SEED=20240921 # NO-OP: the old seed, no longer used
TR_PATTERN_LEN=10 # ticks of movement history used as the search key
TR_PATTERN_DEPTH=500 # how far back the history scan may reach
TR_PATTERN_RAD_OFFSET=0.0 # px added to the aim distance; negative aims short
# Both RAD_* knobs are read LAZILY, inside predict() — the one place a
# mid-run change can matter, and it still does not. The live aim is bearing
# only, so a purely radial offset is structurally invisible: MEASURED
# byte-identical on bmPath. Docs: docs/env_reference.md §7.
TR_PATTERN_RAD_SCALE=1.0 # multiplier on the whole aim distance
# The SBC library (common_libs/bitbrain), not a gun knob. Registered so a config
@@ -250,32 +704,71 @@ TR_BITBRAIN_DECAY_SHIFT=1 # forgetting strength, counted mode only; 0 disa
#TR_BITBRAIN_RESET_ON_TARGET=on # LEGACY: old name of TR_LEADGAIN_RESET_ON_TARGET
# ── debug overlays (all on top of the gun; they never change a decision) ─────
TR_DEBUG_DRAW=on # master switch for every mover's debugGraphics
TR_GEO_DEBUG=off # draw the shared candidate-tile geometry overlay
TR_VBULLET_DEBUG=off # draw each admitted gun's virtual-bullet paths
TR_VBULLET_DEBUG_GUN= # which gun the overlay draws: empty = the selected one
TR_VBULLET_DEBUG_MAX=32 # max virtual bullets drawn per gun
# WHAT: master switch for every mover's debugGraphics. VALUE: on | 0/off.
TR_DEBUG_DRAW=on
# WHAT: draw the shared candidate-tile geometry overlay. VALUE: on | 0/off.
# DRAW ONLY — it cannot change a decision, so it is the safest knob here.
# GOTCHA: independent of TR_DEBUG_DRAW: the overlay is drawn either way, and
# TR_DEBUG_DRAW=0 is what suppresses the movers' own graphics.
# TRY: TR_GEO_DEBUG=on -> the geometry circles appear over the arena.
TR_GEO_DEBUG=off
# WHAT: draw each admitted gun's virtual-bullet paths. VALUE: 1/on/yes to
# enable, 0/off/no/false to disable, unset = off. DRAW ONLY.
# GOTCHA: needs a gun in the rack to be legible; the shipped rack admits
# Pattern only, so set TR_VBULLET_DEBUG_GUN too.
# TRY: TR_VBULLET_DEBUG=1 + TR_VBULLET_DEBUG_GUN=all
# -> travelled path, aim ring and miss vector for every admitted gun.
TR_VBULLET_DEBUG=off
# WHICH gun the overlay draws. VALUES: empty = the currently selected gun;
# `all` or `*` = every gun; otherwise a gun name, e.g. `Pattern`.
# TRY: TR_VBULLET_DEBUG_GUN=all -> every admitted gun at once.
TR_VBULLET_DEBUG_GUN=
TR_VBULLET_DEBUG_MAX=32 # max virtual bullets drawn per gun (clamped to >= 1)
TR_VBULLET_ADMIT_ONLY=on # on = a gun the rack does not admit is not even predicted
# ── logs (set the value to 1; presence alone turns some of them on) ──────────
TR_RESULT_LOG=on # one line per round result
#TR_POWER_LOG=1 # presence-only: one line per power decision
#TR_RAM_LOG=1 # presence-only: one line per ram start/stop and why
#TR_MOVEMENT_LOG=1 # presence-only: movement band/class changes
#TR_STRAFE_LOG=1 # presence-only: one line per strafe tile pick
#TR_TMHORIZON_LOG=1 # presence-only: the per-shot thinking of the TM horizon gun
# ── logs (set the value to 1; PRESENCE alone turns these on) ─────────────────
# WHAT: one line per round result. VALUE-based (unlike the block below): 0/off
# really disables it, which is why this one is written out uncommented.
# TRY: TR_RESULT_LOG=off -> no [result] lines at all.
TR_RESULT_LOG=on
#TR_POWER_LOG=1 # PRESENCE-only: one line per power decision (0 would ENABLE it)
#TR_RAM_LOG=1 # PRESENCE-only: one line per ram start/stop and why
#TR_MOVEMENT_LOG=1 # PRESENCE-only: movement band/class changes
#TR_STRAFE_LOG=1 # PRESENCE-only: one line per strafe tile pick
#TR_TMHORIZON_LOG=1 # VALUE-based: the per-shot thinking of the TM horizon gun
GUN_STATS_PATH=/tmp/gun_stats.jsonl # where the per-round gun stats are written
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl # where the per-shot log is written
# ── measurement helpers (leave off unless you are measuring) ─────────────────
#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
# 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
TR_TRACKER_PROBE_PATH=/tmp/tracker_probe.jsonl # where that dump is written
# ── dotenv / boot report ─────────────────────────────────────────────────────
# Name of the env file to load. Must be set in the REAL environment, not in the
# file it points at. Empty = use ./.env, else .env next to the binary.
# GOTCHA: the line below sets it to the EMPTY string, which is the correct
# "use the default" spelling; putting a real path here would make this file
# load itself, recursively, at every start.
TR_ENV_FILE=
# 1 = print the [env] report on startup (default). 0 = do not print it.
TR_ENV_REPORT=1
+45
View File
@@ -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,
+129
View File
@@ -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
+4 -1
View File
@@ -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,
@@ -23,6 +23,8 @@
## A trailing live end marker is also optional:
## {"end":{"enemy_died":<bool>,"ticks":<int>}}
## 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
+18
View File
@@ -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: <gun> shots=<n> hits=<n>, 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
+132
View File
@@ -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: <gun> shots=<n> hits=<n>, 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)
+131 -5
View File
@@ -47,13 +47,123 @@ misled people:**
warning at all**.
2. **Some flags are presence-based, not value-based.** They are read with
`existsEnv`, so **`TR_POWER_LOG=0` turns the log ON** (any value does).
Presence-based: `TR_POWER_LOG`, `TR_RAM_LOG`, `TR_MOVEMENT_LOG`,
`TR_RECORD_WORLDSTATE`, `TR_RADAR_FORCE_SPIN`, `TR_RADAR_SCANLOG`,
`TR_TRACKER_PROBE`. Value-based (`0`/`false`/`off` really disable):
`TR_ENV_REPORT`, `TR_TMHORIZON_LOG`, `TR_TMHORIZON_ACCURVE`,
Value-based (`0`/`false`/`off` really disable): `TR_ENV_REPORT`,
`TR_TMHORIZON_LOG`, `TR_TMHORIZON_ACCURVE`,
`TR_TMHORIZON_RESET_ON_TARGET`, `TR_POWER_POLICY`, `TR_POWER_FINISH_KILL`,
`TR_RAM_OPPORTUNITY`, `TR_RAM_PLAN`, `GUN_SELECTOR_POOL`,
`TR_VBULLET_ADMIT_ONLY`.
`TR_VBULLET_ADMIT_ONLY`, `TR_LEADGAIN_LOG`, `TR_LEARNED_LOG`,
`TR_LEARNED_GLOBAL`, `TR_LEARNED_REAL_EVENTS`, `TR_FIRE_FIX`,
`TR_STRAFE_FIRE_FIX`, `TR_STRAFE_ESCAPE`, `TR_STRAFE_HEAT_GRID`,
`TR_TFIL_HEAT_TIME`, `TR_TFIL_PILLAR_ON`, `TR_TFIL_DIAG`, `TR_TFIL_NO_REV`,
`TR_TFIL_HOLD_WHEN_TRAPPED`, `TR_TFIL_COMMIT_ARRIVAL`,
`TR_TFIL_RING_COMMIT_ARRIVAL`, `TR_VBULLET_DEBUG`, `TR_GEO_DEBUG`,
`TR_DEBUG_DRAW`, `TR_RESULT_LOG`.
### THE FULL PRESENCE-GATED LIST (j172, from `grep -rn existsEnv`)
The list above was **incomplete**: it was missing four knobs. The complete set,
from `grep -rn 'existsEnv' ModularBot_garage/src common_libs | grep -v /tests/`,
is:
| knob | read at | what it logs / does |
|---|---|---|
| `TR_POWER_LOG` | `ModularBot.nim:145` | one line per power-decision CHANGE |
| `TR_RAM_LOG` | `ram_decision.nim:119` | one line per ram start/stop + reason |
| `TR_MOVEMENT_LOG` | `the_floor_is_lava_ring.nim:192` | movement band / range-class changes |
| `TR_STRAFE_LOG` | `strafe.nim:402` | one line per strafe tile pick |
| `TR_SURF_LOG` | `wave_surfer.nim:102` | one line per wave-surfing decision |
| `TR_FIRE_DIAG` | `ModularBot.nim:137`, `the_floor_is_lava.nim:299`, `strafe.nim:415` | per-reading fire-detection tick/raw/correction |
| `TR_RECORD_WORLDSTATE` | `ModularBot.nim:70` | dump every observed world state |
| `TR_RADAR_SCANLOG` | `ModularBot.nim:80` | log every radar scan tick |
| `TR_RADAR_FORCE_SPIN` | `ModularBot.nim:79` | force the old full-360 spin radar |
| `TR_TRACKER_PROBE` | `ModularBot.nim:86` | dump the enemy-tracker internals |
**For these ten, `NAME=0` turns the feature ON.** OFF means the line is ABSENT.
That is why `.env.example` shows every one of them commented out: there is no
"off" spelling, only absence. To disable one, delete its line.
Two more are read with `existsEnv` but are *not* features — `TR_ENV_FILE` (an
empty value is the correct "use the default" spelling) and the loader's own
`existsEnv(e.key)` conflict check.
**And one label is misleading:** `env_report.nim` prints `TR_TFIL_DIAG`,
`TR_TFIL_HOLD_WHEN_TRAPPED`, `TR_TFIL_COMMIT_ARRIVAL` and
`TR_TFIL_RING_COMMIT_ARRIVAL` through `sourceOfPresence`, but all four are read
**by value** (`getEnvBool`) in the source. The value is always right; only the
`(source: ...)` label is affected. Do not read that label as "presence-gated".
---
## ONE EXPERIMENT, END TO END (mirrored from `.env.example`)
Pick ONE knob. Here it is `TR_TFIL_ARRIVE_TICKS`; the shape is the same for
every knob.
```sh
# 1. write the arm as its own file — that is how you GUARANTEE the arm, because
# nothing else can be applied on top of it
cat > /tmp/arm_arrive15.env <<'EOF'
TR_MOVEMENT=tfil
TR_TFIL_ARRIVE_TICKS=15.0
EOF
# 2. RESTART THE BOT. Env is read ONCE, at boot (module init). Editing .env
# while the bot runs changes nothing; there is no live reload.
cd ModularBot_garage && ./ModularBot.sh # or restart the GUI
# 3. CONFIRM IT TOOK EFFECT, before reading a single result line.
# `source: .env` = your file was applied. `source: default` = it was not.
grep '^\[env\]' /tmp/modularbot_stdout.log | grep -E 'env file|ARRIVE_TICKS'
# [env] env file: /tmp/arm_arrive15.env (source: TR_ENV_FILE)
# [env] TR_TFIL_ARRIVE_TICKS = 15.0 (source: .env)
# 4. point at the file instead of copying it into .env:
TR_ENV_FILE=/tmp/arm_arrive15.env ./out/ModularBot
./out/ModularBot --env-file /tmp/arm_arrive15.env
# A file you ASKED for and that does not exist stops the bot with an error; a
# missing default .env is silent. This is what an A/B run does: one frozen
# binary, one env file per arm.
# 5. what is switched on at all:
grep '^\[modules\]' /tmp/modularbot_stdout.log
```
## SAFE TO EXPERIMENT WITH RIGHT NOW
The honest list is SHORT: after the recent campaign most experimental knobs are
either never live-tested or already measured null/harmful, and `.env.example`
says so on every one of them. These four are safe in the sense that they either
cannot change a decision, or are the ones a measurement actually supports.
| knob | what changes | what to watch | a good result |
|---|---|---|---|
| `TR_GEO_DEBUG=on` | draw-only geometry overlay | the circles on the two tanks, each heading line | you can SEE the tile the picker chose; it cannot change a decision |
| `TR_VBULLET_DEBUG=1` + `TR_VBULLET_DEBUG_GUN=all` | draw-only: each admitted gun's virtual bullets | travelled path, aim ring, miss vector | you can see the signal the selector ranks on; also draw-only |
| `TR_TFIL_DIAG=on` | fills the per-pick loss histogram (tfil only) | the tfil pick log line | `sReach/sCool/sSafe/sCand` tell you where tiles are lost; provably moves no command |
| `TR_MOVEMENT=tfil` | runs the long-shipped mover | nothing to compare against | you are reproducing an older, documented behaviour; only do it together with the `TR_TFIL_*` knobs |
## ALREADY REJECTED OR MEASURED NULL — WITH THE NUMBER
Do not re-run these by accident.
| knob / arm | result | where |
|---|---|---|
| `TR_TFIL_GEO_MODE=both-rej` + `TR_TFIL_GEO_TAU=60` | **REJECTED** live, 420 battles, 15 opponents: damage/run **-8.83**, p(sign-flip) **0.0061**, Wilcoxon p 0.011. Round wins null. Offline it did what was predicted (arrivals 4.5% -> 29.4%) and that is why it is bad: +26 px distance on 15/15, less damage. | `docs/tfil_geo_ab.md` |
| `TR_RAM_FLOOR_ENERGY=5` | **CLEAN NULL**: **-0.018 wins/run**, p(sign-flip) **0.7676**, under a **0.1420 wins/run** MDE, 900 battles. Mechanism fired on 0.04% of ticks (~200x less than the offline ruler said). Do not re-test: more runs buy resolution on an effect that is not there. | `docs/ram_floor_exhaustion_ab.md` |
| `TR_RAM_FLOOR_ENERGY=10/20` | measured COSTLY offline (20 blocked 24.7% of all ticks) and the zone it guards is nearly empty: only 4.8% of shots are taken below 10 energy. | same |
| `TR_TMHORIZON_WINDOW=150` | **MEASURED HARMFUL** live: 26.5% round wins vs 49.0% for the shipped rack, p = 0.036. Keep 0. | env_reference "Measured verdicts" |
| `TR_POWER_POLICY=0` | **MEASURED HARMFUL** live: real hit rate 10.61% -> 7.88%, p = 0.0012. | same |
| `TR_TFIL_HEAT_TIME=1` | **MEASURED HARMFUL** live at every tau tried (3/5/9/15); tau15 alone is -22 damage/run, p = 0.046. | `docs/tfil_heat_pillar_ab.md` |
| `TR_MOVEMENT=tfil_ring` | **MEASURED**: round wins 16/49 -> 6/49, p = 0.012. Best live hit rate of anything measured, half the survival. | same / env_reference |
| the full `TR_RACK_*` rack | **MEASURED NEGATIVE VALUE**: Pattern ALONE beats the full 13-gun rack, p = 0.0012. Adding guns costs rounds. | `docs/gun_rack_analysis.md` |
| `TR_TFIL_TURN_BIAS=9` + `_TURN_REF_DEG=0` | **LIVE NULL**: +0.15 wins/run, p(sign) 0.244, under a 0.30 MDE, 300 battles. | `docs/movement_campaign.md` (j145) |
| `TR_RAM_OPPORTUNITY=on` | **MEASURED not to convert**: 0/59 opportunity -> contact. The finisher ram is the only path that converts, and it is always on. | env_reference |
| `TR_TFIL_PILLAR_ON=1` | live-tested, and the recommendation to revert to pillar-on was **OVERRULED by the owner**: the contrast is inside the MDE (33 damage/run, 1.22 wins/run at n=10). Pillar stays removed. | `docs/tfil_heat_pillar_ab.md` |
> **A null is only a null at the resolution that run reached.** The frozen
> 15-opponent panel at 14 runs/arm resolves ~0.17 wins/run and ~7.65 damage/run;
> the j163 run resolved 0.1420 wins/run. "Clean null" here means *no effect at or
> above that size* — not *no effect*.
---
@@ -506,6 +616,7 @@ 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_CAPTURE_AIM` | off | presence-based; append `aim_scan` / `aim_fire` records — what the lead model BELIEVED (gun id, blst, age, boff, aim, turret, terr, heat, ax/ay, tof) to the same capture file as `TR_RECORD_WORLDSTATE` (needs `TR_RECORD_WORLDSTATE=1`) |
| `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 |
@@ -523,6 +634,21 @@ selector's training signal visible. Turn it on with:
- colour per gun is the SAME table as the turret (`vbullet_draw.gunColors`), with
a one-line legend in the top-left corner.
### Known limitations — `TR_CAPTURE_AIM` (j177)
**`aim_fire` only records shots that PASSED `setFire`.** The capture is written
from the bot's own fire call, so a shot the **server rejected or that the bot
never issued** produces no `aim_fire` record at all.
That is a real blind spot: from these records you can never answer *why* a shot
did not happen — e.g. the gun was still hot, or the turret was not yet aligned.
A missing `aim_fire` is ambiguous between "no target / didn't try" and "tried and
was refused". The `aim_scan` records carry the belief state (age, boff, turret,
heat) for every scan, so you can often *infer* the cause by looking at the scans
that precede the gap, but the capture does not state it. Read the gaps as
"no recorded shot", never as "the server blocked it". Closing this needs a
pre-`setFire` gate record, which is j177+ work, not present today.
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`,
`FreshnessTicks=16`, `FreshStreakTicks=3`, `MarginDeg=20`,
+283
View File
@@ -0,0 +1,283 @@
# Range vs approach: melee is unavailable against DrussGT, so the gun at range is the only lever
**Date:** 2026-09-27 · **Job:** j167 · **Branch:** `research/lead-targeting`
**Evidence base:** j166 (`worktrees/j166-aim` @ `a5a49bd`), j165 (`fe77056`), j159
(`4a1f3e1`), j165/j151/j152/j154, j160/j163 (`0df7763` / `51bfa57`), j161
(`docs/ram_floor_exhaustion_ab.md:219`).
**Instrument for the new numbers below:** `worktrees/j167-ceiling/j167_probe.py`
(branch `j167-ceiling`) — pure replay of the recorded corpora
(`/tmp/tfil_ab2/out/`, 60 252 of our own scored shots over 140 battles; and
`/tmp/firelag_live2/`, 1 700 shots / 1 664 incoming bullets over 4 battles).
**No battle, A/B, server or GUI was run for this document.**
---
## 1. The ceiling
**Melee is structurally unavailable against DrussGT, and the exhaust/ram line is a
niche rather than a lever.** The evidence is two-sided and independent: (a) *our
mover's own ruler* — 94% of forced (no-safe-tile) picks happen at range > 300 u
and only 6-7% of picks reach the chosen tile at the estimated arrival time, with
the destination hot on arrival 35-42% of the time; and (b) *the j166 pursuit
probe* — 35 windows × 250 ticks of open-loop kinematics in which **every**
steering law is equal-or-worse than doing nothing clever:
| steering law (j166) | closing (u/tick) | contact % | TTI (ticks) |
|---|---:|---:|---:|
| current-position closing | 4.03 | 65.7 | 72.3 |
| body/barrel ray | 1.38 | 40.0 | 131.4 |
| velocity intercept (degenerate at equal speed) | — | 0 over 2 118 ticks | — |
| best case: lag-5 lead | 4.20 | 65.7 | 68.9 |
The root cause of the historical **0/59 proactive-ram** result
(`docs/ramming_negative_result.md`) is not a bad gate: **DrussGT never let the
distance drop.** Per-round minimum distance 152-338 u, median ~490 u, and
`frac(dist < 50) = 0.000` in all four recorded rounds. A pursuit that never gets
below 152 u cannot make contact, whatever the gate says. It is also not a
gun-side problem: **the server never transmits the enemy's gun direction**
(`ScannedBotEvent` = `energy, x, y, direction` where `direction` is the BODY
heading, plus `speed`; `TurnProcessor.kt:313-323`). There is no aim-based lead,
no aim-based dodge and no early warning available. Against DrussGT the
body-to-bullet angle has median **90.1 deg**, and the body ray passes within
10 deg of us on **0.0% of 1 794 ticks** — its gun is always on us, its body
never is.
**Recorded so the idea is not re-proposed:** the j166 lag-5 residue does improve
TTI (72.3 → 68.9) and **converts to contact 0% of the time**. A 4% TTI gain with
zero contact conversion is noise, not a lead.
### The honest remaining niches for exhaust/ram
1. **An opponent that closes on us.** Ram works whenever the other side comes to
us. Nothing here generalises away from that.
2. **A late-round exhaustion when they are already near.** The one conversion
ever recorded came from a *finisher* (enemy 16 → 1 energy), which is already
the default gate.
3. **Any 2v1+ mode**, where closing dynamics are not symmetric.
Against DrussGT specifically none of these will move the score, and
`TR_RAM_FLOOR_ENERGY` is under test in j163 — do not duplicate it.
---
## 2. What the ceiling implies
**If range is held, the only remaining lever is the gun at range, and the binding
numbers are the gun's, not the tile picker's.** The long-range hit rate is
**~9-10%** (Pattern live: 12.3% at 300-450 px, 9.2% at 450+; overall 10.5% —
`docs/headon_longrange_live.md`), the live hit half-window at 450 px is
**`atan(18/450) = 2.29°`** (`docs/gun_campaign.md:59`), and the measured arrival
aim error is **16.19° mean-abs at 450+** (`docs/bitbrain_campaign.md:107`,
`docs/headon_longrange_live.md:85`). 16.19° is **7× the window**. The tile picker
cannot close a 7× gap that sits downstream of the gun.
### New measurement — arrival aim error decomposed (j167, 23 275 shots at 450+ px)
Arrival aim error is defined non-circularly: the angle between the fired bearing
and the bearing to where the target *actually is* when the bullet arrives
(`tof = 20 - 3·power`, so the flight time comes from the power, not from the
shot's own geometry). It splits **exactly**, as signed angles, into
* **B, the model part** = the error the gun's own lead model leaves behind, and
* **C, manoeuvre** = the target's path curvature relative to the
constant-velocity extrapolation from the true state at fire time.
| band (px) | n | mean&#124;A&#124; | mean&#124;B&#124; (model) | mean&#124;C&#124; (manoeuvre) | sd(B) | sd(C) | corr(B,C) |
|---|---:|---:|---:|---:|---:|---:|---:|
| 0-100 | 24 767 | 83.36 | 101.56 | 49.38 | 124.5 | 75.9 | −0.65 |
| 300-450 | 11 372 | 13.07 | 21.77 | 11.00 | 26.0 | 13.0 | −0.87 |
| **450+** | **23 275** | **11.26** | **17.18** | **7.73** | **20.7** | **9.3** | **−0.85** |
At 450+ the model part's variance is **2.2× the manoeuvre part's**, and
`corr(B,C) = −0.85` means the two largely *cancel* — the net 11.26° is much
smaller than either part. **The 16° is a lead-model number, not a dodge number.**
Two supporting numbers: a naive constant-velocity extrapolation of a **2-tick-old**
position scores 8.45° mean-abs at 450+, and the time-of-flight implied by the
shot's own geometry (holding the current velocity) sits a **median 10 ticks short**
of the power-derived arrival tick (p10 −18, p90 +31) — i.e. the gun systematically
**under-leads in time**, consistent with `docs/lead_capture_by_range.md`
(capture 0.135 at 450+).
> **Do not read "a stale-CV model scores 8.45°" as "simplify the gun".** This is
> exactly the offline-ruler trap that killed HeadOn: the ruler said a no-lead gun
> was equal-or-better at 300+ and live it hit **20×/23× less**
> (`docs/headon_longrange_live.md`). The corpus is closed-loop — the target's
> manoeuvre is a *reaction to our own bullet* — so (B) and (C) are not separable
> here, and per `docs/offline_harness_trust.md` (j89: 0/6 on closed-loop) this
> instrument ranks per-gun single-tick prediction, it does not predict a live A/B.
### Cross-reference: what is still open in the gun docs
| doc | finding | status after this ceiling |
|---|---|---|
| `docs/gun_campaign.md:59` | hit half-window 2.29° at 450 px; measured signal 4.6-7.6° | **STILL OPEN and now the load-bearing number.** The decomposition says the gap is in the *model*, and the model is systematically 10 ticks short in time-of-flight. |
| `docs/gun_campaign.md:40-45` | lead amplitude is dead (1.0/1.5/2.0/3.0 all worse); radial knobs are bearing-invariant by construction | **CLOSED.** |
| `docs/gun_campaign.md:737-753` | `len6` +0.49 wins/run (p=0.039, n=15) did not replicate on n=33 | **CLOSED.** |
| `docs/bitbrain_campaign.md:189` | BitBrain / TMHorizon corrector adds no measurable aim (16.199 vs 16.193) | **CLOSED.** |
| `docs/bitbrain_campaign.md:107` | Pattern's own lead correlation with the required lead is 0.165 at 450+ | **STILL OPEN.** It is the same defect the decomposition names. |
| `docs/state_window_gate.md` | single wave-relative state at Q=4 predicts the miss bin at 0.4094 vs 0.2348 majority, but bins are 4.58-7.63° wide | **STILL OPEN, and now the best-placed surviving idea** — it is a *model* correction, which is where the error is. |
| `docs/gun_rack_analysis.md:423-455` | the 16-candidate rack ranking A/B found no winner; knobs added, all neutral | **CLOSED** (13 guns, `onlyPattern` shipped). |
---
## 3. Negative-results ledger — mechanisms closed by measurement
Do not re-litigate any row. The unit of evidence is the **opponent**.
| mechanism | knob / job | headline number | verdict |
|---|---|---|---|
| Geometry-weighted tile draw | `TR_TFIL_GEO_MODE/TAU`, j152 `38fbc6e`, A/B'd j159 `4a1f3e1` | **−8.83 damage/run, p=0.0061**; wins −0.05, p=0.46; +26.3 px mean distance on 15/15 opponents | **REJECTED.** Default off, stays off. |
| The bounded hold | `TR_TFIL_HOLD_MAX_TICKS`, j154 `2223ca6` | mechanism-positive, outcome-null (j146/j153) | **Default off.** No live win. |
| The proactive ram | `oldram` vs `base` gate `dist<200` | **p=0.69**, damage 279 vs 284, survival 17/49 vs 16/49; **0/59 opportunity→contact** | **CLOSED** (`docs/ramming_negative_result.md`). |
| The aim-based ram | j166 `a5a49bd` | body ray within 10° of us on **0.0% of 1 794 ticks**; body/barrel ray contact 40.0% vs 65.7% for doing nothing clever | **IMPOSSIBLE** — the server never sends gun direction (`TurnProcessor.kt:313-323`). |
| Arrival commitment (`tfil`) | j144 `d2005ab` | mechanism-positive, outcome-null | Default off. |
| Turn-cost tiebreak among safe tiles | j145 `39c90fd` | real but small mechanism, under-powered outcome null (300 battles, 5 arms) | Default off. |
| Field shape (safety) | j146 `de5d02b` | safe-set broken 63.5% → 30.4% offline; live null on damage and wins (375 battles, 5 arms) | **Default off.** |
| Corridor bound | j148 `5e213df` `TR_{TFIL,STRAFE}_CORRIDOR_TICKS` | never landed in a live A/B | Untested, not a candidate. |
| Ring arrival commitment | j165 `fe77056` `TR_TFIL_RING_COMMIT_ARRIVAL` | reach 0.24% → **3.05%**, picks 5 521 → 525, byte-for-byte default parity over 20 026 ticks, 148 guards | **Mechanism-positive, default off.** The strongest surviving movement mechanism. |
| Firing floor / enemy-exhaustion ram | j160 `23bce2d`, A/B'd j163 `51bfa57` | **clean negative**; the offline energy corpus missed the live game by 200× | **Under test in j163 — do not duplicate.** |
| Fire-detection lag | j147 `d21f7ce` `TR_FIRE_LAG` | displacement 19.06 → 5.37 px, deadline error 0.99 → 0.06 ticks; **live outcome-neutral**; ceiling ~10% of incoming damage (measured below) | **Default off, permanently.** |
| Hard arrival bound | j151 `a01141c` `TR_TFIL_ARRIVE_TICKS` | mechanism-positive, outcome-null | Default off. |
> **Methodological caution (j161), binding on everything above.** Pooled tests
> can hide real per-opponent effects: j159's safety signal was **p=0.0008
> per-opponent while the pooled test was null** (`docs/ram_floor_exhaustion_ab.md:219`).
> **Any future mechanism claim must report per-opponent mechanism metrics, not a
> pooled mean.** A pooled null is not evidence of absence; it is evidence that
> the heterogeneity was not averaged down.
---
## 4. Lead-time lever 1 — what a 2-tick-stale ghost really costs
`TR_FIRE_LAG` back-dates the bullet ghost (default 0). Energy-drop shot
detection lags **1.9 ticks mean**; median bullet flight is **19 ticks**
(`onHitByBullet` gives 82 hits / 7 421 ticks, one update per ~90 ticks).
**Measured on 55 750 incoming bullets** (`/tmp/tfil_ab2/out/`). For each bullet:
the time to closest approach of the target's recorded path to the bullet line
(**median 9 ticks**, p10 1, p90 39), and the minimum number of ticks of lead time
a max-speed hard-turn dodge needs to build 17 px of lateral displacement:
| minimum dodge lead time (ticks) | 0 | 1 | 2 | 3 | 4 | 5+ |
|---|---:|---:|---:|---:|---:|---:|
| share of incoming bullets | **57%** | 33% | 4% | 2% | 1% | 2% |
**57% of incoming bullets are already undodgeable at the instant they are fired**,
and only **~10%** (need ≥ 2 ticks) are in a regime where a 2-tick detection lag
can change anything. Applying the lag to the open-loop dodge model:
| ghost lag (ticks) | modelled hits | Δ vs perfect | share of all bullets whose hit/miss verdict flips |
|---|---:|---:|---:|
| 0 | 22 419 | — | — |
| **1.9 / 2** | **25 100** | **+2 681 (+12.0%)** | **10.18%** |
| 3 | 26 230 | +14.6% | 14.63% |
| 5 | 28 949 | +22.0% | 22.04% |
**Verdict: the 1.9-tick lag costs on the order of 10% more incoming hits** — at
the measured ~200 damage/run, roughly **20 damage/run**, an order of magnitude
below the movement A/B damage MDE. This is consistent with `TR_FIRE_LAG`'s already
measured live outcome-neutral result. The ghost is *wrong*, but wrongness at
10% of incoming damage cannot be turned into wins at this sample size.
**Recommendation: `TR_FIRE_LAG` stays off permanently.** It is a correctness fix
with a measured, bounded, sub-MDE payoff.
---
## 5. Lead-time lever 2 — the 16° decomposed, component by component
At 450+ px (23 275 shots), against the 11.26° net arrival error:
| component | measured | addressable? |
|---|---|---|
| **(a) enemy body-gun decoupling** | `\|gun dir − body heading\|` median **89.9°** (p10 25.9, p90 154.0, n=60 928). Extrapolating the target along its **gun** instead of its **body** would put the arrival bearing **79.5° median** wrong. | **Not present, and not addressable.** The intercept model uses the target's *recorded position and velocity*, both of which are the true body quantities and both exactly observed. Body-gun decoupling therefore contributes **exactly 0** to our arrival error. It is fatal for *aim-based* leading and threat warning (j166) and irrelevant to *position-based* leading. |
| **(b) our own leading model** | mean&#124;·&#124; **17.18°**, sd **20.7**; implied time-of-flight a **median 10 ticks short** of the power-derived arrival tick | **DOMINANT, and addressable.** This is ~2.2× the manoeuvre variance and it is the whole of the 16°. |
| **(c) target manoeuvre between scan and fire** | mean&#124;·&#124; **7.73°**, sd **9.3** | Small relative to (b), and **irreducible** — it is the dodger's own unpredictability, exactly the ~half of the under-lead `docs/lead_capture_by_range.md` attributes to a trivial predictor's own ceiling. |
| **(d) gun turn rate / time-to-fire** | the correct solution drifts a **median 0.416°/tick** (p90 5.45). The gun turns at 10°/tick, so a 17° correction takes **1.7 ticks ≈ 0.40°** of drift. | **Not binding.** Contributes ~**0.4°, i.e. ~3% of the 11.26° error.** The gun can always reach the answer; it aims at the wrong answer. |
**So the 16° is not (a), not (c) and not (d). It is (b) — the lead model's
time-of-flight, short by ~10 ticks.** Caveat, stated once and load-bearing: on a
closed-loop corpus (B) and (C) are not cleanly separable, since the target's
manoeuvre is a reaction to our own shot; the `corr(B,C) = −0.85` is exactly that
confound showing up. The *rank order* (b) ≫ (c) ≫ (d) > (a)=0 is robust to it
because (b) and (c) differ by 2.2× in variance and (d) is 3%.
---
## 6. The proposed lever: pre-multiply before learning — PREMISE DEAD
The design: aim error is largely a *product* (bearing-rate × time-of-flight), so
pre-multiply the two features and feed one small Tsetlin machine. **Measured on
the same corpus, the premise does not hold and the experiment should not be
built.** `y` = the required lead angle (current bearing → arrival bearing), i.e.
exactly the quantity the gun must predict; `b` = the observable 4-tick finite
difference of the bearing; `t = 20 − 3·power`.
| band (px) | n | corr(**b·t**, y) | corr(b+t, y) | R² additive [1,b,t] | R² product [1,b·t] | held-out side acc, additive | held-out side acc, product | held-out residual rms (deg) |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| 0-200 | 24 934 | −0.1020 | −0.1022 | 0.0105 | 0.0104 | 0.579 | 0.580 | 75.98 |
| 200-300 | 671 | 0.1190 | 0.0766 | 0.0147 | 0.0142 | 0.488 | 0.487 | 13.70 |
| 300-450 | 11 372 | **0.1501** | 0.0299 | 0.0256 | 0.0225 | 0.544 | 0.534 | 8.43 |
| **450+** | **23 275** | **0.2833** | 0.1052 | **0.0831** | 0.0803 | **0.603** | **0.601** | **6.68** |
| pooled | 60 252 | −0.1005 | −0.1009 | 0.0102 | 0.0101 | — | — | — |
*(side accuracy is 2-fold held-out and balanced; the TM record is ~0.47-0.49)*
Two things are true and the second kills the idea:
1. **As a single scalar, the product is much the better feature at range**:
`corr(b·t, y) = 0.283` vs `corr(b+t, y) = 0.105` at 450+ — 2.7× better, and
5× better at 300-450. So the *premise* ("the error is a product, not a sum")
is **confirmed as a statement about correlation**.
2. **But it buys nothing a weight-sum cannot already express.** The best linear
additive model on the same two features reaches **R² 0.0831 vs the product's
0.0803**, and the held-out balanced side accuracy is **0.603 (additive) vs
0.601 (product)** — a 0.002 difference, i.e. nothing. Pooled, the two are
identical (−0.1005 vs −0.1009; R² 0.0102 vs 0.0101). A TM with two input
features **already reconstructs the product term**; the multiplication is what
the network was doing anyway.
**Even the ceiling is out of reach.** The best held-out residual on the required
lead at 450+ is **6.68° rms**, against a live hit half-window of **2.29°** — a
2.9× shortfall. Pre-multiplying does not get a classifier to 2.29°; nothing in
this family does. **Do not build it.** The spec is recorded here so the idea is
closed on measurement rather than on taste.
*(Had it survived, the spec would have been: one TM, ONE input feature `b·t`
binarised on sign, plus the 4-bit horizon one-hot as today; offline gate =
held-out balanced side accuracy above 0.55 and residual rms below 3° at 450+;
live gate = wins/run with CI excluding 0 and sign-flip p<0.05 at 210 runs/arm,
damage not detectably down, MDE 0.17 wins/run. Predicted accuracy was 0.60 side
accuracy, which is a real signal against the 0.47-0.49 record — and still not
close enough to the window to convert.)*
---
## 7. The A/B queue, in priority order, with the MDE honestly restated
Throughput **22.7-23.2 runs/min**; movement gate resolved **0.17 wins/run at 210
runs/arm**; the `1/√n` extrapolation to 0.10 wins/run is **607 runs/arm ≈ 1.4 h —
a FLOOR on elapsed time, not an estimate**, because opponent heterogeneity does
not average down. A null at this sample size **only excludes a LARGE effect.**
(j163 additionally measured 14-22 runs/min, not 22.7-23.2, so even the floor is
optimistic.)
| # | experiment | what it tests | cost | a null would license |
|---|---|---|---|---|
| **1** | **The lead-model time-of-flight correction** (j167's (b)): re-derive the gun's arrival prediction so the implied flight is the power-derived tick, not 10 ticks short. | The one component that carries 2.2× the error variance at 450+, and the only open axis in `docs/gun_campaign.md` (lead *information*, not amplitude). | Offline gate first: arrival aim error at 450+ must fall below 11.26° mean-abs on held-out battles, ideally <8°; only then 2 arms × 15 opponents × 14 runs = 420 battles ≈ **0.3-0.4 h** wall. | Closing the single open gun axis. Nothing left in the gun. |
| 2 | `TR_TFIL_RING_COMMIT_ARRIVAL` (j165, default off) | Whether the largest surviving *movement* mechanism (reach 0.24% → 3.05%, picks 5 521 → 525, 148 guards) converts to wins. | 210 runs/arm ≈ **1.4 h floor**. | Retiring the whole ring/approach programme: if even a 12× reach gain is outcome-null, the ceiling argument is confirmed end to end. |
| 3 | `TR_FIRE_LAG` (tfil/strafe, default off) | Nothing worth testing — its ceiling is now measured at **~10% of incoming damage ≈ 20 dmg/run**, below the MDE. | Would be 1.4 h to learn nothing. | Nothing. **Skip it**; the measurement has already answered it. |
| 4 | `TR_TFIL_ARRIVE_TICKS` (j151, default off) | Whether a hard arrival bound converts now that the ring is rehabilitated. | 1.4 h. | Retiring it with j151's own null attached. |
| 5 | `TR_RAM_FLOOR_ENERGY` (j160, j163) | **Under test in j163. DO NOT DUPLICATE.** | — | — |
**Recommendation.** Run **only experiment 1**, and only after the *offline* gate
passes; if the offline gate does not move the 450+ arrival error below ~8°, run
nothing at all. Given five consecutive nulls or near-nulls (j144, j145, j146,
j147, j159) plus a clean negative in j163, spending 1.4 h of live time on
experiments 2-4 is not justified — those are mechanism-positive
mechanisms whose outcome nulls are already the standing record, and a null there
teaches nothing that the ledger does not already say.
**"The ceiling is real and we should stop spending on movement" is the answer.**
The remaining budget belongs to the gun's lead model, or it is not spent.