Files
SirRoboGarage/docs/env_reference.md
T
SirStone 23bce2dad5 j160 (default-off): the energy-reserve FIRING FLOOR + ENEMY-EXHAUSTION ram trigger
TR_RAM_FLOOR_ENERGY (0.0 = off): at/below this self energy we start no NEW
shot, holding back the reserve for a final ram exchange. Justified by the only
energy gain in the game being +3*power per bullet hit LANDED, so not firing
denies the enemy its only refill. Blocks only NEW shots (gunHeat already gates
committed ones) and is bypassed while ramming.

TR_RAM_ENEMY_ENERGY (0.0 = off): last-scanned enemy energy <= this -> ram
mode. Enemy energy IS observable (ScannedBotEvent.energy, schemas.nim:306),
1-8 ticks stale. This is the shipped finisher with its energy tolerance
promoted to a knob, keeping the self>enemy surplus guard because RAM_DAMAGE
0.6 applies to BOTH bots on every contact tick.

Open-loop measurement (measure_ramfloor_energy, 8149 recordings / 29871
rounds / 33.8M ticks): 'both low' is COMMON (10.4% of ticks below 20, 15.1%
below 25) but neither side goes low first (enemy 52.7% / us 47.3%), and the
owner's literal trigger - enemy so low it cannot fire (energy <= 1.95) - is
only 2.5% of ticks, 1.1% while we are healthy.

Guards 136 -> 147 in test_tfil_commit_env.nim, all green. A/B PRE-REGISTERED
in docs/ram_floor_exhaustion_ab.md and NOT RUN.
2026-09-27 12:46:46 +02:00

583 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
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`,
`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`.
---
## 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_<GUN>` | 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_<GUN>` = `both`/`1v1`/`melee` | that gun may be selected | `TR_RACK_<GUN>=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` = `<int>` | 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_<GUN>` | `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 |
`<GUN>` 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_<GUN>` 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_<GUN>=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<E≤16.
## 4. Movement
> **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 |
Defaults read in `the_floor_is_lava_ring.nim:116-133`.
**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_VBULLET_DEBUG` | off | presence-based; overlay the virtual bullets in the GUI debug graphics (see below) |
| `TR_VBULLET_DEBUG_GUN` | selected gun | `all`/`*` for every gun, or a gun name (e.g. `Pattern`); unset = only the currently selected gun |
| `TR_VBULLET_DEBUG_MAX` | `32` | cap on bullets drawn per tick |
The **virtual-bullet overlay** (`TR_VBULLET_DEBUG`, off by default) makes the
selector's training signal visible. Turn it on with:
TR_VBULLET_DEBUG=1 TR_VBULLET_DEBUG_GUN=all ./out/ModularBot
- **travelled path** — thin line from the fire point along the fire direction,
as far as the bullet has actually flown.
- **aim ring** — small hollow circle at the predicted target position.
- **miss vector** — line from the aim ring to the closest-approach point; a
filled dot there is a HIT, a hollow ring a MISS (the score the selector sees).
- colour per gun is the SAME table as the turret (`vbullet_draw.gunColors`), with
a one-line legend in the top-left corner.
The **adaptive-melee radar** has no env knobs. Its tuning lives in compile-time
constants in `radars/adaptive_melee_radar.nim:36-50`: `MaxRadarTurnRate=45`,
`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.