Files
SirRoboGarage/common_libs/movement_harness/fire_tracker.nim
T
SirStone d21f7ce5f5 j147: the 1-tick fire-detection lag is OURS — measure it, then back-date it (TR_FIRE_LAG)
MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions, 1777
matched ghost spawns, both movers): the server dispatches a turn's fire AFTER
our go() for that same turn, so a turn-T shot's energy drop first reaches our
scan at turn T+1 — and a bullet takes its FIRST step during the turn it is
fired, so the true bullet is already one whole bullet step (11-20 px) downrange.
Both movers place the ghost at the SCANNED enemy position (where the bullet was
born), so the whole ghost trajectory is the true one shifted one turn later and
the arrival deadline is a full tick late.

MEASURED: detection lag +1 tick on 100% of 1777 matched spawns; ghost-vs-
observer displacement 19.06 px mean / 22.00 p90 (tfil) and 16.08 / 21.81
(strafe); arrival-deadline error 0.99 / 0.77 ticks. NOT a rendering artefact:
the draw/advance order is correct (advanceBullets -> detectFires -> build).

THE FIX: TR_FIRE_LAG (int, default 0 = today byte-for-byte) in the shared
fire_tracker, applied by both movers at spawn: x = origin + dir*speed*lag.
The deadline needs no separate change — both movers derive it from the ghost's
own position, so a correct position gives a correct deadline.
WITH IT: displacement 19.06 -> 5.37 px mean (the residue is the enemy's own
<=8 px scan staleness) and the deadline error 0.99 -> 0.06 ticks.

Guards: test_tfil_commit_env 77 -> 87 checks (default golden parity, exact
n-step back-date, deadline shortens by exactly lag, junk/negative degrade to 0,
reaped exactly one tick earlier); test_env_report + test_env_dotenv green.
TR_FIRE_LAG registered in env_report + knownEnvNames + .env.example +
docs/env_reference.md. Live A/B pre-registered in docs/movement_campaign.md
(Batch 8) with its MDE stated up front; arms tools/ab/arms_fire_lag.txt.
TR_FIRE_DIAG gains a per-round ROUND line (the tick->getTurn anchor) and a
per-spawn SPAWN line (the ghost's drawn position).
2026-09-26 23:08:57 +02:00

179 lines
8.7 KiB
Nim

## 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`. 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 ──────────────────────────────────────────────────────────────────
## The tracker stores NO enable flag: the CALLER passes `fix` to `detect` and
## guards its `note*` calls with its own knob. That is deliberate — an
## instance-level flag was silently false on the ModularBot construction path
## (the movers are built as object literals, not via `init*`), so the "fix"
## never ran live. The mover's module-global switch is now the single source of
## truth: `TR_FIRE_FIX` (default ON) for every mover, ANDed with
## `TR_STRAFE_FIRE_FIX` for STRAFE. With `fix=false`, `detect` is exactly
## `prev - energy` inside the caller's own window — byte-identical to shipped.
##
## ── Why the correction is applied with a one-SCAN delay ─────────────────────
## MEASURED LIVE (job j134, `TR_FIRE_DIAG`): the server emits the hit event on
## turn N but applies the energy change to the SHARED energy reading of turn
## N+1. Concretely, `EV hit getTurn=67` (dispatched at `bot.tick=66`, i.e.
## after that turn's movement) is followed by the `raw=-5.803` gain in the
## reading of `getTurn=68`. Because `note*` is called during `go()`, AFTER the
## same-tick `endScan`, a one-slot double buffer (`incoming` -> `pending`) makes
## the correction land on the reading that actually carries the change:
## * scan T : `detect` applies `pending`; `endScan` rotates incoming->pending
## * `go()` : `note*` adds to `incoming`
## * scan T+1 : `detect` applies 0 (event not yet rotated in)
## * scan T+2 : `detect` applies the event's correction — the reading whose
## `raw` shows the gain. Without this the correction lands one
## reading EARLY on a zero delta (a spurious wave) and the real
## delta is left uncorrected.
##
## (The j133 offline corpus model already applied the event to the row that
## carries its energy change; this makes the live path agree with it.)
import std/math
type
FireTracker* = object
## Per-enemy last-known energy plus the event corrections. One instance
## per mover.
prevEnergy*: seq[tuple[id: int, energy: float]]
hitBonusPending*: float ## applied to the CURRENT reading
dealtPending*: float
hitBonusIncoming: float ## noted since the last `endScan`
dealtIncoming: float
splitWaves*: int ## waves emitted by splitting a too-large drop
correctedTicks*: int ## readings whose drop was non-trivially corrected
proc initFireTracker*(): FireTracker =
FireTracker(prevEnergy: @[])
proc reset*(t: var FireTracker) =
## Per-ROUND reset: drops the energy memory and any un-consumed event
## correction.
t.prevEnergy = @[]
t.hitBonusPending = 0.0
t.dealtPending = 0.0
t.hitBonusIncoming = 0.0
t.dealtIncoming = 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`. The caller gates this on its switch.
## Staged for the scan AFTER next (see the header).
t.hitBonusIncoming += 3.0 * power
proc noteDamageDealt*(t: var FireTracker, damage: float) =
## `onBulletHit` -> `e.damage`. The caller gates this on its switch.
t.dealtIncoming += damage
proc detect*(t: var FireTracker, id: int, energy: float,
lo, hi: float, fix: bool): 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 `fix=false` the result
## is exactly the old `if drop >= lo and drop <= hi: @[drop]`. With `fix`
## 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 fix:
drop += t.hitBonusPending - t.dealtPending
if abs(drop - raw) > 1e-9: inc t.correctedTicks
t.prevEnergySet(id, energy)
if fix 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]
import std/[math, os, strutils]
## ── j147: the DETECTION LAG back-date (`TR_FIRE_LAG`, default 0) ─────────────
## MEASURED LIVE (`common_libs/tests/measure_fire_ghost_lag.py`, 1171/1171 ghost
## spawns over two movers x 4 rounds, `TR_FIRE_DIAG=1`): the server dispatches a
## turn's fire AFTER our `go()` for that same turn, so the energy drop of a
## turn-T shot first reaches our scan at turn T+1 (our bot tick T). A bullet
## takes its FIRST step during the turn it is fired, so by then the true bullet
## is already `speed` px (11..20 px, one whole bullet step) downrange and the
## arrival deadline is a full tick shorter than the ghost's. Both movers place
## the ghost at the SCANNED enemy position, i.e. exactly where the bullet was
## born: the whole ghost trajectory is the true one shifted one turn later.
## The per-tick `advanceBullets` then keeps it there for the bullet's whole life.
##
## The compensation is the inverse: at SPAWN, back-date the shot by `lag` ticks
## (`x = origin + dir * speed * lag`, `y = ...`). The arrival deadline needs no
## separate change — every mover derives it from the ghost's own position
## (`heatDecay(along / speed)`, the `dot < 0` reap), so a correct position gives a
## correct deadline.
##
## DEFAULT 0 = the shipped behaviour, byte for byte (`x` is only touched when
## `lag > 0`), so the default-parity guard stays green.
var FireLag*: int = 0
proc loadFireTrackerEnv*() =
## Read the shared fire knobs. Called once at module init; callable again
## after `putEnv` so a guard test can exercise the arms in one process.
let s = getEnv("TR_FIRE_LAG", "").strip()
FireLag = (try: max(0, parseInt(s)) except ValueError: 0)
loadFireTrackerEnv()
proc endScan*(t: var FireTracker) =
## Call once after the per-enemy scan. Rotates the event corrections one
## slot: the events noted since the previous `endScan` become the corrections
## applied by the NEXT scan, and the ones applied by the current scan are
## discarded. See the header for the measured one-turn server lag this
## encodes.
t.hitBonusPending = t.hitBonusIncoming
t.dealtPending = t.dealtIncoming
t.hitBonusIncoming = 0.0
t.dealtIncoming = 0.0