#95/#84: missile salvos deliver their FULL authored damage, and stop double-exploding

THE SALVO DAMAGE (#95).  Players measured an LRM 15 landing "3ish points".  The
logs agreed: [projectile] IMPACT damage=3.33333 (Oracle, LRM15) and 3.5 (Rajel,
LRM10).  Those are right PER MISSILE -- 50/15 and 35/10 -- and the bench shows
why the salvo still under-delivers: each launcher pushes N rounds of which
exactly ONE carries damage.

Two individually-correct changes composed into an N-fold shortfall:

  * The ctor does what the binary's MissileLauncher ctor does (@0x3ac/@0x3d4):
        damageData.burstCount    = missileCount;
        damageData.damageAmount /= missileCount;
    The record holds the PER-MISSILE amount plus the count; the arcade
    reconstitutes amount x burstCount when it applies the hit.
  * Task #62 then correctly stopped the port applying the hit once per visual
    round (that was ~missileCount-x too lethal) by damaging only the lead round
    -- but handed it the already-divided amount.

Our DamageZone::TakeDamage is `damageLevel += amount * scale` and drops
burstCount, so the salvo delivered amount/missileCount.  Since the port collapses
the cluster to one damaging round, multiply the count back in there.  Bench: an
SRM6 salvo now lands amt=35 (the authored total) taking a zone 0 -> 0.556, where
it previously landed 5.83.

THE DOUBLE EXPLOSION (#84).  Oracle: "missile appear to register hit explosions
twice, once where target was and again where the target is."  There are two
spawn sites: BTSpawnRoundDetonation at the round's own impact point, and the
message manager's bundled explosion at the CONSOLIDATED point a frame later.
The duplicate was known and thought harmless -- "among a rippled volley it is
invisible" -- which held only while a salvo landed N detonations.  A projectile
now marks its weapon (MarkRoundDetonated) and the consolidation skips queueing a
second blast for it; direct-fire weapons never mark, so lasers/AC keep the
bundled explosion they rely on.  Bench: 4 missile impacts -> 4 SKIPPED, while 11
direct-fire hits still queue normally.

SWEPT CONTACT (#84 tail).  Contact was a 10-unit sphere sampled only at the END
of each step.  With the authored thruster live (#84) field rounds arrive at
v=955 -- a ~16 unit step at 60fps, larger than the radius -- so samples can
straddle the target.  (Pre-#84 rounds flew ~100-300 = a 1.7-5 unit step and could
never skip it: the velocity fix exposed this, it did not cause it.)  Now tests
the whole segment travelled and bursts at the point of NEAREST APPROACH, which
also stops the detonation being flung past the target at speed.

RETRACTION: I posted tunnelling as the leading explanation for the lost salvo.
The bench disproves it -- zero fizzles, and the "missing" rounds are the dmg=0
visual rounds of the cluster, which never registered damage by design.  The
sweep is kept as speed-independent robustness, not as the #95 fix.

Bench: scratchpad/night8/salvo.sh (BT_PROJ_LOG + BT_FIRE_LOG).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-07-31 23:59:44 -05:00
co-authored by Claude Opus 5
parent 839b040619
commit efc3e9ff1a
7 changed files with 233 additions and 6 deletions
+41 -4
View File
@@ -1636,8 +1636,37 @@ static void
// contact"). Proximity = the hit; the flight-cap expiry is a FIZZLE -- // contact"). Proximity = the hit; the flight-cap expiry is a FIZZLE --
// no damage, matching the binary (a missile that dies mid-air detonates // no damage, matching the binary (a missile that dies mid-air detonates
// nothing; only the world-collision hit spawns the Damage entity). // nothing; only the world-collision hit spawns the Damage entity).
Scalar dx = p.targetPos.x - p.pos.x, dy = p.targetPos.y - p.pos.y, dz = p.targetPos.z - p.pos.z; // SWEPT contact (issue #84/#95). This was a 10-unit sphere sampled at the
const int contact = (dx*dx + dy*dy + dz*dz < (10.0f*10.0f)); // END of the step only. Now that the authored thruster is applied (#84),
// field logs show rounds arriving at v=955 -- a ~16 unit step at 60fps,
// LARGER than the contact radius, so consecutive samples can straddle the
// target and the round flies on untouched. (Pre-#84 rounds flew a constant
// ~100-300 = a 1.7-5 unit step, which could never skip the sphere -- so the
// velocity fix exposed this, it did not cause it.) Test the whole segment
// travelled this frame instead, and burst at the point of NEAREST APPROACH
// rather than wherever the step happened to end -- which also stops the
// detonation being flung past the target at high speed.
Point3D hitPos = p.pos;
Scalar contactD2;
{
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;
}
hitPos.x = prev.x + t*sx; hitPos.y = prev.y + t*sy; hitPos.z = prev.z + t*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;
}
const int contact = (contactD2 < (10.0f*10.0f));
if (!contact && p.traveled >= p.range) if (!contact && p.traveled >= p.range)
{ {
if (getenv("BT_PROJ_LOG")) if (getenv("BT_PROJ_LOG"))
@@ -1647,7 +1676,7 @@ static void
} }
if (contact) if (contact)
{ {
BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, p.pos); BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, hitPos);
Entity *tgt = p.target; Entity *tgt = p.target;
// Deliver to the projectile's target mech -- the launcher set 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 // from the shooter's 0x388 slot (the picked victim; any peer mech in
@@ -1666,7 +1695,7 @@ static void
dmg.damageType = (Enumeration)p.damageType; // issue: was hardcoded Explosive dmg.damageType = (Enumeration)p.damageType; // issue: was hardcoded Explosive
dmg.damageAmount = p.damage; dmg.damageAmount = p.damage;
dmg.burstCount = 1; dmg.burstCount = 1;
dmg.impactPoint = p.pos; dmg.impactPoint = hitPos; // nearest approach, not the step end
// Route through the SHOOTER's SubsystemMessageManager with the // Route through the SHOOTER's SubsystemMessageManager with the
// firing launcher's roster index (task #7 bundling): the // firing launcher's roster index (task #7 bundling): the
// consolidation resolves roster[id]+0x3E4 = the weapon's // consolidation resolves roster[id]+0x3E4 = the weapon's
@@ -1679,6 +1708,14 @@ static void
mgr = (SubsystemMessageManager *)((Mech *)p.shooter)->GetMessageManager(); mgr = (SubsystemMessageManager *)((Mech *)p.shooter)->GetMessageManager();
if (mgr != 0) if (mgr != 0)
{ {
// issue #84: this round already spawned its OWN detonation
// at hitPos above. Tell the manager, so the consolidation
// does not ALSO queue this weapon's explosion at the bundle's
// consolidated point a frame later -- the double blast
// players see as "once where the target was, again where it
// is". Direct-fire weapons never mark, so lasers/AC keep
// the bundled explosion they depend on.
mgr->MarkRoundDetonated(p.weaponSubsys);
Entity::TakeDamageMessage take_damage( Entity::TakeDamageMessage take_damage(
Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage),
p.shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, p.shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/,
+42 -1
View File
@@ -164,6 +164,8 @@ SubsystemMessageManager::SubsystemMessageManager(
commonDamageInformation.damageZoneIndex = -1; // this[0x3A] commonDamageInformation.damageZoneIndex = -1; // this[0x3A]
commonDamageInformation.impactPoint = Point3D::Identity; // this[0x3B] = DAT_004e0f80 commonDamageInformation.impactPoint = Point3D::Identity; // this[0x3B] = DAT_004e0f80
selfDetonatedCount = 0; // port-only (issue #84)
Check_Fpu(); Check_Fpu();
} }
@@ -388,7 +390,19 @@ void
DEBUG_STREAM << "[boom-q] subsysID=" << info->subsystemID DEBUG_STREAM << "[boom-q] subsysID=" << info->subsystemID
<< " weapon=" << (firingWeapon ? firingWeapon->GetName() : "<none>") << " weapon=" << (firingWeapon ? firingWeapon->GetName() : "<none>")
<< " explosionID=" << explosionID << std::endl; << " explosionID=" << explosionID << std::endl;
if (explosionID != ResourceDescription::NullResourceID //
// issue #84: skip the bundled explosion for a weapon whose round
// already detonated itself at its own impact point. Queueing it
// too puts a second blast at the CONSOLIDATED point a frame later
// -- the "explosion where the target was, and again where it is".
//
if (DidRoundDetonate(info->subsystemID))
{
if (getenv("BT_FIRE_LOG"))
DEBUG_STREAM << "[boom-q] subsysID=" << info->subsystemID
<< " SKIPPED (round detonated itself)" << std::endl;
}
else if (explosionID != ResourceDescription::NullResourceID
&& !weaponExplosions.Find(explosionID)) // chain+0xFC, slot 0xC && !weaponExplosions.Find(explosionID)) // chain+0xFC, slot 0xC
{ {
ResourceIDPlug *plug = new ResourceIDPlug(explosionID); ResourceIDPlug *plug = new ResourceIDPlug(explosionID);
@@ -433,6 +447,7 @@ void
commonDamageInformation.entityHit = 0; // this[0x39] commonDamageInformation.entityHit = 0; // this[0x39]
commonDamageInformation.damageZoneIndex = -1; // this[0x3A] commonDamageInformation.damageZoneIndex = -1; // this[0x3A]
commonDamageInformation.impactPoint = Point3D::Identity; // this[0x3B] commonDamageInformation.impactPoint = Point3D::Identity; // this[0x3B]
selfDetonatedCount = 0; // port-only (issue #84): marks are per-flush
} }
@@ -513,6 +528,32 @@ ResourceDescription::ResourceID
return ResourceDescription::NullResourceID; return ResourceDescription::NullResourceID;
} }
//
// PORT-ONLY (issue #84) -- see the header note. A projectile that spawned its
// own round detonation marks its weapon here so the consolidation does not queue
// a SECOND, differently-placed explosion for the same hit.
//
void
SubsystemMessageManager::MarkRoundDetonated(int subsystem_id)
{
Check(this);
if (subsystem_id < 0 || DidRoundDetonate(subsystem_id))
return;
if (selfDetonatedCount >= kMaxSelfDetonated)
return; // full: fall back to the bundled blast
selfDetonated[selfDetonatedCount++] = subsystem_id;
}
Logical
SubsystemMessageManager::DidRoundDetonate(int subsystem_id) const
{
Check(this);
for (int i = 0; i < selfDetonatedCount; ++i)
if (selfDetonated[i] == subsystem_id)
return True;
return False;
}
// //
// SubmitExplosion -- the real spawn (task #7 tail). The binary posts a // SubmitExplosion -- the real spawn (task #7 tail). The binary posts a
// Registry::MakeEntityMessage (id 3, class 0x31 Explosion, flags 0x100) at // Registry::MakeEntityMessage (id 3, class 0x31 Explosion, flags 0x100) at
+30
View File
@@ -163,6 +163,36 @@ class
ResourceDescription::ResourceID ResourceDescription::ResourceID
terrainHitExplosionID; //@0x12C (resource +0x30) terrainHitExplosionID; //@0x12C (resource +0x30)
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// PORT-ONLY -- appended AFTER every layout-locked member (issue #84).
//
// A projectile spawns its OWN per-round detonation at its exact impact point
// (BTSpawnRoundDetonation), which is the arcade's rippling volley look. It then
// routes its damage through this manager, which ALSO queues the firing weapon's
// explosion -- a second blast, at the bundle's CONSOLIDATED impact point and on
// a later frame. Two explosions, two places: "once where the target was and
// again where the target is" (Oracle, 4.11.674).
//
// That duplicate was known and thought harmless -- "among a rippled volley it is
// invisible" -- which held while a salvo landed N detonations. It stopped being
// true when the cluster collapsed to ONE damaging round.
//
// So: a weapon that already detonated its own round this frame is marked here,
// and the consolidation skips queueing its explosion. Direct-fire weapons
// (lasers/AC) never mark, so they keep the bundled explosion they rely on.
// Cleared every flush. Marked by roster index -- exact, because a weapon is
// either a projectile launcher or an emitter, never both in one frame.
//
public:
void
MarkRoundDetonated(int subsystem_id);
Logical
DidRoundDetonate(int subsystem_id) const;
protected:
enum { kMaxSelfDetonated = 16 };
int selfDetonated[kMaxSelfDetonated];
int selfDetonatedCount;
void void
CreateWeaponExplosions( CreateWeaponExplosions(
Logical terrain_hit, Logical terrain_hit,
+15 -1
View File
@@ -333,8 +333,22 @@ void MissileLauncher::FireWeapon()
// lethal (the "2-shot kill" regression). Only the LEAD round (i==0) // lethal (the "2-shot kill" regression). Only the LEAD round (i==0)
// carries the missile's damageAmount + the cluster splash; the rest are // carries the missile's damageAmount + the cluster splash; the rest are
// VISUAL (damage 0) -- the ripple of tracers, no extra damage. // VISUAL (damage 0) -- the ripple of tracers, no extra damage.
//
// ...but it must carry the WHOLE SALVO's damage (issue #95). The ctor
// above does what the binary's MissileLauncher ctor does:
// damageData.burstCount = missileCount; // @0x3d4
// damageData.damageAmount /= missileCount; // @0x3ac
// i.e. the record holds PER-MISSILE amount plus the count, and the arcade
// reconstitutes the total as amount x burstCount when it applies the hit.
// Our application (DamageZone::TakeDamage) applies `amount * scale` ONCE
// and drops burstCount -- so handing the lead round the already-divided
// amount delivered amount/missileCount, i.e. 1/N of the authored salvo.
// Two individually-correct changes composed into a silent N-fold shortfall:
// players measured an LRM 15 landing ~3 points instead of 50.
// Multiply the count back in here, where the port collapses the cluster to
// one damaging round, so the salvo delivers exactly its authored total.
BTPushProjectile(muzzle, o, target, targetPos, speed, BTPushProjectile(muzzle, o, target, targetPos, speed,
(i == 0) ? damageData.damageAmount : 0.0f, (i == 0) ? damageData.damageAmount * (Scalar)nmiss : 0.0f,
&launchVelocity, 1, &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 baseBurst*/, (i == 0) ? nmiss : 0 /*salvo-lead: cluster splash baseBurst*/,
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# #95/#84 CONFIRMATION: are missiles TUNNELLING through the target?
#
# Contact is a fixed 10-unit sphere sampled ONCE PER FRAME. Logged field speeds
# reach v=955, which at 60fps is a ~16 unit step -- larger than the radius, so
# consecutive samples can straddle the target entirely. Prediction: a salvo
# lands only the rounds that pass near dead centre, and the rest hit the flight
# cap and FIZZLE. BT_PROJ_LOG=1 turns on the fizzle line (it is gated, which is
# why the field logs could not answer this).
#
# Verdict = the IMPACT:FIZZLE ratio per salvo.
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 2
sed "s/^map=.*/map=grass/; s/^time=.*/time=day/" MP.EGG > SALVO.EGG
LOG=salvo_${1:-pre}.log
rm -f "$LOG"
# Walk up to the spawned target and hold the missile group down.
BT_PROJ_LOG=1 BT_DMG_LOG=1 BT_FIRE_LOG=1 \
BT_SPAWN_ENEMY=1 BT_GOTO=enemy BT_GOTO_STOP=2 \
BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=3 \
bt_launch "$LOG" SALVO.EGG 0x03
sleep 100
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
echo "=== impacts vs fizzles ==="
printf "IMPACT %s\n" "$(grep -c 'projectile\] IMPACT' "$LOG")"
printf "FIZZLE %s\n" "$(grep -c 'projectile\] FIZZLE' "$LOG")"
printf "WORLD %s\n" "$(grep -c 'projectile\] WORLD' "$LOG")"
echo "=== impact speeds (the tunnelling driver) ==="
grep -o 'IMPACT .*v=[0-9.]*' "$LOG" | grep -o 'v=[0-9.]*' | sort -t= -k2 -n | tail -3
+32
View File
@@ -0,0 +1,32 @@
"""Split a player log into per-session blocks and report what each session was.
Night-8 triage: Oracle ran 4.11.659 for the first half of the evening and only
switched to 4.11.674 at 21:27, so every claim from his log has to be attributed
to the right build before it means anything.
"""
import re
import sys
import os
path = sys.argv[1]
want_build = sys.argv[2] if len(sys.argv) > 2 else None
data = open(path, "r", encoding="latin-1", errors="replace").read()
hdr = re.compile(r"===== BT411 SESSION\s+build=(\S+)\s+\(\S+\)\s+machine=(\S+)\s+user=(\S+).*?local=(\S+ \S+)")
marks = [(m.start(), m.group(1), m.group(2), m.group(3), m.group(4))
for m in hdr.finditer(data)]
print("%s: %d session(s)" % (os.path.basename(path), len(marks)))
for i, (pos, build, mach, user, when) in enumerate(marks):
end = marks[i + 1][0] if i + 1 < len(marks) else len(data)
block = data[pos:end]
if want_build and build != want_build:
continue
# what actually happened in this session
mech = set(re.findall(r"vehicle[= ]+([A-Za-z0-9_]+)", block))
kills = len(re.findall(r"\bkill\b", block, re.I))
leaks = len(re.findall(r"\[cool\]|leak", block, re.I))
print(" #%-2d %s %s len=%-9d mech=%-22s lines=%d"
% (i + 1, build, when, len(block),
",".join(sorted(mech))[:22] or "-", block.count("\n")))
+36
View File
@@ -0,0 +1,36 @@
"""Night-8 log triage: pull the evidence for each reported issue out of the
player logs, attributing everything to the build the session actually ran."""
import re, os, sys, collections
LOGS = [
("Oracle (SCREECH-PC)", "steam_20260731.log"),
("Sauron (XIAOLONG)", "steam_20260731 (1).log"),
("Rajel (GAMERSLAB)", "steam_20260731 (2).log"),
("ConnMan (MS-FIREFLY)", "zipx/steam_20260731.log"),
]
def load674(path):
d = open(path, encoding="latin-1", errors="replace").read()
i = d.find("build=4.11.674")
return d[i:] if i >= 0 else ""
def show(who, blk, label, pat, n=5, flags=0):
hits = [l for l in blk.split("\n") if re.search(pat, l, flags)]
print(" %-22s %-26s %5d" % (who, label, len(hits)))
return hits
for label, pat in [
("#86 weapon fire gate", r"destroyed=|NoAmmo \(gate"),
("#95 missile damage", r"\[dmg\].*(missile|lrm|srm)|warhead"),
("#101 round end", r"round (end|over)|mission (end|over)|\[score\].*end"),
("#55 respawn heat", r"respawn|\[dzreq\]|Reset"),
("#103 collision", r"^\[crashdmg\]"),
]:
print("\n=== %s (pattern: %s)" % (label, pat))
for who, path in LOGS:
if not os.path.exists(path):
print(" %-22s MISSING" % who); continue
blk = load674(path)
if not blk:
print(" %-22s no 4.11.674 session" % who); continue
show(who, blk, "", pat, flags=re.I)