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