#171 IMPLEMENTED: the AC is trigger-time hitscan again (@004bc104 restored: counter/TracerInterval batching, ComputeTimeOfFlight inside the tracer branch refreshing LIVE effectiveRange, dist<=+0x328 fire-time gate, ONE single-panel batched damage to the locked target via @004b9728; the flying round is the cosmetic 0xBCD tracer, damage 0 -- shell class 0xBD1 stays dead). Missiles keep flight, corrected to the binary: lock-drop = movementMode 2||9 exactly (@0049fb54, tracks THROUGH the fall), proximity fuze 4.0 (_DAT_004bf5a4 -- was misused as a turn gain), salvo roll floor-of-1 REMOVED (binary sender rolls 0; the receiver burst do-while is the floor, mech.cpp:1229). Gauss untouched pending the era witnesses (shipped binary deals NO Gauss damage -- group question on #171). Verification: line-by-line vs part_013.c:16023-16125/18260-18285 (verify workflow + review); end-to-end receipts from the engaged bench run: type=1 burst=1 hitscan hits incl. a 13-round batch, all DETs dmg=0. Local rig cannot form avatar locks for a clean re-run (rig, not feature) -- Friday's field receipts are armed and decisive. Closes the #168 mis-generalization; #112 single-panel restored

This commit is contained in:
Joe DiPrima
2026-08-13 13:19:18 -05:00
parent 80037a7c78
commit bbb3b4ec82
2 changed files with 112 additions and 22 deletions
+45 -6
View File
@@ -1746,11 +1746,29 @@ static void
// autocannon could not miss a moving target. Track the entity for the // autocannon could not miss a moving target. Track the entity for the
// contact test; only GUIDED rounds also steer onto it. // contact test; only GUIDED rounds also steer onto it.
extern int BTIsRegisteredMech(Entity *e); extern int BTIsRegisteredMech(Entity *e);
if (p.target != 0 && BTIsRegisteredMech(p.target) if (p.target != 0 && BTIsRegisteredMech(p.target))
&& !((Mech *)p.target)->IsMechDestroyed())
{ {
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition; // #171 LOCK DROP predicate = @0049fb54 EXACTLY: movementMode 2||9,
p.targetPos.y += p.aimOffsetY; // the latched death modes -- NOT IsMechDestroyed(), whose extra
// graphicAlarm>=9 term fires at the vital-kill TRIGGER, seconds
// before the collapse (modes 5-8) reaches 2/9. The binary seeker
// keeps TRACKING (and its 4.0u fuze stays live) all through the
// fall; it releases only once the wreck latches disabled
// (@004bef78: IsKindOf(Mech) && FUN_0049fb54 -> seeker+0xfc = 0).
const int mm = ((Mech *)p.target)->MovementMode();
if (!(mm == 2 || mm == 9))
{
p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition;
p.targetPos.y += p.aimOffsetY;
}
else if (p.guided)
{
// Seeker released: the round flies on UNGUIDED; only geometry
// can detonate it now. (The old port kept steering to the
// corpse's last position and could still sphere-contact it.)
p.guided = 0;
p.target = 0; // fuze disarmed with the lock
}
} }
if (p.guided) if (p.guided)
{ {
@@ -1910,7 +1928,15 @@ static void
cz = p.targetPos.z - hitPos.z; cz = p.targetPos.z - hitPos.z;
contactD2 = cx*cx + cy*cy + cz*cz; contactD2 = cx*cx + cy*cy + cz*cz;
} }
const int contact = (contactD2 < (10.0f*10.0f)); // #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
// misattributed as "MissileTurnGain"; the 4.0/8.0 turn rates above are
// PORT TUNING [T3], not that constant). Cosmetic rounds (damage 0:
// 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);
if (!contact && (p.age >= p.ttl || (p.guided && p.pos.y < -1.0f))) if (!contact && (p.age >= p.ttl || (p.guided && p.pos.y < -1.0f)))
{ {
if (getenv("BT_PROJ_LOG")) if (getenv("BT_PROJ_LOG"))
@@ -1978,9 +2004,22 @@ static void
int bursts = (p.splashBurst > 0) ? p.splashBurst : 1; int bursts = (p.splashBurst > 0) ? p.splashBurst : 1;
if (bursts > 1) if (bursts > 1)
{ {
// #171: the binary roll (part_013.c:18272-18281) has NO
// floor-of-1 -- rolled = min(Random(n) + n/4, n), and
// Random(n) is 0..n-1, so n<4 can roll ZERO. The
// VICTIM side is what floors it: the binary burst loop
// is a DO-WHILE (part_012.c:14660-14685 -- local_2c =
// burstCount, apply, decrement, `if (local_2c < 1)
// break` AFTER the body), so a 0-burst message still
// applies ONE burst; mech.cpp:1229's `burstsLeft < 1
// -> 1` guard is the faithful mirror of that shape.
// The old sender-side floor made the end-to-end count
// identical, but shipped a message field the binary
// never sends (burstCount 1 where the binary says 0);
// roll it exactly as the binary does and let the
// receiver's do-while supply the floor.
int rolled = Random(bursts) + (bursts >> 2); int rolled = Random(bursts) + (bursts >> 2);
if (rolled > bursts) rolled = bursts; if (rolled > bursts) rolled = bursts;
if (rolled < 1) rolled = 1;
bursts = rolled; bursts = rolled;
} }
dmg.burstCount = bursts; dmg.burstCount = bursts;
+67 -16
View File
@@ -1002,10 +1002,9 @@ void
{ {
Check(this); Check(this);
// Refresh the lead solution (leadPosition / targeting) for the spawn below // (ComputeTimeOfFlight @004bc06c is called INSIDE the tracer branch below,
// (@004bc06c; the recovered sim no longer calls it -- best-effort placement // exactly where @004bc104 calls it -- :16091, between the muzzle resolve and
// inside the unrecovered @4bc104 body, which is where the lead data is used). // the range gate. The old top-of-body placement predated the re-export.)
ComputeTimeOfFlight(); // @004bc06c
// THE FIRING HEAT (task #9; was "a separate pre-existing gap"): the // THE FIRING HEAT (task #9; was "a separate pre-existing gap"): the
// binary adds heatCostToFire RAW to the weapon's own pendingHeat -- // binary adds heatCostToFire RAW to the weapon's own pendingHeat --
@@ -1047,27 +1046,79 @@ void
} }
} }
// WAVE 7 Phase B: launch one flying ballistic round toward the owner's target (port //
// reconstruction; the byte-exact world-entity Projectile is blocked by the 2007 engine // #171 (2026-08-13) -- THE AUTHENTIC AC: TRIGGER-TIME HITSCAN (@004bc104
// Entity base mismatch -- see BTPushProjectile / mech4.cpp). Speed = |launchVelocity|. // [T1], the body the stale banner hid; the prior flying-damage round was
// the #168 mis-generalization from the MISSILE launcher). Binary shape:
// increment the round counter (+0x408); every TracerInterval-th (+0x438)
// round, dist = |owner aimPoint(+0x37c) - muzzle| gated against the LIVE
// effectiveRange (+0x328, the same gate the Emitter uses) -> ONE
// single-panel Damage (amount = batchedCount x DamageAmount) to the
// LOCKED target *(mech+0x388) via SendDamageMessage @004b9728, AT FIRE
// TIME. Out of range: the batch silently discards. A locked AC inside
// range cannot miss; nothing about the hit rides the visible round.
// (This also restores the binary's ONE-message-per-batch = single-panel
// damage -- closing #112's multi-zone shotgun, which was the port's
// per-round zone re-roll scatter.)
//
char *o = (char *)owner; // inherited MechSubsystem::owner (the Mech) char *o = (char *)owner; // inherited MechSubsystem::owner (the Mech)
// The owner's target slots (raw 0x388/0x37c -- the same raw offsets mech4's
// targeting block writes; a consistent producer/consumer pair on our
// compiled object). Task #41: the slots hold the WORLD PICK -- the enemy
// mech under the boresight, or the terrain downrange (a missile fired at
// the scenery flies to the ground point and detonates without damage), or
// null at the sky (BTPushProjectile refuses; the weapon's own 0x388 gate
// wouldn't have fired either).
void *target = (o != 0) ? *(void **)(o + 0x388) : 0; void *target = (o != 0) ? *(void **)(o + 0x388) : 0;
Point3D targetPos = (o != 0) ? *(Point3D *)(o + 0x37c) : muzzle; Point3D targetPos = (o != 0) ? *(Point3D *)(o + 0x37c) : muzzle;
++tracerCounter; // @0x408 -- incremented BEFORE the test
// @4bc104:16079: bVar1 = (interval != 0) && (counter % interval == 0).
// interval==0 -> bVar1 never set: NO damage ever, counter never resets
// (and the short-circuit means no divide) -- the port matches exactly.
if (tracerInterval != 0 && (tracerCounter % tracerInterval) == 0)
{
// @4bc104:16088-16091: muzzle transform (@004b9948, resolved above as
// `muzzle`), then the lead/targeting refresh -- INSIDE the tracer
// branch, not per-round. (Also re-derives effectiveRange via
// UpdateTargeting, so the gate below reads the LIVE value.)
ComputeTimeOfFlight(); // @004bc06c
Vector3D toAim;
toAim.Subtract(targetPos, muzzle); // :16096 FUN_00408644(aimPoint(+0x37c), muzzle)
Scalar dist = (Scalar)sqrtf(toAim.x*toAim.x + toAim.y*toAim.y + toAim.z*toAim.z);
if (dist <= effectiveRange && target != 0) // :16098 gate (+0x328); the null test is the
{ // port's guard (binary trusts 0x388!=0 upstream)
// @4bc104 fills EXACTLY TWO fields of the batched record (:16100-16120):
// damageAmount = counter x authored amount (:16110)
// damageForce = rootSegRot . leadPosition(+0x420) x float(+0x41c)
// impactPoint is NOT set here -- SendDamageMessage @004b9728 sets it
// (FUN_00408440(dmg+0x20, mech+0x37c) = the aim point); the port's
// SendDamageMessage doesn't do that write, so it is FOLDED here as
// impactPoint = targetPos (net-identical). burstCount RIDES the
// authored record untouched (Damage ctor = 1 for an AC; only
// MissileLauncher re-authors it, and that class overrides FireWeapon).
// damageForce divergence [T3, deliberate]: the binary's value is the
// lead point rotated by the mech root-segment matrix scaled by the
// float at +0x41c (a dword lifted from the tracer-model record --
// direction-equivalent junk; gyro rattle consumes DIRECTION only,
// and the Emitter's own path authors force = target - muzzle
// (emitter.cpp:360 @0x3B0)), so the port uses the same toAim shape.
Scalar batched = (Scalar)tracerCounter * damageData.damageAmount; // :16110
Damage batchSave = damageData; // binary builds a LOCAL copy (:16100);
damageData.damageAmount = batched; // the port mutates+restores because our
damageData.impactPoint = targetPos; // SendDamageMessage reads the member
damageData.damageForce = toAim;
SendDamageMessage((Entity *)target); // @004b9728 -- at fire time
damageData = batchSave;
}
tracerCounter = 0; // :16123 -- resets on HIT and MISS alike,
} // but ONLY on tracer rounds
// The VISIBLE round: the binary's 0xBCD renderer tracer, cosmetic only
// (the shell entity 0xBD1 is dead code -- nothing shipped ever spawns a
// damage-carrying ballistic round). Fly the pool round as that visual:
// damage 0, straight at the pick, authentic muzzle speed.
Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x Scalar speed = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y + launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z); + launchVelocity.z*launchVelocity.z);
BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount, BTPushProjectile(muzzle, o, target, targetPos, speed, 0.0f /*cosmetic tracer*/,
0 /*aim straight at the pick*/, 0 /*BALLISTIC: no seeker on a shell*/, 0 /*aim straight at the pick*/, 0 /*BALLISTIC: no seeker on a shell*/,
subsystemID /*messmgr explosion bundling at impact (task #7)*/, subsystemID /*messmgr explosion bundling at impact (task #7)*/,
0 /*splash_burst*/, -1 /*muzzle_seg*/, 0 /*splash_burst*/, -1 /*muzzle_seg*/,
(int)damageData.damageType /*issue: autocannon = Ballistic, was Explosive*/); (int)damageData.damageType);
// task #61: mark this shot for REPLICATION so the peer sees the enemy's // task #61: mark this shot for REPLICATION so the peer sees the enemy's
// cannon. ++fireCounter; the CALLER's Loaded case makes the two // cannon. ++fireCounter; the CALLER's Loaded case makes the two