diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 0429eb5..6c2ae5c 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -1636,8 +1636,37 @@ static void // contact"). Proximity = the hit; the flight-cap expiry is a FIZZLE -- // no damage, matching the binary (a missile that dies mid-air detonates // 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; - const int contact = (dx*dx + dy*dy + dz*dz < (10.0f*10.0f)); + // SWEPT contact (issue #84/#95). This was a 10-unit sphere sampled at the + // 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 (getenv("BT_PROJ_LOG")) @@ -1647,7 +1676,7 @@ static void } if (contact) { - BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, p.pos); + BTSpawnRoundDetonation(p.shooter, p.weaponSubsys, hitPos); Entity *tgt = 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 @@ -1666,7 +1695,7 @@ static void dmg.damageType = (Enumeration)p.damageType; // issue: was hardcoded Explosive dmg.damageAmount = p.damage; dmg.burstCount = 1; - dmg.impactPoint = p.pos; + 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 @@ -1679,6 +1708,14 @@ static void mgr = (SubsystemMessageManager *)((Mech *)p.shooter)->GetMessageManager(); 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::TakeDamageMessageID, sizeof(Entity::TakeDamageMessage), p.shooter->GetEntityID(), -1 /*unaimed -> cylinder resolves*/, diff --git a/game/reconstructed/messmgr.cpp b/game/reconstructed/messmgr.cpp index ac932be..1ef22b2 100644 --- a/game/reconstructed/messmgr.cpp +++ b/game/reconstructed/messmgr.cpp @@ -164,6 +164,8 @@ SubsystemMessageManager::SubsystemMessageManager( commonDamageInformation.damageZoneIndex = -1; // this[0x3A] commonDamageInformation.impactPoint = Point3D::Identity; // this[0x3B] = DAT_004e0f80 + selfDetonatedCount = 0; // port-only (issue #84) + Check_Fpu(); } @@ -388,7 +390,19 @@ void DEBUG_STREAM << "[boom-q] subsysID=" << info->subsystemID << " weapon=" << (firingWeapon ? firingWeapon->GetName() : "") << " 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 { ResourceIDPlug *plug = new ResourceIDPlug(explosionID); @@ -433,6 +447,7 @@ 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 } @@ -513,6 +528,32 @@ 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 331e95e..333be08 100644 --- a/game/reconstructed/messmgr.hpp +++ b/game/reconstructed/messmgr.hpp @@ -163,6 +163,36 @@ 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 3bc00b7..9bca8b2 100644 --- a/game/reconstructed/mislanch.cpp +++ b/game/reconstructed/mislanch.cpp @@ -333,8 +333,22 @@ void MissileLauncher::FireWeapon() // lethal (the "2-shot kill" regression). Only the LEAD round (i==0) // 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. BTPushProjectile(muzzle, o, target, targetPos, speed, - (i == 0) ? damageData.damageAmount : 0.0f, + (i == 0) ? damageData.damageAmount * (Scalar)nmiss : 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/salvo.sh b/scratchpad/night8/salvo.sh new file mode 100644 index 0000000..a164cfa --- /dev/null +++ b/scratchpad/night8/salvo.sh @@ -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 diff --git a/scratchpad/night8/split674.py b/scratchpad/night8/split674.py new file mode 100644 index 0000000..1fb9260 --- /dev/null +++ b/scratchpad/night8/split674.py @@ -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"))) diff --git a/scratchpad/night8/triage.py b/scratchpad/night8/triage.py new file mode 100644 index 0000000..ecc31ac --- /dev/null +++ b/scratchpad/night8/triage.py @@ -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)