From 4c54f7ef0ccdce702d04f7cd287acbce7ff38607 Mon Sep 17 00:00:00 2001 From: arcattack Date: Mon, 13 Jul 2026 17:23:59 -0500 Subject: [PATCH] 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 --- context/open-questions.md | 19 +++++ game/reconstructed/mech4.cpp | 34 ++++++++ game/reconstructed/projweap.cpp | 134 +++++++++++++++++++++++++++++++- game/reconstructed/projweap.hpp | 22 ++++++ 4 files changed, 208 insertions(+), 1 deletion(-) diff --git a/context/open-questions.md b/context/open-questions.md index 8655a5e..c82d23a 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -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, replicant gait — tasks #46-#51); remaining = the real pod-LAN config (real IPs, bare-IP 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) - **.PFX effect-layer polish (deferred 2026-07-08; the layer itself is LIVE + verified).** The diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 2d50c82..0f31f94 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -851,6 +851,40 @@ int 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 // round spawns ITS OWN ExplosionModelFile at its impact point, resource diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index f50fdae..b50b3d9 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -98,6 +98,42 @@ extern void BTPushProjectile(const Point3D &muzzle, void *shooter, void *target, 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). // @@ -681,6 +717,20 @@ void Point3D muzzle; 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 // reconstruction; the byte-exact world-entity Projectile is blocked by the 2007 engine // 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*/, 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(); } +//############################################################################# +// 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 // diff --git a/game/reconstructed/projweap.hpp b/game/reconstructed/projweap.hpp index 6985fb4..2930c96 100644 --- a/game/reconstructed/projweap.hpp +++ b/game/reconstructed/projweap.hpp @@ -195,6 +195,28 @@ class NotationFile; void 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: // TASK #51 RENAME: slot 9 is TakeDamage (the old ReadUpdateRecord // attribution was swapped; the record read is INHERITED from