#168 settled from the binary: projectiles are TIME-capped, never range-capped -- plain shell 5.0s (@4bddec), missile burnTime+10s + y<-1 kill-plane (@4bef78), WeaponRange is fire-control only (FireWeapon @4bcc60 has no range gate), so AFC50 hits at 2000+u are authentic. Two port defects killed: the invented len*1.3+60 flight cap (replaced by the authentic clocks) and the stale-point auto-hit (unguided contact tested the fire-time pick, so a locked AC could not miss a moving target -- the pool now tracks the live target). KB: decomp-reference gains the flight/expiry section, CLASSMAP's swapped authoritative/ghost Performance labels corrected (ReplicantInstance=4 [T0]), missile.cpp's misdecoded @4bf594 constant pool re-annotated, Missile proximity fuse 4.0u recorded. Bench: proj_ttl_bench.sh (probe fizzles 5.012/12.012 vs caps 5.0/12.0; 86 field launches, 0 early, 0 phantoms). Rides along: the #163 BTSteamNet_ShutdownAll header decl that missed d3f4b9a.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-08-11 04:39:37 -05:00
co-authored by Claude Fable 5
parent dfefdfa1d0
commit e2ab17665d
7 changed files with 255 additions and 28 deletions
+8
View File
@@ -199,6 +199,14 @@ Fixed-torso mechs (the BLH: `TorsoHorizontalEnabled=0` — no jointtorso in the
boresight dead-ahead. Once locked, `Emitter::FireWeapon` converges with NO aim/arc test
(part_013.c:7758). `MechWeapon::UpdateTargetState` (`FUN_004b9bdc` [T1]): `targetWithinRange =
dist < (1 hostZoneDamage) × weaponRange`. The 0x388 WRITER is in the same un-exported gap.
`weaponRange` is **fire-control data only** (#168 audit [T1]): projectile FLIGHT is never
range-capped — a plain shell (AFC) lives exactly 5.0 s (@4bddec) and a Missile lives
BurnTime+10 s with a y<1 kill-plane (@4bef78); FireWeapon @4bcc60 has no range gate, so an
AFC50 (authored range 750) landing hits at 2000+ u is AUTHENTIC arcade behavior. Constants +
the whole flight/expiry chain: [[decomp-reference]] §5 "Projectile/Missile flight & expiry".
The same audit killed the pool's stale-point auto-hit (unguided contact was tested against
the fire-time pick, so a locked AC shell could not miss a moving target — fixed 2026-08-11:
the contact test now tracks the target's live position on every round kind).
**`hostZoneDamage` is `Subsystem::damageZone->damageLevel` (weapon `@0xE0 → +0x158`), NOT
heatLoad** — the port originally computed `effectiveRange = (1 heatLoad) × weaponRange`
(mechweap.cpp), the SAME `@0xE0`-DamageZone-vs-heat misattribution corrected in
+28
View File
@@ -253,6 +253,34 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a
@004b0b18) + @004b0abc (auto/manual, modeAlarm@0x2CC); attach/detach @004b0dd8/@004b0e30
(generator client capacity @0x1e4/count @0x1e8).
### Projectile/Missile flight & expiry (#168 audit, 2026-08-11) [T1]
- **No range cap exists anywhere in the flight chain.** WeaponRange feeds fire-control only
(effectiveRange @0x328 = (1hostZoneDamage)×WeaponRange — targeting/reticle "in range" call).
FireWeapon @4bcc60 reads no +0x328: the trigger fires at any target distance.
- **Plain Projectile (AFC shell) live integrator @4bddec** (master); expiry: age since
spawn(+0x19c) > `_DAT_004be054` = **5.0 s** → FUN_0042061c kill. Dead-reckon divergence²
vs predicted pos(+0x260, predictor FUN_00422060 via +0x254) > `_DAT_004be050` = 0.1 →
updateModel(+0x18)|=1 (ForceUpdate, replication-dirty — NOT a kill).
- **Missile live integrator @4bef78** (master); expiry: age > burnTime(+0x340, model rec
+0x44) + `_DAT_004bf5ac` = **10.0 s** coast margin, OR pos.y < `_DAT_004bf5b0` = **1.0**
kill-plane → FUN_0042061c. Keepalive: no update for `_DAT_004bf594`=2.0 s → dirty bit.
**Proximity fuse**: seeker rangeToTarget(+0x10C) < `_DAT_004bf5a4` = **4.0** → detonate on
targetEntity without a geometry hit. Seeker drops a destroyed target (movementMode 2|9,
FUN_0049fb54).
- **Performance split** (Projectile ctor @4be1bc / Missile ctor @4bf5b4): instance
(flags&0xC)==ReplicantInstance(4) → FUN_004221c0 dead-reckoning smoother (no physics);
master → 4bddec/4bef78. (CLASSMAP's old authoritative/ghost labels were swapped —
corrected 2026-08-11.)
- **FUN_0042061c = Entity kill**: removes from world list + simulationFlags(+0x28) |= 0x2000|2.
- All tick→seconds via `DAT_0052140c` = 28.0 (the frame-rate global).
- Port consequences (both fixed in mech4.cpp, 2026-08-11): (a) the pool's old
`p.range = len×1.3+60` distance-to-target cap was an invention — replaced by the
authentic time caps; (b) the pool's contact sphere tested UNGUIDED rounds against
the FIRE-TIME pick point and dispatched damage to the entity wherever it moved —
a locked AC/Gauss could not miss a moving target (bench: 86/86 "hits" vs a
circling mech). The pool now tracks the target's live position for the contact
test on every round kind; only guided rounds steer.
---
### The CONFIG-MODE session chain (task #6) [T1]
+4
View File
@@ -32,6 +32,10 @@ int
BTSteamNet_Active();
void
BTSteamNet_Pump(); // callbacks + rx drain (game thread)
void
BTSteamNet_ShutdownAll(); // #163: ordered transport death for
// process exit -- close every
// connection, then SteamAPI_Shutdown
//
// The wire-seam surface (BTNet* wrappers in L4NET.CPP + the marshal).
+20 -1
View File
@@ -398,10 +398,29 @@ Missile flight-entity cluster (spawned by MissileLauncher::FireWeapon @4bcc60, s
members (best-effort, past Entity base): modelUpdateContext@0x250(+bound update PTR_FUN_005129dc@0x254), world@0x300, currentSegment@0x304,
segmentIndex@0x308(desc+0x84), sourceEntity@0x30C(desc+0x88), sourceWeapon@0x310(desc+0x8C), launchPosition@0x314(desc+0x90),
launchVelocity@0x320(desc+0x9C), aimDirection@0x32C(desc+0xA8), modelIndex@0x338(desc+0xB4), damageRecord@0x33C(desc+0xB8).
Performance variants PTR_LAB_005129e8(authoritative)/005129f4(ghost); base MoveAndCollide body past window — live integrator is Missile override @4bef78.
Performance variants (CORRECTED 2026-08-11, was swapped): ctor @4be1bc picks by instance —
(flags&0xC)==ReplicantInstance(4, ENTITY.h:318 [T0]) → PTR_LAB_005129e8 → FUN_004221c0 = the REPLICANT
dead-reckoning smoother (blends toward predicted state, no physics/damage); else (master/local) →
PTR_LAB_005129f4 → FUN_004bddec = the LIVE plain-Projectile integrator (was "past window" in the old
export; present in the 2026-08-06 rebuild). Same split in the Missile ctor: 512da0→4221c0 (replicant),
512dac→4bef78 (master integrator).
Plain-Projectile flight/expiry @4bddec [T1]: integrate → world/entity collide FUN_0042291c (shooter
excluded via @4be058) → hit: detonate+damage FUN_004be078; no-hit: dead-reckon predictor
FUN_00422060 via +0x254 refreshes predicted pos +0x260, divergence² > _DAT_004be050=0.1 sets
+0x18|=1 (replication-dirty, NOT kill); tail: age since spawn(+0x19c) > _DAT_004be054=5.0s →
FUN_0042061c kill (remove + flags|=0x2000|2). NO WeaponRange / distance cap on flight — a shell
flies exactly 5.0s (#168: the authored range=750 is fire-control data only; FireWeapon @4bcc60
reads no effectiveRange +0x328 — the trigger has no range gate).
└─ Missile vtable 00512f2c ctor @4bf5b4 dtor @4bf890 alloc @4bf8bc (size 0x368) [missile.cpp]
MoveAndCollide @4bef78 (age/guide/integrate/collide; collision FUN_0042291c, detonate FUN_004be078
streamed ClassID 0x5C), WriteUpdateRecord @4bef4c (slot7, tag 0x78).
Expiry @4bef78 tail [T1]: age since spawn(+0x19c) > burnTime(+0x340, model rec +0x44) +
_DAT_004bf5ac=10.0s coast margin, OR pos.y(+0x104) < _DAT_004bf5b0=-1.0 kill-plane → FUN_0042061c
kill. Also: no replication update for _DAT_004bf594=2.0s → +0x18|=1 (dirty keepalive);
dead-reckon divergence² > _DAT_004bf5a8=0.1 → +0x18|=1. Seeker target DROPPED when the victim's
movementMode ∈ {2,9} = destroyed (FUN_0049fb54). PROXIMITY FUSE: seeker rangeToTarget(+0x10C) <
_DAT_004bf5a4=4.0 → treat targetEntity as the collision victim (detonate) even without a geometry
hit. LRM "range" is therefore physics (speed × lifetime), not a gate.
Hosts 2 subsystems: this[0x49]=2 @0x124, roster @0x128 → {Seeker, MissileThruster}.
enum {SeekerSubsystem=0, MissileThrusterSubsystem=1}. members (best-effort):
lifetime@0x340 ageFraction@0x344 thrustScale@0x348 targetConn@0x34C targetOffset@0x350
+71 -18
View File
@@ -844,8 +844,9 @@ struct BTProjectile {
Point3D pos;
Vector3D vel; // world velocity (authored MuzzleVelocity, steered per frame)
Scalar speed; // |vel| held constant through the steer
Scalar traveled;
Scalar range;
Scalar age; // flight seconds since launch (#168)
Scalar ttl; // authentic lifetime cap (s): 5.0 plain shell (@004bddec),
// BurnTime+10.0 missile (@004bef78) -- see the launch site
Entity *target;
Point3D targetPos;
Scalar aimOffsetY; // vertical aim offset vs the target's origin (live re-lead)
@@ -1473,6 +1474,8 @@ void
? (mz.y - ((Mech *)shooter)->localOrigin.linearPosition.y) : mz.y;
DEBUG_STREAM << "[projectile] PUSH target=" << (void*)target
<< " len=" << len << " speed=" << speed << " dmg=" << damage
<< " guided=" << guided
<< " ttl=" << (guided ? ((thrust_burn > 0.0f ? thrust_burn : 0.0f) + 10.0f) : 5.0f)
<< " mz=(" << mz.x << "," << mz.y << "," << mz.z << ") relY=" << relY
<< " lv=" << (launch_velocity
? "(auth)" : "(fallback)") << "\n" << std::flush;
@@ -1645,8 +1648,18 @@ void
{
p.vel.x = d.x/len*p.speed; p.vel.y = d.y/len*p.speed; p.vel.z = d.z/len*p.speed;
}
p.traveled = 0.0f;
p.range = len * 1.3f + 60.0f; // arc margin; expire past the target
p.age = 0.0f;
// AUTHENTIC lifetime (#168). The old cap (len*1.3+60, distance-to-target)
// was a port invention: the binary never range-caps a round in flight.
// The plain Projectile lives exactly 5.0s (@004bddec tail:
// age > _DAT_004be054=5.0 -> FUN_0042061c kill) and the Missile lives
// BurnTime+10.0s (@004bef78 tail: age > burnTime(+0x340) +
// _DAT_004bf5ac=10.0), plus a kill-plane at y < -1 (_DAT_004bf5b0).
// WeaponRange is FIRE-CONTROL data only (effectiveRange @0x328) -- the
// trigger itself has no range gate (@004bcc60 reads no +0x328), so a
// shell landing far beyond its listed range is authentic arcade behavior.
p.ttl = guided ? ((thrust_burn > 0.0f ? thrust_burn : 0.0f) + 10.0f)
: 5.0f;
p.target = (Entity *)target;
p.targetPos = tpos; // resolved target position (fallback-aware)
// live re-lead (authentic: the Seeker re-leads the MOVING target every
@@ -1724,19 +1737,23 @@ static void
// squared-error guidance: rotate the velocity toward the lofted aim at
// the decomp turn gain (MissileTurnGain = 4.0, _DAT_004bf5a4), speed
// held at the authored launch speed.
// LIVE TARGET TRACKING -- for EVERY round kind, not just guided (#168
// audit). The contact sphere below is the port's stand-in for the
// binary's real-geometry collision (FUN_0042291c hits the mech's BODY
// wherever it is NOW); testing it against the fire-time pick point let
// an unguided shell "contact" empty air after the target moved and
// still dispatch full damage to the departed mech -- a locked
// autocannon could not miss a moving target. Track the entity for the
// contact test; only GUIDED rounds also steer onto it.
extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target)
&& !((Mech *)p.target)->IsMechDestroyed())
{
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
if (p.guided)
{
// LIVE RE-LEAD (authentic: Seeker::LeadTarget runs every slice on
// the MOVING target): refresh the aim from the target's current
// position, preserving the launch aim's body height.
extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target)
&& !((Mech *)p.target)->IsMechDestroyed())
{
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
Point3D aim = p.targetPos;
Scalar rx = aim.x - p.pos.x, ry = aim.y - p.pos.y, rz = aim.z - p.pos.z;
Scalar range = (Scalar)sqrtf(rx*rx + ry*ry + rz*rz);
@@ -1789,7 +1806,7 @@ static void
p.speed = ns;
}
p.pos.x += p.vel.x*dt; p.pos.y += p.vel.y*dt; p.pos.z += p.vel.z*dt;
p.traveled += p.speed * dt;
p.age += dt;
// WORLD IMPACT (authentic: the binary missile runs a world collision
// query every frame, FUN_0042291c, and DETONATES on geometry). Ray the
@@ -1894,10 +1911,13 @@ static void
contactD2 = cx*cx + cy*cy + cz*cz;
}
const int contact = (contactD2 < (10.0f*10.0f));
if (!contact && p.traveled >= p.range)
if (!contact && (p.age >= p.ttl || (p.guided && p.pos.y < -1.0f)))
{
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] FIZZLE (flight cap, no contact)\n" << std::flush;
DEBUG_STREAM << "[projectile] FIZZLE t=" << p.age
<< " ttl=" << p.ttl << " guided=" << p.guided
<< ((p.guided && p.pos.y < -1.0f) ? " KILLPLANE" : "")
<< "\n" << std::flush;
p.active = 0;
continue;
}
@@ -7106,6 +7126,39 @@ void
gBTGenSelKey = pulse ? s_gsID : 0;
}
// TIMEOUT PROBE (BT_PROJ_TEST=1, off by default; the #168 bench). At
// t=+5s push two DAMAGE-0 rounds aimed at a point 40000u overhead --
// unreachable at speed 400, no target, no terrain in the path -- so
// each MUST end in the [projectile] FIZZLE receipt at exactly its
// lifetime cap: unguided ttl=5.0 (@004bddec), guided burn=2 ->
// ttl=12.0 (@004bef78). The 2-node rig proves real flights never
// fizzle EARLY; this proves the timeout itself fires at the authentic
// second.
{
static int s_ptArmed = -1;
static float s_ptClock = 0.0f;
if (s_ptArmed < 0)
s_ptArmed = (getenv("BT_PROJ_TEST") != 0) ? 1 : 0;
if (s_ptArmed == 1
&& (Entity *)this == application->GetViewpointEntity())
{
s_ptClock += dt;
if (s_ptClock >= 5.0f)
{
s_ptArmed = 2; // once per process
extern void BTPushProjectile(const Point3D &, void *, void *,
const Point3D &, Scalar, Scalar, const Vector3D *, int,
int, int, int, int, Scalar, Scalar, int);
Point3D mzp = localOrigin.linearPosition; mzp.y += 14.0f;
Point3D upp = mzp; upp.y += 40000.0f;
BTPushProjectile(mzp, this, 0, upp, 400.0f, 0.0f, 0, 0,
-1, 0, -1, 2, 0.0f, 0.0f, 0);
BTPushProjectile(mzp, this, 0, upp, 400.0f, 0.0f, 0, 1,
-1, 0, -1, 2, 0.0f, 2.0f, 0);
}
}
}
// SELF-DAMAGE HARNESS (BT_SELF_DAMAGE=<amount per second>, off by default).
// The whole RESPAWN family -- torso twist after respawn (#70), ammo/weapon
// reset (#22), coolant/heat/generator restore (#55), the respawn latch
+25 -9
View File
@@ -20,9 +20,23 @@
// excluded : the Projectile base ctor @004be1bc and the 0x41xxxx/0x42xxxx
// Entity engine vtable slots
//
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0):
// 0x40000000 = 2.0f 0x38d1b717 = 1.0e-4f 0x40800000 = 4.0f
// 0x3dcccccd = 0.1f 0x41200000 = 10.0f 0xbf800000 = -1.0f
// Decoded MoveAndCollide tuning constants (.rdata @004bf594..@004bf5b0).
// ⚠ RE-DECODED 2026-08-11 (#168 audit, byte-read + full @4bef78 walk) -- the
// original decode below misattributed most of these. Verified semantics:
// _DAT_004bf594 = 2.0f replication KEEPALIVE: no update sent for 2.0s
// -> updateModel(+0x18)|=1 (ForceUpdate), NOT a death
// _DAT_004bf598 = 0.0f per-axis drag-coefficient sign pick (rel-vel vs wind)
// _DAT_004bf59c/a0 = ∓1e-4 signed-square deadband in the DRAG term
// _DAT_004bf5a4 = 4.0f PROXIMITY FUSE: seeker rangeToTarget(+0x10C) < 4.0
// -> detonate on targetEntity without a geometry hit
// _DAT_004bf5a8 = 0.1f dead-reckon divergence² (vs predictor FUN_00422060
// pos +0x260) -> replication-dirty
// _DAT_004bf5ac = 10.0f COAST MARGIN: expiry at age > burnTime(+0x340)+10.0s
// _DAT_004bf5b0 = -1.0f kill-plane: pos.y < -1 -> FUN_0042061c retire
// The steering/lifetime code below still reflects the OLD decode -- this TU is
// DORMANT (nothing constructs Missile; the mech4.cpp pool is the live path) --
// rework it against context/decomp-reference.md §5 "Projectile/Missile flight
// & expiry" before ever wiring it live.
//
// Helper-function name mapping:
// FUN_004be1bc Projectile base constructor (vtable @00512a5c)
@@ -53,12 +67,14 @@
//
// MoveAndCollide tuning constants (read-only globals in the decomp).
//
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 age-ratio dead threshold
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf598/9c/a0 steering deadband
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8
static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac/a8 min squared-speed before fizzle
static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0
// ⚠ names below carry the OLD (wrong) decode -- see the corrected table in the
// header comment; kept only so the dormant skeleton still compiles unchanged.
static const Scalar MissileLifeWarn = 2.0f; // _DAT_004bf594 actually: replication keepalive (s)
static const Scalar MissileSteerEps = 1.0e-4f;// _DAT_004bf59c/a0 actually: drag signed-square deadband
static const Scalar MissileTurnGain = 4.0f; // _DAT_004bf5a4 actually: proximity-fuse radius (u)
static const Scalar MissileDriftGain = 0.1f; // _DAT_004bf5a8 actually: dead-reckon divergence²
static const Scalar MissileMinSpeed2 = 10.0f; // _DAT_004bf5ac actually: coast margin (s) past burnTime
static const Scalar MissileDead = -1.0f; // _DAT_004bf5b0 kill-plane y (this one was right)
//###########################################################################
//###########################################################################
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# #168: projectile lifetime bench (v5). Two phases:
#
# PHASE 1 (probe, solo ~40s): BT_PROJ_TEST pushes two damage-0 rounds straight
# up (no target, no terrain in the path) -- each MUST fizzle at exactly its
# authentic cap: unguided ttl=5.0s (@004bddec _DAT_004be054), guided burn=2 ->
# ttl=12.0s (@004bef78 burnTime + _DAT_004bf5ac=10). Proves the timeout fires
# at the authentic second.
#
# PHASE 2 (field, 2-node ~250s): A (avatar) autofires AC + launcher salvos at
# B (madcat) walking at full throttle. Proves: every launch stamps the
# authentic ttl (5.0 / burn+10), NO round fizzles early (the old len*1.3+60
# distance cap is gone), and unguided DETs land only on the target's LIVE
# position (stale-point phantom hits dead: DET at() must ~= aim()).
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
taskkill //F //IM btl4.exe >/dev/null 2>&1; sleep 3
rm -f pttl_probe.log pttl_a.log pttl_b.log pttl_relay.log
# ---------------- PHASE 1: the timeout probe (solo) ----------------
bt_expert_egg MP.EGG PTTL.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=avatar/" PTTL.EGG
( export BT_PROJ_TEST=1 BT_PROJ_LOG=1
bt_launch pttl_probe.log PTTL.EGG 0x03 -egg PTTL.EGG )
sleep 75
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe >/dev/null 2>&1; sleep 3
# ---------------- PHASE 2: the field rig (2-node) ----------------
bt_expert_egg MP.EGG PTTL.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/" PTTL.EGG
python - <<'EOF'
lines = open('PTTL.EGG').read().splitlines(True)
n = 0
for i, l in enumerate(lines):
if l.startswith('vehicle='):
n += 1
lines[i] = 'vehicle=avatar\n' if n == 1 else 'vehicle=madcat\n'
open('PTTL.EGG', 'w').writelines(lines)
print('vehicles set:', n)
EOF
( export BT_AUTODRIVE=1.0 BT_SPIN_SELF=45 BT_MP_LOG=1
bt_launch pttl_b.log PTTL.EGG 0x0C -net 1601 )
sleep 2
( export BT_SPIN_SELF=30 BT_KEY_NOFOCUS=1
export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7
export BT_PROJ_LOG=1 BT_MP_LOG=1
bt_launch pttl_a.log PTTL.EGG 0x03 -net 1501 )
sleep 5
python ../tools/btconsole.py PTTL.EGG 127.0.0.1:1501 127.0.0.1:1601 > pttl_relay.log 2>&1 &
RELAY=$!
sleep 240
kill $RELAY 2>/dev/null
sleep 2
bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe >/dev/null 2>&1
python - <<'PY'
import io, re, math
ok = True
# ---- phase 1: the probe ----
ptxt = io.open("pttl_probe.log", encoding="latin-1", errors="replace").read()
pfz = re.findall(r"\[projectile\] FIZZLE t=([0-9.]+) ttl=([0-9.]+) guided=(\d)", ptxt)
u = [(float(t), float(c)) for t, c, g in pfz if g == '0']
g = [(float(t), float(c)) for t, c, g in pfz if g == '1']
print("=== #168 PHASE 1: TIMEOUT PROBE ===")
print("probe fizzles unguided/guided:", u, "/", g)
p1 = (len(u) == 1 and len(g) == 1
and abs(u[0][1] - 5.0) < 0.001 and u[0][0] >= 5.0 and u[0][0] < 5.3
and abs(g[0][1] - 12.0) < 0.001 and g[0][0] >= 12.0 and g[0][0] < 12.3)
print("phase 1:", "PASS -- timeouts fired at the authentic caps" if p1 else "FAIL")
ok &= p1
# ---- phase 2: the field rig ----
txt = io.open("pttl_a.log", encoding="latin-1", errors="replace").read()
push = re.findall(r"\[projectile\] PUSH .*guided=(\d) ttl=([0-9.]+)", txt)
fz = re.findall(r"\[projectile\] FIZZLE t=([0-9.]+) ttl=([0-9.]+) guided=(\d)", txt)
p0 = [t for gg, t in push if gg == '0']; p1n = [t for gg, t in push if gg == '1']
badp0 = [t for t in p0 if abs(float(t) - 5.0) > 0.001]
badp1 = [t for t in p1n if float(t) < 10.0]
early = [(t, c, gg) for t, c, gg in fz if float(t) < float(c) - 0.05]
# phantom check: every unguided DET must burst ON the live aim (at ~= aim)
phantom = []
for m in re.finditer(r"DET at\(([-0-9.]+),([-0-9.]+),([-0-9.]+)\) dmg=13 .*aim\(([-0-9.]+),([-0-9.]+),([-0-9.]+)\)", txt):
ax, ay, az, bx, by, bz = map(float, m.groups())
if math.sqrt((ax-bx)**2 + (ay-by)**2 + (az-bz)**2) > 12.0:
phantom.append(m.group(0)[:90])
print("=== #168 PHASE 2: FIELD RIG ===")
print("PUSH unguided/guided :", len(p0), "/", len(p1n))
print("bad ttl stamps :", len(badp0) + len(badp1))
print("early fizzles :", len(early), early[:3])
print("phantom unguided DETs:", len(phantom), phantom[:2])
p2 = (len(p0) > 0 and len(p1n) > 0 and not badp0 and not badp1
and not early and not phantom)
print("phase 2:", "PASS -- stamps authentic, none early, no stale-point hits" if p2 else "FAIL")
ok &= p2
print("VERDICT:", "PASS" if ok else "FAIL")
PY