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
+1
View File
@@ -268,6 +268,7 @@ name, with no new knob:
| `TR_RACK_<GUN>` = `both`/`1v1`/`melee` | that gun may be selected | `TR_RACK_<GUN>=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` = `<int>` | 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 |
+117
View File
@@ -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)*