fix(guns): speed-sensitive caches, dead stop-shot branch, exact TM trace pairing

Four guns cached a whole prediction per tick while predict() is called once
per power bin, so every bin after the first (and the real fired shot, which
shares lastState) reused the power-1.0 lead. Fixed by caching only the
speed-INDEPENDENT derived state and recomputing the lead per requested speed:
- stop_shot: also fixes prevSpeed being written before it was read, which
  made abs(speed) < abs(prev) permanently false and the entire
  stop-prediction branch unreachable (it was just Linear).
- displacement: the cache key included bulletSpeed, so the guard missed on
  all four bins and the 15-tick window advanced ~4x/tick, making the
  inferred velocity ~4x too small.
- averaged_lead: tick cache removed outright. pattern_matcher: split into
  speed-independent match+path and per-call lead.

FeedbackEvent gains fireTick/powerBin (additive; only virtual_bullets
constructs one) so guns can pair feedback to the exact shot instead of
guessing by coordinates. tsetlin uses it: traces are now keyed exactly by
(fireTick, powerBin) with a 1024-slot ring, and the 10-frame window shifts
at most once per tick (it was shifting ~4-5x/tick, so isWarmedUp tripped
after ~2 ticks).

KNOWN INCOMPLETE: tsetlin still does not diverge from Linear in battle. The
two named bugs are fixed (a 600-tick sim shows trainedShots=2141,
traceMisses=0, and a fixed-input probe converges to a 9.6px correction), but
the TM's clause feedback itself is broken: ~131 of 1740 literals end up
included per clause, so its conjunction never fires. Sweeping TM_S,
TM_N_CLAUSES and a two-branch Type-I update did not change the correction
from 0. Needs a real TM fix or removal, not another bug fix.

First-ever guard tests for the gun selector: common_libs/tests/
test_gun_harness.nim (14 checks, headless, no Java). There were none before,
which is how six broken guns survived a full analysis cycle. Against the
previous HEAD, 5 of these checks FAIL - that is the regression guard.
This commit is contained in:
2026-09-20 22:47:26 +02:00
parent 0cc682152d
commit e53690036b
9 changed files with 480 additions and 171 deletions
+61 -44
View File
@@ -23,11 +23,19 @@ type
prevSpeed: float
prevTick: int
hasPrev: bool
# per-tick cache — avoid re-searching for multiple power bins
cacheTick: int
cacheX: float
cacheY: float
# Per-tick, speed-INDEPENDENT pattern state. Both the history search and the
# replayed enemy path depend only on observed movement, never on bulletSpeed,
# so they are built at most once per tick. The bullet lead (number of replay
# steps + coast) is derived from this path on every call.
cacheValid: bool
cacheTick: int
bestMatch: int ## -1 = no usable match (linear fallback)
playStart: int
playAvail: int
pathX: array[HistorySize + 1, float]
pathY: array[HistorySize + 1, float]
pathHeading: array[HistorySize + 1, float] ## radians after s steps
pathSpeed: array[HistorySize + 1, float] ## speed after s steps
debugGraphics*: bool
# --- circular buffer helpers ---
@@ -55,11 +63,11 @@ proc linearPredict(state: WorldState, bulletSpeed: float): (float, float) =
# --- pattern search + play-forward ---
proc searchAndProject(g: PatternMatcherGun, state: WorldState,
bulletSpeed: float): (float, float) =
## Returns projected (x, y). Falls back to linear if history too short.
proc findBestMatch(g: PatternMatcherGun): int =
## Speed-independent history search. Returns the start index of the best
## matching pattern, or -1 when there is not enough history.
if g.count < PatternLen * 2:
return linearPredict(state, bulletSpeed)
return -1
# key = last PatternLen entries
let keyStart = g.count - PatternLen
@@ -79,14 +87,28 @@ proc searchAndProject(g: PatternMatcherGun, state: WorldState,
if score < bestScore:
bestScore = score
bestMatch = i
bestMatch
if bestMatch < 0:
return linearPredict(state, bulletSpeed)
# play forward from bestMatch + PatternLen
let playStart = bestMatch + PatternLen
let playAvail = g.count - 1 - playStart # ticks we can replay
proc buildPath(g: var PatternMatcherGun, state: WorldState, bestMatch: int) =
## Precompute the matched pattern replayed forward from the current state.
## Only depends on observed movement, so it is valid for every power bin.
g.playStart = bestMatch + PatternLen
g.playAvail = g.count - 1 - g.playStart # ticks we can replay
g.pathX[0] = state.enemyX
g.pathY[0] = state.enemyY
g.pathHeading[0] = degToRad(state.enemyHeading)
g.pathSpeed[0] = state.enemySpeed
for s in 1 .. g.playAvail:
let m = g.readAt(g.playStart + s - 1)
g.pathHeading[s] = g.pathHeading[s - 1] + m.headingDelta
g.pathSpeed[s] = m.velocity
g.pathX[s] = g.pathX[s - 1] + cos(g.pathHeading[s]) * g.pathSpeed[s]
g.pathY[s] = g.pathY[s - 1] + sin(g.pathHeading[s]) * g.pathSpeed[s]
proc projectFromPath(g: PatternMatcherGun, state: WorldState,
bulletSpeed: float): (float, float) =
## Speed-dependent lead: walk the cached path as far as this bulletSpeed's
## estimated flight time reaches, then coast linearly for the remainder.
# iterative time estimate
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
var t = dist0 / bulletSpeed
@@ -94,22 +116,14 @@ proc searchAndProject(g: PatternMatcherGun, state: WorldState,
var ey = state.enemyY
for _ in 0..4:
let steps = min(int(t + 0.5), playAvail)
ex = state.enemyX
ey = state.enemyY
var heading = degToRad(state.enemyHeading)
var speed = state.enemySpeed
for s in 0 ..< steps:
let m = g.readAt(playStart + s)
heading += m.headingDelta
speed = m.velocity
ex += cos(heading) * speed
ey += sin(heading) * speed
let steps = min(int(t + 0.5), g.playAvail)
ex = g.pathX[steps]
ey = g.pathY[steps]
# if we ran out of replay data, coast linearly from last simulated pos
let remaining = t - steps.float
if remaining > 0.0:
ex += cos(heading) * speed * remaining
ey += sin(heading) * speed * remaining
ex += cos(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
ey += sin(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
let ndx = ex - state.selfX
let ndy = ey - state.selfY
t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed
@@ -125,30 +139,33 @@ proc predict*(g: var PatternMatcherGun, state: WorldState,
if bulletSpeed <= 0.0:
return GunPrediction(x: state.enemyX, y: state.enemyY)
# Update history once per tick
if g.hasPrev and state.tick > g.prevTick:
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
# wrap to [-π, π]
while dh > PI: dh -= 2.0 * PI
while dh < -PI: dh += 2.0 * PI
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
# Roll history forward and (re)build the speed-independent pattern path at
# most once per tick. The old code returned a single cached (x, y) per tick,
# so all four power bins shared bin 0's lead.
if not g.cacheValid or state.tick != g.cacheTick:
if g.hasPrev:
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
# wrap to [-π, π]
while dh > PI: dh -= 2.0 * PI
while dh < -PI: dh += 2.0 * PI
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
if not g.hasPrev or state.tick > g.prevTick:
g.prevHeading = state.enemyHeading
g.prevSpeed = state.enemySpeed
g.prevTick = state.tick
g.hasPrev = true
g.cacheValid = false # new tick invalidates cache
g.cacheValid = true
g.cacheTick = state.tick
# Return cached result for same-tick calls (multiple power bins)
if g.cacheValid and state.tick == g.cacheTick:
return GunPrediction(x: g.cacheX, y: g.cacheY)
g.bestMatch = g.findBestMatch()
if g.bestMatch >= 0:
g.buildPath(state, g.bestMatch)
let (px, py) = g.searchAndProject(state, bulletSpeed)
g.cacheX = px
g.cacheY = py
g.cacheTick = state.tick
g.cacheValid = true
if g.bestMatch < 0:
let (px, py) = linearPredict(state, bulletSpeed)
return GunPrediction(x: px, y: py)
let (px, py) = g.projectFromPath(state, bulletSpeed)
GunPrediction(x: px, y: py)
proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) =