j126: read bot settings from a .env file (default ./.env, --env-file flag, TR_ENV_FILE); file wins over shell leftovers, boot report labels (source: .env)

This commit is contained in:
2026-09-26 09:08:39 +02:00
parent 8e109bae0b
commit 2d8b7d7875
10 changed files with 495 additions and 6 deletions
+3
View File
@@ -22,6 +22,9 @@ __pycache__/
devbox.json devbox.json
devbox.lock devbox.lock
# Local settings file (explicit path only - never a wildcard pattern)
ModularBot_garage/.env
# Compiled bot binaries — all builds go to *_garage/out/ # Compiled bot binaries — all builds go to *_garage/out/
*_garage/out/ *_garage/out/
+53
View File
@@ -0,0 +1,53 @@
# Copy this file to `.env` (same folder as the bot) and edit what you need.
# The bot reads `.env` automatically when you start it from its folder.
# To use a different file: ./out/ModularBot --env-file /path/to/my.env
#
# The file WINS over variables you exported in the shell. If a shell value
# differs, the bot prints a warning line so you know it was overridden.
#
# Everything is optional. Leave a line out (or commented, with #) to keep the
# built-in default. Values shown are the defaults.
# ── movement ─────────────────────────────────────────────────────────────────
# Which dodging engine to run: strafe (default), tfil, tfil_ring, surf.
TR_MOVEMENT=strafe
# ── gun rack: which guns the bot may choose (off | 1v1 | melee | both) ───────
# The shipped default is Pattern only. Turn a gun on with `both`.
TR_RACK_PATTERN=both
#TR_RACK_TMHORIZON=both
#TR_RACK_HEADON=both
# Drop whole guns by number (comma separated ids; empty = keep all).
GUN_RACK_DISABLE=
# ── power / energy policy ────────────────────────────────────────────────────
# 1 = use the energy-aware power cap (default). 0 = no cap (for experiments).
TR_POWER_POLICY=1
# Lowest cap when our own energy is low. NOTE: this is a CEILING, not a floor.
TR_POWER_ENERGY_MIN=0.5
# ── logs (set the value to 1; presence alone turns some of them on) ──────────
# Print one line per power decision.
#TR_POWER_LOG=1
# Print one line per ram start/stop and why.
#TR_RAM_LOG=1
# Print movement band/class changes.
#TR_MOVEMENT_LOG=1
# Print the per-shot thinking of the TM horizon gun.
#TR_TMHORIZON_LOG=1
# Print one line per round result.
TR_RESULT_LOG=1
# Where the per-round gun stats and the per-shot log are written.
GUN_STATS_PATH=/tmp/gun_stats.jsonl
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl
# ── measurement helpers (leave off unless you are measuring) ─────────────────
#TR_RECORD_WORLDSTATE=1
#TR_RADAR_SCANLOG=1
#TR_TRACKER_PROBE=1
# ── boot report ──────────────────────────────────────────────────────────────
# 1 = print the [env] report on startup (default). 0 = do not print it.
TR_ENV_REPORT=1
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/sh #!/bin/sh
cd "$(dirname "$0")" cd "$(dirname "$0")"
exec ./out/ModularBot >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log exec ./out/ModularBot "$@" >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/sh #!/bin/sh
cd "$(dirname "$0")" cd "$(dirname "$0")"
exec ./out/ModularBot >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log exec ./out/ModularBot "$@" >> /tmp/modularbot_stdout.log 2>> /tmp/modularbot_stderr.log
+2
View File
@@ -3,6 +3,8 @@
## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick. ## Radar: RadarLockModule (1v1) / AdaptiveMeleeRadarModule (2+ enemies), auto-switched per tick.
## Movement: OscillatorModule (perpendicular strafing). ## Movement: OscillatorModule (perpendicular strafing).
import env_boot # MUST be first: applies the .env file before any other
# module's top-level `let` reads the environment.
import std/[math, os, strformat, tables, sets, json, random, strutils] import std/[math, os, strformat, tables, sets, json, random, strutils]
import robocode_tankroyale_botapi import robocode_tankroyale_botapi
import radar_harness/radar_interface import radar_harness/radar_interface
+14
View File
@@ -0,0 +1,14 @@
## Imported FIRST by ModularBot.nim, purely for the module-init side effect
## below. Nim runs an imported module's top-level code before the importer's,
## and sibling imports in the order they are listed, so:
##
## import env_boot # <-- .env applied HERE
## import robocode_tankroyale_botapi
## import movements/... # <-- their top-level `let`s now see the file
##
## That ordering is the whole point: it is what lets every existing env reader
## in every other module keep working unchanged. Do not reorder this import.
import env_dotenv
bootDotEnv()
+201
View File
@@ -0,0 +1,201 @@
## .env file support for the bot.
##
## Why: a dozen TR_* variables were exported in the shell that launched the
## server/GUI, and stale leftovers were repeatedly mistaken for live settings
## (e.g. a forgotten TR_RACK_TMHORIZON). This module lets those settings live in
## a file instead — and the FILE WINS over a leftover shell export.
##
## Values are applied with putEnv() before anything else reads the environment,
## so every existing reader (getEnv, existsEnv, the envInt/envFloat/envBool
## helpers, the PRESENCE-based knobs like TR_POWER_LOG) keeps working unchanged.
##
## Resolution order (first hit wins):
## 1. --env-file <path> (also --env-file=<path>)
## 2. TR_ENV_FILE
## 3. ./.env in the current working directory (the bot root when launched
## from the bot dir, which the launcher scripts do)
## 4. .env next to the executable
## 5. none
##
## An EXPLICIT request (1 or 2) that names a missing file is a hard error: an
## explicit request that silently does nothing is exactly the bug this fixes.
## A missing default file (3/4) is normal and silent.
##
## The boot report (env_report.nim) reads the resolved path/source and the set
## of keys that came from the file, so it can label values `(source: .env)`.
import std/[os, strutils, sets]
const EnvFileFlag* = "--env-file"
const EnvFileEnvVar* = "TR_ENV_FILE"
const DefaultEnvFile* = ".env"
type
EnvFileError* = object of CatchableError
## Raised when an explicitly requested env file cannot be used.
EnvEntry* = object
key*: string
value*: string
EnvConflict* = object
## A key present in BOTH the file and the real environment with different
## values. The file value is the one applied; `shellValue` is what it
## replaced (the stale leftover that used to win by accident).
key*: string
fileValue*: string
shellValue*: string
EnvFileChoice* = object
path*: string ## resolved path; "" means "no file"
source*: string ## "flag" | "TR_ENV_FILE" | "cwd" | "exe" | "none"
explicit*: bool ## requested via flag/env var (missing => hard error)
# ── resolved state (read by the boot report) ─────────────────────────────────
var
gEnvFilePath = ""
gEnvFileSource = "none"
gEnvFileKeys: HashSet[string]
gEnvFileLoaded = false
proc envFilePath*(): string = gEnvFilePath
proc envFileSource*(): string = gEnvFileSource
proc isFromEnvFile*(key: string): bool = key in gEnvFileKeys
# ── CLI parsing ──────────────────────────────────────────────────────────────
proc flagEnvFilePath*(params: openArray[string] = commandLineParams()): string =
## The value of `--env-file` / `--env-file=`, or "" when absent. Raises when
## `--env-file` is the last argument (a request we cannot honour).
var i = 0
while i < params.len:
let p = params[i]
if p == EnvFileFlag:
if i + 1 < params.len:
return params[i + 1]
raise newException(EnvFileError, EnvFileFlag & " needs a path argument")
elif p.startsWith(EnvFileFlag & "="):
return p[EnvFileFlag.len + 1 .. ^1]
inc i
return ""
# ── resolution ───────────────────────────────────────────────────────────────
proc chooseEnvFile*(flagValue, envValue, cwdCandidate, exeCandidate: string): EnvFileChoice =
## Pure resolution: flag, then TR_ENV_FILE, then `./.env`, then the `.env`
## next to the executable, then none.
if flagValue.len > 0:
return EnvFileChoice(path: flagValue, source: "flag", explicit: true)
if envValue.len > 0:
return EnvFileChoice(path: envValue, source: EnvFileEnvVar, explicit: true)
if fileExists(cwdCandidate):
return EnvFileChoice(path: cwdCandidate, source: "cwd", explicit: false)
if fileExists(exeCandidate):
return EnvFileChoice(path: exeCandidate, source: "exe", explicit: false)
return EnvFileChoice(path: "", source: "none", explicit: false)
# ── parsing ──────────────────────────────────────────────────────────────────
proc parseEnvFileContent*(content, filename: string): seq[EnvEntry] =
## Parse the usual .env shape: one KEY=VALUE per line, blank lines and `#`
## comments skipped, a leading `export ` tolerated, surrounding single or
## double quotes stripped, whitespace trimmed, an empty VALUE allowed.
##
## A malformed line RAISES with the file name and line number — it is never
## silently ignored, because a typo that does nothing is the failure mode we
## are fixing.
var lineno = 0
for raw in content.splitLines():
inc lineno
var line = raw
if line.len > 0 and line[^1] == '\r': line.setLen(line.len - 1)
let stripped = line.strip()
if stripped.len == 0 or stripped[0] == '#': continue
var body = stripped
if body.startsWith("export") and (body.len == 6 or body[6] in Whitespace):
body = body[6 .. ^1].strip()
let eq = body.find('=')
if eq < 0:
raise newException(ValueError,
filename & ":" & $lineno & ": expected KEY=VALUE, got: " & stripped)
let key = body[0 ..< eq].strip()
if key.len == 0:
raise newException(ValueError,
filename & ":" & $lineno & ": empty key in: " & stripped)
var value = body[eq + 1 .. ^1].strip()
if value.len >= 2 and
((value[0] == '"' and value[^1] == '"') or
(value[0] == '\'' and value[^1] == '\'')):
value = value[1 ..< value.len - 1]
result.add EnvEntry(key: key, value: value)
# ── applying ─────────────────────────────────────────────────────────────────
proc applyEnvFile*(path: string): seq[EnvConflict] =
## Read `path`, apply every entry with putEnv (the FILE WINS over the real
## environment), remember the from-file keys, and return the keys whose file
## value replaced a DIFFERENT exported value. Raises on a read error or a
## malformed line.
let entries = parseEnvFileContent(readFile(path), path)
for e in entries:
if existsEnv(e.key):
let shell = getEnv(e.key)
if shell != e.value:
result.add EnvConflict(key: e.key, fileValue: e.value, shellValue: shell)
putEnv(e.key, e.value)
gEnvFileKeys.incl e.key
proc loadEnvFile*(choice: EnvFileChoice): seq[EnvConflict] =
## Apply the chosen file. A missing EXPLICIT file raises EnvFileError; a
## missing default file is a silent no-op. No file at all is a silent no-op.
if choice.path.len == 0: return @[]
if not fileExists(choice.path):
if choice.explicit:
raise newException(EnvFileError, "env file not found: " & choice.path)
return @[]
result = applyEnvFile(choice.path)
proc bootDotEnv*() =
## Resolve and apply the .env file exactly once. Called at process start by
## env_boot.nim (imported first by ModularBot.nim) so every later module-level
## read sees the file values.
if gEnvFileLoaded: return
gEnvFileLoaded = true
var flag = ""
try:
flag = flagEnvFilePath()
except EnvFileError as e:
stderr.writeLine "[dotenv] ERROR: " & e.msg
quit(1)
let envValue = if existsEnv(EnvFileEnvVar): getEnv(EnvFileEnvVar).strip() else: ""
let choice = chooseEnvFile(flag, envValue,
getCurrentDir() / DefaultEnvFile,
getAppDir() / DefaultEnvFile)
if choice.path.len == 0:
gEnvFilePath = ""
gEnvFileSource = "none"
return
try:
let conflicts = loadEnvFile(choice)
gEnvFilePath = choice.path
gEnvFileSource = choice.source
for c in conflicts:
stderr.writeLine "[dotenv] " & c.key & " = " & c.fileValue &
" (from " & choice.path & ") overrides the exported value " & c.shellValue
except EnvFileError as e:
stderr.writeLine "[dotenv] ERROR: " & e.msg
stderr.writeLine "[dotenv] it was requested explicitly (--env-file or " &
EnvFileEnvVar & "); refusing to start with the request silently ignored."
quit(1)
except ValueError as e:
stderr.writeLine "[dotenv] ERROR: " & e.msg
quit(1)
except CatchableError as e:
stderr.writeLine "[dotenv] ERROR: cannot use env file " & choice.path &
": " & e.msg
quit(1)
+16 -4
View File
@@ -20,6 +20,7 @@
## printed, because unrelated variables may hold secrets or machine paths. ## printed, because unrelated variables may hold secrets or machine paths.
import std/[os, strutils, algorithm, times, sets] import std/[os, strutils, algorithm, times, sets]
import env_dotenv
import gun_harness/virtual_bullets import gun_harness/virtual_bullets
import gun_harness/selector import gun_harness/selector
import movements/ram_decision import movements/ram_decision
@@ -89,13 +90,19 @@ proc tieBreakName(t: TieBreakMode): string =
proc sourceOf(name: string): string = proc sourceOf(name: string): string =
## Parsed-value knobs go through `getEnv(name, "")`; an empty value means the ## Parsed-value knobs go through `getEnv(name, "")`; an empty value means the
## shipped default, so a non-empty value is the only thing that counts as ## shipped default, so a non-empty value is the only thing that counts as
## "from the env". ## "from the env". A value that came from the resolved .env file is labelled
if getEnv(name, "").len > 0: "env" else: "default" ## `.env` even though the file applied it with putEnv (so `getEnv` finds it).
if isFromEnvFile(name): ".env"
elif getEnv(name, "").len > 0: "env"
else: "default"
proc sourceOfPresence(name: string): string = proc sourceOfPresence(name: string): string =
## Flags read with `existsEnv` are "from the env" whenever they are present, ## Flags read with `existsEnv` are "from the env" whenever they are present,
## even if their value is "0" (presence is what the code tests). ## even if their value is "0" (presence is what the code tests). A value that
if existsEnv(name): "env" else: "default" ## came from the .env file is labelled `.env`.
if isFromEnvFile(name): ".env"
elif existsEnv(name): "env"
else: "default"
proc disabledGunsString(guns: HashSet[int]): string = proc disabledGunsString(guns: HashSet[int]): string =
if guns.len == 0: return "none" if guns.len == 0: return "none"
@@ -461,6 +468,8 @@ proc knownEnvNames*(): seq[string] =
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY", "TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
"TR_POWER_LOG", "TR_POWER_LOG",
"TR_RESULT_LOG", "TR_RESULT_LOG",
# the .env loader's own environment hook (TR_ENV_FILE names the file)
"TR_ENV_FILE",
"TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN", "TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN",
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST", "TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG", "TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",
@@ -609,6 +618,9 @@ proc printEnvReport*(ctx: EnvReportContext) =
return return
echo "[env] === ENVIRONMENT (boot report) ===" echo "[env] === ENVIRONMENT (boot report) ==="
echo "[env] env file: ",
(if envFilePath().len > 0: envFilePath() & " (source: " & envFileSource() & ")"
else: "none")
printRawEnvironment() printRawEnvironment()
printMistakeWarnings() printMistakeWarnings()
printEffectiveValues(ctx) printEffectiveValues(ctx)
+180
View File
@@ -0,0 +1,180 @@
## Guard test for the .env loader (the module the bot uses to read its
## settings from a `.env` file).
##
## NO battle, NO Java, NO server. Run with:
## nim c -r --path:common_libs common_libs/tests/test_env_dotenv.nim
##
## It pins the pieces that must not silently drift:
## * the parser (comments, `export `, quotes, empty value, CRLF, and a
## malformed line that MUST raise with the file+line);
## * resolution precedence (flag > TR_ENV_FILE > ./.env > .env next to the
## executable > none);
## * the FILE WINS over the real environment, and the conflict is reported;
## * an explicitly requested missing file raises (the caller then exits
## nonzero), while a missing DEFAULT file is a silent no-op.
import std/[os, strutils]
import "../../ModularBot_garage/src/env_dotenv"
var failures = 0
var checks = 0
proc check(name: string, ok: bool) =
inc checks
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
let tmpDir = getTempDir() / "test_env_dotenv_guard"
createDir(tmpDir)
proc tmpPath(name: string): string = tmpDir / name
proc writeTmp(name, content: string): string =
result = tmpPath(name)
writeFile(result, content)
# ── 1. the parser ────────────────────────────────────────────────────────────
proc testParser() =
let entries = parseEnvFileContent("""# a comment, ignored
TR_MOVEMENT=tfil
export TR_RACK_TMHORIZON=both
QUOTED="hello world"
SINGLE='x y'
EMPTY=
SPACED = trimmed
""", "sample.env")
check "comment + blank lines are skipped (6 entries)", entries.len == 6
if entries.len == 6:
check "plain KEY=VALUE", entries[0].key == "TR_MOVEMENT" and entries[0].value == "tfil"
check "leading `export ` is tolerated",
entries[1].key == "TR_RACK_TMHORIZON" and entries[1].value == "both"
check "double quotes are stripped",
entries[2].key == "QUOTED" and entries[2].value == "hello world"
check "single quotes are stripped",
entries[3].key == "SINGLE" and entries[3].value == "x y"
check "an empty value is allowed",
entries[4].key == "EMPTY" and entries[4].value == ""
check "whitespace around key and value is trimmed",
entries[5].key == "SPACED" and entries[5].value == "trimmed"
let crlf = parseEnvFileContent("A=1\r\nB=two\r\n", "crlf.env")
check "CRLF line endings are stripped from the value",
crlf.len == 2 and crlf[0].value == "1" and crlf[1].value == "two"
var raised = false
try:
discard parseEnvFileContent("this line has no equals sign\n", "bad.env")
except ValueError as e:
raised = true
check "malformed line error names the file", "bad.env" in e.msg
check "malformed line error names the line number", ":1:" in e.msg
check "malformed line RAISES (never silently ignored)", raised
var emptyKeyRaised = false
try:
discard parseEnvFileContent("=value\n", "bad2.env")
except ValueError:
emptyKeyRaised = true
check "an empty key raises", emptyKeyRaised
# ── 2. resolution precedence ─────────────────────────────────────────────────
proc testResolution() =
let flagChoice = chooseEnvFile("/x/flag.env", "/x/tr.env",
"/cwd/.env", "/exe/.env")
check "flag beats TR_ENV_FILE and the defaults",
flagChoice.source == "flag" and flagChoice.path == "/x/flag.env" and
flagChoice.explicit
let envChoice = chooseEnvFile("", "/x/tr.env", "/cwd/.env", "/exe/.env")
check "TR_ENV_FILE beats the default paths",
envChoice.source == EnvFileEnvVar and envChoice.path == "/x/tr.env" and
envChoice.explicit
let cwdFile = writeTmp("cwd.env", "A=1\n")
let exeFile = writeTmp("exe.env", "A=2\n")
let cwdChoice = chooseEnvFile("", "", cwdFile, exeFile)
check "./.env (cwd) is chosen when it exists and is not explicit",
cwdChoice.source == "cwd" and cwdChoice.path == cwdFile and
not cwdChoice.explicit
let exeChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"), exeFile)
check ".env next to the executable is the fallback",
exeChoice.source == "exe" and exeChoice.path == exeFile
let noneChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"),
tmpPath("missing_exe.env"))
check "no candidate exists -> none (silent)",
noneChoice.path == "" and noneChoice.source == "none" and
not noneChoice.explicit
check "--env-file <path> is parsed",
flagEnvFilePath(@["--server", "x", "--env-file", "/a/b.env"]) == "/a/b.env"
check "--env-file=<path> is parsed",
flagEnvFilePath(@["--env-file=/a/b.env"]) == "/a/b.env"
check "no --env-file yields empty", flagEnvFilePath(@["--server", "x"]) == ""
var raised = false
try:
discard flagEnvFilePath(@["--env-file"])
except EnvFileError:
raised = true
check "--env-file with no argument raises", raised
# ── 3. the file wins over the real environment ───────────────────────────────
proc testFileWins() =
let f = writeTmp("wins.env", "TR_TEST_J126=from-file\nTR_TEST_SAME_J126=same\n")
putEnv("TR_TEST_J126", "from-shell")
putEnv("TR_TEST_SAME_J126", "same")
let conflicts = applyEnvFile(f)
check "the file value WINS over the exported value",
getEnv("TR_TEST_J126") == "from-file"
check "the conflict is reported with key, file value and shell value",
conflicts.len == 1 and conflicts[0].key == "TR_TEST_J126" and
conflicts[0].fileValue == "from-file" and
conflicts[0].shellValue == "from-shell"
check "an identical value is NOT a conflict",
conflicts.len == 1 # TR_TEST_SAME_J126 is absent from conflicts
check "keys from the file are tracked for the boot report",
isFromEnvFile("TR_TEST_J126") and isFromEnvFile("TR_TEST_SAME_J126")
delEnv("TR_TEST_J126")
delEnv("TR_TEST_SAME_J126")
# ── 4. missing explicit file vs missing default file ─────────────────────────
proc testMissingFiles() =
var raised = false
try:
discard loadEnvFile(EnvFileChoice(path: tmpPath("nope.env"),
source: "flag", explicit: true))
except EnvFileError as e:
raised = true
check "missing explicit file error names the path", "nope.env" in e.msg
check "missing explicit file RAISES (caller exits nonzero)", raised
var quiet = true
var n = 0
try:
n = loadEnvFile(EnvFileChoice(path: tmpPath("missing-default.env"),
source: "cwd", explicit: false)).len
except CatchableError:
quiet = false
check "missing default file is a silent no-op", quiet and n == 0
check "no file at all is a silent no-op",
loadEnvFile(EnvFileChoice(path: "", source: "none", explicit: false)).len == 0
# ── driver ───────────────────────────────────────────────────────────────────
testParser()
testResolution()
testFileWins()
testMissingFiles()
echo "\n", checks, " checks, ", failures, " failure(s)"
if failures > 0:
echo checks, " check(s) run, ", failures, " FAILED"
quit(1)
echo "All env-dotenv checks passed."
+24
View File
@@ -1,5 +1,29 @@
# Environment variable reference # 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.
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 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`/ are read lazily on first use** (`TR_TMHORIZON_SHIFT`/`_BIG_MULT`/`_LOG`/
`_RESET_ON_TARGET` in `ensureConfig`, `TR_PATTERN_RAD_*` inside `predict`). Either `_RESET_ON_TARGET` in `ensureConfig`, `TR_PATTERN_RAD_*` inside `predict`). Either