diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 6c2ae5c..47ffded 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -1694,41 +1694,61 @@ static void Damage dmg; dmg.damageType = (Enumeration)p.damageType; // issue: was hardcoded Explosive dmg.damageAmount = p.damage; - dmg.burstCount = 1; dmg.impactPoint = hitPos; // nearest approach, not the step end - // Route through the SHOOTER's SubsystemMessageManager with the - // firing launcher's roster index (task #7 bundling): the - // consolidation resolves roster[id]+0x3E4 = the weapon's - // ExplosionModelFile (mslhit/acanhit) and fires it AT the - // impact point -- the missing missile-hit explosion (the - // laser path always did this via SendDamageMessage; the - // projectile path bypassed the manager entirely). - SubsystemMessageManager *mgr = 0; - if (p.shooter != 0 && p.weaponSubsys >= 0 && BTIsRegisteredMech(p.shooter)) - mgr = (SubsystemMessageManager *)((Mech *)p.shooter)->GetMessageManager(); - if (mgr != 0) + // + // CLUSTER BURST (issue #95). A missile salvo is ONE arcade + // Missile entity carrying the PER-MISSILE amount (the launcher + // ctor does `damageAmount /= missileCount; burstCount = + // missileCount` @004bcff0) -- the salvo total is reconstituted + // at the RECEIVER, where Mech::TakeDamageMessageHandler applies + // the hit `burstCount` times, re-rolling the struck zone each + // burst (@0x4a0439-0x4a04d8; mech.cpp, already faithful). + // + // This site hardcoded burstCount = 1, so every salvo delivered + // one missile's worth -- an LRM 15 landing ~3 points instead of + // 50, exactly as players measured. + // + // How many of the cluster connect is ROLLED at impact, in the + // binary's Missile::Perform right before it dispatches + // (part_013.c:10082): + // n = burstCount + // b = Random(n) + n/4, clamped to n + // i.e. between a quarter of the salvo and all of it. That + // variance + the handler's per-burst zone re-roll IS the + // authentic spread; pre-multiplying the amount instead would + // dump the whole salvo on one zone with no scatter. + int bursts = (p.splashBurst > 0) ? p.splashBurst : 1; + if (bursts > 1) { - // 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::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), - p.shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, - dmg, p.weaponSubsys); - mgr->AddDamageMessage(tgt, &take_damage); - } - else - { - Entity::TakeDamageMessage take_damage( - Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), - 0 /*inflictor id: bring-up*/, -1 /*unaimed -> cylinder resolves*/, dmg); - tgt->Dispatch(&take_damage); + int rolled = Random(bursts) + (bursts >> 2); + if (rolled > bursts) rolled = bursts; + if (rolled < 1) rolled = 1; + bursts = rolled; } + dmg.burstCount = bursts; + // DISPATCH DIRECTLY, as the binary's Missile does + // (Missile::Perform -> FUN_004be078: `param_2->Dispatch(&msg)` + // straight at the struck entity). A projectile hit does NOT go + // through the shooter's SubsystemMessageManager in the arcade. + // + // The port used to route it there (task #7) purely to borrow the + // manager's impact explosion, and that cost us both defects + // players reported this session: + // * the consolidation rebuilds the damage record from a stream + // carrying only {damageType, damageAmount, subsystemID} -- + // DamageInformation has no burstCount field, so the CLUSTER + // COUNT was dropped in transit and every salvo applied once + // (issue #95); and + // * it queued a SECOND explosion at the bundle's consolidated + // point a frame later -- the double blast (issue #84). + // The round spawns its own detonation at hitPos above, so the + // manager buys us nothing here. Dispatch reroutes cross-pod for + // a replicant victim on its own. + Entity::TakeDamageMessage take_damage( + Entity::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), + (p.shooter != 0) ? p.shooter->GetEntityID() : EntityID::Null, + -1 /*unaimed -> cylinder resolves*/, dmg, p.weaponSubsys); + tgt->Dispatch(&take_damage); // gauge scoring wave (Step 6): a projectile hit credits SCORE too // (tgt == gEnemyMech here; local player is the viewpoint shooter). BTPostDamageScore((Entity *)tgt, p.damage); @@ -1755,7 +1775,7 @@ static void DEBUG_STREAM << "[projectile] IMPACT damage=" << p.damage << " subsys=" << p.weaponSubsys << " v=" << p.speed << " burnLeft=" << p.burnLeft // #84 thrust evidence - << (mgr ? " (msgmgr bundled)" : " (direct)") + << " burst=" << dmg.burstCount << " (direct dispatch)" << " (zone cyl-resolved)\n" << std::flush; // SPLASH (task #62): a detonating missile SALVO damages every diff --git a/game/reconstructed/messmgr.cpp b/game/reconstructed/messmgr.cpp index 1ef22b2..ac932be 100644 --- a/game/reconstructed/messmgr.cpp +++ b/game/reconstructed/messmgr.cpp @@ -164,8 +164,6 @@ SubsystemMessageManager::SubsystemMessageManager( commonDamageInformation.damageZoneIndex = -1; // this[0x3A] commonDamageInformation.impactPoint = Point3D::Identity; // this[0x3B] = DAT_004e0f80 - selfDetonatedCount = 0; // port-only (issue #84) - Check_Fpu(); } @@ -390,19 +388,7 @@ void DEBUG_STREAM << "[boom-q] subsysID=" << info->subsystemID << " weapon=" << (firingWeapon ? firingWeapon->GetName() : "") << " explosionID=" << explosionID << std::endl; - // - // 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 + if (explosionID != ResourceDescription::NullResourceID && !weaponExplosions.Find(explosionID)) // chain+0xFC, slot 0xC { ResourceIDPlug *plug = new ResourceIDPlug(explosionID); @@ -447,7 +433,6 @@ void commonDamageInformation.entityHit = 0; // this[0x39] commonDamageInformation.damageZoneIndex = -1; // this[0x3A] commonDamageInformation.impactPoint = Point3D::Identity; // this[0x3B] - selfDetonatedCount = 0; // port-only (issue #84): marks are per-flush } @@ -528,32 +513,6 @@ ResourceDescription::ResourceID 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 // Registry::MakeEntityMessage (id 3, class 0x31 Explosion, flags 0x100) at diff --git a/game/reconstructed/messmgr.hpp b/game/reconstructed/messmgr.hpp index 333be08..331e95e 100644 --- a/game/reconstructed/messmgr.hpp +++ b/game/reconstructed/messmgr.hpp @@ -163,36 +163,6 @@ class ResourceDescription::ResourceID 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 CreateWeaponExplosions( Logical terrain_hit, diff --git a/game/reconstructed/mislanch.cpp b/game/reconstructed/mislanch.cpp index 9bca8b2..e09a3c0 100644 --- a/game/reconstructed/mislanch.cpp +++ b/game/reconstructed/mislanch.cpp @@ -334,21 +334,16 @@ void MissileLauncher::FireWeapon() // carries the missile's damageAmount + the cluster splash; the rest are // 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. + // The lead round carries the PER-MISSILE amount, exactly as the binary's + // ctor prepared it (damageAmount /= missileCount, burstCount = missileCount). + // The salvo total is reconstituted at IMPACT, where the round hands the + // handler a burstCount and the handler applies the hit that many times + // (Mech::TakeDamageMessageHandler @0x4a0423-0x4a04d8, mech.cpp) -- see the + // cluster-burst note at the impact site in mech4.cpp. Do NOT pre-multiply + // here: that would deliver the salvo as one lump on one zone, losing both + // the per-burst zone re-roll and the arcade's hit-count variance. BTPushProjectile(muzzle, o, target, targetPos, speed, - (i == 0) ? damageData.damageAmount * (Scalar)nmiss : 0.0f, + (i == 0) ? damageData.damageAmount : 0.0f, &launchVelocity, 1, subsystemID /*messmgr explosion bundling at impact (task #7)*/, (i == 0) ? nmiss : 0 /*salvo-lead: cluster splash baseBurst*/, diff --git a/scratchpad/night8/allweap.py b/scratchpad/night8/allweap.py new file mode 100644 index 0000000..7b8bc60 --- /dev/null +++ b/scratchpad/night8/allweap.py @@ -0,0 +1,45 @@ +"""Per-weapon-class damage breakdown from a bench log (#95 burst-loop audit). + +Classifies each [dmghit] by the damage TYPE it carried and the burst count, and +reports the delivered damageLevel delta, so a change to the shared burst loop +cannot silently rescale one class without showing up here. +""" +import re, sys, collections + +path = sys.argv[1] +d = open(path, encoding="latin-1", errors="replace").read() + +# [dmghit] mech=1:323 zone=4 vital=0 type=2 amt=35 burst=1 lvl 0->0.555556 +rx = re.compile(r"\[dmghit\] mech=(\S+) zone=(\d+) vital=(\d) type=(\d+) " + r"amt=([0-9.eE+-]+) burst=([0-9.]+) lvl ([0-9.eE+-]+)->([0-9.eE+-]+)") +rows = [m.groups() for m in rx.finditer(d)] +print("total zone applications: %d\n" % len(rows)) + +TYPE = {0: "COLLISION(diverted)", 1: "type1", 2: "EXPLOSIVE(missile/AC)", + 3: "ENERGY(beam)", 4: "type4(short-gen)"} + +byType = collections.defaultdict(list) +for mech, zone, vital, ty, amt, burst, a, b in rows: + byType[int(ty)].append((float(amt), float(burst), float(b) - float(a), int(zone))) + +print("%-24s %6s %10s %10s %10s %8s" % ("class", "hits", "amt", "burst", "dlvl", "zones")) +for ty in sorted(byType): + v = byType[ty] + amts = set(round(x[0], 3) for x in v) + bursts = collections.Counter(int(x[1]) for x in v) + zones = len(set(x[3] for x in v)) + print("%-24s %6d %10s %10s %10.4f %8d" % ( + TYPE.get(ty, "type%d" % ty), len(v), + ("%.3f" % list(amts)[0]) if len(amts) == 1 else "%d vals" % len(amts), + ",".join("%dx%d" % (b, c) for b, c in sorted(bursts.items())), + sum(x[2] for x in v), zones)) + +# burst distribution for the cluster class -- should span 1..N, not be pinned +mis = [int(x[1]) for x in byType.get(2, []) if x[1] > 1] +if mis: + print("\ncluster burst rolls: %s" % dict(sorted(collections.Counter(mis).items()))) + print(" -> expect a SPREAD in [n/4 .. n], not a constant") + +# collision must never touch armour +print("\ncollision applications that reached a zone: %d (must be 0)" + % len(byType.get(0, []))) diff --git a/scratchpad/night8/allweap.sh b/scratchpad/night8/allweap.sh new file mode 100644 index 0000000..cc5d166 --- /dev/null +++ b/scratchpad/night8/allweap.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# ALL-WEAPON-CLASS damage audit (#95 burst fix). +# +# The cluster-burst change routes missile damage through the handler's burst loop +# (which re-rolls the zone per burst). That loop is shared by EVERY damage +# consumer, so this run exercises all of them together and reports the delivered +# damage per class, so nothing is silently rescaled: +# +# ENERGY -- Emitter::FireWeapon -> SendDamageMessage (burst 1) +# BALLISTIC -- ProjectileWeapon rounds (burst 1) +# MISSILE -- cluster round (burst = rolled hit count, 1..N) +# SPLASH -- BTApplySplashDamage (burst = distance falloff) +# COLLISION -- type 0, diverted to internal rattle, must NEVER reach armour +# +# Verdict comes from [dmghit]: amt / burst / the damageLevel delta per class. +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 > ALLW.EGG + +LOG=allweap_${1:-post}.log +rm -f "$LOG" + +# All weapon groups auto-fire (Trigger + missile group); walk into the target so +# ballistic/energy/missile all connect, and splash lands on the victim's zones. +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=2 \ + bt_launch "$LOG" ALLW.EGG 0x03 + +sleep 110 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +echo "=== see allweap.py for the per-class breakdown ==="