j134 fire fix: share ONE fire_tracker across tfil/ring/strafe/learned/surf (TR_FIRE_FIX, default on); env-gated TR_FIRE_DIAG alignment trace

This commit is contained in:
2026-09-26 12:40:19 +02:00
parent bd57db6d63
commit 6ad5d99922
11 changed files with 435 additions and 214 deletions
@@ -0,0 +1,117 @@
## fire_tracker.nim — the ONE enemy-fire detector every mover calls (job j134).
##
## Before this, each mover carried its own copy of the same energy-drop
## detector (`tfil`, `tfil_ring`, `strafe`, `learned`, `surf`). Job j133 found
## that shipped detector classifies the enemy energy delta with a single window
##
## if drop >= 0.09 and drop <= 3.01: spawn one wave with power = drop
##
## and fixed it in `strafe.nim` only. The other four movers stayed blind to a
## MEASURED 1.11% of enemy shots — the bug survived precisely because there
## were four copies. This module is the single implementation; the movers own
## their own wave geometry and spawn code, but the DELTA CLASSIFICATION (and
## the two server-fact corrections below) lives here, once.
##
## ── The two server facts that break the window (verified in the server) ─────
## * `rules.kt BULLET_HIT_ENERGY_GAIN_FACTOR = 3`: when an ENEMY bullet hits
## US the SHOOTER's energy RISES by `3*power` in the same tick. That rise
## is folded into the delta we read and can MASK the `power` the enemy spent
## firing the same tick (net delta >= 0 reads as "no fire").
## `noteEnemyBulletHit` adds the bonus back.
## * our own bullet damaging the enemy the same tick adds `damage` to the
## delta, which can push it above the power cap and get the enemy's OWN
## shot rejected. `noteDamageDealt` subtracts it.
## * a still-too-large delta is SPLIT into the fewest waves each <= 3.0
## instead of being silently dropped.
##
## MEASURED on the 70-battle corpus (67065 true enemy fires): the shipped path
## catches 98.888%, the corrected path 100.000% (see
## `common_libs/tests/measure_strafe_fire_catch.py`).
##
## ── Switch ──────────────────────────────────────────────────────────────────
## Default ON. `TR_FIRE_FIX=0` disables the correction in EVERY mover;
## `TR_STRAFE_FIRE_FIX=0` still disables it in STRAFE (j133 back-compat), so
## STRAFE is on only when BOTH are on. With the fix off `detect` is exactly
## `prev - energy` inside the caller's own window — byte-identical to the
## shipped detector.
import std/math
type
FireTracker* = object
## Per-enemy last-known energy plus the corrections accumulated from the
## event stream since the previous scan. One instance per mover.
prevEnergy*: seq[tuple[id: int, energy: float]]
hitBonusPending*: float ## 3 * power of enemy bullets that hit us
dealtPending*: float ## damage our bullets dealt to the enemy
enabled*: bool ## the j134 correction is active
splitWaves*: int ## waves emitted by splitting a too-large drop
correctedTicks*: int ## readings whose drop was non-trivially corrected
proc initFireTracker*(enabled: bool): FireTracker =
FireTracker(prevEnergy: @[], enabled: enabled)
proc reset*(t: var FireTracker) =
## Per-ROUND reset: drops the energy memory and any un-consumed event
## correction. Keeps `enabled` (a module-knob, not round state).
t.prevEnergy = @[]
t.hitBonusPending = 0.0
t.dealtPending = 0.0
t.splitWaves = 0
t.correctedTicks = 0
proc prevEnergyGet*(t: FireTracker, id: int): float =
for e in t.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergySet*(t: var FireTracker, id: int, energy: float) =
for i in 0..<t.prevEnergy.len:
if t.prevEnergy[i].id == id:
t.prevEnergy[i].energy = energy
return
t.prevEnergy.add((id: id, energy: energy))
proc noteEnemyBulletHit*(t: var FireTracker, power: float) =
## `onHitByBullet` -> `e.bullet.power`. No-op when the fix is off.
if t.enabled:
t.hitBonusPending += 3.0 * power
proc noteDamageDealt*(t: var FireTracker, damage: float) =
## `onBulletHit` -> `e.damage`. No-op when the fix is off.
if t.enabled:
t.dealtPending += damage
proc detect*(t: var FireTracker, id: int, energy: float,
lo, hi: float): seq[float] =
## Advance the tracker with one enemy energy reading and return the fire
## powers to spawn (empty = no fire).
##
## `lo`/`hi` are the CALLER's shipped window, so with the fix off the result
## is exactly the old `if drop >= lo and drop <= hi: @[drop]`. With the fix
## on the delta is corrected for the two observable server effects and a
## still-too-large delta is split rather than dropped.
let prev = t.prevEnergyGet(id)
let raw = prev - energy
var drop = raw
if t.enabled:
drop += t.hitBonusPending - t.dealtPending
if abs(drop - raw) > 1e-9: inc t.correctedTicks
t.prevEnergySet(id, energy)
if t.enabled and drop > hi:
# NEVER silently drop a drop. A delta above the power cap is either several
# fires folded into one reading or un-modelled contamination; some heat
# beats none. Split into the fewest waves each <= 3.0.
let n = int(ceil(drop / 3.0))
let p = drop / n.float
inc t.splitWaves
result = newSeq[float](n)
for i in 0..<n: result[i] = p
elif drop >= lo and drop <= hi:
result = @[drop]
proc endScan*(t: var FireTracker) =
## Call once after the per-enemy scan: each event correction applies to
## exactly one reading.
t.hitBonusPending = 0.0
t.dealtPending = 0.0
+52 -46
View File
@@ -84,6 +84,7 @@ import std/[math, os]
from std/strutils import parseFloat, parseInt, strip, toLowerAscii
import gun_harness/gun_interface
import movement_harness/movement_interface
import movement_harness/fire_tracker
import bitbrain/sbc
const
@@ -163,6 +164,9 @@ var
LearnedLabel* = llHistogram
LearnedRealEvents* = false
LearnedLog* = false
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
LearnedFireFix* = true
proc getEnvFloat(name: string, default: float): float =
let s = getEnv(name, "")
@@ -195,6 +199,7 @@ proc loadLearnedEnv*() =
LearnedWallMargin = max(0.0, getEnvFloat(LearnedWallMarginEnv, 48.0))
LearnedGlobal = envOn(LearnedGlobalEnv)
LearnedRealEvents = envOn(LearnedRealEventsEnv)
LearnedFireFix = envOn("TR_FIRE_FIX", true)
LearnedLog = envOn(LearnedLogEnv)
LearnedLabel =
case getEnv(LearnedLabelEnv, "").strip().toLowerAscii()
@@ -272,7 +277,7 @@ type
missGlobal: int ## llOutcome: non-hits seen (for the prior)
scores: seq[float]
waves: seq[LSWave]
prevEnergy: seq[tuple[id: int, energy: float]]
fire: FireTracker ## shared energy-drop detector (j134)
strafeDir: float64
dir: float64 ## direction commanded last tick (+-1)
prevX, prevY: float64 ## our position one tick ago
@@ -290,7 +295,7 @@ proc resetRound*(m: var LearnedSurferModule) =
## it survives a round boundary without becoming a battle-long static average
## (the j115 defect). Use `resetBattle` for a hard wipe.
m.waves = @[]
m.prevEnergy = @[]
m.fire.reset()
m.strafeDir = 1.0
m.dir = 1.0
m.prevX = 0.0
@@ -307,6 +312,7 @@ proc resetRound*(m: var LearnedSurferModule) =
proc initLearnedSurfer*(): LearnedSurferModule =
result.debugGraphics = false
result.fire = initFireTracker(LearnedFireFix)
result.sbc = initCountedSbc(LS_NADE, LS_BINS,
max(0, LearnedDecayEvery),
max(0, LearnedDecayShift))
@@ -485,58 +491,57 @@ proc predictState*(m: var LearnedSurferModule, row, col: int,
# ── fire detection + state ──────────────────────────────────────────────────
proc prevEnergyGet(m: LearnedSurferModule, id: int): float =
for e in m.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergyGet(m: LearnedSurferModule, id: int): float = m.fire.prevEnergyGet(id)
proc prevEnergySet(m: var LearnedSurferModule, id: int, energy: float) =
for i in 0..<m.prevEnergy.len:
if m.prevEnergy[i].id == id:
m.prevEnergy[i].energy = energy
return
m.prevEnergy.add((id: id, energy: energy))
m.fire.prevEnergySet(id, energy)
proc noteEnemyBulletHit*(m: var LearnedSurferModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var LearnedSurferModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
m.fire.noteDamageDealt(damage)
proc detectFire(m: var LearnedSurferModule, id: int, ex, ey, eenergy: float,
ws: WorldState) =
## One enemy's energy sample. A plausible one-tick firepower drop IS a wave;
## the state is the wave-relative state at THIS tick (the fire tick).
let prev = m.prevEnergyGet(id)
let drop = prev - eenergy
m.prevEnergySet(id, eenergy)
if drop < 0.09 or drop > 3.01: return
## One enemy's energy sample. A plausible (corrected) one-tick firepower
## drop IS a wave; the state is the wave-relative state at THIS tick (the
## fire tick). Window 0.09..3.01, LEARNED's shipped window; the shared
## tracker corrects the delta and splits an over-cap drop across waves.
for drop in m.fire.detect(id, eenergy, 0.09, 3.01):
let botX = ws.selfX
let botY = ws.selfY
let bspeed = 20.0 - 3.0 * drop
let d = hypot(botX - ex, botY - ey)
let bearing = arctan2(botY - ey, botX - ex) # centre line
let ux = cos(bearing)
let uy = sin(bearing)
let botX = ws.selfX
let botY = ws.selfY
let bspeed = 20.0 - 3.0 * drop
let d = hypot(botX - ex, botY - ey)
let bearing = arctan2(botY - ey, botX - ex) # centre line
let ux = cos(bearing)
let uy = sin(bearing)
# vlat: lateral velocity in the wave frame. The centre line passes through us
# at this tick, so lat(now) == 0 and vlat == -lat(prev).
let dxp = m.prevX - ex
let dyp = m.prevY - ey
var vlat = -(dxp * (-uy) + dyp * ux)
if m.prevX == 0.0 and m.prevY == 0.0 and m.prevHeading == 0.0: vlat = 0.0
let roomDx = if vlat >= 0.0: -uy else: uy
let roomDy = if vlat >= 0.0: ux else: -ux
let room = roomToWall(botX, botY, roomDx, roomDy, ws.arenaWidth, ws.arenaHeight)
let turn = wrap180(float(ws.selfHeading) - float(m.prevHeading))
# vlat: lateral velocity in the wave frame. The centre line passes through us
# at this tick, so lat(now) == 0 and vlat == -lat(prev).
let dxp = m.prevX - ex
let dyp = m.prevY - ey
var vlat = -(dxp * (-uy) + dyp * ux)
if m.prevX == 0.0 and m.prevY == 0.0 and m.prevHeading == 0.0: vlat = 0.0
let roomDx = if vlat >= 0.0: -uy else: uy
let roomDy = if vlat >= 0.0: ux else: -ux
let room = roomToWall(botX, botY, roomDx, roomDy, ws.arenaWidth, ws.arenaHeight)
let turn = wrap180(float(ws.selfHeading) - float(m.prevHeading))
let row = code(vlat, VlatEdges) * LS_Q + code(d, DistEdges)
let col = code(room, RoomEdges) * LS_Q + code(turn, TurnEdges)
let row = code(vlat, VlatEdges) * LS_Q + code(d, DistEdges)
let col = code(room, RoomEdges) * LS_Q + code(turn, TurnEdges)
m.waves.add LSWave(
ownerId: id, fireTick: ws.tick,
originX: ex, originY: ey, bearing: bearing, speed: bspeed,
startDist: d, power: drop,
ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))),
fresh: true,
selfEnergyAtFire: ws.selfEnergy,
stateRow: row, stateCol: col,
)
m.waves.add LSWave(
ownerId: id, fireTick: ws.tick,
originX: ex, originY: ey, bearing: bearing, speed: bspeed,
startDist: d, power: drop,
ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))),
fresh: true,
selfEnergyAtFire: ws.selfEnergy,
stateRow: row, stateCol: col,
)
# ── the mover ───────────────────────────────────────────────────────────────
@@ -551,6 +556,7 @@ proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand =
m.detectFire(ei.id, ei.x, ei.y, ei.energy, ws)
if seen == 0 and (ws.enemyX != 0.0 or ws.enemyY != 0.0):
m.detectFire(-1, ws.enemyX, ws.enemyY, ws.enemyEnergy, ws)
m.fire.endScan()
# ── 2. advance + resolve waves; every resolution is a training sample ────
var i = 0
+37 -57
View File
@@ -182,6 +182,7 @@ from std/strutils import parseFloat, parseInt, strip, toLowerAscii
import std/strformat
import gun_harness/gun_interface
import movement_harness/movement_interface
import movement_harness/fire_tracker
import robocode_tankroyale_botapi/graphics
import robocode_tankroyale_botapi/color
# j105/j106 reuse: the exported time-indexed heat helpers + pillar globals.
@@ -351,8 +352,15 @@ var
## subtracts it.
## MEASURED on 70 recorded battles (67065 true enemy fires): catches
## 98.888% of enemy fires with the knob OFF and 100.000% with it ON.
## Default ON; `TR_STRAFE_FIRE_FIX=0` restores the shipped detector exactly.
## j134: the detector now lives ONCE in `movement_harness/fire_tracker.nim`
## and every mover calls it. This flag is STRAFE's local gate: it is ON only
## when BOTH `TR_STRAFE_FIRE_FIX` and the global `TR_FIRE_FIX` are on, so
## either knob set to an off value restores the shipped detector exactly.
StrafeFireFix*: bool = true
## TEMPORARY j134 diagnostic (TASK B): when `TR_FIRE_DIAG` is set, print one
## `[firediag] READ tick=…` line per enemy energy reading so the event/reading
## tick alignment can be checked live. OFF by default; observability only.
StrafeFireDiag*: bool = false
## GUI: draw the full lava field (every non-zero tile, value-labelled) the
## way TFIL does. Default ON; `TR_STRAFE_HEAT_GRID=0` hides the field so the
## strafe overlays can be read on their own.
@@ -386,7 +394,9 @@ proc loadStrafeEnv*() =
StrafeWallBias = max(0.0, min(1.0, getEnvFloat("TR_STRAFE_WALL_BIAS", DefaultStrafeWallBias)))
StrafeWallSafe = max(0.0, getEnvFloat("TR_STRAFE_WALL_SAFE", DefaultStrafeWallSafe))
StrafeEscape = getEnvBool("TR_STRAFE_ESCAPE", true)
StrafeFireFix = getEnvBool("TR_STRAFE_FIRE_FIX", true)
StrafeFireFix = getEnvBool("TR_STRAFE_FIRE_FIX", true) and
getEnvBool("TR_FIRE_FIX", true)
StrafeFireDiag = existsEnv("TR_FIRE_DIAG")
StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
loadStrafeHeatEnv()
@@ -419,7 +429,6 @@ type
arenaWidth*, arenaHeight*: float
lava: seq[float]
bullets: seq[TrackedBullet]
prevEnergy: seq[tuple[id: int, energy: float]]
# ── decision state ──
targetX*, targetY*: float ## chosen tile centre (world coords)
targetValid*: bool
@@ -444,11 +453,8 @@ type
wallEscapePicks*: int ## picks forced inward by the all-hot escape
escapeModeTicks*: int ## ticks the escape bearing was in effect
lastMode*: string ## "pick" | "fallback" | "escape" | "radial"
# ── fire-detection fix (j133): event-fed energy-delta corrections ──
hitBonusPending: float ## 3 * power of enemy bullets that hit us this tick
dealtPending: float ## damage our bullets dealt to the enemy this tick
fireFixSplitWaves*: int ## waves emitted by splitting a too-large drop
fireFixCorrectedTicks*: int ## ticks whose drop was non-trivially corrected
# ── fire detection (j134): the shared enemy-fire tracker ──
fire: FireTracker ## ONE detector for every mover (movement_harness/fire_tracker)
# ── diagnostics (gate B + GUI) ──
callCount*: int
picks*: int
@@ -468,7 +474,7 @@ type
lastTileCol, lastTileRow: int
proc initStrafe*(): StrafeModule =
StrafeModule(debugGraphics: false)
StrafeModule(debugGraphics: false, fire: initFireTracker(StrafeFireFix))
proc removeBulletNear*(m: var StrafeModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
@@ -482,17 +488,12 @@ proc removeBulletNear*(m: var StrafeModule, x, y: float) =
if bestIdx >= 0:
m.bullets.del(bestIdx)
proc prevEnergyGet(m: StrafeModule, id: int): float =
for e in m.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergyGet(m: StrafeModule, id: int): float = m.fire.prevEnergyGet(id)
proc prevEnergySet(m: var StrafeModule, id: int, energy: float) =
for i in 0..<m.prevEnergy.len:
if m.prevEnergy[i].id == id:
m.prevEnergy[i].energy = energy
return
m.prevEnergy.add((id: id, energy: energy))
## Kept for the guard test / callers that seed an energy reading directly;
## delegates to the shared tracker.
m.fire.prevEnergySet(id, energy)
proc clearGraphics*(m: var StrafeModule) =
## No-op: the SVG buffer is a module-level global cleared by the framework
@@ -501,7 +502,7 @@ proc clearGraphics*(m: var StrafeModule) =
proc resetRound*(m: var StrafeModule) =
m.bullets = @[]
m.prevEnergy = @[]
m.fire.reset()
m.targetValid = false
m.targetLava = 0.0
m.targetPathHeat = 0.0
@@ -521,10 +522,6 @@ proc resetRound*(m: var StrafeModule) =
m.wallEscapePicks = 0
m.escapeModeTicks = 0
m.lastMode = ""
m.hitBonusPending = 0.0
m.dealtPending = 0.0
m.fireFixSplitWaves = 0
m.fireFixCorrectedTicks = 0
m.callCount = 0
m.picks = 0
m.lastPickCall = 0
@@ -594,47 +591,30 @@ proc noteEnemyBulletHit*(m: var StrafeModule, power: float) =
## `power` the enemy spent firing in the same tick (net delta >= 0 reads as
## "no fire"). ModularBot forwards `onHitByBullet`'s `e.bullet.power` here so
## `detectFires` can add the bonus back before classifying the delta.
## No-op when `TR_STRAFE_FIRE_FIX` is off (shipped detector preserved).
if StrafeFireFix:
m.hitBonusPending += 3.0 * power
## No-op when the fix is off (shipped detector preserved).
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var StrafeModule, damage: float) =
## The mirror contamination: OUR bullet damaging the enemy this tick adds
## `damage` to the enemy's energy drop, which can push it above the 3.0 power
## cap and get the enemy's OWN shot rejected by the shipped `<= 3.01` test.
## ModularBot forwards `onBulletHit`'s `e.damage` here.
## No-op when `TR_STRAFE_FIRE_FIX` is off.
if StrafeFireFix:
m.dealtPending += damage
## No-op when the fix is off.
m.fire.noteDamageDealt(damage)
proc detectFires(m: var StrafeModule, ws: WorldState) =
## The shared tracker does the delta correction + split; STRAFE supplies its
## shipped window (0.09 .. 3.01) and its own wave geometry.
for ei in ws.enemies:
let prev = m.prevEnergyGet(ei.id)
let raw = prev - ei.energy
var drop = raw
if StrafeFireFix:
# Undo the server's known energy contaminations. The bonus raises the
# enemy's energy (shrinking/negating the drop); our damage lowers it
# (inflating the drop). Both are known exactly from the events.
drop += m.hitBonusPending - m.dealtPending
if abs(drop - raw) > 1e-9: inc m.fireFixCorrectedTicks
m.prevEnergySet(ei.id, ei.energy)
if StrafeFireFix and drop > 3.01:
# NEVER silently drop a drop. A delta above the power cap is either
# several fires folded into one reading (unobserved radar latency) or
# un-modelled contamination; either way SOME heat beats none. Split into
# the fewest waves each <= 3.0, all from the same origin.
let n = int(ceil(drop / 3.0))
let p = drop / n.float
for _ in 0..<n:
m.spawnTrackedWave(ws, ei, p)
inc m.fireFixSplitWaves
elif drop >= 0.09 and drop <= 3.01:
m.spawnTrackedWave(ws, ei, drop)
if StrafeFireFix:
# Consumed: each event-tick correction applies to exactly one reading.
m.hitBonusPending = 0.0
m.dealtPending = 0.0
if StrafeFireDiag:
let raw = m.fire.prevEnergyGet(ei.id) - ei.energy
echo "[firediag] READ tick=", ws.tick, " id=", ei.id,
" raw=", raw,
" bonus=", m.fire.hitBonusPending,
" dealt=", m.fire.dealtPending
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01):
m.spawnTrackedWave(ws, ei, p)
m.fire.endScan()
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
var i = 0
@@ -1099,9 +1079,9 @@ proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
m.dwell = 0
m.targetValid = false
m.bullets = @[]
m.prevEnergy = @[]
m.fire.prevEnergy = @[]
for ei in ws.enemies:
m.prevEnergySet(ei.id, ei.energy)
m.fire.prevEnergySet(ei.id, ei.energy)
m.advanceBullets(ws.selfX, ws.selfY)
m.detectFires(ws)
+47 -36
View File
@@ -6,6 +6,7 @@ import std/os
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
import gun_harness/gun_interface
import movement_harness/movement_interface
import movement_harness/fire_tracker
import robocode_tankroyale_botapi/graphics
import robocode_tankroyale_botapi/color
@@ -104,6 +105,9 @@ var
TfilCommitTicks*: int = DefaultTfilCommitTicks
TfilNoRev*: bool = false
TfilCommitLogPath*: string = ""
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
TfilFireFix*: bool = true
proc getEnvInt(name: string, default: int): int =
let s = getEnv(name, "")
@@ -132,6 +136,7 @@ proc loadTfilCommitEnv*() =
TfilCommitTicks = max(1, getEnvInt("TR_TFIL_COMMIT_TICKS", DefaultTfilCommitTicks))
TfilNoRev = getEnvBool("TR_TFIL_NO_REV", false)
TfilCommitLogPath = getEnv("TR_TFIL_COMMIT_LOG", "")
TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
loadTfilCommitEnv()
@@ -243,7 +248,7 @@ type
arenaWidth, arenaHeight: float
lava: seq[float] # flat row-major, index = row*cols + col
bullets: seq[TrackedBullet]
prevEnergy: seq[tuple[id: int, energy: float]] # enemy id -> last known energy
fire: FireTracker ## shared energy-drop detector (j134)
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
commitTicks: int ## ticks remaining on commitment
commitLava: float ## lava at commit time (for spike detection)
@@ -258,7 +263,7 @@ type
lastPickCall: int ## callCount at the last pick (log only)
picks: int ## number of picks this round (log only)
proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false)
proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false, fire: initFireTracker(TfilFireFix))
proc removeBulletNear*(m: var TFILModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
@@ -272,17 +277,10 @@ proc removeBulletNear*(m: var TFILModule, x, y: float) =
if bestIdx >= 0:
m.bullets.del(bestIdx)
proc prevEnergyGet(m: TFILModule, id: int): float =
for e in m.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergyGet(m: TFILModule, id: int): float = m.fire.prevEnergyGet(id)
proc prevEnergySet(m: var TFILModule, id: int, energy: float) =
for i in 0..<m.prevEnergy.len:
if m.prevEnergy[i].id == id:
m.prevEnergy[i].energy = energy
return
m.prevEnergy.add((id: id, energy: energy))
m.fire.prevEnergySet(id, energy)
proc clearGraphics*(m: var TFILModule) =
## No-op: the SVG buffer is a module-level global cleared by the framework
@@ -291,7 +289,7 @@ proc clearGraphics*(m: var TFILModule) =
proc resetRound*(m: var TFILModule) =
m.bullets = @[]
m.prevEnergy = @[]
m.fire.reset()
m.commitTicks = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
@@ -349,30 +347,43 @@ proc initGrid(m: var TFILModule, arenaWidth, arenaHeight: float) =
m.arenaHeight = arenaHeight
m.lava = newSeq[float](m.cols * m.rows) # all 0.0
proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
power: float) =
## One tracked bullet/wave for a confirmed fire of `power`: linear prediction
## of our position at arrival becomes the aim heading. (Was inlined in
## `detectFires`; extracted so the shared split can spawn several.)
let speed = 20.0 - 3.0 * power
let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2)
let travelTime = dist / speed
let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * PI / 180.0) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: power,
alive: true,
age: 0)
proc noteEnemyBulletHit*(m: var TFILModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var TFILModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
m.fire.noteDamageDealt(damage)
proc detectFires(m: var TFILModule, ws: WorldState) =
## Check all enemies for energy drops; spawn a tracked bullet per confirmed fire.
## Check all enemies for energy drops; spawn a tracked bullet per confirmed
## fire. The shared tracker corrects the delta and splits over-cap drops.
for ei in ws.enemies:
let prev = m.prevEnergyGet(ei.id)
let drop = prev - ei.energy
m.prevEnergySet(ei.id, ei.energy)
if drop >= 0.09 and drop <= 3.01:
let speed = 20.0 - 3.0 * drop
# Linear prediction: aim at where we will be when the bullet arrives
let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2)
let travelTime = dist / speed
let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * PI / 180.0) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: drop,
alive: true,
age: 0)
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01):
m.spawnTrackedWave(ws, ei, p)
m.fire.endScan()
proc advanceBullets(m: var TFILModule, selfX, selfY: float) =
## Advance positions and reap bullets that are: passed us, out of bounds, or too old.
@@ -519,9 +530,9 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
m.bullets = @[] # bullet positions are hopelessly stale
m.blockedTile = (col: 0, row: 0, active: false)
# Re-snapshot prevEnergy so energy changes during ramming aren't misread as fires
m.prevEnergy = @[]
m.fire.prevEnergy = @[]
for ei in ws.enemies:
m.prevEnergySet(ei.id, ei.energy)
m.fire.prevEnergySet(ei.id, ei.energy)
# Tile-change replan — see the knob rationale at the top of the file.
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
@@ -82,9 +82,10 @@
## 10.0 / 15.0; the comment now says so.
import std/[math, random, os, strformat]
from std/strutils import parseFloat, strip # selective: strutils.fromHex clashes with color.fromHex
from std/strutils import parseFloat, strip, toLowerAscii # selective: strutils.fromHex clashes with color.fromHex
import gun_harness/gun_interface
import movement_harness/movement_interface
import movement_harness/fire_tracker
import robocode_tankroyale_botapi/graphics
import robocode_tankroyale_botapi/color
@@ -128,6 +129,18 @@ proc getEnvFloat(name: string, default: float): float =
except ValueError:
result = default
proc getEnvBool(name: string, default: bool): bool =
let s = getEnv(name, "").strip().toLowerAscii()
if s.len == 0: return default
s in ["1", "true", "on", "yes"]
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
var TfilRingFireFix* = true
proc loadTfilRingFireEnv*() =
TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true)
loadTfilRingFireEnv()
const
DefaultRangeLo = 100.0
DefaultRangeHi = 200.0
@@ -223,7 +236,7 @@ type
arenaWidth, arenaHeight: float
lava: seq[float] # flat row-major, index = row*cols + col
bullets: seq[TrackedBullet]
prevEnergy: seq[tuple[id: int, energy: float]] # enemy id -> last known energy
fire: FireTracker ## shared energy-drop detector (j134)
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
commitTicks: int ## ticks remaining on commitment
commitLava: float ## lava at commit time (for spike detection)
@@ -241,7 +254,8 @@ type
loggedOnce: bool
proc initTFILRing*(): TFILRingModule =
TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi))
TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi),
fire: initFireTracker(TfilRingFireFix))
proc removeBulletNear*(m: var TFILRingModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
@@ -255,17 +269,10 @@ proc removeBulletNear*(m: var TFILRingModule, x, y: float) =
if bestIdx >= 0:
m.bullets.del(bestIdx)
proc prevEnergyGet(m: TFILRingModule, id: int): float =
for e in m.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergyGet(m: TFILRingModule, id: int): float = m.fire.prevEnergyGet(id)
proc prevEnergySet(m: var TFILRingModule, id: int, energy: float) =
for i in 0..<m.prevEnergy.len:
if m.prevEnergy[i].id == id:
m.prevEnergy[i].energy = energy
return
m.prevEnergy.add((id: id, energy: energy))
m.fire.prevEnergySet(id, energy)
proc clearGraphics*(m: var TFILRingModule) =
## No-op: the SVG buffer is a module-level global cleared by the framework
@@ -274,7 +281,7 @@ proc clearGraphics*(m: var TFILRingModule) =
proc resetRound*(m: var TFILRingModule) =
m.bullets = @[]
m.prevEnergy = @[]
m.fire.reset()
m.commitTicks = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
@@ -299,30 +306,42 @@ proc initGrid(m: var TFILRingModule, arenaWidth, arenaHeight: float) =
m.arenaHeight = arenaHeight
m.lava = newSeq[float](m.cols * m.rows) # all 0.0
proc spawnTrackedWave(m: var TFILRingModule, ws: WorldState, ei: EnemyInfo,
power: float) =
## One tracked bullet/wave for a confirmed fire of `power` (extracted from
## `detectFires` so the shared split can spawn several).
let speed = 20.0 - 3.0 * power
let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2)
let travelTime = dist / speed
let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * PI / 180.0) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: power,
alive: true,
age: 0)
proc noteEnemyBulletHit*(m: var TFILRingModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var TFILRingModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
m.fire.noteDamageDealt(damage)
proc detectFires(m: var TFILRingModule, ws: WorldState) =
## Check all enemies for energy drops; spawn a tracked bullet per confirmed fire.
## Check all enemies for energy drops; spawn a tracked bullet per confirmed
## fire. The shared tracker corrects the delta and splits over-cap drops.
for ei in ws.enemies:
let prev = m.prevEnergyGet(ei.id)
let drop = prev - ei.energy
m.prevEnergySet(ei.id, ei.energy)
if drop >= 0.09 and drop <= 3.01:
let speed = 20.0 - 3.0 * drop
# Linear prediction: aim at where we will be when the bullet arrives
let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2)
let travelTime = dist / speed
let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * PI / 180.0) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: drop,
alive: true,
age: 0)
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01):
m.spawnTrackedWave(ws, ei, p)
m.fire.endScan()
proc advanceBullets(m: var TFILRingModule, selfX, selfY: float) =
## Advance positions and reap bullets that are: passed us, out of bounds, or too old.
@@ -456,9 +475,9 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
m.bullets = @[] # bullet positions are hopelessly stale
m.blockedTile = (col: 0, row: 0, active: false)
# Re-snapshot prevEnergy so energy changes during ramming aren't misread as fires
m.prevEnergy = @[]
m.fire.prevEnergy = @[]
for ei in ws.enemies:
m.prevEnergySet(ei.id, ei.energy)
m.fire.prevEnergySet(ei.id, ei.energy)
# Tile-change replan: catches gradual displacement that position threshold misses
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
+31 -20
View File
@@ -50,6 +50,7 @@ import std/[math, os]
from std/strutils import parseFloat, strip, toLowerAscii
import gun_harness/gun_interface
import movement_harness/movement_interface
import movement_harness/fire_tracker
const
WS_BINS = 31
@@ -76,6 +77,9 @@ var
SurfWallMargin* = DefaultWallMargin
SurfRadialFrac* = DefaultRadialFrac
SurfLog* = false
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
SurfFireFix* = true
proc getEnvFloat(name: string, default: float): float =
let s = getEnv(name, "")
@@ -83,6 +87,11 @@ proc getEnvFloat(name: string, default: float): float =
try: result = parseFloat(s.strip())
except ValueError: result = default
proc envOn(name: string, default = false): bool =
let s = getEnv(name, "").strip().toLowerAscii()
if s.len == 0: return default
s notin ["0", "false", "no", "off"]
proc loadSurfEnv*() =
## Read the surfer knobs; callable again after `putEnv` so a gate can
## exercise arms in one process.
@@ -91,6 +100,7 @@ proc loadSurfEnv*() =
SurfWallMargin = max(0.0, getEnvFloat(SurfWallMarginEnv, DefaultWallMargin))
SurfRadialFrac = clamp(getEnvFloat(SurfRadialFracEnv, DefaultRadialFrac), 0.0, 1.0)
SurfLog = existsEnv(SurfLogEnv)
SurfFireFix = envOn("TR_FIRE_FIX", true)
loadSurfEnv()
@@ -106,7 +116,7 @@ type
WaveSurferModule* = object
bins: array[WS_BINS, float64]
waves: seq[WSWave]
prevEnergy: seq[tuple[id: int, energy: float]]
fire: FireTracker ## shared energy-drop detector (j134)
strafeDir: float64 ## +1.0 or -1.0
debugGraphics*: bool
@@ -114,12 +124,12 @@ proc resetRound*(m: var WaveSurferModule) =
## Wipe per-round state. The danger histogram IS reset here (defect 2): it is
## a per-round learner, not a battle-long static average.
m.waves = @[]
m.prevEnergy = @[]
m.fire.reset()
m.strafeDir = 1.0
for i in 0..<WS_BINS: m.bins[i] = 1.0
proc initWaveSurfer*(): WaveSurferModule =
var m = WaveSurferModule(debugGraphics: false)
var m = WaveSurferModule(debugGraphics: false, fire: initFireTracker(SurfFireFix))
m.resetRound()
m
@@ -132,17 +142,18 @@ proc removeBulletNear*(m: var WaveSurferModule, x, y: float) {.inline.} =
## No-op: the surfer tracks WAVES (energy drops), not bullet bodies.
discard
proc prevEnergyGet(m: WaveSurferModule, id: int): float =
for e in m.prevEnergy:
if e.id == id: return e.energy
100.0
proc prevEnergyGet(m: WaveSurferModule, id: int): float = m.fire.prevEnergyGet(id)
proc prevEnergySet(m: var WaveSurferModule, id: int, energy: float) =
for i in 0..<m.prevEnergy.len:
if m.prevEnergy[i].id == id:
m.prevEnergy[i].energy = energy
return
m.prevEnergy.add((id: id, energy: energy))
m.fire.prevEnergySet(id, energy)
proc noteEnemyBulletHit*(m: var WaveSurferModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var WaveSurferModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
m.fire.noteDamageDealt(damage)
proc gfToBin(gf: float64): int {.inline.} =
clamp(int(round((gf.clamp(-1.0, 1.0) + 1.0) * 0.5 * float64(WS_BINS - 1))),
@@ -176,13 +187,12 @@ proc nearestWave(m: WaveSurferModule, botX, botY: float64): int =
proc detectFire(m: var WaveSurferModule, id: int, ex, ey, eenergy,
botX, botY: float64) =
## One enemy's energy sample: emit a wave iff its energy dropped by a
## plausible firepower in one tick. `drop` is the firepower exactly.
let prev = m.prevEnergyGet(id)
let drop = prev - eenergy
m.prevEnergySet(id, eenergy)
if drop >= 0.1 and drop <= 3.0:
let bspeed = 20.0 - 3.0 * drop
## One enemy's energy sample: emit a wave per plausible firepower in the
## (corrected) one-tick drop. `drop` IS the firepower. Window 0.1..3.0,
## SURF's shipped window; the shared tracker corrects the delta and splits an
## over-cap drop across several waves.
for power in m.fire.detect(id, eenergy, 0.1, 3.0):
let bspeed = 20.0 - 3.0 * power
let bearing = arctan2(botY - ey, botX - ex)
let d = hypot(botX - ex, botY - ey)
m.waves.add WSWave(
@@ -191,7 +201,7 @@ proc detectFire(m: var WaveSurferModule, id: int, ex, ey, eenergy,
speed: bspeed,
radius: 0.0,
startDist: d,
power: drop,
power: power,
)
proc computeMove*(m: var WaveSurferModule, ws: WorldState): MoveCommand =
@@ -206,6 +216,7 @@ proc computeMove*(m: var WaveSurferModule, ws: WorldState): MoveCommand =
if seen == 0 and (ws.enemyX != 0.0 or ws.enemyY != 0.0):
# Fallback to the tracked target when `enemies` is empty.
m.detectFire(-1, ws.enemyX, ws.enemyY, ws.enemyEnergy, botX, botY)
m.fire.endScan()
# ── Advance waves; record the GF of each wave that reaches us ─────────────
var i = 0
+12 -4
View File
@@ -1,16 +1,24 @@
corpus: /tmp/tfil_ab2/out (70 runs, 5 arms)
== SHIPPED (TR_STRAFE_FIRE_FIX=0) ==
== SHIPPED (TR_FIRE_FIX=0) ==
true enemy fires: 67065
caught (wave on the fire's own tick): 66319 catch rate = 0.98888
missed: 746 (drop > 3.01 [multi-fire/contamination]: 290, drop < 0.09 [masked by the +3*power bonus]: 456)
latency (ticks after the fire's own tick): -1=737, 0=66319, 2=1, 3=2, 4=1, 5=5
== FIXED (TR_STRAFE_FIRE_FIX=1) ==
== FIXED (TR_FIRE_FIX=1) ==
true enemy fires: 67065
caught (wave on the fire's own tick): 67065 catch rate = 1.00000
missed: 0 (drop > 3.01 [multi-fire/contamination]: 0, drop < 0.09 [masked by the +3*power bonus]: 0)
latency (ticks after the fire's own tick): 0=67065
== live scan interval (3024 consecutive readings over 6 captures) ==
lst increment histogram: 1:3024 (100.00%)
== live scan interval (1639 consecutive readings over 2 captures) ==
lst increment histogram: 1:1639 (100.00%)
== per-mover catch rate (shared fire_tracker, j134) ==
mover window shipped fixed blind_before blind_after
tfil 0.09-3.01 0.98888 1.00000 746 0
ring 0.09-3.01 0.98888 1.00000 746 0
strafe 0.09-3.01 0.98888 1.00000 746 0
learned 0.09-3.01 0.98888 1.00000 746 0
surf 0.1-3.0 0.98886 1.00000 747 0
+42 -7
View File
@@ -48,6 +48,19 @@ import sys
# a corpus artifact only.
CORPUS_EVENT_SHIFT = 1
# Per-mover detector windows, as wired in j134. Every mover now calls the SAME
# shared `movement_harness/fire_tracker.nim`; only the window it supplies differs
# (`strafe` is the j133 reference). The correction and the over-cap SPLIT are
# shared, so the FIXED catch rate must be identical for all of them; the SHIPPED
# rate can differ slightly where the windows differ.
MOVER_WINDOWS = {
"tfil": (0.09, 3.01),
"ring": (0.09, 3.01),
"strafe": (0.09, 3.01),
"learned": (0.09, 3.01),
"surf": (0.10, 3.00),
}
def load_run(path: str):
rows, events = [], []
@@ -66,7 +79,7 @@ def load_run(path: str):
return rows, events, rounds
def detect(rows, events, rounds, fix: bool):
def detect(rows, events, rounds, fix: bool, lo: float = 0.09, hi: float = 3.01):
"""Replay the detector; return {global_tick: [powers]} of created waves."""
starts = {r["round"]: r["startTick"] for r in rounds}
by_global = {r["startTick"] + k: (r["round"], k)
@@ -118,11 +131,11 @@ def detect(rows, events, rounds, fix: bool):
if fix:
drop = raw + bonus.get((rn, rt + CORPUS_EVENT_SHIFT), 0.0) \
- dealt.get((rn, rt + CORPUS_EVENT_SHIFT), 0.0)
if fix and drop > 3.01:
if fix and drop > hi:
n = int(math.ceil(drop / 3.0))
for _ in range(n):
waves[t].append(drop / n)
elif 0.09 <= drop <= 3.01:
elif lo <= drop <= hi:
waves[t].append(drop)
return waves, energy, eowner
@@ -139,8 +152,8 @@ def fires_of(events, rounds, eowner):
return out
def score(rows, events, rounds, fix: bool):
waves, energy, eowner = detect(rows, events, rounds, fix)
def score(rows, events, rounds, fix: bool, lo: float = 0.09, hi: float = 3.01):
waves, energy, eowner = detect(rows, events, rounds, fix, lo, hi)
starts = {r["round"]: r["startTick"] for r in rounds}
by_global = {r["startTick"] + k: (r["round"], k)
for r in rounds for k in range(r["count"])}
@@ -163,7 +176,7 @@ def score(rows, events, rounds, fix: bool):
drop = raw
if fix:
drop = raw # classification below only needs the miss kind
if raw > 3.01:
if raw > hi:
cats["reject_high"] += 1
else:
cats["reject_low"] += 1
@@ -228,7 +241,7 @@ def main() -> int:
caught += c
lat.update(l)
cats.update(k)
tag = "FIXED (TR_STRAFE_FIRE_FIX=1)" if fix else "SHIPPED (TR_STRAFE_FIRE_FIX=0)"
tag = "FIXED (TR_FIRE_FIX=1)" if fix else "SHIPPED (TR_FIRE_FIX=0)"
out(f"\n== {tag} ==")
out(f"true enemy fires: {total}")
out(f"caught (wave on the fire's own tick): {caught} "
@@ -247,6 +260,28 @@ def main() -> int:
out("lst increment histogram: " + ", ".join(
f"{k}:{v} ({100.0 * v / total:.2f}%)" for k, v in sorted(incs.items())))
# ── per-mover table (j134) ──────────────────────────────────────────────
# Every mover now calls the shared `movement_harness/fire_tracker.nim`; only
# the window differs. This is the propagation proof: the SHIPPED path leaves
# each mover blind to the same 1.11%, the FIXED path is 100% for all.
out("\n== per-mover catch rate (shared fire_tracker, j134) ==")
out(f"{'mover':<8} {'window':<14} {'shipped':>9} {'fixed':>9} "
f"{'blind_before':>13} {'blind_after':>12}")
for name, (wlo, whi) in MOVER_WINDOWS.items():
per = {}
for fix in (False, True):
total = caught = 0
for path in runs:
rows, events, rounds = load_run(path)
t, c, _, _ = score(rows, events, rounds, fix, wlo, whi)
total += t
caught += c
per[fix] = (total, caught)
total, caught0 = per[False]
_, caught1 = per[True]
out(f"{name:<8} {f'{wlo}-{whi}':<14} {caught0 / total:>9.5f} "
f"{caught1 / total:>9.5f} {total - caught0:>13} {total - caught1:>12}")
if args.report:
os.makedirs(os.path.dirname(args.report) or ".", exist_ok=True)
with open(args.report, "w") as f:
@@ -131,7 +131,17 @@ proc testDefaultParity() =
# regression — see the file header). With the pillar restored via
# TR_TFIL_PILLAR_ON=1 the old golden would no longer match, by design.
doAssert fileExists(goldenPath), "missing golden: " & goldenPath
# j134: the golden was generated from the SHIPPED detector. Force the shipped
# fire-detection path (`TR_FIRE_FIX=0`) for this check so it still proves the
# PRE-CHANGE path is byte-identical. The correction ON legitimately changes
# the path (it no longer drops an over-cap energy delta), which is the whole
# point of j134 — see `test_strafe_fire_fix.nim` for the per-behaviour checks.
when declared(TfilFireFix):
let savedFireFix = TfilFireFix
TfilFireFix = false
let recs = replay(loadStates(), loadRoundStarts())
when declared(TfilFireFix):
TfilFireFix = savedFireFix
var golden: seq[string]
for rawLine in lines(goldenPath):
if rawLine.startsWith("#"): continue