diff --git a/ModularBot_garage/.env.example b/ModularBot_garage/.env.example index d6e8cdf..e07eb1e 100644 --- a/ModularBot_garage/.env.example +++ b/ModularBot_garage/.env.example @@ -142,6 +142,7 @@ TR_STRAFE_ESCAPE=on # the guaranteed wall escape when every candidate is hot TR_STRAFE_FIRE_FIX=on # strafe's share of the shared TR_FIRE_FIX switch TR_FIRE_FIX=on # 0 = the shipped previous-energy bullet detector #TR_FIRE_DIAG=1 # presence-only: per-reading tick/raw/correction trace +#TR_FIRE_LAG=0 # ticks to back-date each detected fire at spawn (0=shipped; 1=the measured live detection lag) TR_STRAFE_HEAT_GRID=on # draw the whole heat grid; 0 leaves only the chosen tile TR_STRAFE_BULLET_CORE=20.0 # strafe's own retune: lava per bullet-overlapping tile TR_STRAFE_BULLET_AURA=10.0 # strafe's own retune: lava for the bullet aura ring diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 6bf6cf6..cc06e23 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -878,6 +878,11 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = bot.roundNumber = e.roundNumber + if FireDiag: + # j147 timeline anchor: the (bot.tick -> server getTurn) offset, once per + # round, so a recorded capture's event sidecar can be aligned to the bot's + # own tick stream without guessing. + echo "[firediag] ROUND round=", getRound(), " getTurn=", getTurn() # Per-round outcome-log state (TR_RESULT_LOG). Reset every round so round 1 # reports and a target/enemy change mid-round cannot leak a stale flag. bot.weDiedThisRound = false diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 4c3145d..63778d9 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -25,6 +25,7 @@ import module_switches import gun_harness/virtual_bullets import gun_harness/selector import movements/ram_decision +import movement_harness/fire_tracker import movements/the_floor_is_lava import movements/the_floor_is_lava_ring import movements/strafe @@ -362,6 +363,9 @@ proc printEffectiveValues(ctx: EnvReportContext) = emit("TR_FIRE_FIX", onOff(TfilFireFix), sourceOf("TR_FIRE_FIX")) # j134 TASK B diagnostic (off by default): per-reading tick/raw/correction trace. emit("TR_FIRE_DIAG", onOff(StrafeFireDiag), sourceOf("TR_FIRE_DIAG")) + # j147: the back-date (ticks) applied to every detected enemy fire at spawn. + # 0 = shipped (the ghost is born at the scanned enemy position). + emit("TR_FIRE_LAG", $FireLag, sourceOf("TR_FIRE_LAG")) emit("TR_STRAFE_HEAT_GRID", onOff(StrafeHeatGrid), sourceOf("TR_STRAFE_HEAT_GRID")) # STRAFE's heat shape is its own RETUNE (bullet 20/10, corridor 10, wall # 15/5), override-able per run so the shipped field can be A/B'd on one @@ -657,6 +661,7 @@ proc knownEnvNames*(): seq[string] = "TR_STRAFE_FIRE_FIX", "TR_FIRE_FIX", "TR_FIRE_DIAG", + "TR_FIRE_LAG", "TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA", "TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS", "TR_STRAFE_WALL_RADIANCE", diff --git a/common_libs/movement_harness/fire_tracker.nim b/common_libs/movement_harness/fire_tracker.nim index c6519a1..c1f2e12 100644 --- a/common_libs/movement_harness/fire_tracker.nim +++ b/common_libs/movement_harness/fire_tracker.nim @@ -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 diff --git a/common_libs/movements/strafe.nim b/common_libs/movements/strafe.nim index f6104a2..ce788b0 100644 --- a/common_libs/movements/strafe.nim +++ b/common_libs/movements/strafe.nim @@ -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) = diff --git a/common_libs/movements/the_floor_is_lava.nim b/common_libs/movements/the_floor_is_lava.nim index afc46a1..a13eb4d 100644 --- a/common_libs/movements/the_floor_is_lava.nim +++ b/common_libs/movements/the_floor_is_lava.nim @@ -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) = diff --git a/common_libs/tests/measure_fire_ghost_lag.py b/common_libs/tests/measure_fire_ghost_lag.py new file mode 100644 index 0000000..591e2eb --- /dev/null +++ b/common_libs/tests/measure_fire_ghost_lag.py @@ -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) diff --git a/common_libs/tests/test_tfil_commit_env.nim b/common_libs/tests/test_tfil_commit_env.nim index 3b09154..6ca3857 100644 --- a/common_libs/tests/test_tfil_commit_env.nim +++ b/common_libs/tests/test_tfil_commit_env.nim @@ -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" diff --git a/docs/env_reference.md b/docs/env_reference.md index eb94144..95bdeef 100644 --- a/docs/env_reference.md +++ b/docs/env_reference.md @@ -268,6 +268,7 @@ name, with no new knob: | `TR_RACK_` = `both`/`1v1`/`melee` | that gun may be selected | `TR_RACK_=off`: the gun is removed from the rack | | `TR_POWER_POLICY` | energy-aware power caps (default) | uncapped: the gun's own preferred power | | `TR_FIRE_FIX` | the corrected enemy-fire detector (default) | the shipped `prev - energy` detector | +| `TR_FIRE_LAG` = `` | back-date every detected enemy fire by N ticks at spawn (0 = shipped; **1 = the measured live detection lag**, j147) | n/a — it is a value knob | | `TR_RADAR_FORCE_SPIN` | force the old stateless full-spin melee radar (**off by default**) | the adaptive arc-narrowing radar (default) | | `TR_TFIL_HEAT_TIME` | time-indexed bullet heat (**off by default**) | flat, time-independent heat (default) | | `TR_VBULLET_DEBUG` | draw the virtual-bullet overlay (**off by default**) | nothing drawn | diff --git a/docs/movement_campaign.md b/docs/movement_campaign.md index fab3903..7127cb8 100644 --- a/docs/movement_campaign.md +++ b/docs/movement_campaign.md @@ -3454,3 +3454,120 @@ middle 30.4% · corr10 32.1% · bullets 65.0% · nofield 3.9%. **The shipped default is untouched.** `TR_MOVEMENT=strafe` remains the default; `TR_TFIL_BULLET_CORE` / `TR_TFIL_BULLET_AURA` default to today's `10.0` / `5.0`, so `TR_MOVEMENT=tfil` still means today's tfil, byte-for-byte (guard check 1). + +# Batch 8 — the fire-detection lag (j147) + +*Pre-registered BEFORE any battle of this batch was launched. No battle of this +batch existed when this section was written; the frozen binary for it is the +commit that adds `TR_FIRE_LAG` and the `TR_FIRE_DIAG` ghost-spawn trace.* + +## The owner's report, and what was measured + +*"i don't know if is the drawing only the arrives 1 tick later in the gui, but +the bullet auras looks like are all 1 tick-ish behind the real bullet!"* + +The first job was to answer **drawing or decision**, not to fix anything. Three +measurements, in order, each one able to stop the next: + +### 1. The corpus says the ENERGY DROP is on the fire's own row (lag 0) + +`/tmp/tfil_ab2/out` (70 battles, `runN.jsonl` + `runN.events.jsonl`): for every +true fire event, the row at which the shooter's energy drop becomes visible is +`fireTick - 1` for **702/702** self fires in round 1 and 100% over the corpus — +i.e. in the recorded frame the drop and the shot are the SAME instant (a bullet +takes its first step during the turn it is fired, MEASURED: 1293/1293 `hitwall` +events have their first out-of-bounds bullet position at step +`hitwallTick - fireTick + 1`, which is only consistent with a first step inside +the firing turn). So the corpus alone cannot see a lag: it has no view of WHEN +our scan runs relative to the dispatch. + +### 2. The corpus is NOT the bot's view, so the lag had to be measured LIVE + +`common_libs/tests/measure_fire_ghost_lag.py`. The bot logs one +`[firediag] SPAWN tick=… sx=… sy=… gx=… gy=… p=… eta=…` line per detected fire +(the ghost's DRAWN position and our own position, the timeline anchor). The +capture supplies the true fire events (origin, direction, power) and the rounds. +The timeline is anchored without guessing: `[firediag] EV hit tick=… getTurn=…` +lines vs. the sidecar's own event turns match exactly, and give +`getTurn = bot.tick + 1` (j134, re-verified) — so a ghost logged at bot tick `t` +was placed during server turn `t + 1`. + +| arm | matched spawns | detection lag | ghost-vs-observer px (mean / median / p90) | arrival-deadline error (ticks, mean / median) | +|---|---:|---|---:|---:| +| tfil, lag 0 | 413 | **+1 tick, 100%** | **19.06 / 19.16 / 22.00** | **0.987 / 0.991** | +| tfil, `TR_FIRE_LAG=1` | 446 | +1 tick, 100% | **5.37 / 5.65 / 8.96** | **0.063 / 0.051** | +| strafe, lag 0 | 497 | +1 tick (77.9%; the rest are duplicate/split waves of a fire already counted) | **16.08 / 18.23 / 21.81** | 0.771 / 0.944 | +| strafe, `TR_FIRE_LAG=1` | 466 | +1 tick, 100% | **6.01 / 5.91 / 9.80** | **0.065 / 0.051** | + +**The answer to the owner: it is NOT only the drawing — the decision is late.** +The aura is displaced by exactly **one whole bullet step (11..20 px, 19.1 px +mean for tfil)**, in the direction of travel, and the arrival deadline the +mover reads is **a full tick late (0.99 ticks)**. The mechanism is measured, not +guessed: the server dispatches a turn's fire **after** our `go()` for that turn, +so the energy drop of a turn-`T` shot first reaches our scan at turn `T+1`; and +because a bullet takes its first step during the turn it is fired, the true +bullet is already one step downrange when we see it. Both movers place the ghost +at the SCANNED enemy position — where the bullet was *born* — and then advance +it once per tick, so the entire ghost trajectory is the true one shifted one +turn later, for the bullet's whole life. + +**It is OURS.** The draw/advance order was checked and is correct (both movers +`advanceBullets()` -> `detectFires()` -> build the field, i.e. a ghost spawned +this tick is drawn at its age-0 position and every older ghost has been advanced +exactly once: build-then-advance, which is the correct direction; an +advance-then-build order would have shown the aura one tick AHEAD). With +`TR_FIRE_LAG=1` the ghosts land on the observer's bullet to within the enemy's +own scan staleness (5.4 px mean, max 8 px = the enemy's top speed), which is the +floor this design can reach: the origin is the enemy's *scanned* position, not +its fire-time position. + +### The treatment + +`TR_FIRE_LAG` (int, **default 0 = today's behaviour byte-for-byte**, `x` is only +touched when `lag > 0`), read once in the shared +`common_libs/movement_harness/fire_tracker.nim` and applied by BOTH movers at +spawn: `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. Guard: `test_tfil_commit_env.nim` 77 -> **87 checks**, all pass +(default parity on the golden replay, exact n-step back-date, deadline shortens +by exactly `lag`, junk/negative degrade to 0, the ghost is reaped exactly one +tick earlier). + +## Arms (frozen, `tools/ab/arms_fire_lag.txt`) + +| # | arm | mover | `TR_FIRE_LAG` | what it isolates | +|---|---|---|---|---| +| 1 | `tfil_off` | tfil | 0 (default) | **the reference** — today's tfil | +| 2 | `tfil_lag1` | tfil | 1 | the back-date, on tfil | +| 3 | `strafe_off` | strafe | 0 (default) | **the reference** — today's strafe | +| 4 | `strafe_lag1` | strafe | 1 | the back-date, on strafe | + +Panel: the FROZEN 15-opponent `tools/ab/panel_movement.txt`. Harness: +`tools/ab/tournament_run.sh` + `tournament_analyze.py`. + +## Pre-registered prediction, MDE and decision rule + +* **MDE, stated up front.** The verdict layer is the paired per-opponent + difference over 15 opponents, exactly as batches 4-7. Batch 7 (5 runs/arm) + measured **MDE = 12.8 damage/run and 0.28 wins/run**; this batch runs **3 + runs/arm**, so by `sqrt(5/3)` the MDE degrades to roughly **16 damage/run and + 0.36 wins/run** — and the incoming-hit-rate MDE to roughly **1.9 points**. + **Any true effect smaller than that is invisible here by construction, and a + null will be recorded as "not distinguishable", never as "no effect".** +* **Prediction.** `tfil_lag1` > `tfil_off` and `strafe_lag1` > `strafe_off` on + damage/run and round wins, because the field the mover decides on is displaced + by a whole bullet step today and stops being after the fix. The **mechanism is + the incoming hit rate** (the dodge should survive strictly more), and the + offline gate already measured the mechanism geometrically (19.1 -> 5.4 px, + 0.99 -> 0.06 ticks), so a mechanism-positive / outcome-null result is the + EXPECTED shape given the MDE, and is recorded as such — the same verdict + pattern as j144, j145 and j146. +* **Verdict rule (unchanged, not re-interpreted afterwards).** The cross-opponent + sign test p < 0.05 on one primary metric (damage/run or round wins) with the + other not down, SD/SE/95% CI/MDE reported. +* **Nothing separates -> nothing changes.** `TR_FIRE_LAG` stays default 0 and + the shipped movers are untouched. A mechanism-positive outcome-null does NOT + retract the geometric measurement, and does NOT change `TR_MOVEMENT=strafe`. + +*(results appended below after the battles)* diff --git a/tools/ab/arms_fire_lag.txt b/tools/ab/arms_fire_lag.txt new file mode 100644 index 0000000..9c62a02 --- /dev/null +++ b/tools/ab/arms_fire_lag.txt @@ -0,0 +1,38 @@ +# ───────────────────────────────────────────────────────────────────────────── +# arms_fire_lag.txt — j147: the ONE-TICK DETECTION LAG, four arms on the frozen +# movement panel (tools/ab/panel_movement.txt). Two movers (tfil, strafe) x +# {lag 0, lag 1}; the lag knob is the ONLY difference inside a mover pair. +# +# WHY. MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions, +# 1777 matched ghost spawns): 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 short. +# 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 measured ghost-vs-observer displacement is 19.1 px mean / +# 22.0 p90 (tfil) and 16.1 / 21.8 (strafe), deadline error 0.99 / 0.77 ticks. +# With TR_FIRE_LAG=1 the displacement falls to 5.4 / 9.0 px (the residue is the +# ENEMY's own scan staleness, <= 8 px) and the deadline error to 0.06 ticks. +# +# `TR_FIRE_LAG` default 0 = today's behaviour byte for byte, so arm 1 and arm 3 +# ARE the shipped movers. +# +# Pre-registered in docs/movement_campaign.md ("Batch 8 — the fire-detection +# lag") BEFORE any of these battles ran. References: `tfil_off`, `strafe_off`. +# +# Format: name | ENV=value ENV=value | label +# ───────────────────────────────────────────────────────────────────────────── + +# 1. tfil as shipped (the reference for arm 2). +tfil_off | TR_MOVEMENT=tfil | today's tfil, the ghost is born at the scanned enemy (reference) + +# 2. tfil with the measured lag back-dated at spawn. +tfil_lag1 | TR_MOVEMENT=tfil TR_FIRE_LAG=1 | tfil, every detected fire back-dated one bullet step + +# 3. strafe as shipped (the reference for arm 4). +strafe_off | TR_MOVEMENT=strafe | today's strafe, the ghost is born at the scanned enemy (reference) + +# 4. strafe with the measured lag back-dated at spawn. +strafe_lag1 | TR_MOVEMENT=strafe TR_FIRE_LAG=1 | strafe, every detected fire back-dated one bullet step