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).
This commit is contained in:
2026-09-26 23:08:57 +02:00
parent de5d02ba3f
commit d21f7ce5f5
11 changed files with 512 additions and 2 deletions
@@ -134,6 +134,38 @@ proc detect*(t: var FireTracker, id: int, energy: float,
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
+18 -1
View File
@@ -577,9 +577,16 @@ proc spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo,
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0)
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
var gx = ei.x
var gy = ei.y
if FireLag > 0:
gx += (speed * cos(heading)) * FireLag.float
gy += (speed * sin(heading)) * FireLag.float
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
x: gx, y: gy,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: power, alive: true, age: 0)
@@ -616,6 +623,16 @@ proc detectFires(m: var StrafeModule, ws: WorldState) =
" dealt=", m.fire.dealtPending
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, StrafeFireFix):
m.spawnTrackedWave(ws, ei, p)
if StrafeFireDiag and m.bullets.len > 0:
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
# position (the timeline anchor) and the ghost's DRAWN position.
let b = m.bullets[^1]
let sp = 20.0 - 3.0 * p
echo "[firediag] SPAWN tick=", ws.tick,
" sx=", ws.selfX, " sy=", ws.selfY,
" gx=", b.x, " gy=", b.y,
" p=", p,
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
m.fire.endScan()
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
+23 -1
View File
@@ -152,6 +152,10 @@ var
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
TfilFireFix*: bool = true
## j134/j147: the env-gated live trace (`TR_FIRE_DIAG`) — one `SPAWN` line per
## detected enemy fire, for the ghost-vs-observer position probe. Observability
## only; off by default.
TfilFireDiag*: bool = false
proc getEnvInt(name: string, default: int): int =
let s = getEnv(name, "")
@@ -186,6 +190,7 @@ proc loadTfilCommitEnv*() =
TfilTurnBias = max(0.0, getEnvFloat("TR_TFIL_TURN_BIAS", 0.0))
TfilTurnRefDeg = max(0.0, getEnvFloat("TR_TFIL_TURN_REF_DEG", 45.0))
TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
TfilFireDiag = existsEnv("TR_FIRE_DIAG")
loadTfilCommitEnv()
@@ -426,9 +431,16 @@ proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
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
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
var gx = ei.x
var gy = ei.y
if FireLag > 0:
gx += (speed * cos(heading)) * FireLag.float
gy += (speed * sin(heading)) * FireLag.float
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
x: gx, y: gy,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: power,
@@ -451,6 +463,16 @@ proc detectFires(m: var TFILModule, ws: WorldState) =
for ei in ws.enemies:
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilFireFix):
m.spawnTrackedWave(ws, ei, p)
if TfilFireDiag and m.bullets.len > 0:
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
# position (the timeline anchor) and the ghost's DRAWN position.
let b = m.bullets[^1]
let sp = 20.0 - 3.0 * p
echo "[firediag] SPAWN tick=", ws.tick,
" sx=", ws.selfX, " sy=", ws.selfY,
" gx=", b.x, " gy=", b.y,
" p=", p,
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
m.fire.endScan()
proc advanceBullets(m: var TFILModule, selfX, selfY: float) =
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Ghost-vs-observer probe (j147) — is the 1-tick aura lag a DECISION lag?
For every enemy fire in the recorded event sidecar we locate the ghost our
mover spawned for it (the `[firediag] SPAWN` line) and compare
* WHEN the ghost was spawned (its tick) vs WHEN the enemy fired, and
* WHERE the ghost was drawn vs WHERE the true bullet is at that instant.
Timeline anchoring is done in ABSOLUTE arena coordinates: the bot logs its own
position at the spawn tick, and the capture row carrying that position pins
`captureRow = botTick + k` (a per-round constant = the server's delivery
offset). With k known,
trueBullet(row) = fireOrigin + (row - fireRow) * v (v = 20 - 3*power)
ghost(row) = spawnGhostPos + (row - spawnRow) * v
so both the time lag and the pixel displacement are directly measurable.
"""
import json, math, re, sys, collections
SPAWN_RE = re.compile(
r"SPAWN tick=(\d+) sx=([-\d.eE+]+) sy=([-\d.eE+]+) gx=([-\d.eE+]+) "
r"gy=([-\d.eE+]+) p=([-\d.eE+]+) eta=([-\d.eE+]+)")
def load(arm_dir):
rows = [json.loads(l) for l in open(f"{arm_dir}/run1.jsonl") if '"tick"' in l]
ev = [json.loads(l) for l in open(f"{arm_dir}/run1.events.jsonl") if l.strip()]
rounds = json.load(open(f"{arm_dir}/run1.jsonl.rounds.json"))["rounds"]
spawns = []
for line in open(f"{arm_dir}/run1.bot.stdout.log"):
m = SPAWN_RE.search(line)
if m:
g = [float(x) for x in m.groups()]
spawns.append(dict(tick=int(g[0]), sx=g[1], sy=g[2], gx=g[3], gy=g[4],
p=g[5], eta=g[6]))
return rows, ev, rounds, spawns
def split_rounds(spawns):
"""The bot's tick restarts every round -> segment on a tick decrease."""
out, cur = [], []
for s in spawns:
if cur and s["tick"] <= cur[-1]["tick"]:
out.append(cur); cur = []
cur.append(s)
if cur:
out.append(cur)
return out
def analyse(arm_dir, verbose=True):
rows, ev, rounds, spawns = load(arm_dir)
pos = {r["tick"]: r for r in rows}
starts = {r["round"]: r["startTick"] for r in rounds}
counts = {r["round"]: r["count"] for r in rounds}
# who is the enemy, and which capture row does a fire turn correspond to
votes = collections.Counter()
for e in ev:
if e["type"] != "fire":
continue
gt = starts[e["round"]] + e["tick"]
for off in (-3, -2, -1, 0, 1):
d = pos.get(gt + off)
if not d:
continue
de = math.hypot(d["ex"] - e["x"], d["ey"] - e["y"])
ds = math.hypot(d["sx"] - e["x"], d["sy"] - e["y"])
votes[(e["owner"], "e" if de < ds else "s")] += 1
break
enemy_owner = max(votes.items(), key=lambda kv: kv[1])[0][0]
fires = [e for e in ev if e["type"] == "fire" and e["owner"] == enemy_owner]
by_round = collections.defaultdict(list)
for e in fires:
by_round[e["round"]].append(e)
recs, unmatched = [], 0
chunks = split_rounds(spawns)
# which capture row holds a given position (the bot's own, for the anchor)
where = collections.defaultdict(list)
for r in rows:
where[(round(r["sx"], 3), round(r["sy"], 3))].append(r["tick"])
for chunk in chunks:
for s in chunk:
# TIMELINE, no guessing. MEASURED LIVE (`[firediag] EV hit` lines vs
# the capture's event sidecar, exact matches): the sidecar's per-round
# `tick` IS the server getTurn and the bot runs with
# `getTurn = bot.tick + 1` (j134), so a ghost logged at bot tick `t`
# was placed during server turn `t + 1` of its round. The round is
# found from the bot's OWN logged position (unique per round).
hits = where.get((round(s["sx"], 3), round(s["sy"], 3)), ())
rnd = None
for hrow in hits:
for r_ in rounds:
if r_["startTick"] <= hrow < r_["startTick"] + r_["count"]:
rnd = r_["round"]
break
if rnd is not None:
break
if rnd is None:
unmatched += 1
continue
sturn = s["tick"] + 1
cands = []
for e in by_round[rnd]:
if abs(e["tick"] - sturn) > 2 or abs(e["power"] - s["p"]) > 1e-6:
continue
oerr = math.hypot(s["gx"] - e["x"], s["gy"] - e["y"])
if oerr < 32.0: # the scanned enemy IS the shooter
cands.append((abs(e["tick"] - sturn), oerr, e, e["tick"]))
if not cands:
unmatched += 1
continue
cands.sort()
_, oerr, e, eturn = cands[0]
sp = 20.0 - 3.0 * e["power"]
th = math.radians(e["dir"])
vx, vy = sp * math.cos(th), sp * math.sin(th)
d = sturn - eturn # +ve = ghost spawned LATE
# a bullet takes its FIRST step during the turn it is fired, so at
# the start of server turn `eturn + n` the true bullet sits at
# origin + n * v.
tx, ty = e["x"] + vx * d, e["y"] + vy * d
# arrival deadline: the mover's own eta (logged) vs the true remaining
# flight time to the TRUE bullet from the same reference point.
etaTrue = math.hypot(s["sx"] - tx, s["sy"] - ty) / sp
recs.append(dict(round=rnd, tick=s["tick"], lag=d, power=e["power"],
speed=sp, origin_err=oerr, eta=s["eta"],
err=math.hypot(s["gx"] - tx, s["gy"] - ty),
lagerr=abs(s["eta"] - etaTrue)))
if verbose:
print(f"\n=== {arm_dir}")
print(f"enemy owner id={enemy_owner} spawns={len(spawns)} matched={len(recs)}"
f" unmatched={unmatched}")
if not recs:
return None, []
print("DETECTION LAG (capture row of the spawn - capture row of the fire),"
" +ve = detected LATE:")
for lag, n in sorted(collections.Counter(r["lag"] for r in recs).items()):
print(f" lag={lag:+d} ticks : {n:4d} ({100.0*n/len(recs):5.1f}%)")
errs = sorted(r["err"] for r in recs)
n = len(errs)
print(f"GHOST-vs-TRUTH displacement px: mean={sum(errs)/n:.2f} median={errs[n//2]:.2f}"
f" p90={errs[int(0.9*n)]:.2f} max={errs[-1]:.2f}")
oe = sorted(r["origin_err"] for r in recs)
print(f" of which ghost ORIGIN vs fire origin (the scanned enemy position):"
f" mean={sum(oe)/n:.2f} median={oe[n//2]:.2f} max={oe[-1]:.2f}")
# the pure time part: lag * speed
pure = sorted(abs(r["lag"]) * r["speed"] for r in recs)
print(f"TIME part only (|lag| * speed): mean={sum(pure)/n:.2f} "
f"median={pure[n//2]:.2f} max={pure[-1]:.2f}")
le = sorted(r["lagerr"] for r in recs)
print(f"ARRIVAL-DEADLINE error (mover's eta - the true remaining flight),"
f" ticks: mean={sum(le)/n:.3f} median={le[n//2]:.3f}"
f" p90={le[int(0.9*n)]:.3f} max={le[-1]:.3f}")
return None, recs
if __name__ == "__main__":
for d in sys.argv[1:]:
analyse(d)
+108
View File
@@ -848,6 +848,113 @@ when declared(loadTfilCommitEnv):
" mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2),
" >90deg=", pct(s.bigTurn, s.picks)
## j147: TR_FIRE_LAG back-dates the ghost by the MEASURED detection lag (1
## tick live: 1777/1777 matched spawns, `measure_fire_ghost_lag.py`). Default
## 0 must be byte-for-byte today's spawn, and lag=n must move the ghost exactly
## n bullet steps downrange — which is what shortens the arrival deadline,
## because every mover derives the deadline from the ghost's own position.
proc testJ147() =
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
enemySpeed: 0.0, enemyEnergy: 100.0,
selfX: 400.0, selfY: 320.0, selfHeading: 0.0,
selfSpeed: 0.0, selfEnergy: 100.0,
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 1,
enemies: @[])
let ei = EnemyInfo(id: 1, x: 200.0, y: 320.0, heading: 0.0,
speed: 0.0, energy: 100.0)
const Power = 1.0
let speed = 20.0 - 3.0 * Power # 17 px/tick
proc spawnGhost(): TrackedBullet =
var m = initTFIL()
discard m.computeMove(ws) # initGrid
m.spawnTrackedWave(ws, ei, Power)
m.bullets[^1]
# 1. default parity: unset -> 0, and the ghost is EXACTLY the scanned origin
delEnv("TR_FIRE_LAG")
loadFireTrackerEnv()
check "j147: TR_FIRE_LAG unset -> FireLag 0", FireLag == 0
let g0 = spawnGhost()
check "j147: default (lag 0) puts the ghost exactly on the scanned enemy",
g0.x == ei.x and g0.y == ei.y
# 2. lag 1 back-dates by EXACTLY one bullet step, on the ghost's own heading
putEnv("TR_FIRE_LAG", "1")
loadFireTrackerEnv()
let g1 = spawnGhost()
check "j147: TR_FIRE_LAG=1 places the ghost one bullet step downrange",
FireLag == 1 and
abs((g1.x - g0.x) - g1.velX) < 1e-9 and
abs((g1.y - g0.y) - g1.velY) < 1e-9
check "j147: ... and that step is the true bullet speed, not a scaled one",
abs(sqrt(g1.velX * g1.velX + g1.velY * g1.velY) - speed) < 1e-9
# 3. the ARRIVAL DEADLINE shortens by exactly `lag` ticks. The mover's own
# arrival proxy is dist(self, ghost) / speed (tfil `heatDecay(along/speed)`,
# the `dot < 0` reap); the true bullet is one step further along.
let etaGhost = sqrt((ws.selfX - g1.x)^2 + (ws.selfY - g1.y)^2) /
sqrt(g1.velX * g1.velX + g1.velY * g1.velY)
let etaTrue0 = sqrt((ws.selfX - g0.x)^2 + (ws.selfY - g0.y)^2) / speed
let etaTrue1 = sqrt((ws.selfX - (g0.x + g0.velX))^2 +
(ws.selfY - (g0.y + g0.velY))^2) / speed
check "j147: with lag=1 the mover's deadline equals the TRUE remaining " &
"flight (" & etaGhost.formatFloat(ffDecimal, 6) & " vs " &
etaTrue1.formatFloat(ffDecimal, 6) & "), the lag-0 deadline being " &
etaTrue0.formatFloat(ffDecimal, 6) & " — a full tick late",
abs(etaGhost - etaTrue1) < 1e-9 and
abs((etaTrue0 - etaTrue1) - 1.0) < 1e-9
# 4. lag n is n steps, and n=2 shortens the deadline by exactly 2
putEnv("TR_FIRE_LAG", "2")
loadFireTrackerEnv()
let g2 = spawnGhost()
check "j147: TR_FIRE_LAG=2 back-dates by two steps",
abs((g2.x - g0.x) - 2.0 * g0.velX) < 1e-9 and
abs((g2.y - g0.y) - 2.0 * g0.velY) < 1e-9
let etaTrue2 = sqrt((ws.selfX - (g0.x + 2.0 * g0.velX))^2 +
(ws.selfY - (g0.y + 2.0 * g0.velY))^2) / speed
check "j147: ... so the deadline shortens by exactly 2 ticks",
abs((etaTrue0 - etaTrue2) - 2.0) < 1e-9
# 5. a junk value falls back to 0, never to a negative/garbage back-date
putEnv("TR_FIRE_LAG", "junk")
loadFireTrackerEnv()
let gj = spawnGhost()
putEnv("TR_FIRE_LAG", "-4")
loadFireTrackerEnv()
let gn = spawnGhost()
check "j147: a junk / negative TR_FIRE_LAG degrades to the shipped lag 0",
FireLag == 0 and gj.x == ei.x and gn.x == ei.x
# 6. the ARRIVAL DEADLINE end-to-end: the ghost is reaped (`dot < 0`, the
# geometric arrival) exactly `lag` ticks earlier, because it is `lag`
# steps further along. This is the deadline the decision actually uses.
proc ticksToReap(): int =
var m = initTFIL()
randomize(Seed)
discard m.computeMove(ws)
m.spawnTrackedWave(ws, ei, Power)
for t in 1..80:
discard m.computeMove(ws)
if m.bullets.len == 0: return t
99
putEnv("TR_FIRE_LAG", "0")
loadFireTrackerEnv()
let reap0 = ticksToReap()
putEnv("TR_FIRE_LAG", "1")
loadFireTrackerEnv()
let reap1 = ticksToReap()
check "j147: the ghost arrives — and is reaped — exactly 1 tick earlier " &
"with the back-date (" & $reap0 & " -> " & $reap1 & " ticks)",
reap0 > 0 and reap1 > 0 and reap0 - reap1 == 1
# 7. restore the shipped default for every later check in this process
delEnv("TR_FIRE_LAG")
loadFireTrackerEnv()
check "j147: clearing the knob restores the shipped spawn exactly",
FireLag == 0 and spawnGhost().x == ei.x
# ── driver ───────────────────────────────────────────────────────────────────
testDefaultParity()
@@ -857,6 +964,7 @@ when declared(loadTfilCommitEnv):
testJ144()
testJ145()
testJ146()
testJ147()
if failures > 0:
echo "\n", failures, " check(s) FAILED"