From 145a69f86509f4d9c26c3d6ff7f595906159906d Mon Sep 17 00:00:00 2001 From: arcattack Date: Mon, 13 Jul 2026 19:43:50 -0500 Subject: [PATCH] Combat: FIX missile splash over-application -- once per salvo, not per round (task #62) Live regression: a clustered bystander died in ~2 missile salvos ("suddenly lethal"). Root cause, not authentic: the arcade fires ONE cluster Missile per trigger (burstCount=missileCount) doing ONE SplashDamage event with baseBurst = missileCount, floored at 1 ONCE. The port re-expresses that cluster as N flying BTProjectile rounds, and task #62 fired splash PER ROUND -- each baseBurst=1, each floored at 1 -- so the distance floor was applied N times = ~missileCount x too much splash. Fix: BTProjectile.splashBurst tags ONLY the salvo-lead round. MissileLauncher:: FireWeapon passes nmiss (the cluster count) on i==0 and 0 on every other round; the contact + world-impact splash hooks fire only when splashBurst>0, using it as baseBurst. One splash event per salvo with baseBurst=missileCount -- matches the single arcade cluster missile. Replicant mirror rounds carry 0 (damage 0 too) so the master's cross-pod splash isn't doubled. AC unaffected (not a MissileLauncher; splash_burst defaults 0). Verified headless (BT_SPAWN_ENEMY=2 clustered rig, 45s): bystander now takes 6 splash events (1/salvo, baseBurst=6) and SURVIVES (was ~2 shots); primary dies to direct hits in ~3 trigger pulls (36 missiles == single-enemy TTK); AC does not splash; no crash. Also: BT_SPAWN_ENEMY read as a COUNT (=2 clusters a bystander within SplashRadius) for eyeballing splash; entity-id logging on [enemy]/[splash]. KB: combat-damage.md documents the N-round cluster trap. Co-Authored-By: Claude Opus 4.8 (1M context) --- context/combat-damage.md | 21 ++++- game/reconstructed/btplayer.cpp | 132 ++++++++++++++++++++------------ game/reconstructed/mech4.cpp | 44 +++++++---- game/reconstructed/mislanch.cpp | 10 ++- game/reconstructed/projweap.cpp | 2 +- 5 files changed, 139 insertions(+), 70 deletions(-) diff --git a/context/combat-damage.md b/context/combat-damage.md index 26c118f..3dc0310 100644 --- a/context/combat-damage.md +++ b/context/combat-damage.md @@ -469,9 +469,24 @@ resolves launcher → `BTWeaponAmmoBin` → `BTAmmoRoundModelResource` (+0x1e8) `BTApplySplashDamage` walks `BTGetTargetCandidates` (excludes shooter), skips the direct victim + destroyed/zoneless mechs, applies the `dist>radius` gate + the burst falloff, delivers through the shooter's msgmgr (cross-pod) or `Dispatch`. Hooked at BOTH detonation paths (world-impact + contact). -**Verified:** radius resolves to 30 for missiles / 0 for AC+Emitters; near-miss dist=15 → 1 burst -delivered to a bystander; live missiles detonate + exclude the direct victim; AC does not splash; no -crash. Env `BT_SPLASH_LOG`, `BT_SPLASH_TEST` (synthetic near-miss), `BT_AF_MISSILE` (missile autofire). + +**⚠ THE SALVO-LEAD FIX (task #62 bug, found 2026-07-13 by live regression) — the N-round trap.** +The arcade fires ONE cluster Missile per trigger (burstCount=missileCount) → ONE SplashDamage event +with `baseBurst = missileCount`, floored at 1 ONCE. The port re-expresses that cluster as N flying +`BTProjectile` rounds (visual tracers). Firing splash PER ROUND (each `baseBurst=1`, each floored at +1) applied the floor N times = **~`missileCount`× too much splash** — a clustered bystander died in +~2 salvos (user-reported "missiles suddenly lethal"; direct hits were fine — a single enemy survived +24). Fix: `BTProjectile.splashBurst` tags ONLY the salvo-lead round (`FireWeapon` passes `nmiss` on +`i==0`, `0` on the rest); splash fires once per salvo with `baseBurst = missileCount`. Replicant +mirror rounds carry `0`/damage-0 (master delivers splash cross-pod — no double). **Lesson:** a cluster +weapon re-expressed as N rounds must apply per-CLUSTER effects (splash, and note the direct hit's +zone-concentration is likewise SPREAD in the port vs the arcade's single-zone cluster — a separate, +pre-existing under-concentration) once per salvo, not once per round. +**Verified:** radius resolves to 30 for missiles / 0 for AC+Emitters; near-miss dist=15 → 1 burst; +live rig (2 clustered mechs, 45s): bystander takes 6 splash events (1/salvo, `baseBurst=6`) and +SURVIVES (was ~2 shots pre-fix); primary dies to direct hits in ~3 trigger pulls (36 missiles, == +single-enemy TTK); AC does not splash; no crash. Env `BT_SPLASH_LOG`, `BT_SPLASH_TEST` (synthetic +near-miss), `BT_AF_MISSILE` (missile autofire), `BT_SPAWN_ENEMY=2` (clustered bystander rig). **Deferred [T3]:** the per-player enable sub-gate `Missile+0x360 = BTPlayer+0x264` (part_013.c:8757) — its decomp writers (4668/10512) read as a per-frame state toggle, not a clean config flag, and the port's `showDamageInflicted` label is itself a guess; port treats authored `SplashRadius>0` as the diff --git a/game/reconstructed/btplayer.cpp b/game/reconstructed/btplayer.cpp index 8d35ae7..6f712dd 100644 --- a/game/reconstructed/btplayer.cpp +++ b/game/reconstructed/btplayer.cpp @@ -937,79 +937,111 @@ void if (enemy_res) { enemy_res->Lock(); + const ResourceDescription::ResourceID enemyResID = enemy_res->resourceID; - // Place it ahead of the player's drop ALONG THE SPAWN FACING (the mech + // COUNT: BT_SPAWN_ENEMY is read as a count -- "1" (or any truthy value) + // drops one dummy; "2"+ CLUSTERS extra dummies laterally so a bystander + // sits within a missile's SplashRadius (30) of the primary -- the rig for + // eyeballing splash damage (task #62): shoot ONE, watch the OTHER take + // collateral. Clamped to a sane range. + int enemyCount = atoi(getenv("BT_SPAWN_ENEMY")); + if (enemyCount < 1) enemyCount = 1; // non-numeric truthy -> 1 + if (enemyCount > 8) enemyCount = 8; + + // Base origin: 120 ahead of the player ALONG THE SPAWN FACING (the mech // faces local -Z; rotate that axis into world by the spawn orientation). // The old fixed "z -= 120" assumed the bring-up drive's forced heading=0; // the real-controls drive keeps the authentic spawn orientation, so the // dummy must follow the actual facing or the auto-walk leaves it behind. - Origin enemy_origin = mech_location; + Origin base_origin = mech_location; + UnitVector spawnZ; { AffineMatrix facing; facing.BuildIdentity(); facing = mech_location.angularPosition; // rotation from the spawn pose - UnitVector spawnZ; facing.GetFromAxis(Z_Axis, &spawnZ); // local Z basis in world - enemy_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z - enemy_origin.linearPosition.y -= spawnZ.y * 120.0f; - enemy_origin.linearPosition.z -= spawnZ.z * 120.0f; + base_origin.linearPosition.x -= spawnZ.x * 120.0f; // forward = -Z + base_origin.linearPosition.y -= spawnZ.y * 120.0f; + base_origin.linearPosition.z -= spawnZ.z * 120.0f; // FACE THE PLAYER: copying the spawn pose left BOTH mechs facing // the same way (the player stared at the enemy's back). Flip the // enemy's yaw 180 deg about Y so the two face each other. Engine // yaw convention (MATRIX.cpp:196-209): the Z basis = (sin y, 0, // cos y) -> yaw = atan2(z.x, z.z); enemy yaw = that + pi. Scalar spawnYaw = (Scalar)atan2((double)spawnZ.x, (double)spawnZ.z); - enemy_origin.angularPosition = + base_origin.angularPosition = EulerAngles(0.0f, spawnYaw + 3.14159265f, 0.0f); } - - Mech::MakeMessage - create_enemy( - Mech::MakeMessageID, - sizeof(Mech::MakeMessage), - host_manager->MakeUniqueEntityID(), - (Entity::ClassID)Mech::MechClassID, - EntityID::Null, - enemy_res->resourceID, - Mech::DefaultFlags, - enemy_origin, - Motion::Identity, - Motion::Identity, - playerMission->GetBadgeName(), - playerMission->GetColorName(), - ((BTMission *)playerMission)->GetPatchName()); - enemy_res->Unlock(); - Mech *enemy = Mech::Make(&create_enemy); - if (enemy) + // Right vector in the ground plane (perp to the forward XZ): cluster the + // extra dummies sideways so a hit on the primary catches the bystander. + Vector3D rightv(spawnZ.z, 0.0f, -spawnZ.x); { - Register_Object(enemy); - // Mark it a VALID master so Entity::Dispatch delivers messages - // SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred - // "entity invalid" events that never fire -- otherwise TakeDamage never - // lands. (The player gets validated via MakeViewpointEntity's CheckLoad - // handshake; a manually-spawned entity must set it itself. Its resources - // are already loaded -- it renders + its zones are built.) Also force - // pre-run + interest so it ticks like a live entity. - enemy->SetValidFlag(); - enemy->SetPreRunFlag(); - if (enemy->interestCount == 0) enemy->interestCount = 1; - // GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant - // for GetCurrentCollisions (the engine iterates it unchecked, - // MOVER.cpp:894). The player gets one in MakeViewpointEntity - // (btl4app.cpp:591); this dummy is a master too, so start its own - // -- without it the authentic ground block skips its collision half. - enemy->StartCollisionAssistant(); - gEnemyMech = enemy; // the player's targeting step locks onto this - DEBUG_STREAM << "[enemy] spawned target mech at (" - << enemy_origin.linearPosition.x << ", " - << enemy_origin.linearPosition.y << ", " - << enemy_origin.linearPosition.z << ")\n" << std::flush; + Scalar rl = (Scalar)sqrt((double)(rightv.x*rightv.x + rightv.z*rightv.z)); + if (rl < 1e-4f) { rightv.x = 1.0f; rightv.z = 0.0f; } + else { rightv.x /= rl; rightv.z /= rl; } } - else + + for (int ei = 0; ei < enemyCount; ++ei) { - DEBUG_STREAM << "[enemy] Mech::Make returned NULL\n" << std::flush; + Origin enemy_origin = base_origin; + // #0 at the primary spot; extras step +/-25u (inside SplashRadius 30). + Scalar off = (ei == 0) ? 0.0f + : 25.0f * (Scalar)(((ei + 1) / 2)) * ((ei & 1) ? 1.0f : -1.0f); + enemy_origin.linearPosition.x += rightv.x * off; + enemy_origin.linearPosition.z += rightv.z * off; + + Mech::MakeMessage + create_enemy( + Mech::MakeMessageID, + sizeof(Mech::MakeMessage), + host_manager->MakeUniqueEntityID(), + (Entity::ClassID)Mech::MechClassID, + EntityID::Null, + enemyResID, + Mech::DefaultFlags, + enemy_origin, + Motion::Identity, + Motion::Identity, + playerMission->GetBadgeName(), + playerMission->GetColorName(), + ((BTMission *)playerMission)->GetPatchName()); + + Mech *enemy = Mech::Make(&create_enemy); + if (enemy) + { + Register_Object(enemy); + // Mark it a VALID master so Entity::Dispatch delivers messages + // SYNCHRONOUSLY (Receiver::Receive) instead of posting them as deferred + // "entity invalid" events that never fire -- otherwise TakeDamage never + // lands. (The player gets validated via MakeViewpointEntity's CheckLoad + // handshake; a manually-spawned entity must set it itself. Its resources + // are already loaded -- it renders + its zones are built.) Also force + // pre-run + interest so it ticks like a live entity. + enemy->SetValidFlag(); + enemy->SetPreRunFlag(); + if (enemy->interestCount == 0) enemy->interestCount = 1; + // GROUND MODEL (task #15): a MASTER mech needs a CollisionAssistant + // for GetCurrentCollisions (the engine iterates it unchecked, + // MOVER.cpp:894). The player gets one in MakeViewpointEntity + // (btl4app.cpp:591); this dummy is a master too, so start its own + // -- without it the authentic ground block skips its collision half. + enemy->StartCollisionAssistant(); + if (ei == 0) + gEnemyMech = enemy; // the player's targeting step locks onto the primary + DEBUG_STREAM << "[enemy] spawned dummy #" << ei + << " id=" << enemy->GetEntityID() + << (ei == 0 ? " (PRIMARY/direct-target)" : " (bystander/splash)") + << " at (" + << enemy_origin.linearPosition.x << ", " + << enemy_origin.linearPosition.y << ", " + << enemy_origin.linearPosition.z << ")\n" << std::flush; + } + else + { + DEBUG_STREAM << "[enemy] Mech::Make returned NULL (#" << ei << ")\n" << std::flush; + } } } else diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 7ecb071..cfd6346 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -783,6 +783,12 @@ struct BTProjectile { int weaponSubsys; // firing weapon's roster index (-1 = unthreaded) -- // resolves the weapon's ExplosionModelFile (mslhit/ // acanhit) in the messmgr, task #7 bundling + int splashBurst; // SALVO-LEAD cluster count for splash (task #62 fix): + // >0 ONLY on the first round of a missile salvo -- the + // baseBurst for ONE splash event = the whole cluster + // (matches the ONE arcade cluster missile). 0 on every + // other round (a straight tracer / non-lead volley round) + // so splash is NOT re-applied + floored-at-1 per round. int active; }; static BTProjectile gProjectiles[64]; @@ -1061,9 +1067,10 @@ void e->Dispatch(&td); } if (s_log) - DEBUG_STREAM << "[splash] victim=" << (void *)e << " dist=" << dist + DEBUG_STREAM << "[splash] victim=" << e->GetEntityID() << " dist=" << dist << " radius=" << radius << " bursts=" << bursts - << " amount=" << dmg.damageAmount << "\n" << std::flush; + << " amount=" << dmg.damageAmount << " baseBurst=" << (int)baseBurst + << "\n" << std::flush; } } @@ -1073,7 +1080,7 @@ void void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos, Scalar speed, Scalar damage, const Vector3D *launch_velocity, int guided, - int weapon_subsys) + int weapon_subsys, int splash_burst) { // MUZZLE (muzzle wave, 2026-07-12): the passed muzzle is now the weapon's // AUTHENTIC mount segment (GetMuzzlePoint reads the real segmentIndex -- @@ -1170,6 +1177,7 @@ void p.guided = guided; // autocannon shells fly straight (no seeker in the binary's plain Projectile) p.shooter = (Entity *)shooter; p.weaponSubsys = weapon_subsys; + p.splashBurst = splash_burst; // >0 only on a salvo-lead round // RACK-TUBE SPREAD [T3, physically grounded]: the binary's Missile // entities each launch from their own rack tube (per-tube authored @@ -1300,14 +1308,14 @@ static void // inside the collision branch @part_013.c:10045, which fires // for a world hit as well as a mover hit). A missile that // bursts on terrain near a mech still catches it in the blast. - // No direct victim here, and no-op for non-missile rounds - // (BTApplySplashDamage returns radius 0 for the AC / p.damage 0). - if (p.damage > 0.0f) + // ONCE per salvo (splashBurst>0), baseBurst = the cluster count; + // no direct victim here. No-op for non-missile rounds. + if (p.damage > 0.0f && p.splashBurst > 0) { Damage sdmg; sdmg.damageType = Damage::ExplosiveDamageType; sdmg.damageAmount = p.damage; - sdmg.burstCount = 1; + sdmg.burstCount = p.splashBurst; // cluster count as baseBurst sdmg.impactPoint = hp; extern void BTApplySplashDamage(Entity *, int, const Point3D &, Entity *, const Damage &); @@ -1407,13 +1415,21 @@ static void << (mgr ? " (msgmgr bundled)" : " (direct)") << " (zone cyl-resolved)\n" << std::flush; - // SPLASH (task #62): a detonating round with an authored - // SplashRadius also damages every OTHER mech in the blast - // (tgt excluded -- it took the direct hit above). No-op for - // weapons with no splash radius (lasers never reach here). - extern void BTApplySplashDamage(Entity *, int, const Point3D &, - Entity *, const Damage &); - BTApplySplashDamage(p.shooter, p.weaponSubsys, p.pos, tgt, dmg); + // SPLASH (task #62): a detonating missile SALVO damages every + // OTHER mech in the blast (tgt excluded -- it took the direct hit + // above). Fires ONCE per salvo, on the salvo-LEAD round only + // (splashBurst>0), with baseBurst = the whole cluster count -- the + // ONE arcade cluster missile. The other visual rounds of the + // salvo carry splashBurst=0 so the falloff floor-at-1 is applied + // once, not N times (the missileCount-x over-splash bug). + if (p.splashBurst > 0) + { + Damage sdmg = dmg; + sdmg.burstCount = p.splashBurst; // cluster count as baseBurst + extern void BTApplySplashDamage(Entity *, int, const Point3D &, + Entity *, const Damage &); + BTApplySplashDamage(p.shooter, p.weaponSubsys, p.pos, tgt, sdmg); + } } } p.active = 0; diff --git a/game/reconstructed/mislanch.cpp b/game/reconstructed/mislanch.cpp index 2c4ce08..61569e4 100644 --- a/game/reconstructed/mislanch.cpp +++ b/game/reconstructed/mislanch.cpp @@ -73,7 +73,7 @@ extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos, Scalar speed, Scalar damage, const Vector3D *launch_velocity = 0, int guided = 1, - int weapon_subsys = -1); + int weapon_subsys = -1, int splash_burst = 0); //########################################################################### // Port-side salvo replication state (missile-visibility wave). @@ -305,9 +305,15 @@ void MissileLauncher::FireWeapon() // on the authored up-tilt, then the seeker loft + steering arc it onto the target. int nmiss = (missileCount > 0 && missileCount < 40) ? missileCount : 1; for (int i = 0; i < nmiss; ++i) + // SPLASH (task #62 fix): the whole salvo re-expresses ONE arcade cluster + // missile, which does ONE SplashDamage event with baseBurst = the cluster + // count. Tag ONLY the first round as the salvo lead (splash_burst = nmiss); + // the rest carry 0 so splash fires once per salvo, not once per visual round + // (which floored-at-1 N times = ~missileCount-x over-splash). BTPushProjectile(muzzle, o, target, targetPos, speed, damageData.damageAmount, &launchVelocity, 1, - subsystemID /*messmgr explosion bundling at impact (task #7)*/); + subsystemID /*messmgr explosion bundling at impact (task #7)*/, + (i == 0) ? nmiss : 0 /*salvo-lead cluster splash*/); // Salvo replication (missile-visibility wave): bump the fire counter + stamp // the aim point; the extended update record carries both to peer nodes. diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index b50b3d9..9df8bfc 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -95,7 +95,7 @@ extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, const Point3D &targetPos, Scalar speed, Scalar damage, const Vector3D *launch_velocity = 0, int guided = 1, - int weapon_subsys = -1); + int weapon_subsys = -1, int splash_burst = 0); // AC: no cluster splash (default 0) //#############################################################################