# Environment variable reference ## Putting your settings in a .env file Instead of typing `export ...` in a shell, put your settings in a file. The bot reads them for you. This is easier to keep tidy, and no stale shell variable can surprise you. 1. In the bot folder, copy `.env.example` to `.env`. 2. Open `.env` and set the knobs you want. One `KEY=VALUE` per line. Lines starting with `#` are comments. A trailing comment is allowed and stripped (`KEY=0 # off`) as long as the `#` is outside quotes; a value that still contains whitespace or a `#` after that gets one `[dotenv] WARNING` line. 3. Start the bot from the bot folder (`./ModularBot`). It picks up `.env` automatically. 4. To use a different file, pass it on the command line: `./out/ModularBot --env-file /path/to/my.env`. 5. If you ask for a file that does not exist, the bot stops with an error. A missing default `.env` is fine and its absence is silent. **The file wins over the shell.** If the same name is set in both places with a different value, the file value is used, and the bot prints one line telling you which shell value was overridden. Start the report check with: `grep '^\[env\]' /tmp/modularbot_stdout.log`. Values that came from the file are labelled `(source: .env)`. --- Every knob the bot reads. **Most are read once per process, at module init; a few are read lazily on first use** (`TR_TMHORIZON_SHIFT`/`_BIG_MULT`/`_LOG`/ `_RESET_ON_TARGET` in `ensureConfig`, `TR_PATTERN_RAD_*` inside `predict`). Either way, changing one mid-run has no effect. > **The bot is spawned by the server (or by the GUI if it starts its own server).** > It inherits the **server's** environment. Export in the shell that launches the > server/GUI — not in an unrelated terminal. See > [Did my env vars actually reach the bot?](#did-my-env-vars-actually-reach-the-bot). **Read these two traps before the tables — they are the two ways this document has misled people:** 1. **Defaults are silent for numeric/bool knobs.** Unset / empty / unparseable means the shipped default. Only the enumerated *string* knobs (`TR_RACK_*`, `GUN_VBULLET_METRIC`, `GUN_SELECTOR_MODE`, `GUN_SELECTOR_RANK`, `GUN_SELECTOR_TIEBREAK`) **warn on stderr** on a bad value; the `envInt`/`envFloat`/`envBool` helpers fall back to the default with **no 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). 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_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*. --- ## COMPILE-TIME vs RUNTIME: the two namespaces **A `-d:` compile-time define and a runtime environment variable are different namespaces. The naming is only *partly* parallel, so guessing an env name from a define name is unreliable.** | compile-time define | runtime env | notes | |---|---|---| | `-d:TMH_NSTATES=N` | `TR_TMHORIZON_NSTATES=N` | **The ONLY pair with both forms.** Env wins at runtime; clamped `2..4096`. `tm_horizon.nim:102,139,381` | | `-d:TMH_NCLAUSES=N` | *(none)* | compile-time ONLY — no env form. `tm_horizon.nim:101` | | `-d:TMH_S_DEF="x"` | *(none)* | compile-time ONLY. `tm_horizon.nim:103` | | `-d:TMH_MIN_OBS=N`, `-d:TMH_STALE_MAX=N` | *(none)* | compile-time ONLY. `tm_horizon.nim:106,108` | | `-d:TM_NCLAUSES=N`, `-d:TM_NSTATES=N`, `-d:TM_S_DEF="x"`, `-d:TM_MIN_OBS=N`, `-d:TM_CLASSES=N` | *(none)* | `tm_pattern.nim` — compile-time ONLY. Note **`TM_NCLAUSES`** (no underscore) here. `tm_pattern.nim:50,53,55,57,59` | | `-d:TM_N_CLAUSES=N`, `-d:TM_N_STATES=N`, `-d:TM_S_DEF="x"`, `-d:TM_T_DEF`, `-d:TM_WINDOW_SIZE=N` | *(none)* | `tsetlin.nim` — compile-time ONLY. Note **`TM_N_CLAUSES`** (underscore) here, and `TM_N_STATES` not `TM_NSTATES`. `tsetlin.nim:29,116,118,119,120` | Rules and warnings: - The `-d:TMH_X` ↔ `TR_TMHORIZON_X` mapping holds **only for `NSTATES`**. `TMH_NCLAUSES`, `TMH_S_DEF`, `TMH_MIN_OBS`, `TMH_STALE_MAX` have **no env form** — there is no `TR_TMHORIZON_NCLAUSES` etc. - `-d:TM_S_DEF` is defined in **both** `tm_pattern.nim` (default `"3.0"`) and `tsetlin.nim` (default `"1.5"`); the same flag sets both. - **If you set an env var and the bot seems to ignore it, the name is probably wrong — `TMH_NSTATES` is NOT an env var, the env var is `TR_TMHORIZON_NSTATES`.** Likewise `TM_N_CLAUSES`, `TMH_NCLAUSES`, etc. are compile-time only. --- ## Did my env vars actually reach the bot? ### 1. The spawn trap in one sentence The bot is spawned by the **server/GUI**, so it inherits the **server's** environment — exporting a variable in a separate terminal does **nothing**, because that terminal is not the bot's parent. ### 2. The no-code check (works today, no boot report needed) `/proc/PID/environ` is the environment **at exec time** — authoritative, exactly what the bot started with, before it read anything: ```sh for p in $(pgrep -f ModularBot); do echo "== pid $p" tr '\0' '\n' < /proc/$p/environ | grep -E '^(TR_|GUN_)' | sort \ || echo ' (none - wrong process!)' done ``` **Warning:** filtering to `TR_|GUN_` **hides a wrongly-named variable** — e.g. a variable called `TMH_NSTATES` will not show up under this filter (and neither will a `TR_TMHORIZON_*` typo). When you are debugging a *missing* var, grep for the distinctive part of the name with **no prefix filter**: ```sh tr '\0' '\n' < /proc/$p/environ | grep -i tmh ``` ### 3. The boot report (`TR_ENV_REPORT`, default `1`) > **Available in the build that adds the boot report** (`ModularBot_garage/src/ > env_report.nim`, committed as `9bf3005`). If your binary predates it, use §2. The bot prints a one-shot `[env]` block to **stdout** at boot (`/tmp/modularbot_stdout.log` when launched by the GUI, or the GUI/server console). Recover just the report with: ```sh grep '^\[env\]' /tmp/modularbot_stdout.log ``` It has two sections plus build identity: - **A. raw process environment** (`[env] --- A. raw process environment ...`): every `TR_*`/`GUN_*` this process *actually* received, sorted, followed by `[env] raw: N TR_*/GUN_* of M total environment variables`. If `N == 0` it prints a loud `[env] WARNING: this process has NO TR_*/GUN_* variables - the env you exported did NOT reach the bot.` It then prints the process identity: `pid`/`ppid`, `cwd`, the bot's own command line (`self_cmd`), and the **parent's command line** (`parent_cmd`). The parent command line is the conclusive proof of who spawned the bot — under the GUI it is the server/GUI, never your interactive shell. - **B. effective values** (`[env] --- B. effective values (resolved at boot) ---`): one line per knob, `[env] NAME = value (source: env|default)`, i.e. the value the bot will *actually* use after parsing, clamping and fallback (e.g. `TR_TMHORIZON_NSTATES` is clamped to `2..4096`; the rack's empty-set fallback is shown). Where a value is only resolvable later it says `(raw - not resolved here)` rather than guessing. It also prints `[env] rack active 1v1 = ...` and `[env] rack active melee = ...`. - **build identity**: `NimVersion`, compile date/time, and the binary path + size + mtime, so a stale binary is obvious. `TR_ENV_REPORT=0` suppresses the report (it is value-based: `0`/`false`/`no`/`off` all suppress). ### 4. Launching the GUI with knobs Export in the **same shell** that launches the GUI/server, then start it from that shell. Because the launcher **appends** to the log, clear it **first** so you read only the new run: ```sh rm -f /tmp/modularbot_stdout.log export TR_POWER_ENERGY_MIN=1.0 TR_RACK_PATTERN=off ./start-gui.sh # whatever launches the server/GUI grep '^\[env\]' /tmp/modularbot_stdout.log ``` ### 5. Prove the trap deliberately Terminal A — where you *exported*, but never launched anything: ```sh export TR_POWER_ENERGY_MIN=1.0 ``` Terminal B — where you launch the GUI: ```sh ./start-gui.sh grep '^\[env\]' /tmp/modularbot_stdout.log | grep WARNING # [env] raw: 0 TR_*/GUN_* of ... total environment variables # [env] WARNING: this process has NO TR_*/GUN_* variables - the env you exported did NOT reach the bot. ``` Terminal A's variable never reached the bot because the bot's parent is the server, not terminal A. Fix it by exporting in terminal B before launching. --- ## Measured verdicts — read before tuning (MEASURED on the live arena vs real DrussGT) These are live battles, not offline replay. Do not re-derive an offline-only win and ship it. (Offline claims are labelled as such.) | experiment | result | verdict | |---|---|---| | shipped rack (Pattern only) | **24/49 rounds = 49.0%** (up from 12.5% earlier in the session) | the baseline | | `TR_TMHORIZON_WINDOW=150` (with TMHorizon admitted) | **13/49 = 26.5%** vs 49.0%, **p=0.036** | **MEASURED HARMFUL live.** Looked like an offline +9.3pp late-accuracy win; the arena disagreed. Keep `0`. | | TMHorizon `TR_TMHORIZON_NSTATES=2` | 42.9% | no better than shipped (p>=0.8) | | TMHorizon `TR_TMHORIZON_NSTATES=8` | 42.9% | no better than shipped (p>=0.8) | | TMHorizon `TR_TMHORIZON_NSTATES=64` (default) | 53.1% | not significant vs Pattern (p>=0.8) | | refusing to fire below power 1.0 | 22/49 vs 21/49, **p=1.0** | no help | | sub-1.0 vs >=1.0 bullet accuracy | 10.80% vs 10.35%, **p=0.37** | sub-1.0 bullets are **not** less accurate | | `TR_POWER_POLICY=0` (uncapped) | real hit rate 10.61% → 7.88%, p=0.0012 | policy helps; keep it on | | `TR_VBULLET_ADMIT_ONLY=1` | 87 → 146 ticks/s (+68%) | keep it on | | `TR_MOVEMENT=tfil_ring` | round wins 16/49 → 6/49, p=0.012 | glass cannon; do not ship | | `TR_RAM_OPPORTUNITY=1` | 0/59 opportunity→contact conversions | finisher-only is the default | On the TM rows: **no TM configuration beats the shipped rack**, and **low inertia does not help** (contrary to an earlier offline trend, where it was a substitute for forgetting, not a fix). On the power rows: the floor experiment exposed the real tradeoff — power `p` costs a `10+2p` tick reload, so a floor costs ~13% of your shots/round, while the bullets it suppresses were *not* less accurate (they are simply worth less damage per hit, `4p`, which is what makes low-power shooting *look* like missing). --- ## The ones you'll actually use live | variable | default | what it does | |---|---|---| | `TR_MOVEMENT` | `strafe` | movement engine: `strafe` (DEFAULT — the measured champion), `tfil` (long-shipped; explicit override) or `tfil_ring` (range-weighted variant; any other value falls back to the `tfil` mover) | | `TR_RACK_` | see below | move a gun in/out of the rack per mode | | `TR_POWER_POLICY` | `1` | `0` = uncapped control arm (today's behaviour without the energy policy) | | `TR_POWER_LOG` | off | **presence-based**: if the var exists at all (even `=0`) log each power decision | | `TR_RAM_LOG` | off | **presence-based**: log ram on/off with the reason | | `TR_RAM_FLOOR_ENERGY` | `0.0` | j160 firing floor: at/below this self energy we start no NEW shot, holding a ram reserve. `0` = off. `~5` = one p=1.0 return hit + two 0.1 shots. Bypassed while ramming | | `TR_RAM_ENEMY_ENERGY` | `0.0` | j160 exhaustion trigger: last-scanned enemy energy `<=` this -> ram mode. `0` = off. Keeps the finisher's energy-surplus and 300px guards | | `TR_MOVEMENT_LOG` | off | **presence-based**: log movement band/class changes | | `TR_TMHORIZON_LOG` | off | value-based: `1` = let the horizon TM gun log its thinking per shot | | `TR_ENV_REPORT` | `1` | print the boot-time `[env]` report to stdout; `0` suppresses it | --- ## Module on/off switches — the `[modules]` boot list Every module appears once in the boot report, so one command shows the state of them all right now: ```sh grep '^\[modules\]' /tmp/modularbot_stdout.log ``` The **new** switches all share the prefix `TR_MODULE_`. A module that already had an off switch keeps its old name (no duplicate knobs). "OFF" always means the simpler fallback, never a broken bot. | switch | ON means | OFF falls back to | |---|---|---| | `TR_MODULE_VBULLETS` | guns predict and score virtual bullets so the selector can rank them (default) | no virtual bullets; the selector has no fitness and fires its floor gun (the first gun the rack admits) | | `TR_MODULE_RAM` | the ram decider may start a ram: finisher, desperation and the optional triggers (default) | the bot never rams; the movement engine drives every tick | | `TR_MODULE_MOVE_TFIL` | the `tfil` / lava-field engine may be selected (default) | it is skipped when picking the effective engine | | `TR_MODULE_MOVE_TFIL_RING` | the range-weighted ring engine may be selected (default) | same — skipped in the fallback | | `TR_MODULE_MOVE_STRAFE` | the perpendicular-strafe engine may be selected (default) | same — skipped in the fallback | | `TR_MODULE_MOVE_SURF` | the wave-surfer engine may be selected (default) | same — skipped in the fallback | | `TR_MODULE_MOVE_LEARNED` | the learned danger engine may be selected (default) | same — skipped in the fallback | There is no "no movement". If the engine named by `TR_MOVEMENT` is switched off, the effective engine is the first engine still on, in the order `tfil`, `tfil_ring`, `strafe`, `surf`, `learned`. If all of them are off the bot still runs `tfil`. The modules that already had a switch — surfaced in `[modules]` under that one name, with no new knob: | switch | ON means | OFF falls back to | |---|---|---| | `TR_RACK_` = `both`/`1v1`/`melee` | that gun may be selected | `TR_RACK_=off`: the gun is removed from the rack | | `TR_POWER_POLICY` | energy-aware power caps (default) | uncapped: the gun's own preferred power | | `TR_FIRE_FIX` | the corrected enemy-fire detector (default) | the shipped `prev - energy` detector | | `TR_FIRE_LAG` = `` | back-date every detected enemy fire by N ticks at spawn (0 = shipped; **1 = the measured live detection lag**, j147) | n/a — it is a value knob | | `TR_RADAR_FORCE_SPIN` | force the old stateless full-spin melee radar (**off by default**) | the adaptive arc-narrowing radar (default) | | `TR_TFIL_HEAT_TIME` | time-indexed bullet heat (**off by default**) | flat, time-independent heat (default) | | `TR_VBULLET_DEBUG` | draw the virtual-bullet overlay (**off by default**) | nothing drawn | | `TR_GEO_DEBUG` | draw the geometry overlay (**off by default**) | nothing drawn | | `TR_DEBUG_DRAW` | per-mover debug graphics (default) | the movers draw nothing | | `TR_STRAFE_HEAT_GRID` | draw the full heat grid in STRAFE (default) | the heat grid is hidden | Always on, listed in `[modules]` but with no switch, because the bot cannot work without them: the enemy tracker, target selection, the gun selector, the 1v1 radar lock, and the Tank Royale body API that actually drives and turns the tank. --- ## 1. Gun rack — which gun(s) may be chosen | variable | default | what it does | |---|---|---| | `TR_RACK_` | `PATTERN=both`, all other 15 guns `off` | membership: `both` \| `1v1` \| `melee` \| `off` | | `GUN_RACK_DISABLE` | empty | comma-separated gun **ids** to remove entirely (older mechanism; the gun never even spawns virtual bullets) | | `GUN_STATS_PATH` | `/tmp/gun_stats.jsonl` | where the per-round per-gun stats are written | | `GUN_SHOTLOG_PATH` | `/tmp/shot_log.jsonl` | where the per-shot JSONL is written | `` names (ids 0-15, `selector.nim:52`): `HEADON LINEAR TSETLIN CIRCULAR GUESSFACTOR PATTERN WALLBOUNCE ACCEL STOPSHOT DISPLACE AVGLEAD DECAYGF KNN TMSELECT TMPATTERN TMHORIZON`. Shipped default membership (`selector.nim:DefaultRackMembership`): | gun | default | gun | default | |---|---|---|---| | HEADON | off | STOPSHOT | off | | LINEAR | off | DISPLACE | off | | TSETLIN | off | AVGLEAD | off | | CIRCULAR | off | DECAYGF | off | | GUESSFACTOR | off | KNN | off | | **PATTERN** | **both** | TMSELECT | off | | WALLBOUNCE | off | TMPATTERN | off | | ACCEL | off | TMHORIZON | off | `TR_RACK_` accepts aliases: `both`/`any`/empty → both; `1v1`/`single`/`lock` → 1v1; `melee`/`multi` → melee; `off`/`none`/`disabled` → off. An **unknown value warns on stderr and falls back to `both`** (`selector.nim:parseRackMembership`). The `1v1`/`melee` split is driven by **server truth**, `getEnemyCount()`: `== 1` → 1v1 rack, `> 1` → melee rack (`virtual_bullets.rackMode`). **Empty-set fallback:** if every gun is filtered out for a mode, the *selector* falls back to the **full rack** so it can never end up with no gun (`admittedGuns`, `virtual_bullets.nim`). Note this fallback is on *selection*; the spawn gate (`TR_VBULLET_ADMIT_ONLY`) uses `rackAdmitted` and would spawn nothing, so an all-`off` rack is not a useful configuration. **The shipped default is Pattern only.** To restore the full old rack: ```sh TR_RACK_PATTERN=both TR_RACK_HEADON=both TR_RACK_LINEAR=both TR_RACK_TSETLIN=both \ TR_RACK_CIRCULAR=both TR_RACK_GUESSFACTOR=both TR_RACK_WALLBOUNCE=both \ TR_RACK_ACCEL=both TR_RACK_STOPSHOT=both TR_RACK_DISPLACE=both TR_RACK_AVGLEAD=both \ TR_RACK_DECAYGF=both TR_RACK_KNN=both TR_RACK_TMSELECT=both ./out/ModularBot ``` To force one gun alone: `TR_RACK_PATTERN=off TR_RACK_TMHORIZON=both`. Enabling a gun is `TR_RACK_=both`; TMHorizon additionally needs `TR_RACK_TMHORIZON=both` before any `TR_TMHORIZON_*` knob can matter. ## 2. The selector (virtual-bullet fitness) | variable | default | what it does | |---|---|---| | `GUN_VBULLET_METRIC` | `path` | how a virtual bullet is scored: `path` (swept ray — the better coarse signal, 7.4% vs 4.7% real) or `point` (arrival accuracy) | | `GUN_SELECTOR_MODE` | `relative` | tie-band model: `relative` (scale-aware) or `absolute` (legacy fixed bars) | | `GUN_SELECTOR_WINDOW` | `100` | rolling window (ticks) for the fitness rates; **clamped `1..100`** | | `GUN_SELECTOR_TIE` | `0.20` | tie band width: tied if `rate >= bestRate*(1-this)` | | `GUN_SELECTOR_FLOOR` | `0.25` | relative floor: fire HeadOn only if best rate < this × its peak | | `GUN_SELECTOR_MINOBS` | `50` | min observations before a gun×bin may compete (clamped to ≥1) | | `GUN_SELECTOR_POOL` | `on` | pool the gun's power bins when computing its rate | | `GUN_SELECTOR_RANK` | `mean` | window statistic: `mean` \| `wilson` \| `ucb` \| `thompson` \| `shrunk` | | `GUN_SELECTOR_SHRINK` | `20.0` | empirical-Bayes shrink strength (`shrunk` rank only; clamped ≥0) | | `GUN_SELECTOR_DWELL` | `10` | ticks the incumbent gun is held before it may be displaced (clamped ≥0) | | `GUN_SELECTOR_MARGIN` | `0.05` | a challenger must beat the incumbent by this fraction to displace it (clamped ≥0) | | `GUN_SELECTOR_SEED` | unset | integer seed for the tie-break RNG; unset = time+pid per process | | `GUN_SELECTOR_TIEBREAK` | `off` | `off` (shipped) / `point` / `pointCommit`. Measured **negative** on real hit rate; opt-in only | | `GUN_SELECTOR_POINT_TIE` | `0.5` | margin used by the `point` tie-break (clamped `0..1`) | Constants live in `virtual_bullets.nim:16,28,31,33,35`. **Note:** the per-tick random draw inside the tie band is **load-bearing** — replacing it with commitment to the virtual-best measurably *lowered* real hit rate (7.02% → 5.10%, p=0.002). Three attempts to "smarten" the band all failed. ## 3. Power policy (energy economy) | variable | default | what it does | |---|---|---| | `TR_POWER_POLICY` | `1` | `0` = uncapped control arm. **MEASURED: turning it off drops real hit rate 10.61%→7.88%, p=0.0012** | | `TR_POWER_FAR_DIST` | `200` px | beyond this = "bad chances zone" | | `TR_POWER_FAR_CAP` | `1.0` | power cap beyond that distance | | `TR_POWER_MID_CAP` | `2.0` | cap when close + healthy but chances are not above average | | `TR_POWER_REF` | `0.0` | reference probability: `0` = the gun's own mean, `>0` = a fixed threshold | | `TR_POWER_ENERGY_HI` | `80` | at/above this energy, no energy-slope cap | | `TR_POWER_ENERGY_LO` | `20` | at/below this energy, cap = `ENERGY_MIN` | | `TR_POWER_ENERGY_MIN` | `0.5` | the **cap** at/below `ENERGY_LO` — **NOT a minimum-power floor** (see below) | | `TR_POWER_ENERGY_MAX` | `3.0` | the cap at/above `ENERGY_HI` (3.0 = effectively uncapped) | | `TR_POWER_FINISH_KILL` | `1` | cap power at the **smallest bullet that can still kill** the enemy | | `TR_POWER_LOG` | off | presence-based; logs each decision: `[power] p=… cap=… reason=… dist=… selfE=… enemyE=… gun=…` | Constants live in `virtual_bullets.nim:842-851`; the rule core is `applyPowerPolicy`/`energySlopeCap`/`powerToKill`. > **`TR_POWER_ENERGY_MIN` is a CAP, not a floor.** It is the ceiling applied when > *our* energy is at/below `TR_POWER_ENERGY_LO`. Setting it to `1.0` raises the > low-energy ceiling to 1.0; it does **not** forbid shots below 1.0. Sub-1.0 > bullets come from the caps (`ENERGY_MIN`, `FAR_CAP`, `FINISH_KILL`). MEASURED: > refusing to fire below power 1.0 does not help (22/49 vs 21/49, p=1.0), and > sub-1.0 bullets are *not* less accurate (10.80% vs 10.35%, p=0.37). WHY the slope: bullet speed is `20-3p`, so **lower power = faster bullet** (less lead error, higher hit chance), fires more often (`10+2p` ticks) and drains energy more slowly (`p`/shot). `E[dE] = p(3P-1)`, so break-even is **1/3 regardless of power** — and our measured hit rates are 5–27%, far below it. A power floor at 1.0 costs ~13% of your shots/round (the longer reload) and buys no accuracy. WHY the finishing cap: server 1.3.1 caps the damage **score** at the energy actually removed, so **overkill scores nothing**. Damage is `4p` (p≤1) / `6p-2` (p>1), so the smallest killing power is `E/4` for E≤4 and `(E+2)/6` for 4 **CORRECTION (gate v2, 2026-09-26).** The default was flipped from `tfil` to > `strafe` after a **pre-registered** fresh-data confirmation > (`docs/movement_campaign.md`, "Fresh-data confirmation (gate v2)"): 300 fresh > battles (2 arms × 15 opponents × 10 runs × 3 rounds) gave `strafe` +0.30 > wins/run over `tfil`, 95% CI [+0.02, +0.58], sign-flip permutation p = 0.045. > `TR_MOVEMENT=tfil` remains a fully working explicit override. The earlier > gate-v1 confirmation had failed on the plain sign-test leg and did **not** flip > the default; that history is unchanged in the ledger. | variable | default | what it does | |---|---|---| | `TR_MOVEMENT` | `strafe` | `strafe` (DEFAULT — measured champion), `tfil` (long-shipped; explicit override) or `tfil_ring` (the range-weighted variant; any other value falls back to the `tfil` mover, no warning) | | `TR_MOVEMENT_LOG` | off | presence-based; `1` = log band/range-class changes for the ring mover | | `TR_TFIL_RANGE_LO` / `_HI` | `100` / `200` px | the target-range band the ring mover prefers | | `TR_TFIL_RANGE_TEMP` | `0.4` | peakiness of the range weighting. `0` = plain uniform draw = exact control | | `TR_TFIL_RANGE_K` | `60` | softness of the falloff outside the band | | `TR_TFIL_CORRIDOR_HEAT` | `10.0` | heat added along a bullet's corridor to the wall | | `TR_TFIL_WALL_HOTNESS` | `15.0` | peak wall radiance | | `TR_TFIL_RING_COMMIT_ARRIVAL` | off | **presence/value**: `on` = hold the committed dodge tile until we are actually ON it, instead of the fixed 5-tick dwell. Default path byte-identical. **NEVER LIVE-TESTED** | | `TR_TFIL_RING_NOREV_SPEED` | `0.0` | px/tick. Below this self speed a mid-flight target switch may not turn the bot around; `0.0` = off (the pre-knob behaviour). **NEVER LIVE-TESTED** | Defaults read in `the_floor_is_lava_ring.nim:188-196` and `:173-175`. **Discrepancy to be aware of:** that file's header comment still says `CORRIDOR_HEAT default 5.0` / `WALL_HOTNESS default 10.0` (the pre-retune values); the **code defaults are `10.0` / `15.0`** (commit `7f6ccfb`). The code is the truth. **Ring mover is NOT the default and should not be shipped as-is.** It had the best **LIVE** hit rate of anything measured (20.28%, server-side event sidecar ground truth), but it halves engagement range and **halves survival** (round wins 16/49 → 6/49, p=0.012) — a glass cannon. **CORRECTION (see `docs/offline_harness_trust.md`):** this line previously claimed the ring mover had "the best offline hit rate". That was **wrong — the ring mover was never measured offline at all**; the offline harness scores *guns*, and has no movement driver. So this was **not** an offline-vs-live calibration failure (which is how it was repeatedly described). It was a **metric mismatch**: a movement arm judged on hit rate instead of damage/run and round wins — and hit rate is exactly the metric that hid its collapse. ## 5. Ramming | variable | default | what it does | |---|---|---| | `TR_RAM_OPPORTUNITY` | off | enable the proactive ram. **MEASURED: 0/59 opportunity→contact conversions** — a straight-line pursuit cannot catch an equal-speed enemy | | `TR_RAM_OPP_DIST` | `200` px | opportunity trigger distance | | `TR_RAM_OPP_MARGIN` | `15` | how much MORE energy we must have than the enemy | | `TR_RAM_ABORT_DMG` | `2.0` | abort an in-progress ram above this incoming **energy per turn** (the "bullet rain" abort) | | `TR_RAM_PLAN` | off | the speculative "change of plan" trigger | | `TR_RAM_PLAN_DIST` / `_MARGIN` / `_HITRATE` | `250` / `20` / `0.05` | its thresholds | | `TR_RAM_LOG` | off | presence-based; `1` = log `[ram] ON/OFF` with reason | Defaults in `ram_decision.nim:79-97`. The **finisher** ram (enemy <20 energy, we are healthier) is always on and is the only path that converts. ## 6. The horizon TM gun (`TMHORIZON`) **Only relevant when the rack admits it** (`TR_RACK_TMHORIZON=both`); the shipped rack is Pattern-only, so these knobs are inert by default. | variable | default | what it does | |---|---|---| | `TR_TMHORIZON_LOG` | off | value-based; `1` = per-shot thinking log + per-round summary | | `TR_TMHORIZON_SHIFT` | `2.0` deg | how far it may move the aim off Pattern's answer. **`0` = predict but never move the aim** (isolates prediction from application) | | `TR_TMHORIZON_BIG_MULT` | `1.5` | multiplier when the predicted correction is BIG | | `TR_TMHORIZON_NSTATES` | `64` | **automata inertia** — the "mood". Lower = adapts faster, higher = more stubborn. Clamped `2..4096`; overrides the `-d:TMH_NSTATES` compile-time value | | `TR_TMHORIZON_RESET_ON_TARGET` | on | wipe the model when the target changes (no-op in 1v1) | | `TR_TMHORIZON_WINDOW` | `0` | sliding window: retrain on only the most recent N samples. `0` = keep everything. **ON is MEASURED HARMFUL live: 26.5% vs 49.0% round wins, p=0.036 — keep it at `0`.** (Offline it was +9.3pp late accuracy.) | | `TR_TMHORIZON_RESET_DROP` | `0` | change detection: if rolling accuracy falls this many points below its own peak, treat it as "the enemy changed" and re-learn | | `TR_TMHORIZON_ACCURVE` | off | value-based; `1` = log the rolling accuracy periodically | | `TR_TMHORIZON_RETRAIN_EVERY` | `50` | samples between windowed retrains (clamped ≥1) | | `TR_TMHORIZON_EPOCHS` | `1` | epochs per windowed retrain (clamped ≥1) | Defaults in `tm_horizon.nim:126-152,381-412`. Learning **persists across rounds** of the same battle (so it can overfit the current enemy) and wipes only on a new battle or a target change. **No learning is written to disk — nothing carries between battles.** **Measured:** no TM configuration beats the shipped rack — N=2 42.9%, N=8 42.9%, N=64 53.1% (all p>=0.8 vs Pattern), N+window 26.5%. Low-inertia `NSTATES=2` does **not** help (contrary to an earlier offline trend). ## 7. Other gun knobs | variable | default | what it does | |---|---|---| | `TR_PATTERN_RAD_OFFSET` | `0.0` px | shift Pattern's aim along its own bearing (negative = aim short) | | `TR_PATTERN_RAD_SCALE` | `1.0` | multiplier on Pattern's aim distance | (`pattern_matcher.nim:26-27`.) Both are **structurally incapable of changing the shot** — the live aim is bearing-only, so a purely radial offset is invisible. Measured byte-identical on `bmPath`. Kept for experiments; leave at defaults. ## 8. Measurement / instrumentation (all off by default, all zero-cost when off) | variable | default | what it does | |---|---|---| | `TR_VBULLET_ADMIT_ONLY` | `1` | only admitted guns spawn virtual bullets. **MEASURED: +68% tick rate** (87→146 ticks/s); `0` restores the old behaviour | | `TR_ENV_REPORT` | `1` | print the boot-time `[env]` report to stdout; `0` suppresses it (value-based) | | `TR_RECORD_WORLDSTATE` | off | presence-based; append every tick's WorldState to `/tmp/worldstate_record.jsonl` for offline replay | | `TR_RADAR_FORCE_SPIN` | off | presence-based; force the old stateless full-spin melee radar (for A/B on one binary) | | `TR_RADAR_SCANLOG` | off | presence-based; append per-round radar scan/coverage JSON | | `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 | The **virtual-bullet overlay** (`TR_VBULLET_DEBUG`, off by default) makes the selector's training signal visible. Turn it on with: TR_VBULLET_DEBUG=1 TR_VBULLET_DEBUG_GUN=all ./out/ModularBot - **travelled path** — thin line from the fire point along the fire direction, as far as the bullet has actually flown. - **aim ring** — small hollow circle at the predicted target position. - **miss vector** — line from the aim ring to the closest-approach point; a filled dot there is a HIT, a hollow ring a MISS (the score the selector sees). - colour per gun is the SAME table as the turret (`vbullet_draw.gunColors`), with a one-line legend in the top-left corner. ### 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`, `EnterTrackWidthDeg=270`, `ExitTrackWidthDeg=300`. Only `TR_RADAR_FORCE_SPIN`/`TR_RADAR_SCANLOG` are runtime. ## 9. Test harness only (not read by the bot itself) | variable | default | what it does | |---|---|---| | `TR_SERVER_JAR` | `/home/davide/Downloads/robocode-tankroyale/robocode-tankroyale-server.jar` (1.3.1) | which server the tests launch. Set to the 0.35.5 jar (`server_manager.LegacyServerJar`) to reproduce old numbers | | `TR_BATTLE_RUNNER` | `/home/davide/Projects/tank-royale/runner/examples/lib/robocode-tankroyale-runner.jar` | which runner the tests launch (`runner_process.nim`) | | `TR_BATTLE_RUNNER_DIR` | `tools/battle_runner/` | class directory form of the runner | (`TR_SAMPLE_BOTS` is read by `SNNBot_garage/tests/test_bullet_economy.nim` only — not by the ModularBot test harness.) ## 10. Compile-time knobs (`-d:` flags, need a rebuild) | flag | default | file / effect | |---|---|---| | `-d:TMH_NSTATES=N` | 64 | `guns/tm_horizon.nim:102` — automata inertia (the runtime `TR_TMHORIZON_NSTATES` overrides it) | | `-d:TMH_NCLAUSES=N` | 40 | `guns/tm_horizon.nim:101` | | `-d:TMH_S_DEF="x"` | `"3.0"` | `guns/tm_horizon.nim:103` specificity (`s`) | | `-d:TMH_MIN_OBS=N` | 24 | `guns/tm_horizon.nim:106` — cold gate: below this many samples the TM emits no correction | | `-d:TMH_STALE_MAX=N` | 8 | `guns/tm_horizon.nim:108` — a sample is dropped if the enemy was not seen this recently | | `-d:TM_CLASSES=N` | 5 | `guns/tm_pattern.nim:50` — GF buckets | | `-d:TM_NCLAUSES=N` | 40 | `guns/tm_pattern.nim:53` — clauses per class | | `-d:TM_NSTATES=N` | 64 | `guns/tm_pattern.nim:55` | | `-d:TM_S_DEF="x"` | `"3.0"` | `guns/tm_pattern.nim:57` specificity (`s`). **Higher = LONGER clauses**, measured. `s=1.0` is degenerate | | `-d:TM_MIN_OBS=N` | 24 | `guns/tm_pattern.nim:59` | | `-d:TM_CONF_MARGIN_DEF` / `TM_SHRINK_DEF` / `TM_GF_MODE` / `TM_SOFT_BETA_DEF` / `TM_RADIAL_RANGE_DEF` / `TM_RAD_MARGIN_DEF` / `TM_REV_TURN_DEG_DEF` / `TM_REV_MARGIN_DEF` / `TM_REV_GAIN_DEF` | see `tm_pattern.nim:62-96` | strdefines, compile-time only | | `-d:TM_WINDOW_SIZE=N` | 10 | `guns/tsetlin.nim:29` | | `-d:TM_N_CLAUSES=N` | 50 | `guns/tsetlin.nim:116` — clauses per output | | `-d:TM_N_STATES=N` | 32 | `guns/tsetlin.nim:118` — automaton range `[-N..N]` | | `-d:TM_S_DEF="x"` | `"1.5"` | `guns/tsetlin.nim:119` — **same flag name as `tm_pattern`; one `-d` sets both** | | `-d:TM_T_DEF` | `""` (→ `float(TM_HALF)`) | `guns/tsetlin.nim:120` | ## Names that look real but do nothing | name | reality | |---|---| | `TMH_NSTATES` (as an env var) | not an env var. The runtime var is `TR_TMHORIZON_NSTATES` | | `TR_VBULLET_METRIC` | does not exist. The metric env var is `GUN_VBULLET_METRIC` | | `TR_POWER_LOW_ENERGY` | removed (replaced by the energy slope). Only referenced in a comment; `getEnv` never reads it | | `TR_TRACKER_RECONCILE` | printed/set by `common_libs/tests/measure_corpse_melee.nim` only; **no bot module reads it** | ## Adding / finding knobs ```sh # everything the code reads grep -rn 'TR_[A-Z_]*"' --include=*.nim ModularBot_garage/src common_libs | sort -u # what a specific knob defaults to grep -rn 'TR_POWER_ENERGY_MIN' --include=*.nim . ``` Convention followed throughout: read once at module init, into a `let` with an `EnvVar` name constant beside it, so one compiled binary can A/B every arm by environment alone.