Ballistic FX: DAFC muzzle flash + autocannon fire replication (task #61)

The autocannon gains its authentic muzzle effect AND becomes visible when an
enemy fires it -- the ballistic-FX gap behind "I only see the AC from my own
view."

MUZZLE FLASH (the genuine shipped effect, not the cut card):
- MUZFLASH.BGF is an orphaned/cut asset (nothing references it) -- so it is NOT
  rendered.  The shipped projectile-gun muzzle effect is DAFC.PFX, which
  BTDPL.INI documents as "the effect used on all projectile guns" (psfx 6
  external / 14 internal): an orange fire-smoke blast (btfx:firesmoke1),
  maxIssue 25 over ~0.2s so the emitter auto-expires = one burst per shot.
- BTFlashMuzzle (mech4.cpp) spawns it on the gun-port SEGMENT via the existing
  BTStartPfxAttached path; the segment frame sprays -Z out the barrel.  Hooked
  at ProjectileWeapon::FireWeapon (the fire edge).  Default ON (BT_MUZZLE=0
  disables).  AC only -- lasers show their beam, missiles their launch.

ENEMY AC FIRE NOW REPLICATES (the real find):
- ROOT CAUSE: the subsystem-record replication channel EXISTS and works (mech
  ticks subsystem->PerformAndWatch(update_stream); Entity::UpdateMessageHandler
  routes incoming records to GetSimulation(subsystemID-1)->ReadUpdateRecord).
  The emitter (beam) and MissileLauncher (salvo mirror) both call ForceUpdate()
  so their fire serializes -> enemy lasers + missiles ARE visible on the peer.
  The AUTOCANNON set only `simulationFlags |= 0x1` (the +0x28 instance flag,
  NOT the updateModel bit WriteSimulationUpdate walks) and had NO fire record,
  so its shot never crossed the wire -- the enemy's cannon was invisible.
- FIX (the AC twin of the missile salvo mirror): ProjectileWeapon::
  WriteUpdateRecord/ReadUpdateRecord (fire counter + aim) + ForceUpdate() in
  FireWeapon.  The replicant edge-detects the counter and mirrors ONE visual
  round + DAFC muzzle flash from its own resolved muzzle; a null/untargeted aim
  streaks straight out the barrel (launchVelocity) instead of toward origin.
- Verified live 2-node: the watching node logs REPLICANT AC shots + DAFC
  flashes at the enemy's gun-port (seg 7), aimed shots fly to the real target;
  no crashes.

TRACER: all ACs author TracerInterval=1 (every round is a tracer); the existing
amber streak is acceptable-authentic, so no tracer change was needed.

KB: open-questions.md -- CORRECTED the (wrong) earlier note that claimed no
subsystem-record channel exists; it does, the AC just wasn't using it.  Logged
the methodology lesson: the coverage audit finds UNWRITTEN functions, not
"reconstructed but inert" ones (a function present but never called -- the AC
record + the missile mirror both looked done); a LIVENESS audit would catch
that class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arcattack
2026-07-13 17:23:59 -05:00
co-authored by Claude Fable 5
parent 267059ab88
commit 4c54f7ef0c
4 changed files with 208 additions and 1 deletions
+19
View File
@@ -480,6 +480,25 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR
- The WinSock2 stack (L4NET.CPP) is 2-node verified end-to-end (cross-pod kills, beams, - The WinSock2 stack (L4NET.CPP) is 2-node verified end-to-end (cross-pod kills, beams,
replicant gait — tasks #46-#51); remaining = the real pod-LAN config (real IPs, bare-IP replicant gait — tasks #46-#51); remaining = the real pod-LAN config (real IPs, bare-IP
pilot entries). pilot entries).
- **✅ Subsystem-record replication IS wired — the AC just wasn't using it (task #61,
2026-07-13) [T2, CORRECTED].** (An earlier draft of this note WRONGLY claimed "no
subsystem-record channel" — that was concluded from an AC-only test before the AC's
ForceUpdate gap was found. The channel exists and works.) MECHANISM: the mech's per-frame
tick calls `subsystem->PerformAndWatch(till, update_stream)` for every subsystem
(mech4.cpp:3893) → base `Simulation::WriteSimulationUpdate` walks the subsystem's `updateModel`
and serializes any set record; `Entity::UpdateMessageHandler` (ENTITY.cpp:387) routes each
incoming record to `GetSimulation(subsystemID-1)->ReadUpdateRecord` on the peer. A subsystem
replicates its fire IFF its `FireWeapon` calls **`ForceUpdate()`** (sets the updateModel bit).
The EMITTER (beam) and MISSILELAUNCHER (salvo mirror, mislanch.cpp:326) both do → enemy lasers
+ missiles ARE visible on the peer. The AUTOCANNON set only `simulationFlags |= 0x1` (the
+0x28 instance flag, NOT updateModel) and had no fire record → its shot never serialized → the
enemy's cannon (tracer + DAFC muzzle flash) was invisible. FIX: added
`ProjectileWeapon::WriteUpdateRecord/ReadUpdateRecord` (fire counter + aim, the AC twin of the
salvo mirror) + `ForceUpdate()` in FireWeapon. Verified live: watching node now logs
REPLICANT AC shots + muzzle flashes. **LESSON (methodology):** the binary-coverage audit is a
function-PRESENCE census — it can't see "reconstructed but inert" code (a function that exists
but is never CALLED, like the salvo mirror was thought to be, or a missing ForceUpdate). A
LIVENESS audit (does a live call path reach each reconstructed fn?) would catch this class.
## Rendering follow-ups (non-blocking) ## Rendering follow-ups (non-blocking)
- **.PFX effect-layer polish (deferred 2026-07-08; the layer itself is LIVE + verified).** The - **.PFX effect-layer polish (deferred 2026-07-08; the layer itself is LIVE + verified).** The
+34
View File
@@ -851,6 +851,40 @@ int
return 1; return 1;
} }
//###########################################################################
// MUZZLE FLASH (task #61) -- the GENUINE shipped projectile-gun muzzle effect.
//
// BTDPL.INI documents psfx 6 (external) / 14 (internal) = DAFC.PFX as "the
// effect used on all projectile guns": an orange fire-smoke blast
// (btfx:firesmoke1, maxIssue 25 / releasePeriod ~0.2s -> the emitter auto-
// expires after one ~0.2s burst = a per-shot muzzle flash). The AC is the
// only weapon that gets it -- lasers show their beam, missiles their launch.
// NOT the cut MUZFLASH.BGF card (that asset is orphaned; the shipped flash is
// this particle). Disasm of ProjectileWeapon::FireWeapon (@0x4bc104) shows no
// direct spawn there, so the fire edge is the faithful hook; the effect,
// asset, and gun-port attach point are all confirmed shipped [T1 asset/INI].
//
// Attached to the gun-port SEGMENT (BTResolveSegmentWorld re-resolves the
// muzzle position + mech body frame each frame): DAFC sprays -Z, and the mech
// faces -Z, so the fire-smoke blows forward out the barrel.
//###########################################################################
void
BTFlashMuzzle(void *ownerMech, int seg_index, float mx, float my, float mz)
{
if (ownerMech == 0)
return;
float pos[3], rows[9];
if (!BTResolveSegmentWorld(ownerMech, seg_index, pos, rows))
return;
extern void BTStartPfxAttached(int, void *, int, float, float, float, const float *);
// psfx 6 = DAFC (external); the day table maps both 6 and 14 to DAFC.PFX,
// so the external slot serves every viewer.
BTStartPfxAttached(6, ownerMech, seg_index, mx, my, mz, rows);
if (getenv("BT_MUZZLE_LOG"))
DEBUG_STREAM << "[muzzle] DAFC flash seg=" << seg_index
<< " at(" << mx << "," << my << "," << mz << ")\n" << std::flush;
}
//########################################################################### //###########################################################################
// PER-ROUND DETONATION (the binary's Missile::MoveAndCollide @004bef78: every // PER-ROUND DETONATION (the binary's Missile::MoveAndCollide @004bef78: every
// round spawns ITS OWN ExplosionModelFile at its impact point, resource // round spawns ITS OWN ExplosionModelFile at its impact point, resource
+133 -1
View File
@@ -98,6 +98,42 @@ extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target,
int weapon_subsys = -1); int weapon_subsys = -1);
//#############################################################################
// FIRE-VISUAL REPLICATION STATE (task #61) -- the AC twin of MissileLauncher's
// BTSalvoState (mislanch.cpp). Port-side table keyed by the weapon pointer
// (the 0x1D0 object is byte-locked): the master counts its shots + remembers
// the aim; the replicant edge-detects the counter and mirrors ONE visual round
// + DAFC muzzle flash so the enemy's cannon is visible on the peer.
//#############################################################################
namespace {
struct BTAcFireState
{
const void *owner;
int fired; // master: shots fired
int seen; // replicant: last counter applied (-1 = unsynced)
Point3D target; // master: the last shot's aim point
};
BTAcFireState gAcFireTable[64];
BTAcFireState &BTAcFireOf(const void *weapon)
{
int freeSlot = -1;
for (int i = 0; i < 64; ++i)
{
if (gAcFireTable[i].owner == weapon)
return gAcFireTable[i];
if (gAcFireTable[i].owner == 0 && freeSlot < 0)
freeSlot = i;
}
BTAcFireState &s = gAcFireTable[(freeSlot >= 0) ? freeSlot : 0];
s.owner = weapon;
s.fired = 0;
s.seen = -1;
s.target = Point3D(0.0f, 0.0f, 0.0f);
return s;
}
}
//############################################################################# //#############################################################################
// Resolved read-only constants + unrecovered-helper stand-ins (see banner). // Resolved read-only constants + unrecovered-helper stand-ins (see banner).
// //
@@ -681,6 +717,20 @@ void
Point3D muzzle; Point3D muzzle;
GetMuzzlePoint(muzzle); // MechWeapon @004b9948 GetMuzzlePoint(muzzle); // MechWeapon @004b9948
// GENUINE MUZZLE FLASH (task #61): the shipped DAFC fire-smoke blast at the
// gun-port -- BTDPL.INI psfx 6/14 "the effect used on all projectile guns".
// Fires on every AC shot (all ACs author TracerInterval=1); one ~0.2s
// auto-expiring burst. Default ON (BT_MUZZLE=0 disables for diagnostics).
{
const char *mz = getenv("BT_MUZZLE"); // default ON; =0 disables
if (mz == 0 || mz[0] != '0')
{
extern void BTFlashMuzzle(void *ownerMech, int seg_index, float mx, float my, float mz);
BTFlashMuzzle(owner, GetSegmentIndex(),
(float)muzzle.x, (float)muzzle.y, (float)muzzle.z);
}
}
// WAVE 7 Phase B: launch one flying ballistic round toward the owner's target (port // 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 // reconstruction; the byte-exact world-entity Projectile is blocked by the 2007 engine
// Entity base mismatch -- see BTPushProjectile / mech4.cpp). Speed = |launchVelocity|. // Entity base mismatch -- see BTPushProjectile / mech4.cpp). Speed = |launchVelocity|.
@@ -701,11 +751,93 @@ void
0 /*aim straight at the pick*/, 0 /*BALLISTIC: no seeker on a shell*/, 0 /*aim straight at the pick*/, 0 /*BALLISTIC: no seeker on a shell*/,
subsystemID /*messmgr explosion bundling at impact (task #7)*/); subsystemID /*messmgr explosion bundling at impact (task #7)*/);
simulationFlags |= 0x1; // replication-dirty // task #61: mark this shot for REPLICATION so the peer sees the enemy's
// cannon. ++fireCounter + ForceUpdate() -- IDENTICAL to the missile path
// (mislanch.cpp:316/326) and the emitter beam: the master's per-frame
// subsystem serialize writes this weapon's record (WriteUpdateRecord),
// Entity::UpdateMessageHandler routes it to the peer's replicant, and the
// replicant's ReadUpdateRecord mirrors the shot. (The old code set only
// `simulationFlags |= 0x1` -- the +0x28 instance flag, NOT the updateModel
// bit WriteSimulationUpdate walks -- so the AC record never serialized: the
// root cause of the invisible enemy autocannon.)
{
BTAcFireState &fs = BTAcFireOf(this);
++fs.fired;
fs.target = targetPos;
}
simulationFlags |= 0x1; // replication-dirty (state flag)
ForceUpdate(); // set the updateModel bit -> serialize the record
Check_Fpu(); Check_Fpu();
} }
//#############################################################################
// FIRE-VISUAL REPLICATION (task #61) -- the AC twin of MissileLauncher's salvo
// mirror. Master serializes the fire counter + aim after the MechWeapon base
// fields; the replicant edge-detects the counter and mirrors ONE visual round
// + DAFC muzzle flash from its OWN resolved muzzle -- so the enemy's autocannon
// is visible on the peer (the port's local flying round otherwise never left
// the firing node).
//#############################################################################
void
ProjectileWeapon::WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model)
{
MechWeapon::WriteUpdateRecord(message, update_model); // @004b9690 (alarm fields + subsystemID)
ProjectileWeapon__UpdateRecord *rec = (ProjectileWeapon__UpdateRecord *)message;
BTAcFireState &s = BTAcFireOf(this);
rec->recordLength = sizeof(ProjectileWeapon__UpdateRecord);
rec->fireCounter = s.fired;
rec->fireTarget = s.target;
}
void
ProjectileWeapon::ReadUpdateRecord(Simulation__UpdateRecord *message)
{
MechWeapon::ReadUpdateRecord(message); // @004b964c (alarm apply)
ProjectileWeapon__UpdateRecord *rec = (ProjectileWeapon__UpdateRecord *)message;
BTAcFireState &s = BTAcFireOf(this);
if (s.seen < 0)
{
s.seen = rec->fireCounter; // first sync: adopt silently
return;
}
if (rec->fireCounter == s.seen)
return;
s.seen = rec->fireCounter;
// Mirror ONE shot (a missed record collapses to one -- no phantom replays):
// a VISUAL-ONLY round (damage 0; the master's round delivers cross-pod
// damage) from this replicant's own muzzle toward the master's aim point,
// plus the DAFC muzzle flash.
Point3D mz;
GetMuzzlePoint(mz); // @004b9948
Scalar spd = (Scalar)sqrtf(launchVelocity.x*launchVelocity.x
+ launchVelocity.y*launchVelocity.y
+ launchVelocity.z*launchVelocity.z);
// Fly toward the master's aim if it replicated a real pick; else straight
// out the barrel (the shooter-frame launchVelocity, as the missile mirror
// does) so an untargeted shot still streaks forward, not toward the origin.
const int haveAim = (rec->fireTarget.x != 0.0f
|| rec->fireTarget.y != 0.0f
|| rec->fireTarget.z != 0.0f);
BTPushProjectile(mz, owner, 0 /*aim point only, no entity*/, rec->fireTarget,
spd, 0.0f /*VISUAL*/,
haveAim ? 0 : &launchVelocity, 0 /*ballistic*/, subsystemID);
const char *mzf = getenv("BT_MUZZLE"); // default ON; =0 disables
if (mzf == 0 || mzf[0] != '0')
{
extern void BTFlashMuzzle(void *ownerMech, int seg_index, float, float, float);
BTFlashMuzzle(owner, GetSegmentIndex(), (float)mz.x, (float)mz.y, (float)mz.z);
}
if (getenv("BT_PROJ_LOG"))
DEBUG_STREAM << "[projectile] REPLICANT AC shot at(" << rec->fireTarget.x
<< "," << rec->fireTarget.y << "," << rec->fireTarget.z << ")\n" << std::flush;
}
//############################################################################# //#############################################################################
// CreateStreamedSubsystem // CreateStreamedSubsystem
// //
+22
View File
@@ -195,6 +195,28 @@ class NotationFile;
void void
PrintState(); // slot 13, @004bc6c8 PrintState(); // slot 13, @004bc6c8
// FIRE-VISUAL REPLICATION (task #61 -- the AC twin of MissileLauncher's
// salvo mirror, mislanch.hpp). The port's AC is a LOCAL flying round,
// so the enemy's cannon fire never reached the peer. Same proven path
// the missile + emitter use: FireWeapon calls ForceUpdate() (sets the
// updateModel bit), so the mech's per-frame subsystem serialize
// (mech4.cpp:3893 subsystem->PerformAndWatch(update_stream)) writes this
// record; Entity::UpdateMessageHandler routes it to the peer's replicant
// by subsystemID. The master serializes a monotonic fire counter + aim;
// the replicant edge-detects it and mirrors ONE visual round + DAFC
// muzzle flash. Fire STATE lives in a port-side table (projweap.cpp);
// the 0x1D0 object layout is byte-locked and must not grow.
struct ProjectileWeapon__UpdateRecord:
public MechWeapon::UpdateRecord
{
int fireCounter; // AC shots since spawn (edge-detected by peers)
Point3D fireTarget; // the aim point the round flew at
};
typedef ProjectileWeapon__UpdateRecord UpdateRecord;
void WriteUpdateRecord(Simulation__UpdateRecord *message, int update_model); // slot 7
void ReadUpdateRecord(Simulation__UpdateRecord *message); // slot 6
protected: protected:
// TASK #51 RENAME: slot 9 is TakeDamage (the old ReadUpdateRecord // TASK #51 RENAME: slot 9 is TakeDamage (the old ReadUpdateRecord
// attribution was swapped; the record read is INHERITED from // attribution was swapped; the record read is INHERITED from