j177: aim capture - log what the model BELIEVED, not what it did
j176 could not attribute the 11.9 deg aim error at 450+ px: the corpus had
no gun id and no bot-side belief, so staleness was an inverse (unidentifiable)
problem and a good gun was indistinguishable from a bad one. Both are cheap to
log and impossible to recover later.
New default-off knob TR_CAPTURE_AIM (presence-only). It appends TWO record
kinds to the EXISTING TR_RECORD_WORLDSTATE file:
aim_scan - one per onScannedBot, written BEFORE the tracker update, so it is
the pre-update belief by construction: tick, raw scanned values
(ex,ey,eh,es,ee), our own state (sx,sy,sh,ss), the gun in force, the
PREVIOUS belief (bx,by,bh,bs,blst), the scan parity age = tick - blst,
and the radar-lock context (rlock, rdir, lbear, boff).
aim_fire - one per real shot: gun, power, the aim angle handed to setFire,
the turret angle and the signed turret error, gunHeat, the predicted
intercept (ax,ay) and implied TOF, and the exact WorldState the predictor
consumed (ex,ey,eh,es,ee,sx,sy) with the tick it came from (lst).
Row builders live in a new pure module src/aim_capture.nim - no bot API, no
env reads - so the offline guard test and the live bot go through the SAME
builders and a field the test proves present is a field the bot writes.
Per-tick world-state rows also gain a `gun` id. offline_range.nim skips
aim_* lines (they carry no `ex`), so the annotations are inert to the replay.
No aim model changed.
Knob registered in env_report.nim (context field, effective-value emit) and
knownEnvNames(); documented in .env.example. Defaults OFF, diagnostic only,
never live-tested.
Verification (no battle, no Java, no server, no GUI):
- default parity: per-gun shots/hits over 20026 ticks of
tr_drussgt_vs_modularbot.jsonl byte-for-byte identical to the golden
generated from the PRE-CHANGE tree (git archive 55e92bc); the golden was
regenerated from that pre-change tree and re-diffed, so it is not
self-referential. Boot [env] block of the pre- and post-change binaries is
identical except pid/cmdline/build line and the new knob's own line.
- test_aim_capture: ALL PASS (every aim_scan/aim_fire key present, plus the
annotation-inertness replay).
- guards: test_tfil_commit_env 159/0, test_env_report 25/0,
test_tfil_ring_weights 24/0, test_vbullet_draw 30/0.
- .env.example round-trip (env_report via the j172 harness): 210 effective
values + 70 [x]/[modules] lines, 0 diffs, 0 dropped keys, 0 warnings.
- clean `git archive HEAD` + nim c -d:release: [SuccessX].
This commit is contained in:
@@ -153,6 +153,7 @@
|
||||
# TR_SURF_LOG one line per wave-surfing decision
|
||||
# TR_FIRE_DIAG per-reading fire-detection tick/raw/correction
|
||||
# TR_RECORD_WORLDSTATE dump every observed world state to JSONL
|
||||
# TR_CAPTURE_AIM aim_scan/aim_fire records (gun id + bot belief)
|
||||
# TR_RADAR_SCANLOG log every radar scan tick
|
||||
# TR_RADAR_FORCE_SPIN force the old full-360 spin radar
|
||||
# TR_TRACKER_PROBE dump the enemy-tracker internals
|
||||
@@ -737,6 +738,24 @@ GUN_STATS_PATH=/tmp/gun_stats.jsonl # where the per-round gun stats are writte
|
||||
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl # where the per-shot log is written
|
||||
|
||||
# ── measurement helpers (leave off unless you are measuring) ─────────────────
|
||||
# WHAT: aim capture. Adds TWO record kinds to the TR_RECORD_WORLDSTATE file:
|
||||
# aim_scan — one per radar scan: the raw reading, our own state, the gun
|
||||
# that fired, the PREVIOUS tracker belief, and the scan parity
|
||||
# (age = tick - previous lastSeenTick);
|
||||
# aim_fire — one per real shot: gun, power, the aim angle, the turret error,
|
||||
# gun heat, the predicted intercept/TOF, and the exact WorldState the
|
||||
# predictor consumed (with the tick it came from). It also adds the `gun`
|
||||
# id to the per-tick world-state rows.
|
||||
# VALUES: presence-only, like the other keys in this block. Unset = off.
|
||||
# STATUS: default-off, diagnostic only, never live-tested. j176 could not
|
||||
# attribute the 11.9 deg aim error because the corpus had no gun id and no
|
||||
# bot-side belief; this knob makes both a lookup instead of an inverse
|
||||
# problem. No aim model changed with it.
|
||||
# GOTCHA: it only writes when TR_RECORD_WORLDSTATE is on as well, and it makes
|
||||
# the capture file bigger, not different: the extra lines are annotations and
|
||||
# the offline replay skips them.
|
||||
# TRY: TR_RECORD_WORLDSTATE=1 TR_CAPTURE_AIM=1 ./out/ModularBot
|
||||
#TR_CAPTURE_AIM=1 # PRESENCE-only: aim_scan / aim_fire records (needs TR_RECORD_WORLDSTATE)
|
||||
#TR_RECORD_WORLDSTATE=1 # PRESENCE-only: dump every observed world state
|
||||
#TR_RADAR_SCANLOG=1 # PRESENCE-only: log every radar scan tick
|
||||
#TR_TRACKER_PROBE=1 # PRESENCE-only: dump the enemy-tracker's internal state
|
||||
|
||||
@@ -44,6 +44,7 @@ import movement_harness/bullet_shadows
|
||||
import targeting/enemy_tracker
|
||||
import targeting/target_selector
|
||||
import env_report
|
||||
import aim_capture
|
||||
import vbullet_draw
|
||||
import geo_overlay
|
||||
|
||||
@@ -69,6 +70,13 @@ const ShotLog = true
|
||||
## can enable recording for just the battle it spawns by exporting the env var.
|
||||
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
|
||||
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
|
||||
## j177 aim capture: with TR_CAPTURE_AIM set, append two extra record kinds to
|
||||
## the SAME world-state file — `aim_scan` (one per radar scan: the raw reading,
|
||||
## our own state, the GUN, the previous belief and the scan parity) and
|
||||
## `aim_fire` (one per firing decision: the gun, the power, the aim angle, the
|
||||
## turret error, and the exact WorldState the predictor consumed). Off by
|
||||
## default, so a normal run writes byte-for-byte what it wrote before.
|
||||
let CaptureAim* = existsEnv("TR_CAPTURE_AIM")
|
||||
## Radar measurement switches (all RUNTIME, read once at process start):
|
||||
## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full
|
||||
## spin (always 45 deg/tick). This reproduces the
|
||||
@@ -477,6 +485,9 @@ proc recordWorldState(bot: ModularBot, ws: WorldState) =
|
||||
"eid": tid,
|
||||
}
|
||||
if lst >= 0: row["lst"] = %lst
|
||||
# j177: the gun in force when this state was built. Absent before j177,
|
||||
# which made a per-gun decomposition of the aim error impossible.
|
||||
if CaptureAim: row["gun"] = %bot.currentGun
|
||||
try:
|
||||
let f = open(WorldStateRecordPath, fmAppend)
|
||||
f.writeLine($row)
|
||||
@@ -616,6 +627,23 @@ proc recordRadarStats(bot: ModularBot) =
|
||||
inc bot.arcWidthHist[min(11, int(width / 30.0))]
|
||||
|
||||
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
|
||||
# j177 aim capture: the belief we are about to REPLACE, and the fire site's
|
||||
# state, recorded BEFORE the update. Written first so the record is the
|
||||
# pre-update belief by construction, not by argument.
|
||||
if CaptureAim:
|
||||
var rec = AimScan(tick: bot.tick, eid: e.scannedBotId,
|
||||
ex: e.x, ey: e.y, eh: e.direction, es: e.speed, ee: e.energy,
|
||||
sx: getX(), sy: getY(), sh: getDirection(), ss: getSpeed(),
|
||||
gun: bot.currentGun,
|
||||
rlock: bot.radarMode == 0, rdir: getRadarDirection(),
|
||||
bx: 0.0, by: 0.0, blst: -1)
|
||||
if bot.enemyTracker.enemies.contains(e.scannedBotId):
|
||||
let prev = bot.enemyTracker.enemies[e.scannedBotId]
|
||||
rec.bx = prev.x; rec.by = prev.y; rec.bh = prev.heading
|
||||
rec.bs = prev.speed; rec.blst = prev.lastSeenTick
|
||||
rec.lbear = bearing(rec.bx, rec.by, rec.sx, rec.sy)
|
||||
rec.boff = (bearing(rec.ex, rec.ey, rec.sx, rec.sy) - rec.rdir) mod 360.0
|
||||
appendLine(WorldStateRecordPath, scanRow(rec))
|
||||
bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
|
||||
bot.hasContact = true
|
||||
if RadarScanLog and bot.radarMeleeActive:
|
||||
@@ -1467,6 +1495,22 @@ method run*(bot: ModularBot) =
|
||||
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
|
||||
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
|
||||
if not floorBlocks and setFire(power) and getEnergy() > power:
|
||||
# j177 aim capture: one `aim_fire` line per REAL shot, with the gun
|
||||
# that fired and the exact WorldState the predictor consumed. This
|
||||
# is the only place where `lst` is knowable, so it is the only place
|
||||
# the scan parity of a firing decision can be recorded.
|
||||
if CaptureAim:
|
||||
let bspd = bulletSpeed(power)
|
||||
appendLine(WorldStateRecordPath, fireRow(AimFire(
|
||||
tick: bot.tick, eid: tid, gun: selectedGun, power: power,
|
||||
aim: aimTarget, turret: gunDir, terr: normDelta, heat: gunHeat,
|
||||
ax: pred.x, ay: pred.y, tof: (if bspd > 0: distPx / bspd else: 0.0),
|
||||
ex: bot.lastState.enemyX, ey: bot.lastState.enemyY,
|
||||
eh: bot.lastState.enemyHeading, es: bot.lastState.enemySpeed,
|
||||
ee: bot.lastState.enemyEnergy,
|
||||
sx: bot.lastState.selfX, sy: bot.lastState.selfY,
|
||||
lst: (if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
|
||||
bot.enemyTracker.enemies[tid].lastSeenTick else: -1))))
|
||||
bot.pendingFires.add(PendingShot(
|
||||
gunId: selectedGun,
|
||||
angleErr: abs(normDelta),
|
||||
@@ -1558,6 +1602,7 @@ when isMainModule:
|
||||
vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""),
|
||||
vBulletDebugMax: VBulletDebugMax,
|
||||
recordWorldState: RecordWorldState,
|
||||
captureAim: CaptureAim,
|
||||
geoDebug: GeoDebugOn,
|
||||
debugDraw: DebugDrawOn,
|
||||
radarForceSpin: RadarForceSpin,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
## j177 aim capture — the two records that make the aim error ATTRIBUTABLE.
|
||||
##
|
||||
## j176 could not answer "why is the aim 11.9 deg off at 450+ px" because the
|
||||
## corpus has neither the bot's own belief (staleness was an INVERSE problem,
|
||||
## unidentifiable) nor the gun id (a good gun's contribution was
|
||||
## indistinguishable from a bad one's). Both are cheap to log and impossible
|
||||
## to recover later. This module builds the two JSON records; ModularBot.nim
|
||||
## calls it and the lines go into the EXISTING world-state capture
|
||||
## (`TR_RECORD_WORLDSTATE` file), so the offline tooling sees one stream.
|
||||
##
|
||||
## It is deliberately PURE (no bot API, no env reads): the bot passes plain
|
||||
## floats, the offline guard test passes the recorded fixture, and both go
|
||||
## through the SAME row builders — so a field that the test proves present is
|
||||
## a field the live bot writes.
|
||||
##
|
||||
## Key convention: `e*` = the enemy, `s*` = us, `b*` = the enemy's PREVIOUS
|
||||
## belief in the tracker (before this scan's update), `lst` = the tick that
|
||||
## state came from. `age = tick - blst` is the scan PARITY, recorded rather
|
||||
## than inferred.
|
||||
|
||||
import std/[json, os, math]
|
||||
|
||||
const
|
||||
ScanRecordKey* = "aim_scan" ## wrapper key, sibling of "meta"/"end"
|
||||
FireRecordKey* = "aim_fire"
|
||||
|
||||
type
|
||||
AimScan* = object
|
||||
## One `onScannedBot` event, as the bot saw it BEFORE the update.
|
||||
tick*: int
|
||||
eid*: int
|
||||
ex*, ey*: float ## raw scanned values
|
||||
eh*, es*: float ## scanned heading (= direction) and speed
|
||||
ee*: float
|
||||
sx*, sy*: float ## our state at the scan (the FIRE SITE)
|
||||
sh*, ss*: float
|
||||
gun*: int ## bot.currentGun — the gun that fired, not the rack slot
|
||||
bx*, by*: float ## previous belief, BEFORE this scan's update
|
||||
bh*, bs*: float
|
||||
blst*: int ## previous lastSeenTick; -1 = never scanned before
|
||||
rlock*: bool ## radar lock engaged (false = the melee radar)
|
||||
rdir*: float ## 36 deg scan window centre (radar heading)
|
||||
lbear*: float ## bearing the lock is chasing (believed target)
|
||||
boff*: float ## scanned bearing - rdir: where in the window it landed
|
||||
|
||||
AimFire* = object
|
||||
## One firing decision, as the model computed it.
|
||||
tick*: int
|
||||
eid*: int
|
||||
gun*: int ## bot.currentGun = the gun that fired
|
||||
power*: float
|
||||
aim*: float ## the raw angle handed to setFire/turret
|
||||
turret*: float ## getGunDirection() at the command
|
||||
terr*: float ## signed turret error (aim - turret)
|
||||
heat*: float ## getGunHeat() BEFORE firing
|
||||
ax*, ay*: float ## the intercept the gun predicted
|
||||
tof*: float ## implied time of flight, ticks
|
||||
ex*, ey*: float ## the WorldState the predictor CONSUMED
|
||||
eh*, es*: float
|
||||
ee*: float
|
||||
sx*, sy*: float
|
||||
lst*: int ## which tick that enemy state came from (scan parity)
|
||||
|
||||
proc bearing*(x, y, fx, fy: float): float =
|
||||
arctan2(y - fy, x - fx).radToDeg
|
||||
|
||||
proc scanRow*(s: AimScan): JsonNode =
|
||||
## The `aim_scan` line. Every field is unconditional: a capture that
|
||||
## silently omits a field is worse than no capture.
|
||||
result = newJObject()
|
||||
result[ScanRecordKey] = newJObject()
|
||||
let b = result[ScanRecordKey]
|
||||
b["tick"] = %s.tick
|
||||
b["eid"] = %s.eid
|
||||
b["ex"] = %s.ex
|
||||
b["ey"] = %s.ey
|
||||
b["eh"] = %s.eh
|
||||
b["es"] = %s.es
|
||||
b["ee"] = %s.ee
|
||||
b["sx"] = %s.sx
|
||||
b["sy"] = %s.sy
|
||||
b["sh"] = %s.sh
|
||||
b["ss"] = %s.ss
|
||||
b["gun"] = %s.gun
|
||||
b["bx"] = %s.bx
|
||||
b["by"] = %s.by
|
||||
b["bh"] = %s.bh
|
||||
b["bs"] = %s.bs
|
||||
b["blst"] = %s.blst
|
||||
b["age"] = %(if s.blst >= 0: s.tick - s.blst else: -1)
|
||||
b["rlock"] = %s.rlock
|
||||
b["rdir"] = %s.rdir
|
||||
b["lbear"] = %s.lbear
|
||||
b["boff"] = %s.boff
|
||||
|
||||
proc fireRow*(f: AimFire): JsonNode =
|
||||
## The `aim_fire` line.
|
||||
result = newJObject()
|
||||
result[FireRecordKey] = newJObject()
|
||||
let b = result[FireRecordKey]
|
||||
b["tick"] = %f.tick
|
||||
b["eid"] = %f.eid
|
||||
b["gun"] = %f.gun
|
||||
b["power"] = %f.power
|
||||
b["aim"] = %f.aim
|
||||
b["turret"] = %f.turret
|
||||
b["terr"] = %f.terr
|
||||
b["heat"] = %f.heat
|
||||
b["ax"] = %f.ax
|
||||
b["ay"] = %f.ay
|
||||
b["tof"] = %f.tof
|
||||
b["ex"] = %f.ex
|
||||
b["ey"] = %f.ey
|
||||
b["eh"] = %f.eh
|
||||
b["es"] = %f.es
|
||||
b["ee"] = %f.ee
|
||||
b["sx"] = %f.sx
|
||||
b["sy"] = %f.sy
|
||||
b["lst"] = %f.lst
|
||||
|
||||
proc appendLine*(path: string, row: JsonNode) =
|
||||
## Append one JSONL line, fully guarded: a full disk or a bad path must
|
||||
## never take the bot down (same contract as the existing recorders).
|
||||
try:
|
||||
let f = open(path, fmAppend)
|
||||
f.writeLine($row)
|
||||
f.close()
|
||||
except CatchableError:
|
||||
discard
|
||||
@@ -52,6 +52,7 @@ type
|
||||
vBulletDebugGun*: string
|
||||
vBulletDebugMax*: int
|
||||
recordWorldState*: bool
|
||||
captureAim*: bool ## j177: aim_scan / aim_fire records, default off
|
||||
geoDebug*: bool
|
||||
debugDraw*: bool
|
||||
radarForceSpin*: bool
|
||||
@@ -245,6 +246,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG"))
|
||||
emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState),
|
||||
sourceOfPresence("TR_RECORD_WORLDSTATE"))
|
||||
emit("TR_CAPTURE_AIM", onOff(ctx.captureAim),
|
||||
sourceOfPresence("TR_CAPTURE_AIM"))
|
||||
emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin),
|
||||
sourceOfPresence("TR_RADAR_FORCE_SPIN"))
|
||||
emit("TR_RADAR_SCANLOG", onOff(ctx.radarScanLog),
|
||||
@@ -657,7 +660,7 @@ proc knownEnvNames*(): seq[string] =
|
||||
"GUN_SELECTOR_SHRINK", "GUN_SELECTOR_DWELL", "GUN_SELECTOR_MARGIN",
|
||||
"GUN_SELECTOR_POINT_TIE", "GUN_SELECTOR_SEED",
|
||||
"GUN_RACK_DISABLE", "GUN_STATS_PATH", "GUN_SHOTLOG_PATH",
|
||||
"TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE",
|
||||
"TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE", "TR_CAPTURE_AIM",
|
||||
"TR_RADAR_FORCE_SPIN", "TR_RADAR_SCANLOG", "TR_RADAR_SCAN_LOG_PATH",
|
||||
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
|
||||
VBulletDebugEnv, VBulletDebugGunEnv, VBulletDebugMaxEnv,
|
||||
|
||||
Reference in New Issue
Block a user