diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 2bab2b6..a9144f4 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -1746,11 +1746,29 @@ static void // 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()) + if (p.target != 0 && BTIsRegisteredMech(p.target)) { - p.targetPos = ((Mech *)p.target)->localOrigin.linearPosition; - p.targetPos.y += p.aimOffsetY; + // #171 LOCK DROP predicate = @0049fb54 EXACTLY: movementMode 2||9, + // 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) { @@ -1910,7 +1928,15 @@ static void cz = p.targetPos.z - hitPos.z; 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 (getenv("BT_PROJ_LOG")) @@ -1978,9 +2004,22 @@ static void int bursts = (p.splashBurst > 0) ? p.splashBurst : 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); if (rolled > bursts) rolled = bursts; - if (rolled < 1) rolled = 1; bursts = rolled; } dmg.burstCount = bursts; diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index 15757e6..f549192 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -1002,10 +1002,9 @@ void { Check(this); - // Refresh the lead solution (leadPosition / targeting) for the spawn below - // (@004bc06c; the recovered sim no longer calls it -- best-effort placement - // inside the unrecovered @4bc104 body, which is where the lead data is used). - ComputeTimeOfFlight(); // @004bc06c + // (ComputeTimeOfFlight @004bc06c is called INSIDE the tracer branch below, + // exactly where @004bc104 calls it -- :16091, between the muzzle resolve and + // the range gate. The old top-of-body placement predated the re-export.) // THE FIRING HEAT (task #9; was "a separate pre-existing gap"): the // 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 - // Entity base mismatch -- see BTPushProjectile / mech4.cpp). Speed = |launchVelocity|. + // + // #171 (2026-08-13) -- THE AUTHENTIC AC: TRIGGER-TIME HITSCAN (@004bc104 + // [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) - // 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; 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 + launchVelocity.y*launchVelocity.y + 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*/, subsystemID /*messmgr explosion bundling at impact (task #7)*/, 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 // cannon. ++fireCounter; the CALLER's Loaded case makes the two