diff --git a/context/test-harness.md b/context/test-harness.md index ef6be48..39ddc7d 100644 --- a/context/test-harness.md +++ b/context/test-harness.md @@ -234,3 +234,29 @@ and asserts [crit] DESTROYED + [techstat] X + zero named FIRED after + REFUSED>0 fires; P2 partial-crits to 0.5 and asserts the weapon KEEPS firing (freeze regression, the #164 upstream writer). LESSON (gotcha SS30): BT_KILL_SUBSYS writes the cells directly and can only validate READERS -- state benches must drive the real writer path. + +## weapons_sweep (night16) -- the all-families weapons bench (#171/#174/lane-12, PASS 2026-08-13) +`scratchpad/night16/weapons_sweep.sh` + `weapons_check.py`: ONE 2-node bench proving every +weapons family on one tree. Rig: **A=mad2 "Zanin Neko"** (AFC50 + ERPPC + LRM15 + SRM6 x2 + +ERMLaser x2 -- all three families on one chassis, the AC carrier per L4GAUGE.CFG) vs **B=madcat** +(AFC100 + LRM15 x2 + ER lasers), grass/day, `BT_GOTO=enemy` + throttled BT_AUTOFIRE + +BT_AF_MISSILE (periods 4/7), 300s, env `BT_DMG_LOG BT_PROJ_LOG BT_DEATH_LOG BT_AMMO_LOG`. +Asserts per family: energy named [emitter] FIRED + type-3/4 dmghits; AC type=1 dmghits ALL +burst=1 + ballistic (guided=0) PUSHes ALL dmg=0 (#171 hitscan; damage DETs reconciled to guided +pushes per log); missiles [ammo] FIRED + IMPACT dmg>0 with burst>1 + type=2 dmghits (contact +through the 4.0u fuze at drag-governed speeds); #174 cascade-per-life<=1; bystander receipts +individually classified; hard rig floors. `--selftest` feeds the detector pre-#171/pre-#174 +shapes + bystander false-positive shapes and requires each to FAIL (run first, gated). +Rig lessons [T2]: +- **avatar-vs-avatar forms NO locks** (three prior runs, 0 dmghits even from lasers) while the + madcat family fights fine -- dodge with madcat-family chassis; `mad2` is the AC carrier. +- Node->pilot-page mapping: `-net 1501` claims pilot `127.0.0.1:1502`, `-net 1601` claims + `:1602` (net port + 1) -- sed each pilot page's vehicle= line EXACTLY for mixed pairings. +- **BYSTANDER receipts legitimately fire in a 2-mech fight**: the production aim ray designates + SCENERY constantly (the ac_bench BT_FIRE_AT_ICON lesson generalizes to the real pick), and a + mech crossing a scenery-locked round's path detonates it -- the binary world-sweep shape + (FUN_0042291c via @004bef78 [T1]). All 11 receipts of the PASS run were true positives + (struck = the other live mech, aim 146-1016u away, one paired IMPACT each; + `scratchpad/night16/bys_forensics.py`). A zero-receipt assertion is miscalibrated; assert + the false-positive conditions instead (self-strike / phantom id / det-at-lock-aim / missing + paired delivery / print-cap 24 reached). diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 0201782..2e5a6fc 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -1848,10 +1848,10 @@ static void // (sim: binary 492 u/s at 800u vs undamped 695; port t+15% to 800u). // Drag runs during burn AND coast (a burned-out round DECELERATES, // v(t)=v0/(1+COD*v0*t)); thruster rounds only -- the AC round is the - // 0xBCD renderer tracer, a cosmetic with no Mover physics. Still - // unported [T3 minor]: coast gravity (-6.5 world-Y, @4bef78 - // else-branch) and the binary's burnout end to velocity-slaving -- - // both live beyond practical impact ranges (see WEAPONS_DRIFT_AUDIT + // 0xBCD renderer tracer, a cosmetic with no Mover physics. Coast + // gravity ported below (audit close 2026-08-13). Still unported + // [T3 minor]: the binary's burnout end to velocity-slaving (lives + // beyond practical impact ranges -- see WEAPONS_DRIFT_AUDIT // 'VELOCITY/DT INTEGRATION'). if (p.accel > 0.0f) { @@ -1867,6 +1867,29 @@ static void p.vel.x *= k; p.vel.y *= k; p.vel.z *= k; } p.speed = ns; + // COAST GRAVITY (WEAPONS_DRIFT_AUDIT dt-table row closed + // 2026-08-13). The binary applies the environment gravity + // (default 6.5, FUN_00421e2c `vy -= g`) ONLY while COASTING -- + // the @4bef78 else-branch; during burn the thruster re-adds + // thrust with NO gravity term onto the frame-zeroed accumulator + // [T1, decomp-reference flight-model bullet]. It is a fresh + // per-frame ACCELERATION (not an accumulate), so `vy -= 6.5*dt` + // is the faithful integration at any frame rate. coast_dt is + // this frame's non-burning remainder (exact at the burnout + // boundary). Applies to EVERY thruster round, lock or no lock + // -- it is Mover physics, not seeker logic (a lock-dropped + // round coasts ballistic and now DROPS instead of flying level). + Scalar coast_dt = dt - burn_dt; + if (p.burnLeft <= 0.0f && coast_dt > 0.0f) + { + p.vel.y -= 6.5f * coast_dt; // env gravityConstant default [T1] + Scalar sv = (Scalar)sqrtf((float)(p.vel.x*p.vel.x + + p.vel.y*p.vel.y + p.vel.z*p.vel.z)); + if (sv > 0.01f) + p.speed = sv; // keep speed == |vel| (the step + // length + normalizations below + // divide by p.speed) + } } p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt; p.age += dt; @@ -1955,24 +1978,120 @@ static void // detonation being flung past the target at high speed. Point3D hitPos = p.pos; Scalar contactD2; + Scalar tTgt = 0.0f; // target's nearest-approach segment param + // (the bystander sweep compares against it) { const Scalar sx = p.pos.x - prev.x, sy = p.pos.y - prev.y, sz = p.pos.z - prev.z; const Scalar seg2 = sx*sx + sy*sy + sz*sz; - Scalar t = 0.0f; if (seg2 > 1.0e-6f) { const Scalar wx = p.targetPos.x - prev.x, wy = p.targetPos.y - prev.y, wz = p.targetPos.z - prev.z; - t = (wx*sx + wy*sy + wz*sz) / seg2; - if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; + tTgt = (wx*sx + wy*sy + wz*sz) / seg2; + if (tTgt < 0.0f) tTgt = 0.0f; else if (tTgt > 1.0f) tTgt = 1.0f; } - hitPos.x = prev.x + t*sx; hitPos.y = prev.y + t*sy; hitPos.z = prev.z + t*sz; + hitPos.x = prev.x + tTgt*sx; hitPos.y = prev.y + tTgt*sy; hitPos.z = prev.z + tTgt*sz; const Scalar cx = p.targetPos.x - hitPos.x, cy = p.targetPos.y - hitPos.y, cz = p.targetPos.z - hitPos.z; contactD2 = cx*cx + cy*cy + cz*cz; } + // BYSTANDER SWEEP (WEAPONS_DRIFT_AUDIT lane 12, closed 2026-08-13). + // The binary's per-tick contact test is a WORLD sweep (FUN_0042291c, + // called from Missile::MoveAndCollide @004bef78): every solid in the + // flight path can detonate the round -- including a third mech between + // shooter and target [T1]. The port pool tested only the locked + // target sphere + the terrain ray, so a mech standing in someone + // else's crossfire was flown through (splash alone could touch it). + // Port shape [T3 -- radius approximation of the real solid sweep]: + // nearest-approach of this frame's flight segment vs a VERTICAL + // CAPSULE per other registered mech. The capsule is derived from the + // mech's OWN collision template (Mover::GetCollisionTemplate, the same + // BoxedSolid whose maxY is CylinderReferenceHeight): axis = mech + // origin up template minY..maxY, radius = the template's larger + // horizontal half-extent (the axis ignores the template's near-zero + // horizontal center offset, so no yaw rotation is needed). Fallback + // when the template is unresolved: 3.5u x 14u [T3 fixed constants, + // fielded-mech scale -- CylinderReferenceHeight ~14]. Wrecks stay in + // the sweep: the binary detonates on ANY solid, and the victim-side + // zone-state guard (#174) keeps a dead zone from cascading or scoring. + // Cheap by construction: damage-carrying GUIDED rounds only (= the one + // salvo lead; the N-1 visual tracers are damage-0), per-mech + // distance cull, shooter excluded by the registry helper, locked + // target excluded (its own 4.0u fuze above is the authentic test). + Entity *bysMech = 0; + Scalar bysT = 2.0f; + Point3D bysHit = p.pos; + if (p.guided && p.damage > 0.0f) + { + extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut); + Entity *cand[32]; + const int nc = BTGetTargetCandidates(p.shooter, cand, 32); // excludes the shooter + const Scalar sx = p.pos.x - prev.x, sy = p.pos.y - prev.y, + sz = p.pos.z - prev.z; + const Scalar seg2 = sx*sx + sy*sy + sz*sz; + const Scalar step = p.speed * dt; + for (int ci = 0; ci < nc && seg2 > 1.0e-6f; ++ci) + { + Entity *e = cand[ci]; + if (e == 0 || e == p.target || !BTIsRegisteredMech(e)) + continue; + Mech *m = (Mech *)e; + Point3D mp = m->localOrigin.linearPosition; + // distance cull: farther than this frame's step + the largest + // plausible capsule reach -> untouchable this frame + const Scalar cdx = mp.x - prev.x, cdz = mp.z - prev.z; + const Scalar cull = step + 24.0f; + if (cdx*cdx + cdz*cdz > cull*cull) + continue; + Scalar rr, cy0, cy1; + BoxedSolid *tmpl = m->GetCollisionTemplate(); + if (tmpl != 0 && tmpl->maxY > tmpl->minY) + { + const Scalar hx = 0.5f * (tmpl->maxX - tmpl->minX); + const Scalar hz = 0.5f * (tmpl->maxZ - tmpl->minZ); + rr = (hx > hz) ? hx : hz; + cy0 = mp.y + tmpl->minY; + cy1 = mp.y + tmpl->maxY; + } + else + { + rr = 3.5f; cy0 = mp.y; cy1 = mp.y + 14.0f; // [T3] fixed fallback + } + if (!(rr > 0.5f)) rr = 0.5f; // degenerate/garbage template + if (rr > 10.0f) rr = 10.0f; // guards (NaN falls to 0.5) + // closest points between the flight segment prev+t*(sx,sy,sz) + // and the vertical axis segment (mp.x, cy0..cy1, mp.z) -- + // standard segment-segment closest point (RTCD 5.1.9 with + // d2 = (0,h,0)) + const Scalar h = cy1 - cy0; + const Scalar rx = prev.x - mp.x, ry = prev.y - cy0, + rz = prev.z - mp.z; + const Scalar ee = h*h; + const Scalar f = h*ry; + const Scalar c = sx*rx + sy*ry + sz*rz; + const Scalar b = sy*h; + const Scalar den = seg2*ee - b*b; + Scalar t = (den > 1.0e-6f) ? (b*f - c*ee) / den : 0.0f; + if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; + Scalar s = (ee > 1.0e-6f) ? (b*t + f) / ee : 0.0f; + if (s < 0.0f) { s = 0.0f; t = -c / seg2; } + else if (s > 1.0f) { s = 1.0f; t = (b - c) / seg2; } + if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; + const Scalar qx = prev.x + t*sx - mp.x; + const Scalar qy = prev.y + t*sy - (cy0 + s*h); + const Scalar qz = prev.z + t*sz - mp.z; + if (qx*qx + qy*qy + qz*qz < rr*rr && t < bysT) + { + bysT = t; + bysMech = e; + bysHit.x = prev.x + t*sx; + bysHit.y = prev.y + t*sy; + bysHit.z = prev.z + t*sz; + } + } + } // #171 FUZE RADIUS: a GUIDED damage round detonates at the binary's // proximity fuze _DAT_004bf5a4 = 4.0 (seeker rangeToTarget +0x10C < // 4.0, @004bef78 -- the SAME constant the old steering comment @@ -1981,7 +2100,32 @@ static void // AC tracers, cluster visuals) keep the wider 10u so their burst // visuals still read at speed. const Scalar fuzeR = (p.guided && p.damage > 0.0f) ? 4.0f : 10.0f; - const int contact = (contactD2 < fuzeR * fuzeR); + int contact = (contactD2 < fuzeR * fuzeR); + Entity *victim = p.target; + if (bysMech != 0 && (!contact || bysT < tTgt)) + { + // a third mech sits EARLIER on this frame's flight segment than + // the locked target's fuze point -- it takes the round (the + // binary's sweep detonates on the FIRST solid in the path). The + // struck mech becomes the DIRECT victim; the salvo cluster roll + // and the splash package below run unchanged against it. + contact = 1; + victim = bysMech; + hitPos = bysHit; + static int s_bysPrints = 0; // capped receipt (field forensics) + if (s_bysPrints < 24) + { + ++s_bysPrints; + DEBUG_STREAM << "[projectile] BYSTANDER id=" + << (int)bysMech->GetEntityID() + << " t=" << bysT + << " at(" << bysHit.x << "," << bysHit.y << "," << bysHit.z + << ") dmg=" << p.damage + << " lockedTgt=" << (void *)p.target + << ((s_bysPrints == 24) ? " (receipt cap reached)" : "") + << "\n" << std::flush; + } + } if (!contact && (p.age >= p.ttl || (p.guided && p.pos.y < -1.0f))) { if (getenv("BT_PROJ_LOG")) @@ -2006,10 +2150,13 @@ static void << "," << p.targetPos.z << ")" << std::endl; if (p.salvoLead) // ONE Explosion per salvo (@004bcc60) BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, hitPos); - Entity *tgt = p.target; - // Deliver to the projectile's target mech -- the launcher set p.target - // from the shooter's 0x388 slot (the picked victim; any peer mech in - // MP, task #46). A replicant target reroutes cross-pod via Dispatch. + Entity *tgt = victim; + // Deliver to the struck mech -- normally the launcher's locked + // target (the shooter's 0x388 slot; any peer mech in MP, task #46), + // or the BYSTANDER the sweep above found first on the flight + // segment (lane 12: it becomes the direct victim, exactly as if it + // had been the lock). A replicant victim reroutes cross-pod via + // Dispatch. extern int BTIsRegisteredMech(Entity *e); if (tgt != 0 && BTIsRegisteredMech(tgt) && p.damage > 0.0f) { diff --git a/scratchpad/night16/bys_forensics.py b/scratchpad/night16/bys_forensics.py new file mode 100644 index 0000000..8b2ad89 --- /dev/null +++ b/scratchpad/night16/bys_forensics.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +# Forensics for the 11 [projectile] BYSTANDER receipts of the weapons_sweep +# run: classify every DET lock pointer by aim-point motion (a mech lock TRACKS +# the walking enemy; a scenery/wreck lock is stationary), then test each +# receipt: struck id vs the shooter's own id, det-to-aim distance, and the +# paired IMPACT delivery. +import io +import math +import re + +RX_DET = re.compile(r"\[projectile\] DET at\(([^)]*)\) dmg=([\d.eE+\-]+) " + r"lead=(\d+) tgt=(\S+) aim\(([^)]*)\)") +RX_BYS = re.compile(r"\[projectile\] BYSTANDER id=(\d+) t=(\S+) at\(([^)]*)\) " + r"dmg=([\d.eE+\-]+) lockedTgt=(\S+)") +RX_RST = re.compile(r"\[respawn\] Mech::Reset (\d+):(\d+) ") + + +def p3(s): + a = [float(x) for x in s.split(",")] + return a + + +def dist(a, b): + return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) + + +for fn in ("ws_a.log", "ws_b.log"): + lines = io.open("/c/git/bt411/content/" + fn.replace("/", ""), + encoding="latin-1", errors="replace").read().splitlines() \ + if False else io.open("C:/git/bt411/content/" + fn, + encoding="latin-1", errors="replace").read().splitlines() + own = None + dets = {} # tgt ptr -> list of (lineno, aim) + bys = [] # (lineno, id, at, dmg, lockedTgt) + for i, l in enumerate(lines): + m = RX_DET.search(l) + if m and m.group(4) != "0x0" and float(m.group(2)) > 0.0: + dets.setdefault(m.group(4), []).append((i, p3(m.group(5)))) + continue + m = RX_BYS.search(l) + if m: + bys.append((i, int(m.group(1)), p3(m.group(3)), + float(m.group(4)), m.group(5))) + continue + m = RX_RST.search(l) + if m and own is None: + own = int(m.group(2)) + print("=== %s (own mech entity=%s) ===" % (fn, own)) + # classify pointers: max pairwise aim distance within a 1500-line window + cls = {} + for ptr, lst in sorted(dets.items(), key=lambda kv: -len(kv[1])): + move = 0.0 + for j in range(1, len(lst)): + if lst[j][0] - lst[j - 1][0] <= 1500: + d = dist(lst[j][1], lst[j - 1][1]) + if d > move: + move = d + cls[ptr] = "MECH-TRACKING" if move > 60.0 else "STATIONARY" + print(" lock %s damage-DETs=%3d max local aim motion=%7.1fu -> %s" + % (ptr, len(lst), move, cls[ptr])) + ok = True + for (i, mid, at, dmg, ptr) in bys: + # find this receipt's own DET (next damage DET line) + aim = None + imp = False + for j in range(i, min(i + 4, len(lines))): + m = RX_DET.search(lines[j]) + if m and aim is None: + aim = p3(m.group(5)) + if "[projectile] IMPACT damage=" in lines[j]: + imp = True + d_aim = dist(at, aim) if aim else -1.0 + verdict = [] + if mid == own: + verdict.append("SELF-STRIKE (geometry bug)") + if cls.get(ptr) == "MECH-TRACKING": + verdict.append("LOCK WAS THE MECH (exclusion failed)") + if aim is not None and d_aim < 30.0: + verdict.append("det at the lock's own aim point") + if not imp: + verdict.append("no paired IMPACT delivery") + tag = "TRUE-POSITIVE" if not verdict else "FALSE-POSITIVE: " + "; ".join(verdict) + if verdict: + ok = False + print(" bys@%-6d struck=%d dmg=%g lock=%s(%s) det-to-aim=%.0fu imp=%d %s" + % (i + 1, mid, dmg, ptr, cls.get(ptr, "?"), d_aim, imp, tag)) + print(" %s: %s" % (fn, "all receipts TRUE positives" if ok + else "FALSE POSITIVES PRESENT")) diff --git a/scratchpad/night16/weapons_check.py b/scratchpad/night16/weapons_check.py new file mode 100644 index 0000000..448651d --- /dev/null +++ b/scratchpad/night16/weapons_check.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python +# weapons_sweep checker -- ONE comprehensive per-family PASS/FAIL adjudicator +# for scratchpad/night16/weapons_sweep.sh (2-node mad2-vs-madcat fight). +# +# Families / assertions (env on both nodes: BT_DMG_LOG BT_PROJ_LOG BT_DEATH_LOG +# BT_AMMO_LOG): +# A1 ENERGY : named [emitter] FIRED lines present AND laser/PPC [dmghit] +# (type 3/4) land on the victim. +# A2 AC : type=1 [dmghit] present, ALL of them burst=1 (single panel, +# trigger-time hitscan); ZERO damage-carrying ballistic rounds in +# the pool ([projectile] PUSH guided=0 must all be dmg=0 -- the +# #171 restoration: the visible round is the cosmetic 0xBCD +# tracer); every damage-carrying DET is reconciled to a guided +# (missile) push. +# A3 MISSILE: [ammo] LRM/SRM FIRED receipts, [projectile] IMPACT dmg>0 +# deliveries with burst>1 salvo bursts, type=2 [dmghit]s landing +# (burst>1 present) -- contact through the 4.0u fuze at +# drag-governed speeds; plus >=1 death or zone cascade (vitality). +# A4 NO-REGR: no zone cascades twice within one life per log (the #174 +# guard); every [projectile] BYSTANDER receipt in the 2-mech +# fight is a TRUE positive of the lane-12 sweep. +# CALIBRATION NOTE (2026-08-13 run): the original "zero receipts +# in a 2-mech fight" operationalization assumed a lock is always +# the enemy mech. The run disproved the ASSUMPTION, not the +# code: the production aim ray designates SCENERY constantly +# (the ac_bench lesson), and rounds locked onto a structure DO +# fly past the other mech -- the binary's world sweep detonates +# on ANY solid in the path (FUN_0042291c via @004bef78 [T1]), so +# a receipt there is the sweep WORKING. Forensics on all 11 +# receipts of the calibration run (bys_forensics.py): struck == +# the other live mech every time, aim point 146-1016u away from +# the strike (the round was flying elsewhere), one paired IMPACT +# delivery each. The check now asserts the actual false-positive +# conditions per receipt: +# - struck id == the shooter's OWN mech -> self-strike bug +# - struck id not a known player mech -> phantom strike +# - no DET/IMPACT pairing -> lost/duplicated delivery +# - strike within 30u of the round's aim -> the sweep raced the +# authentic 4.0u fuze at the lock itself (exclusion suspect) +# and flags a hit receipt-print cap (24) as unmeasurable. +# A5 FLOORS : rig-sanity minimums so a quiet rig cannot PASS. +# +# --selftest: prove the detector CAN fail (the week's hard rule) by feeding it +# synthetic pre-fix log shapes (pre-#171 damage-carrying ballistic round, +# pre-#174 double cascade, a bystander receipt, and an empty/quiet rig) and +# requiring each to FAIL the matching assertion. +import io +import math +import re +import sys + +RX_DMGHIT = re.compile(r"\[dmghit\] mech=(\S+) zone=(-?\d+) vital=(\d+) " + r"type=(\d+) amt=([\d.eE+\-]+) burst=(\d+)") +RX_PUSH = re.compile(r"\[projectile\] PUSH target=(\S+) len=(\S+) speed=(\S+) " + r"dmg=([\d.eE+\-]+) guided=(\d+)") +RX_DET = re.compile(r"\[projectile\] DET .*?dmg=([\d.eE+\-]+) lead=(\d+)") +RX_IMPACT = re.compile(r"\[projectile\] IMPACT damage=([\d.eE+\-]+) .*?burst=(\d+)") +RX_EMIT = re.compile(r"\[emitter\] FIRED '([^']+)'") +RX_AMMO = re.compile(r"\[ammo\] (\S+) FIRED, rounds left=") +RX_CASC = re.compile(r"\[cascade\] zone (\d+) DESTROYED") +RX_BYS = re.compile(r"\[projectile\] BYSTANDER id=(\d+) t=(\S+) at\(([^)]*)\) " + r"dmg=([\d.eE+\-]+) lockedTgt=(\S+)") +RX_DETAIM = re.compile(r"\[projectile\] DET at\(([^)]*)\).*?aim\(([^)]*)\)") +RX_RESET = re.compile(r"Mech::Reset (\d+):(\d+)") +RX_DEATH = re.compile(r"\[death\] VehicleDead") + +# rig-sanity floors (A5) -- calibrated against the proven kd_bench/cascade_bench +# engagement levels (worker's 180s single-shooter evidence run: 200 dmghits). +FLOOR_DMGHITS_TOTAL = 40 +FLOOR_ENERGY_HITS = 5 # type 3+4 dmghits +FLOOR_AC_HITS = 3 # type=1 dmghits +FLOOR_MISSILE_HITS = 3 # type=2 dmghits +FLOOR_MISSILE_BURSTS = 2 # IMPACT dmg>0 burst>1 deliveries +FLOOR_EMITTER_FIRED = 10 +FLOOR_VITALITY = 1 # deaths + cascades + + +def _p3(s): + try: + v = [float(x) for x in s.split(",")] + return v if len(v) == 3 else None + except ValueError: + return None + + +def _dist(a, b): + return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) + + +def parse(lines): + lines = list(lines) + d = { + "dmghit": [], # (mech, zone, vital, type, amt, burst) + "push": [], # (dmg, guided) + "det": [], # (dmg, lead) + "impact": [], # (dmg, burst) + "emitter": {}, # name -> count + "ammo": {}, # name -> count + "bystander": 0, + "bys_recs": [], # (line, struckid, at, dmg, lockptr, aim|None, paired) + "bys_capped": 0, # the 24-receipt print cap was reached + "own": None, # this node's own mech entity id (first Reset h:ID) + "mechids": set(), # every player-mech entity id seen in Reset lines + "deaths": 0, + "resets": 0, + "casc_total": 0, + "casc_worst": {}, # zone -> worst per-life repeat count + } + life = {} + for i, l in enumerate(lines): + m = RX_DMGHIT.search(l) + if m: + d["dmghit"].append((m.group(1), int(m.group(2)), int(m.group(3)), + int(m.group(4)), float(m.group(5)), int(m.group(6)))) + continue + m = RX_PUSH.search(l) + if m: + d["push"].append((float(m.group(4)), int(m.group(5)))) + continue + m = RX_BYS.search(l) + if m: + struck = int(m.group(1)) + at = _p3(m.group(3)) + if "receipt cap reached" in l: + d["bys_capped"] = 1 + # the receipt's own detonation follows within a few lines: DET + # (carries the round's aim) then the mech delivery IMPACT + aim = None + paired = False + for j in range(i + 1, min(i + 5, len(lines))): + dm = RX_DETAIM.search(lines[j]) + if dm and aim is None: + aim = _p3(dm.group(2)) + if "[projectile] IMPACT damage=" in lines[j]: + paired = True + break + d["bys_recs"].append((i + 1, struck, at, float(m.group(4)), + m.group(5), aim, paired)) + d["bystander"] += 1 + continue + m = RX_DET.search(l) + if m: + d["det"].append((float(m.group(1)), int(m.group(2)))) + continue + m = RX_IMPACT.search(l) + if m: + d["impact"].append((float(m.group(1)), int(m.group(2)))) + continue + m = RX_EMIT.search(l) + if m: + d["emitter"][m.group(1)] = d["emitter"].get(m.group(1), 0) + 1 + continue + m = RX_AMMO.search(l) + if m: + d["ammo"][m.group(1)] = d["ammo"].get(m.group(1), 0) + 1 + continue + m = RX_CASC.search(l) + if m: + z = int(m.group(1)) + life[z] = life.get(z, 0) + 1 + if life[z] > d["casc_worst"].get(z, 0): + d["casc_worst"][z] = life[z] + d["casc_total"] += 1 + continue + if "Mech::Reset" in l: + life = {} + d["resets"] += 1 + m = RX_RESET.search(l) + if m: + mid = int(m.group(2)) + d["mechids"].add(mid) + if d["own"] is None: + d["own"] = mid + continue + if RX_DEATH.search(l): + d["deaths"] += 1 + return d + + +def adjudicate(logs, verbose=True): + """logs: {name: parsed-dict}. Returns (fails, table_rows).""" + fails = [] + + def agg(key): + out = [] + for nm in logs: + out.extend(logs[nm][key]) + return out + + hits = agg("dmghit") + push = agg("push") + det = agg("det") + impact = agg("impact") + emitter = {} + ammo = {} + for nm in logs: + for k, v in logs[nm]["emitter"].items(): + emitter[k] = emitter.get(k, 0) + v + for k, v in logs[nm]["ammo"].items(): + ammo[k] = ammo.get(k, 0) + v + bystander = sum(logs[nm]["bystander"] for nm in logs) + deaths = sum(logs[nm]["deaths"] for nm in logs) + resets = sum(logs[nm]["resets"] for nm in logs) + cascades = sum(logs[nm]["casc_total"] for nm in logs) + + h_energy = [h for h in hits if h[3] in (3, 4)] + h_laser = [h for h in hits if h[3] == 3] + h_ppc = [h for h in hits if h[3] == 4] + h_ball = [h for h in hits if h[3] == 1] + h_missile = [h for h in hits if h[3] == 2] + h_mis_multi = [h for h in h_missile if h[5] > 1] + ball_multi = [h for h in h_ball if h[5] != 1] + + push_ballistic = [p for p in push if p[1] == 0] + push_ballistic_dmg = [p for p in push_ballistic if p[0] != 0.0] + push_guided_dmg = [p for p in push if p[1] == 1 and p[0] > 0.0] + det_dmg = [x for x in det if x[0] != 0.0] + imp_dmg = [x for x in impact if x[0] > 0.0] + imp_multi = [x for x in imp_dmg if x[1] > 1] + + ammo_ac = {k: v for k, v in ammo.items() if k.upper().startswith("AFC")} + ammo_mis = {k: v for k, v in ammo.items() + if k.upper().startswith(("LRM", "SRM", "STRK", "STREAK", "NARC", "NRK"))} + + # ---- A1 ENERGY ---- + a1 = [] + if not emitter: + a1.append("no named [emitter] FIRED lines") + if len(h_energy) < FLOOR_ENERGY_HITS: + a1.append("laser/PPC dmghits %d < floor %d" % (len(h_energy), FLOOR_ENERGY_HITS)) + if not h_laser: + a1.append("no type=3 laser dmghit landed") + if sum(emitter.values()) < FLOOR_EMITTER_FIRED: + a1.append("emitter FIRED total %d < floor %d" + % (sum(emitter.values()), FLOOR_EMITTER_FIRED)) + + # ---- A2 AC HITSCAN (#171) ---- + a2 = [] + if len(h_ball) < FLOOR_AC_HITS: + a2.append("type=1 dmghits %d < floor %d" % (len(h_ball), FLOOR_AC_HITS)) + if ball_multi: + a2.append("%d type=1 dmghit(s) with burst!=1 (multi-panel ballistic -- " + "not the single-panel hitscan)" % len(ball_multi)) + if push_ballistic_dmg: + a2.append("%d ballistic PUSH(es) with dmg!=0 -- a damage-carrying " + "ballistic round entered the pool (pre-#171 shape)" + % len(push_ballistic_dmg)) + if not push_ballistic: + a2.append("no ballistic (guided=0) PUSH at all -- AC never cycled") + if not ammo_ac: + a2.append("no [ammo] AFC* FIRED receipt -- no autocannon cycled a round") + # every damage-carrying DET must be attributable to a guided (missile) push, + # per log (pool is node-local) + for nm in logs: + nd = len([x for x in logs[nm]["det"] if x[0] != 0.0]) + ng = len([p for p in logs[nm]["push"] if p[1] == 1 and p[0] > 0.0]) + if nd > ng: + a2.append("%s: %d damage DETs > %d guided damage PUSHes -- an " + "unguided round detonated carrying damage" % (nm, nd, ng)) + + # ---- A3 MISSILES ---- + a3 = [] + if not ammo_mis: + a3.append("no [ammo] LRM/SRM FIRED receipt") + if len(imp_dmg) < FLOOR_MISSILE_HITS: + a3.append("missile IMPACT deliveries %d < floor %d" + % (len(imp_dmg), FLOOR_MISSILE_HITS)) + if len(imp_multi) < FLOOR_MISSILE_BURSTS: + a3.append("salvo bursts (IMPACT dmg>0 burst>1) %d < floor %d" + % (len(imp_multi), FLOOR_MISSILE_BURSTS)) + if len(h_missile) < FLOOR_MISSILE_HITS: + a3.append("type=2 dmghits %d < floor %d" % (len(h_missile), FLOOR_MISSILE_HITS)) + if not h_mis_multi: + a3.append("no type=2 dmghit with burst>1 (no salvo cluster landed)") + if deaths + resets + cascades < FLOOR_VITALITY: + a3.append("rig vitality: deaths+resets+cascades = %d < %d" + % (deaths + resets + cascades, FLOOR_VITALITY)) + + # ---- A4 NO REGRESSION ---- + a4 = [] + for nm in logs: + dup = {z: c for z, c in logs[nm]["casc_worst"].items() if c > 1} + if dup: + a4.append("%s re-descended zones %s within one life (#174 ALIVE)" + % (nm, dup)) + # BYSTANDER receipts: every receipt must be a TRUE positive (see the + # calibration note in the header -- rounds locked onto scenery legitimately + # detonate on the other mech crossing the flight path; the binary's world + # sweep fires on ANY solid [T1]). False-positive conditions per receipt: + all_mechids = set() + for nm in logs: + all_mechids |= logs[nm]["mechids"] + bys_true = 0 + for nm in logs: + own = logs[nm]["own"] + if logs[nm]["bys_capped"]: + a4.append("%s: BYSTANDER print cap (24) reached -- receipt count " + "unmeasurable, rerun a shorter window" % nm) + for (ln, struck, at, bdmg, lockptr, aim, paired) in logs[nm]["bys_recs"]: + bad = [] + if own is not None and struck == own: + bad.append("SELF-STRIKE (sweep hit the shooter's own mech)") + if all_mechids and struck not in all_mechids: + bad.append("phantom strike: id %d is no known player mech" + % struck) + if aim is None or not paired: + bad.append("no paired DET/IMPACT delivery (lost or duplicated " + "round)") + elif at is not None and _dist(at, aim) < 30.0: + bad.append("strike within %.0fu of the round's own aim -- the " + "sweep raced the 4.0u fuze at the lock itself " + "(target-exclusion suspect)" % _dist(at, aim)) + if bad: + a4.append("%s:%d BYSTANDER FALSE POSITIVE: %s" + % (nm, ln, "; ".join(bad))) + else: + bys_true += 1 + + # ---- A5 FLOORS ---- + a5 = [] + if len(hits) < FLOOR_DMGHITS_TOTAL: + a5.append("total dmghits %d < floor %d (quiet rig proves nothing)" + % (len(hits), FLOOR_DMGHITS_TOTAL)) + + rows = [ + ("ENERGY", a1, "emitterFIRED=%d(names=%d) hits t3=%d t4=%d" + % (sum(emitter.values()), len(emitter), + len(h_laser), len(h_ppc))), + ("AC", a2, "acFIRED=%d hits t1=%d (burst!=1: %d) ballPUSH=%d " + "(dmg!=0: %d)" + % (sum(ammo_ac.values()), len(h_ball), len(ball_multi), + len(push_ballistic), len(push_ballistic_dmg))), + ("MISSILE", a3, "misFIRED=%d IMPACTdmg=%d(burst>1:%d) hits t2=%d" + "(burst>1:%d) FIZZ n/a" + % (sum(ammo_mis.values()), len(imp_dmg), len(imp_multi), + len(h_missile), len(h_mis_multi))), + ("NO-REGR", a4, "cascades=%d worstRepeat=%d bystander=%d " + "(true-positive crossfire=%d, false=%d)" + % (cascades, + max([c for nm in logs + for c in logs[nm]["casc_worst"].values()] or [0]), + bystander, bys_true, bystander - bys_true)), + ("RIG", a5, "dmghits=%d deaths=%d resets=%d DETdmg>0=%d " + "guidedDmgPUSH=%d" + % (len(hits), deaths, resets, len(det_dmg), + len(push_guided_dmg))), + ] + for fam, fl, _ in rows: + fails.extend("%s: %s" % (fam, f) for f in fl) + + if verbose: + print("=== WEAPONS SWEEP -- per-family verdict ===") + for fam, fl, info in rows: + print("%-8s %-4s %s" % (fam, "PASS" if not fl else "FAIL", info)) + for f in fl: + print(" - %s" % f) + print("weapon receipts (named, both logs):") + for k in sorted(set(list(emitter) + list(ammo))): + n = emitter.get(k, 0) + ammo.get(k, 0) + src = "emitter" if k in emitter else "ammo" + print(" %-16s %4d (%s)" % (k, n, src)) + for nm in logs: + print("%s: dmghits=%d pushes=%d dets=%d impacts=%d casc=%d " + "resets=%d deaths=%d bys=%d" + % (nm, len(logs[nm]["dmghit"]), len(logs[nm]["push"]), + len(logs[nm]["det"]), len(logs[nm]["impact"]), + logs[nm]["casc_total"], logs[nm]["resets"], + logs[nm]["deaths"], logs[nm]["bystander"])) + print("RESULT:", "PASS" if not fails else "FAIL") + if fails: + for f in fails: + print(" FAIL:", f) + return fails + + +def load(fn): + return parse(io.open(fn, encoding="latin-1", errors="replace").read().splitlines()) + + +# --------------------------------------------------------------------------- +# --selftest: the detector must FAIL on pre-fix log shapes (negative controls) +# --------------------------------------------------------------------------- +HEALTHY = [ + # energy: named emitters + laser/PPC hits +] + [ + "[emitter] FIRED 'ERMLaser_1' damage=8 heat=2" for _ in range(6) +] + [ + "[emitter] FIRED 'ERPPC' damage=15 heat=6" for _ in range(6) +] + [ + "[dmghit] mech=42 zone=%d vital=0 type=3 amt=8 burst=1 lvl 0->0.1" % (i % 5) + for i in range(8) +] + [ + "[dmghit] mech=42 zone=2 vital=0 type=4 amt=15 burst=1 lvl 0->0.2", +] + [ + # AC: named ammo fire, cosmetic tracer pushes, hitscan type=1 burst=1 hits + "[ammo] AFC50 FIRED, rounds left=93" for _ in range(4) +] + [ + "[projectile] PUSH target=0x0 len=400 speed=250 dmg=0 guided=0 ttl=5 " + "mz=(1,12,1) relY=12 lv=(fallback)" for _ in range(4) +] + [ + "[projectile] DET at(9,2,3) dmg=0 lead=1 tgt=0x0 aim(9,2,3)" for _ in range(4) +] + [ + "[dmghit] mech=42 zone=%d vital=0 type=1 amt=12.5 burst=1 lvl 0->0.1" % (i % 4) + for i in range(6) +] + [ + # missiles: guided pushes (1 lead + visuals), IMPACT bursts, type=2 hits + "[ammo] LRM15 FIRED, rounds left=110" for _ in range(4) +] + [ + "[projectile] PUSH target=0x5a len=300 speed=180 dmg=52.5 guided=1 ttl=13 " + "mz=(1,14,1) relY=14 lv=(auth)" for _ in range(6) +] + [ + "[projectile] DET at(4,5,6) dmg=52.5 lead=1 tgt=0x5a aim(4,5,6)" for _ in range(6) +] + [ + "[projectile] IMPACT damage=52.5 subsys=21 v=540 burnLeft=0 burst=9 " + "(direct dispatch) (zone cyl-resolved)" for _ in range(6) +] + [ + "[dmghit] mech=42 zone=%d vital=0 type=2 amt=3.5 burst=%d lvl 0->0.3" + % (i % 6, 4 + (i % 8)) for i in range(24) +] + [ + "[cascade] zone 17 DESTROYED -> descend=1 destroySibs=0 crits=3", + "[respawn] Mech::Reset 3:42 healed+moved to (0,0,0) alive=1 zones=22 subsys=34", + "[cascade] zone 17 DESTROYED -> descend=1 destroySibs=0 crits=3", + "[death] VehicleDead(-1) dispatched to the owning player", + # the peer's mech id (a second life witness so 43 is a known player mech) + "[respawn] Mech::Reset 2:43 healed+moved to (9,0,9) alive=1 zones=22 subsys=32", +] + +# a TRUE-positive bystander receipt: struck the OTHER mech (43 != own 42), +# far from the round's own aim, one paired delivery -- must NOT fail A4. +BYS_TRUE = [ + "[projectile] BYSTANDER id=43 t=0.7 at(100,10,100) dmg=5 lockedTgt=0BADF00D", + "[projectile] DET at(100,10,100) dmg=5 lead=1 tgt=0BADF00D aim(500,7,900)", + "[projectile] IMPACT damage=5 subsys=29 v=300 burnLeft=2 burst=3 " + "(direct dispatch) (zone cyl-resolved)", +] + + +def selftest(): + ok = True + + def expect(name, lines_a, want_frag): + nonlocal ok + logs = {"fx_a.log": parse(lines_a), "fx_b.log": parse(HEALTHY)} + fails = adjudicate(logs, verbose=False) + hit = any(want_frag in f for f in fails) + print(" selftest %-28s %s" % (name, "DETECTED" if hit else "** MISSED **")) + if not hit: + ok = False + for f in fails: + print(" got:", f) + + print("=== DETECTOR SELF-TEST (negative controls; each MUST fail) ===") + # 1. pre-#171: a damage-carrying ballistic round + multi-panel ballistic hit + fx = list(HEALTHY) + [ + "[projectile] PUSH target=0x5a len=400 speed=250 dmg=12 guided=0 ttl=5 " + "mz=(1,12,1) relY=12 lv=(fallback)", + "[projectile] DET at(9,2,3) dmg=12 lead=1 tgt=0x5a aim(9,2,3)", + "[dmghit] mech=42 zone=3 vital=0 type=1 amt=12 burst=3 lvl 0->0.2", + ] + expect("pre-#171 ballistic round", fx, "damage-carrying ballistic") + expect("pre-#171 multi-panel t1", fx, "burst!=1") + # 2. pre-#174: same zone cascades twice inside one life + fx = list(HEALTHY) + [ + "[cascade] zone 18 DESTROYED -> descend=1 destroySibs=0 crits=4", + "[cascade] zone 18 DESTROYED -> descend=1 destroySibs=0 crits=4", + ] + expect("pre-#174 double cascade", fx, "#174 ALIVE") + # 3. bystander FALSE positives (each must be flagged) ... + fx = list(HEALTHY) + [ + "[projectile] BYSTANDER id=42 t=0.5 at(50,10,50) dmg=5 lockedTgt=0BADF00D", + "[projectile] DET at(50,10,50) dmg=5 lead=1 tgt=0BADF00D aim(500,7,900)", + "[projectile] IMPACT damage=5 subsys=29 v=300 burnLeft=2 burst=3 " + "(direct dispatch) (zone cyl-resolved)", + ] + expect("bystander SELF-strike", fx, "SELF-STRIKE") + fx = list(HEALTHY) + [ + "[projectile] BYSTANDER id=7 t=0.31 at(4,5,6) dmg=8 lockedTgt=0x5a", + ] + expect("bystander phantom strike", fx, "phantom strike") + fx = list(HEALTHY) + [ + "[projectile] BYSTANDER id=43 t=1 at(499,7,899) dmg=5 lockedTgt=0BADF00D", + "[projectile] DET at(499,7,899) dmg=5 lead=1 tgt=0BADF00D aim(500,7,900)", + "[projectile] IMPACT damage=5 subsys=29 v=300 burnLeft=2 burst=3 " + "(direct dispatch) (zone cyl-resolved)", + ] + expect("bystander fuze-race at lock", fx, "raced the 4.0u fuze") + fx = list(HEALTHY) + BYS_TRUE + [ + "[projectile] BYSTANDER id=43 t=0.9 at(200,10,200) dmg=5 " + "lockedTgt=0BADF00D (receipt cap reached)", + "[projectile] DET at(200,10,200) dmg=5 lead=1 tgt=0BADF00D aim(500,7,900)", + "[projectile] IMPACT damage=5 subsys=29 v=300 burnLeft=2 burst=3 " + "(direct dispatch) (zone cyl-resolved)", + ] + expect("bystander print-cap reached", fx, "cap (24) reached") + # ... and the TRUE positive must NOT be flagged (no false alarm) + logs = {"fx_a.log": parse(HEALTHY + BYS_TRUE), "fx_b.log": parse(HEALTHY)} + fails = adjudicate(logs, verbose=False) + bysf = [f for f in fails if "BYSTANDER" in f or "SELF" in f] + print(" selftest %-28s %s" % ("bystander TRUE pos accepted", + "CLEAN" if not bysf else "** FALSE ALARM **")) + if bysf: + ok = False + for f in bysf: + print(" got:", f) + # 4. quiet rig: both logs empty must FAIL floors + logs = {"fx_a.log": parse([]), "fx_b.log": parse([])} + fails = adjudicate(logs, verbose=False) + quiet = any("quiet rig" in f for f in fails) and any("floor" in f for f in fails) + print(" selftest %-28s %s" % ("quiet rig floors", + "DETECTED" if quiet else "** MISSED **")) + ok = ok and quiet + # 5. the healthy fixture itself must PASS (no false alarms in the detector) + logs = {"fx_a.log": parse(HEALTHY), "fx_b.log": parse(HEALTHY)} + fails = adjudicate(logs, verbose=False) + print(" selftest %-28s %s" % ("healthy fixture passes", + "CLEAN" if not fails else "** FALSE ALARM **")) + if fails: + ok = False + for f in fails: + print(" got:", f) + print("DETECTOR:", "OK -- all negative controls detected" if ok else "BROKEN") + return ok + + +if __name__ == "__main__": + if len(sys.argv) >= 2 and sys.argv[1] == "--selftest": + sys.exit(0 if selftest() else 1) + if len(sys.argv) < 3: + print("usage: weapons_check.py | --selftest") + sys.exit(2) + logs = {fn: load(fn) for fn in sys.argv[1:]} + fails = adjudicate(logs, verbose=True) + sys.exit(0 if not fails else 1) diff --git a/scratchpad/night16/weapons_sweep.sh b/scratchpad/night16/weapons_sweep.sh new file mode 100644 index 0000000..7c25cc6 --- /dev/null +++ b/scratchpad/night16/weapons_sweep.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# weapons_sweep.sh -- ONE comprehensive 2-node bench proving every weapons fix +# on the final (2026-08-13) tree: #171 AC trigger-time hitscan, missile +# contact through the 4.0u fuze at drag-governed speeds (b94636b), energy +# emitters, the #174 cascade guard, and the lane-12 bystander sweep's +# no-false-positive property in a 2-mech fight. +# +# RIG (modeled on cascade_bench.sh / kd_bench.sh -- the rigs that demonstrably +# engage). KNOWN TRAP dodged: avatar-vs-avatar forms ZERO locks (three runs, +# 0 dmghits even from lasers -- see HITSCAN_STATE.md / ac_bench.sh) while the +# madcat family fights fine, so BOTH nodes fly madcat-family chassis: +# node A (-net 1501 -> pilot 127.0.0.1:1502) : mad2 "Zanin Neko" (Madcat V2) +# -- AFC50 autocannon + ERPPC + LRM15 + SRM6 x2 + ERMLaser x2: +# every family from one shooter (AC carrier per L4GAUGE.CFG :3008). +# node B (-net 1601 -> pilot 127.0.0.1:1602) : madcat +# -- AFC100 + LRM15 x2 + ERLLaser + ERSLaser x2 (kd_bench's proven +# engager), sparser trigger. +# Engagement = the kd_bench pattern: BT_GOTO=enemy drives them onto each other +# and the production aim ray designates the enemy (mech+0x388 lock). NOT +# BT_FIRE_AT_ICON (that designates buildings -- fine for the cascade rig's +# splash chaos, useless for typed mech-vs-mech receipts). AF periods 4/7: +# throttled (unthrottled autofire trips the FailureHeat brick) and staggered. +# +# Assertions + PASS table: scratchpad/night16/weapons_check.py. The checker's +# --selftest (run FIRST, gate on it) proves the detector flags the pre-#171 +# and pre-#174 log shapes, bystander false positives (self-strike / phantom / +# fuze-race / print-cap), and a quiet rig -- the week's hard rule: prove the +# bench CAN detect failure before trusting PASS. +# +# RESULT 2026-08-13 (evidence: scratchpad/night16/ws_a.log ws_b.log): PASS. +# ENERGY 184 FIRED / 72 laser + 7 PPC hits; AC 42 FIRED / 17 type=1 hits all +# burst=1 / 84 ballistic pushes all dmg=0; MISSILE 151 FIRED / 57 damage +# IMPACTs (50 salvo bursts) / 593 type=2 hits; 11 deaths; cascades 8, none +# re-descended. CALIBRATION FINDING: BYSTANDER receipts DO fire in a 2-mech +# fight (11x) and all were TRUE positives -- the production aim ray locks +# scenery, and the other mech crossing the round's path detonates it (the +# binary world-sweep behavior). The checker asserts the real false-positive +# conditions instead of a zero count; see weapons_check.py header. +set -x +python /c/git/bt411/scratchpad/night16/weapons_check.py --selftest || { + echo "DETECTOR SELF-TEST FAILED -- fix the checker before benching"; exit 1; } + +. /c/git/bt411/scratchpad/night6/bench_common.sh +bt_assert_player_env +cd /c/git/bt411/content || exit 1 +taskkill //F //IM btl4.exe >/dev/null 2>&1; sleep 3 +rm -f ws_a.log ws_b.log ws_r.log +bt_expert_egg MP.EGG WS.EGG +# map/time sed is MANDATORY (test-harness: MP.EGG authors cavern/night -- +# un-sedded copies park the mechs against cavern rock). Vehicle seds are +# EXACT-LINE (the ^vehicle=.* form would blindly stamp both pilots the same). +sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=bhk1$/vehicle=mad2/; s/^vehicle=ava1$/vehicle=madcat/" WS.EGG +grep -q "^vehicle=mad2$" WS.EGG && grep -q "^vehicle=madcat$" WS.EGG || { + echo "FAIL: WS.EGG vehicle sed did not land (check MP.EGG pilot pages)"; exit 1; } +grep -q "advancedDamage=1" WS.EGG || { echo "FAIL: egg lacks advancedDamage=1"; exit 1; } + +# node B FIRST (back window): madcat, sparser trigger, still all families. +( export BT_GOTO=enemy BT_GOTO_STOP=80 BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7 + export BT_DMG_LOG=1 BT_PROJ_LOG=1 BT_DEATH_LOG=1 BT_AMMO_LOG=1 + bt_launch ws_b.log WS.EGG 0x0C -net 1601 ) +sleep 2 +# node A: mad2 (Zanin Neko), the dense shooter -- AC + missiles + energy. +( export BT_GOTO=enemy BT_GOTO_STOP=100 BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=4 + export BT_DMG_LOG=1 BT_PROJ_LOG=1 BT_DEATH_LOG=1 BT_AMMO_LOG=1 + bt_launch ws_a.log WS.EGG 0x03 -net 1501 ) +sleep 5 +python ../tools/btconsole.py WS.EGG 127.0.0.1:1501 127.0.0.1:1601 > ws_r.log 2>&1 & +R=$! +sleep 300 +kill $R 2>/dev/null +bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe >/dev/null 2>&1 + +python /c/git/bt411/scratchpad/night16/weapons_check.py ws_a.log ws_b.log