From 2edde296714f0bc772b75e2127a939c885fcf9c5 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 23:26:11 -0500 Subject: [PATCH] Fix 1 (Gitea #12): disasm-exact ProjectileWeaponSimulation @004bbd04 + the dirty-bit sweep Replace the RivetGun-modeled ProjectileWeaponSimulation body with the fully recovered binary machine (capstone disasm, every branch address-cited): - gate 1 @4bbd36: simulationState@0x40==1 (destroyed) || heatAlarm==FailureHeat || owner disabled -> recoil=rechargeRate + alarm 7 (was: simulationFlags==1) - gate 2 @4bbd71: bin alarm 2/3 or bin destroyed -> alarm 7 re-pinned per frame - Loaded @4bbec2: DENIED shots (viewFireEnable off / no owner target) blip SetLevel(4);SetLevel(2) and STAY LOADED, no ammo pull (the old FireWeapon early-returns under an unconditionally-cycling caller faked a full firing pip cycle -- the "missiles cycle but never launch" incident mechanic); the ammo pull is bin->FeedAmmo @4bbee6 in the CALLER; both updateModel|=1 marks (@4bbf05/@4bbf4c = ForceUpdate) bracket FireWeapon; recoil set @4bbf51 - Loading @4bbdd2: recoil bleeds ONLY here at electrical Ready and CLAMPS at 0 (it ran to -114 before); bin Loaded -> weapon Loaded - 7 @4bbe4d: binary-faithful roach motel (re-asserted unconditionally; recovery = ResetToInitialState only) - slot 17 @004b9c9c reconstructed as MechWeapon::ComputeOutputVoltage (rechargeLevel=(rechargeRate-recoil)/rechargeRate, Emitter overrides with @4ba738) and called from Loading/7 -- the launcher recharge dial ANIMATES (the old "authentically static" claim was wrong) FireWeapon bodies stripped to heat+spawn only per @004bcc60 (no view gate, no ConsumeRound, no recoil); ConsumeRound retired (not a binary method). Gotcha #20 sweep (all sites disasm-verified): simulationFlags|=0x1 == engine DelayWatchersFlag (audio watchers muted forever) removed everywhere -- projweap/mislanch fire marks, ProjectileWeapon::ResetToInitialState (@4bbb47 = updateModel|=1); Emitter::SetDirty retired and split per binary site into ForceUpdate() (@4bafaa/@4ba55d) vs ExecuteOnUpdate() (@4ba99a/@4ba943); Emitter fault gate GetFlags()==1 -> simulationState==1 (@4baab9). AmmoBin::GetAmmoState() accessor added (named-member read of ammoAlarm@0x1A8). BT_LOOK_TEST= scripted verify added (mech4.cpp): holds the rear-view button 300f on/300f off to drive viewFireEnable headlessly. Verified solo ARENA1 (bhk1, autofire): 54-60 [projectile] PUSH + impact smoke; look-back denial window = trigger pulses, state stays 2, zero launches, resumes on release; enemy (disabled) mech launchers pinned 7 + full recoil; STREAK-6 recharge dial sweeps across BT_SHOT frames; lasers fire throughout; zero gauge faults. Co-Authored-By: Claude Fable 5 --- context/decomp-reference.md | 5 +- context/reconstruction-gotchas.md | 8 + game/reconstructed/ammobin.hpp | 9 + game/reconstructed/emitter.cpp | 29 ++- game/reconstructed/emitter.hpp | 6 +- game/reconstructed/mech4.cpp | 22 +++ game/reconstructed/mechweap.cpp | 18 ++ game/reconstructed/mechweap.hpp | 14 +- game/reconstructed/mislanch.cpp | 33 ++-- game/reconstructed/projweap.cpp | 290 +++++++++++++++++------------- game/reconstructed/projweap.hpp | 23 +-- 11 files changed, 292 insertions(+), 165 deletions(-) diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 6906b40..09f6803 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -201,7 +201,9 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a (own heatAlarm level) == 2 -> emitter @004baa88 resets firing + holds charge 0; ballistic @004bbd36 pins `recoil=rechargeRate` + alarm 7. - **@004bbd04 ProjectileWeaponSimulation FULLY RECOVERED (capstone disasm, Gitea #12, - 2026-07-19) [T1]** — the port's RivetGun-modeled body diverges; the authentic machine: + 2026-07-19) [T1] — PORT NOW IMPLEMENTS IT (landed 2026-07-19, projweap.cpp; solo-verified: + missiles fly, denial blip stays Loaded, recoil clamped, slot-17 dial animates; MP re-verify + pending)** — the old RivetGun-modeled body diverged; the authentic machine: PoweredSubsystemSimulation@4b0bd0; trigger=CheckFireEdge@4b9608; UpdateEject@4bbb50; **gate 1 @4bbd36: `simulationState@0x40==1` (destroyed — NOT simulationFlags@0x28!) || heatAlarm@0x184==2 || FUN_49fb54(owner disabled) → recoil=rechargeRate + alarm 7**; @@ -313,6 +315,7 @@ default-ON (`'0'` disables). | `BT_VIEWCYCLE_TEST=` / `BT_MODECYCLE_TEST=` | scripted verify (mech4.cpp): pulse a secondary-schematic cycle / a control-mode cycle at the frame and every 300 after — pixel-verifiable with `BT_DEV_GAUGES_DOCK`+`BT_SHOT` | | `BT_PRESET_TEST=` | scripted verify (mech4.cpp, Gitea #9): from the frame, pulse an upper-MFD preset-page cycle on all three MFDs every 120 frames (Quad → populated Eng pages → Quad) — pixel-verifiable with `BT_DEV_GAUGES_DOCK`+`BT_SHOT` | | `BT_PRESET_HOLD=` | (with BT_PRESET_TEST, Gitea #11) stop after n pulses so a page is HELD — steady-state pixel verification; the 120-frame cycle phase-locks with BT_SHOT's 90-frame cadence, so cycling shots periodically capture the 1-frame page-switch transition | +| `BT_LOOK_TEST=` | scripted verify (mech4.cpp, Gitea #12): from the frame, HOLD the rear-view button 300 frames / release 300, repeating — drives mapper→BTCommitLookState→viewFireEnable so the projectile denied-shot blip (stay-Loaded, no launch) is log-verifiable headless | | `BT_SEEK_LOG` | `[seek] BecameActive/replot` — SeekVoltageGraph activation sentinel + clear/replot events with the response-at-12kV value (Gitea #11, btl4gau2.cpp) | | `BT_CTRLMAP_LOG` | streamed `.CTL` record audit (L4CTRL.cpp CreateStreamedMappings): Direct records with the resolved member offset + EVENT records (button→subsystem message; found the pod's secondary-panel Cycle buttons 0x15/0x18) | | `BT_CAM_LOG` | camera-seat/broadcast trace: streamed camera network count, director pick + Players-group census, ship follow state, PlayerLink dispatch/receive, ranking-window render ([[multiplayer]] camera seat) | diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 9b17942..dc11e27 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -422,6 +422,14 @@ port transcribed as `simulationFlags == 1` — with the spurious `|=1` above, th permanent alarm-7 kill-switch for any launcher whose other flag bits are 0. **Rule:** a binary `[this+0x18] |= 1` = `ForceUpdate()`; `[this+0x28]` = simulationFlags (engine-defined bits — never invent meanings); `[this+0x40]` = MechSubsystem::simulationState (1 = destroyed). [T1] +**FIXED 2026-07-19 (Gitea #12, all sites disasm-verified):** `Emitter::SetDirty` retired — it +conflated `or word [this+0x18],1` (= ForceUpdate; FireWeapon tail @4bafaa, Reset tail @4ba55d) +with `or dword [this+0x28],2` (= ExecuteOnUpdate; ServiceDischarge @4ba99a/@4ba943) — each call +site now uses the correct engine call; the emitter fault gate `GetFlags()==1` → `simulationState +== 1` (@4baab9); `mislanch.cpp` salvo mark and `projweap.cpp` AC mark / ResetToInitialState +(@4bbb47) likewise. ⚠ One un-audited sibling remains: `btplayer.cpp:426` `simulationFlags|=0x1` +("request a forced update" — likely the same mis-transcription; verify against the +VehicleDead handler's disasm before changing). [T2] --- diff --git a/game/reconstructed/ammobin.hpp b/game/reconstructed/ammobin.hpp index e37c3d7..e295d24 100644 --- a/game/reconstructed/ammobin.hpp +++ b/game/reconstructed/ammobin.hpp @@ -160,6 +160,15 @@ void DumpAmmo(); + // The feed alarm's current level (this+0x1A8) -- the linked weapon's + // ProjectileWeaponSimulation @004bbd04 reads it raw off the resolved bin: + // gate 2 @4bbd80 (level 2/3 or bin destroyed -> weapon alarm 7), the + // Loading recoil-clamp Loaded test @4bbe25 (level 1 -> weapon Loaded), and + // the post-fire Empty test @4bbf11 (level 2 -> weapon alarm 7). Named + // accessor per the databinding rule (never a raw offset on our layout). + int + GetAmmoState() const { return ammoAlarm.GetLevel(); } + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Subsystem virtual overrides (vtable @0051286c) // diff --git a/game/reconstructed/emitter.cpp b/game/reconstructed/emitter.cpp index 37f626b..6e715d2 100644 --- a/game/reconstructed/emitter.cpp +++ b/game/reconstructed/emitter.cpp @@ -212,8 +212,16 @@ LWord Emitter::GetFlags() { return simulationFlags; } // the emitter family (task #10) -- an overheated emitter resets its firing // state and holds currentLevel at 0 until it cools below FailureTemperature. int Emitter::GetFaultState() { return heatAlarm.GetLevel(); } -void Emitter::SetDirty() { simulationFlags |= 0x1; } -void Emitter::ClearDirty() { simulationFlags &= ~0x2; } +// Gitea #12 / gotcha #20: SetDirty() { simulationFlags |= 0x1 } RETIRED -- bit 0 +// of simulationFlags is the engine DelayWatchersFlag (SIMULATE.h:170): once set, +// PerformAndWatch skips this subsystem's ExecuteWatchers (the AUDIO watchers) +// FOREVER. The binary sites it transcribed are two DIFFERENT writes: +// `or word [this+0x18], 1` == updateModel |= 1 == engine ForceUpdate() +// (FireWeapon tail @4bafaa, ResetToInitialState tail @4ba55d) +// `or dword [this+0x28], 2` == DontExecuteFlag == engine ExecuteOnUpdate() +// (ServiceDischarge idle @4ba99a + beam-end @4ba943) +// Call sites now use the engine calls directly. +void Emitter::ClearDirty() { simulationFlags &= ~0x2; } // AlwaysExecute (@004ba65c write path) //############################################################################# @@ -344,7 +352,7 @@ void ? *(Entity **)((char *)owner + 0x388) : 0; // 0x474 <- mech target entity targetLocalFlag = 1; // 0x470 - SetDirty(); // this+6 |= 1 (needs replication) + ForceUpdate(); // @4bafaa `or word [this+0x18],1` (updateModel -- needs replication) } // @@ -400,9 +408,12 @@ void } targetWithinRange = UpdateTargeting(); // @004b9bdc -> this+0x34c - // hard failure: powered-off, faulted, or the OWNING MECH destroyed (@004baa88 - // checks IsDestroyed on the owner -- a dead mech's weapons drop everything) - if (GetFlags() == 1 || GetFaultState() == 2 || BTMechDestroyed((Entity *)owner)) + // hard failure @4baab9: weapon DESTROYED (`simulationState@0x40 == 1` -- the + // old `GetFlags() == 1` was the gotcha-#20 mis-transcription onto + // simulationFlags@0x28, a latent kill-switch), own heatAlarm at FailureHeat + // (this+0x184 == 2), or the owning mech disabled (FUN_0049fb54) -- a dead + // mech's weapons drop everything. + if (simulationState == 1 || GetFaultState() == 2 || BTMechDestroyed((Entity *)owner)) { ResetFiringState(); // @004ba9a8 currentLevel = 0.0f; // 0x414 @@ -541,7 +552,7 @@ void { if (firingActive == 0) // 0x418 { - SetDirty(); // this+0x28 |= 2 + ExecuteOnUpdate(); // @4ba99a `or dword [this+0x28],2` (DontExecuteFlag) return; } @@ -554,7 +565,7 @@ void weaponAlarm.SetLevel(2); // Loaded beamFlag = 0; // 0x46c dischargeTimer = dischargeTime; // 0x440 = 0x43c - SetDirty(); + ExecuteOnUpdate(); // @4ba943 `or dword [this+0x28],2` // replicate the beam END: one final Emitter update record turns the // peer's copy off (the 1995 flush wrote idle-flagged subsystems too). ForceUpdate(); @@ -835,7 +846,7 @@ void beamScale = Vector3D(1.0f, 1.0f, 1.0f); // 0x42c beamOrientation = EulerAngles(Radian(0.0f), Radian(0.0f), Radian(0.0f)); // 0x41c firingActive = 0; // 0x418 - SetDirty(); // this+6 |= 1 + ForceUpdate(); // @4ba55d `or word [this+0x18],1` (updateModel) } // diff --git a/game/reconstructed/emitter.hpp b/game/reconstructed/emitter.hpp index 1444df9..5012361 100644 --- a/game/reconstructed/emitter.hpp +++ b/game/reconstructed/emitter.hpp @@ -142,11 +142,15 @@ class NotationFile; protected: // State / flag accessors the recovered bodies read (mapped onto the // MechWeapon weaponAlarm + Simulation flags). + // (Gitea #12 / gotcha #20: the old SetDirty() { simulationFlags |= 0x1 } + // helper is RETIRED -- it conflated TWO different binary writes and its + // bit 0 is the engine DelayWatchersFlag (mutes the audio watchers + // forever). The binary's `or word [this+0x18],1` sites are the engine + // ForceUpdate(); its `or dword [this+0x28],2` sites are ExecuteOnUpdate().) int GetWeaponState(); int GetVoltageState(); LWord GetFlags(); int GetFaultState(); - void SetDirty(); void ClearDirty(); //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index ea5dad5..df61c7a 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -2698,6 +2698,28 @@ void // button (releases back to the forward view). Rear-mounted // weapons (blackhawk/owens back racks) fire only in it. gBTLookBehind = gBTInput.lookBehind; + // Gitea #12 scripted verify (BT_LOOK_TEST=): from the + // given frame, HOLD the rear-view button for 300 frames / + // release 300, repeating -- drives the authentic look-state + // chain (mapper -> BTCommitLookState -> viewFireEnable) so + // the recovered denied-shot blip (front weapons stay Loaded, + // launch NOTHING while looking back) is verifiable headless. + { + static int sLookTestFrom = -2; + if (sLookTestFrom == -2) + { + const char *lt = getenv("BT_LOOK_TEST"); + sLookTestFrom = (lt != 0) ? atoi(lt) : -1; + } + if (sLookTestFrom >= 0) + { + static int sLookFrame = 0; + ++sLookFrame; + if (sLookFrame >= sLookTestFrom + && (((sLookFrame - sLookTestFrom) / 300) % 2) == 0) + gBTLookBehind = 1; + } + } static float sTwist = 0.0f; if (gBTInput.twistAbsolute) { diff --git a/game/reconstructed/mechweap.cpp b/game/reconstructed/mechweap.cpp index 0dfc93e..828de2f 100644 --- a/game/reconstructed/mechweap.cpp +++ b/game/reconstructed/mechweap.cpp @@ -557,6 +557,24 @@ Logical return edge; } +// +// @004b9c9c -- vtable slot 17 (vtbl+0x44): the recharge-dial writer (Gitea #12). +// Disasm (tools/disas2.py 0x4b9c9c): +// fld [eax+0x3dc] ; rechargeRate +// fsub [eax+0x3e8] ; - recoil +// fdiv [eax+0x3dc] ; / rechargeRate +// fstp [eax+0x320] ; -> rechargeLevel +// ProjectileWeaponSimulation @004bbd04 dispatches this from its Loading(3) and +// unavailable(7) cases, so the launcher-panel recharge dial (SegmentArc270 on +// PercentDone/rechargeLevel) is authentically ANIMATED for projectile weapons. +// Emitter overrides the slot with ComputeOutputVoltage @004ba738. +// +void + MechWeapon::ComputeOutputVoltage() +{ + rechargeLevel = (rechargeRate - recoil) / rechargeRate; // @0x320 = (@0x3DC - @0x3E8) / @0x3DC +} + // // @004b9cbc -- copy the owning Mech's current target position (entity+0x1c4). // diff --git a/game/reconstructed/mechweap.hpp b/game/reconstructed/mechweap.hpp index 4109023..0ac8b03 100644 --- a/game/reconstructed/mechweap.hpp +++ b/game/reconstructed/mechweap.hpp @@ -241,6 +241,17 @@ class CockpitHud; virtual void FireWeapon(); // @004b95ec (abstract stub) + // @004b9c9c -- vtable slot 17 (vtbl+0x44): the recharge-dial writer. + // rechargeLevel@0x320 = (rechargeRate@0x3DC - recoil@0x3E8) / rechargeRate + // (disasm: fld [eax+0x3dc]; fsub [eax+0x3e8]; fdiv [eax+0x3dc]; fstp [eax+0x320]). + // ProjectileWeaponSimulation @004bbd04 calls it from the Loading(3) and + // unavailable(7) cases -- the launcher recharge dial is authentically LIVE + // (Gitea #12; the old "authentically static" claim was wrong). Emitter + // overrides this slot with ComputeOutputVoltage @004ba738 (same output + // field, charge-driven instead of recoil-driven). + virtual void + ComputeOutputVoltage(); // slot 17, @004b9c9c + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // Subsystem virtual overrides (slots on vtable @00511d2c) // @@ -346,7 +357,8 @@ class CockpitHud; void *WithinRangePtr() { return &targetWithinRange; } void *WeaponAlarmPtr() { return &weaponAlarm; } void *SimulationStatePtr() { return &simulationState; } // attr 1 ("SimulationState" -- damage state; 1 = Destroyed) - Scalar *RechargeLevelPtr() { return &rechargeLevel; } // the recharge-dial source (1.0 == fully charged); STATIC on projectile weapons + Scalar *RechargeLevelPtr() { return &rechargeLevel; } // the recharge-dial source (1.0 == fully charged); LIVE on BOTH families + // (projectile weapons write it via slot 17 @004b9c9c — Gitea #12) // The reticle pip's AUTHENTIC "loaded" source: attr 0x1C WeaponState @0x350 (the // weaponAlarm StateIndicator level == 2 => Loaded). Unlike rechargeLevel this // cycles for BOTH families -- emitters Loading(3)->Loaded(2)->Firing(0), and diff --git a/game/reconstructed/mislanch.cpp b/game/reconstructed/mislanch.cpp index 1c349eb..531b215 100644 --- a/game/reconstructed/mislanch.cpp +++ b/game/reconstructed/mislanch.cpp @@ -267,11 +267,13 @@ void MissileLauncher::FireWeapon() { Check(this); - // (task #68) the view gate: a weapon disabled in the CURRENT look view - // (rear rack in the forward view, anything in a side view) does not fire - // -- the same +0x3E0 gate the emitter's Loaded->Firing transition reads. - if (!viewFireEnable) - return; + // Gitea #12: this body is heat + spawn ONLY, per the recovered @004bcc60 + // (part_013.c:8741+): NO viewFireEnable gate, NO ammo pull, NO recoil set. + // All of that lives in the CALLER -- the recovered ProjectileWeaponSimulation + // Loaded case (@4bbec2 view/target denial blip, @4bbee6 bin->FeedAmmo, + // @4bbf51 recoil). The old early-returns here, under a caller that cycled + // the alarm unconditionally, faked a full firing pip cycle on every denied + // shot -- the "missiles cycle but never launch" incident mechanic. // THE FIRING HEAT (task #9): heatCostToFire RAW into the launcher's own // pendingHeat (pre-scaled 1e7-unit resources: SRM6 = 5e7, LRM15 = 6.5e7) @@ -281,14 +283,6 @@ void MissileLauncher::FireWeapon() pendingHeat += heatCostToFire; // HeatSink @0x1C8, raw } - // A salvo draws one ammo unit from the linked bin; a dry / not-ready bin - // latches NoAmmo (ConsumeRound) and aborts the launch. - if (!ConsumeRound()) // FUN_004bd4f4 (AmmoBin::FeedAmmo) - return; - - // Begin the per-shot recharge cooldown (recoil bleeds to 0 before ReadyToFire). - recoil = rechargeRate; - // Resolve the muzzle / lead geometry (seeds each flying missile). Point3D muzzle; GetMuzzlePoint(muzzle); // @004b9948 @@ -328,19 +322,16 @@ void MissileLauncher::FireWeapon() // Salvo replication (missile-visibility wave): bump the fire counter + stamp // the aim point; the extended update record carries both to peer nodes. + // The CALLER's Loaded case makes the two `updateModel |= 1` marks + // (@4bbf05/@4bbf4c == ForceUpdate) that serialize the record. + // Gitea #12 / gotcha #20: the old `simulationFlags |= 0x1` here set the + // engine DelayWatchersFlag -- permanently muting this launcher's audio + // watchers after the first salvo; removed. { BTSalvoState &s = BTSalvoOf(this); ++s.fired; s.target = targetPos; } - - simulationFlags |= 0x1; // replication-dirty - // ENQUEUE the record (visibility fix): the dirty flag alone never - // serializes -- ForceUpdate sets the updateModel bit that makes the next - // PerformAndWatch write THIS subsystem's record into the mech's update - // stream (the exact mechanism the Emitter's beam replication uses, - // emitter.cpp:512). Without it the peer never saw a salvo record at all. - ForceUpdate(); Check_Fpu(); } diff --git a/game/reconstructed/projweap.cpp b/game/reconstructed/projweap.cpp index 593cd02..a6b579e 100644 --- a/game/reconstructed/projweap.cpp +++ b/game/reconstructed/projweap.cpp @@ -24,12 +24,14 @@ // ResetToInitialState @004bbaf8, HandleMessage @004bcabc, // PrintState @004bc6c8, CreateStreamedSubsystem @004bc7cc, // UpdateEject @004bbb50, DrawFiringCharge @004bbc78, -// CheckForJam @004bbfcc, ComputeTimeOfFlight @004bc06c. +// CheckForJam @004bbfcc, ComputeTimeOfFlight @004bc06c, +// ProjectileWeaponSimulation @004bbd04 (Performance -- FULLY RECOVERED +// by capstone disasm, Gitea #12 2026-07-19; see context/decomp-reference.md +// s5 -- the old RivetGun-modeled body is retired). // best-effort (vtable slot proven, function prologue present, BODY NOT // recovered by the decompiler -- bodies below are reconstructed from context // and clearly marked): -// FireWeapon @004bc104 (slot 18), ProjectileWeaponSimulation @004bbd04 -// (Performance), GetStatusFlags @004bbf88 (slot 12), +// FireWeapon @004bc104 (slot 18), GetStatusFlags @004bbf88 (slot 12), // UpdateWeaponState @004bbc20 (slot 16). // excluded (belong to sibling/derived classes, NOT ProjectileWeapon): // Emitter family @004bb120/@004bb888/@004ba4d0-@004bb478 (energy weapons), @@ -228,16 +230,18 @@ Logical return True; } -// -// ConsumeRound -- pull one round from the linked AmmoBin (FUN_004bd4f4 -// AmmoBin::FeedAmmo). Returns True when a round was dispensed; otherwise raises -// the NoAmmo alarm and returns False. Shared by both FireWeapon spawn paths. // // Panel bridge (AFC100-counter fix, 2026-07-12): resolve the weapon's ammo // bin through the TYPED connection -- the gauge cluster's hand-rolled raw walk // (*(plug+8) twice at subsystem+0x43c) assumed the 1995 connection-object // internals and returned NULL/garbage on our layout, so the panel's ammo // digits never bound (fired shells consumed rounds invisibly). +// +// (Gitea #12: the old `ConsumeRound()` helper is RETIRED -- the binary has no +// such method. The ammo pull is `bin->FeedAmmo()` @004bd4f4 called by the +// SIMULATION's Loaded case @4bbee6, and a failed pull leaves the weapon Loaded +// SILENTLY -- the helper's SetLevel(NoAmmo) on failure was port invention; the +// authentic NoAmmo pin is gate 2 of the state machine, from the BIN's alarm.) void * BTWeaponAmmoBin(void *weapon) { @@ -245,18 +249,6 @@ void * ? (void *)((ProjectileWeapon *)weapon)->ammoBinLink.Resolve() : 0; } -Logical - ProjectileWeapon::ConsumeRound() -{ - AmmoBin *bin = (AmmoBin*)ammoBinLink.Resolve(); // FUN_00417ab4(this+0x43c) - if (bin != 0 && bin->FeedAmmo() != 0) // FUN_004bd4f4 -- dispensed? - { - return True; - } - weaponAlarm.SetLevel(NoAmmoState); // FUN_0041bbd8(this+0x350, 7) - return False; -} - //############################################################################# // Construction / Destruction @@ -403,7 +395,11 @@ void percentOfEject = 0.0f; // this[0xfe] = 0 ejectState = -1; // this[0x100] = -1 - simulationFlags |= 0x1; // this[6] |= 1 (state dirty) + // @4bbb47: `or word ptr [this+0x18], 1` == updateModel |= 1 == the engine + // ForceUpdate() (replication mark). The old transcription onto + // `simulationFlags |= 0x1` set the engine DelayWatchersFlag instead -- + // permanently muting this subsystem's audio watchers (gotcha #20, Gitea #12). + ForceUpdate(); } // @@ -589,102 +585,159 @@ Scalar // // -// @004bbd04 -- Performance: per-frame ProjectileWeapon update. Body NOT -// recovered by the decompiler (prologue confirmed at 0x4bbd04). From context it -// sequences DrawFiringCharge / UpdateEject / CheckForJam / ComputeTimeOfFlight -// and dispatches FireWeapon (vtable slot 18) when the trigger + charge + ammo -// conditions are met. +// @004bbd04 -- Performance: per-frame ProjectileWeapon update. FULLY RECOVERED +// (capstone disasm via tools/disas2.py, Gitea #12, 2026-07-19 -- every branch +// below cites its binary address). The old RivetGun-modeled body is retired; +// its divergences were the #12 incident mechanics: denied shots faked a full +// Loaded->Firing->Loading pip cycle (binary: a 4-blip that STAYS Loaded, no +// ammo pull), the fault gate read simulationFlags (binary: simulationState), +// recoil bled every frame unconditionally and ran to -114 (binary: Loading-only +// + clamp at 0), and slot 17 (the recharge-dial writer) was never called. // void ProjectileWeapon::ProjectileWeaponSimulation(Scalar time_slice) { Check(this); - // 0. The PoweredSubsystem step FIRST -- disasm @004bbd12 (task #10): - // `call 0x4b0bd0` opens the body, exactly like the emitter sibling. It - // runs HeatSink::HeatSinkSimulation (absorb pendingHeat -> temperature -> - // conduct to the authored condenser) and the electrical state machine. - // Without it the launcher's firing heat (task #9) accumulated in - // pendingHeat forever and never reached the mech's thermal network. - PoweredSubsystem::PoweredSubsystemSimulation(time_slice); // @004b0bd0 + // @4bbd12: the PoweredSubsystem step FIRST (task #10) -- HeatSink absorb -> + // temperature -> conduction, and the electrical state machine. + PoweredSubsystem::PoweredSubsystemSimulation(time_slice); // call 0x4b0bd0 - // 0b. The FAULT gate -- disasm @004bbd36-004bbd6e (task #10): flags==1, the - // weapon's own heatAlarm at FailureHeat (this+0x184 == 2 -- THE consumer - // of the authored FailureTemperature for the ballistic family), or the - // owning mech destroyed -> pin recoil to the full rechargeRate and latch - // alarm state 7 (unavailable). No return: the frame continues. - if (simulationFlags == 1 - || heatAlarm.GetLevel() == HeatSink::FailureHeat // this+0x184 == 2 + // @4bbd1b: the trigger edge is sampled ONCE, before the gates ([ebp-4]). + // AUTHENTIC TRIGGER (task #5): fireImpulse = the TriggerState attribute the + // streamed controls map binds to the pod fire buttons. + Logical trigger = CheckFireEdge(); // call 0x4b9608 + + // @4bbd28: advance any in-progress magazine eject. + UpdateEject(time_slice); // call 0x4bbb50 + + // GATE 1 @4bbd36-4bbd6e: weapon DESTROYED (`simulationState@0x40 == 1` -- + // NOT simulationFlags, the historic mis-transcription of gotcha #20), own + // heatAlarm at FailureHeat (this+0x184 == 2 -- THE consumer of the authored + // FailureTemperature for the ballistic family), or the owning mech disabled + // (FUN_0049fb54) -> pin recoil at the full rechargeRate + latch alarm 7. + // No return: the frame continues into the state machine. + if (simulationState == 1 // [this+0x40] == 1 (destroyed) + || heatAlarm.GetLevel() == HeatSink::FailureHeat // [this+0x184] == 2 || (owner != 0 && owner->IsDerivedFrom(*Mech::GetClassDerivations()) - && ((Mech *)owner)->IsDisabled())) // FUN_0049fb54 + && ((Mech *)owner)->IsDisabled())) // FUN_0049fb54(owner) { - recoil = rechargeRate; // this+0x3E8 = this+0x3DC - weaponAlarm.SetLevel(7); // FUN_0041bbd8(this+0x350, 7) + recoil = rechargeRate; // [this+0x3E8] = [this+0x3DC] + weaponAlarm.SetLevel(7); // call 0x41bbd8(this+0x350, 7) } - // 1. Recover the firing charge (recoil counts down toward 0 in - // DrawFiringCharge) and advance any in-progress magazine eject. - DrawFiringCharge(time_slice); // @004bbc78 -- bleed recoil (+0x3E8) - UpdateEject(time_slice); // @004bbb50 -- idle unless ejectState==0 - - // 2. AUTHENTIC TRIGGER (task #5): fireImpulse = the TriggerState attribute - // the streamed controls map binds to the pod fire buttons (see emitter.cpp - // note). The gBTMissileTrigger bypass is retired. - Logical trigger = CheckFireEdge(); // MechWeapon @004b9608 + // GATE 2 @4bbd71-4bbda6: the linked AmmoBin. Feed alarm Empty(2)/level-3 or + // bin destroyed -> re-pin alarm 7 EVERY FRAME while dry. (The binary + // dereferences the resolved bin UNGUARDED @4bbd80; the port keeps a null + // guard -- shipped content always links a bin, the ctor logs when it can't.) + AmmoBin *bin = (AmmoBin *)ammoBinLink.Resolve(); // call 0x417ab4(this+0x43c) + if (bin != 0 + && (bin->GetAmmoState() == 2 || bin->GetAmmoState() == 3 // [bin+0x1A8] + || bin->GetSimulationState() == 1)) // [bin+0x40] (destroyed) + { + weaponAlarm.SetLevel(7); + } static int s_pwtick = 0; if (getenv("BT_PROJ_LOG") && (((++s_pwtick) % 240) == 0 || trigger)) DEBUG_STREAM << "[projweap] tick " << GetName() << " trig=" << (int)trigger - << " trigState=" << (float)fireImpulse << " ready=" << (int)ReadyToFire() - << " state=" << weaponAlarm.GetState() << " recoil=" << recoil << " x\n" << std::flush; + << " trigState=" << (float)fireImpulse + << " state=" << weaponAlarm.GetState() << " recoil=" << recoil + << " binState=" << (bin != 0 ? bin->GetAmmoState() : -1) << "\n" << std::flush; - // 3. Firing state machine (weaponAlarm @0x350). Modelled on the RP analog - // RivetGun::RivetGunSimulation (WEAPSYS.cpp): the Jammed / NoAmmo latches - // persist and only escalate on a trigger pull; the default (ready) branch - // rolls the heat-scaled jam chance and -- on a clean shot with charge and - // ammo -- dispatches FireWeapon through the vtable (slot 18) so the - // MissileLauncher override spawns Missiles. - switch (weaponAlarm.GetState()) // *(this+0x364) + // THE FIRING STATE MACHINE @4bbda9: switch on weaponAlarm state ([this+0x364]). + // Only states 2/3/5/7 carry a case body; 0/1/4/6 are transient audio blips. + switch (weaponAlarm.GetState()) { - case JammedState: // 5 -- cleared only by ResetToInitialState + case 2: // Loaded @4bbec2 -- armed; everything happens on the trigger edge if (trigger) - weaponAlarm.SetLevel(TriggerDuringJamState); // 6 - break; - - case NoAmmoState: // 7 -- re-assert on a dry trigger pull - if (trigger) - weaponAlarm.SetLevel(NoAmmoState); - break; - - default: // Loaded / Loading / Firing / idle - if (trigger && ReadyToFire()) { - if (CheckForJam()) // @004bbfcc -- heat-scaled jam roll + if (viewFireEnable != 0 && HasActiveTarget()) // [this+0x3E0] && [owner+0x388] { - weaponAlarm.SetLevel(JammedState); // 5 - break; + // @4bbee6: the ammo pull happens HERE, in the caller -- a failed + // pull (bin feeding/not-ready) just stays Loaded, silently. + if (bin != 0 && bin->FeedAmmo() != 0) // call 0x4bd4f4(bin) + { + weaponAlarm.SetLevel(0); // @4bbef4 -> Firing + ForceUpdate(); // @4bbf05 `or word [this+0x18],1` (updateModel) + FireWeapon(); // @4bbf0d vtbl+0x48 (slot 18) + if (bin->GetAmmoState() == 2) // @4bbf11 bin went Empty on the pull + weaponAlarm.SetLevel(7); // -> unavailable (gate 2 re-pins it) + else if (CheckForJam()) // @4bbf2a call 0x4bbfcc (heat-scaled roll) + weaponAlarm.SetLevel(JammedState); // @4bbf34 -> 5 + else + weaponAlarm.SetLevel(3); // @4bbf41 -> Loading (reload) + ForceUpdate(); // @4bbf4c second updateModel mark + recoil = rechargeRate; // @4bbf51 begin the recharge cooldown + } } + else + { + // @4bbf5f THE DENIAL BLIP (Gitea #12 fix): a shot denied by the + // look-view gate or by having NO TARGET does SetLevel(4); + // SetLevel(2) -- a one-frame audio blip, the pip STAYS LOADED and + // NO ammo is pulled. (The old port routed these gates through + // FireWeapon early-returns while the caller cycled the full + // Firing->Loading alarm anyway -- a denied missile shot faked a + // perfect firing cycle and launched nothing: the reported + // "missiles cycle but never launch" phenomenology, verbatim.) + weaponAlarm.SetLevel(4); + weaponAlarm.SetLevel(2); + } + } + break; - // refresh the lead solution (updates leadPosition / targeting), then - // fire. FireWeapon() consumes a round + spawns the projectile/missile - // and (on a dry bin) latches NoAmmo itself. - ComputeTimeOfFlight(); // @004bc06c - FireWeapon(); // vtable slot 18 -> spawn path - if (weaponAlarm.GetState() != NoAmmoState && weaponAlarm.GetState() != JammedState) - weaponAlarm.SetLevel(0); // Firing - } - else + case 3: // Loading @4bbdd2 -- recharging toward Loaded + if (trigger) { - // Idle/reload: drive the ready state so the WeaponState audio is correct. - // The recon set Firing(0)/Loading(3)/Jammed/NoAmmo but NEVER a ready level, - // so at idle the launcher sat in Loading(3) forever and its WeaponState - // audio looped MissileLoading indefinitely (the loop stops only when the - // alarm LEAVES the loading state). Reflect readiness: Loaded(2, charge + - // ammo up = silent ready) once ReadyToFire, else Loading(3, reloading) -- - // SetLevel fires audio only on a real change, so this just yields the - // natural Firing -> Loading(reload) -> Loaded(ready) cycle. [T2] - weaponAlarm.SetLevel(ReadyToFire() ? 2 : 3); + weaponAlarm.SetLevel(4); // @4bbdd9 TriggerDuringLoad blip + weaponAlarm.SetLevel(3); // @4bbdea back to Loading } + if (electricalStateAlarm.GetLevel() == 4) // @4bbdf5 [this+0x278] == Ready + { + // @4bbe04: recoil bleeds ONLY here (Loading + electrical Ready) -- + // the old port bled it unconditionally every frame with no clamp, + // running it to -114 across a mission. + DrawFiringCharge(time_slice); // call 0x4bbc78 + if (recoil < 0.0f) // @4bbe0c fcomp 0.0 (_DAT_004bbf84) + { + recoil = 0.0f; // @4bbe1d clamp AT zero + if (bin != 0 && bin->GetAmmoState() == 1) // @4bbe25 bin Loaded (round chambered) + weaponAlarm.SetLevel(2); // @4bbe30 -> Loaded + } + } + ComputeOutputVoltage(); // @4bbe44 vtbl+0x44 (slot 17 @4b9c9c): + // rechargeLevel = (rate - recoil)/rate + // -- the recharge dial ANIMATES + break; + + case 5: // Jammed @4bbe8e -- cleared only by ResetToInitialState / msg 2 + if (trigger) + { + weaponAlarm.SetLevel(TriggerDuringJamState); // @4bbe95 blip 6 + weaponAlarm.SetLevel(JammedState); // @4bbea6 back to 5 + } + recoil = rechargeRate; // @4bbeb1 held at full recoil + break; + + case 7: // unavailable/NoAmmo @4bbe4d -- THE ROACH MOTEL (binary-faithful: + // the case re-asserts 7 UNCONDITIONALLY; nothing in the machine + // ever leaves it. Entered on: weapon destroyed, FailureHeat, mech + // disabled (gate 1) or bin empty/destroyed (gate 2). Recovery + // paths that EXIST in the binary: ResetToInitialState (slot 10, + // mission reset) and an inbound update record overwriting the + // alarm on a replicant. A launcher that trips FailureHeat + // mid-mission stays dead until mission end -- harsh but authentic; + // whether FailureHeat is reachable at authentic play intensity is + // the open heat-economy question (open-questions.md, MP section). + if (trigger) + weaponAlarm.SetLevel(1); // @4bbe54 dry-trigger blip + weaponAlarm.SetLevel(7); // @4bbe65 re-assert + recoil = rechargeRate; // @4bbe76 + ComputeOutputVoltage(); // @4bbe85 vtbl+0x44 -- dial pinned at 0 + break; + + default: // 0/1/4/6 @4bbdcd: no case body (transient audio-blip states) break; } @@ -695,18 +748,25 @@ void // @004bc104 -- slot 18 FireWeapon (overrides MechWeapon's pure-virtual trap // @004ba45c). Body NOT recovered by the decompiler (prologue confirmed at // 0x4bc104, immediately following the 1.0f literal @0x4bc100). Spawns the -// generic projectile/tracer and starts the eject cycle. MissileLauncher -// overrides this again at @004bcc60 to launch Missile entities. +// generic projectile/tracer. MissileLauncher overrides this again at @004bcc60 +// to launch Missile entities. +// +// Gitea #12: the view/target gate, the ammo pull and the recoil set are +// STRIPPED from this body -- they belong to the CALLER (the recovered +// ProjectileWeaponSimulation Loaded case @4bbec2/@4bbee6/@4bbf51; the sibling +// @004bcc60 confirms a FireWeapon body is heat + spawn ONLY). The old +// early-returns here, combined with the caller's unconditional alarm cycling, +// were the "denied shot fakes a full firing cycle" incident mechanic. // void ProjectileWeapon::FireWeapon() { Check(this); - // (task #68) the view gate: a weapon disabled in the CURRENT look view - // does not fire (see mislanch.cpp / the emitter's Loaded->Firing gate). - if (!viewFireEnable) - return; + // Refresh the lead solution (leadPosition / targeting) for the spawn below + // (@004bc06c; the recovered sim no longer calls it -- best-effort placement + // inside the unrecovered @4bc104 body, which is where the lead data is used). + ComputeTimeOfFlight(); // @004bc06c // THE FIRING HEAT (task #9; was "a separate pre-existing gap"): the // binary adds heatCostToFire RAW to the weapon's own pendingHeat -- @@ -717,10 +777,10 @@ void pendingHeat += heatCostToFire; // HeatSink @0x1C8, raw } - // Binary @4bc136-4bc19c (task #56): the firing RECOIL kick, BEFORE the ammo - // pull (no early-out precedes it in the binary). Gate: per-shot damage > - // 3.0f (@0x4bc3f4) && the owner mech has a gyro. Direction (0, 0.6, -1.5) - // (0x3f19999a/0xbfc00000), magnitude = damageAmount / 16 (@0x4bc3f8). + // Binary @4bc136-4bc19c (task #56): the firing RECOIL kick. Gate: per-shot + // damage > 3.0f (@0x4bc3f4) && the owner mech has a gyro. Direction + // (0, 0.6, -1.5) (0x3f19999a/0xbfc00000), magnitude = damageAmount / 16 + // (@0x4bc3f8). if (damageData.damageAmount > 3.0f && owner != 0) { extern void GyroApplyDamageImpulse(Subsystem *, Scalar, Scalar, Scalar, Scalar); @@ -730,16 +790,6 @@ void damageData.damageAmount * 0.0625f); } - // Pull a round; a dry / not-ready bin latches NoAmmo and aborts the shot. - if (!ConsumeRound()) // FUN_004bd4f4 (AmmoBin::FeedAmmo) - { - return; - } - - // Begin the per-shot recharge cooldown (recoil bleeds back down to 0 in - // DrawFiringCharge before the weapon is ReadyToFire again). - recoil = rechargeRate; // this[0xfa] = this[0xf7] - // Resolve the live muzzle. Point3D muzzle; GetMuzzlePoint(muzzle); // MechWeapon @004b9948 @@ -779,21 +829,17 @@ void subsystemID /*messmgr explosion bundling at impact (task #7)*/); // 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.) + // cannon. ++fireCounter; the CALLER's Loaded case makes the two + // `updateModel |= 1` marks (@4bbf05/@4bbf4c == ForceUpdate) that serialize + // this weapon's record (WriteUpdateRecord) to the peer's replicant. + // Gitea #12 / gotcha #20: the old extra `simulationFlags |= 0x1` here set + // the engine DelayWatchersFlag -- permanently muting this weapon's audio + // watchers after the first shot; removed. { 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(); } diff --git a/game/reconstructed/projweap.hpp b/game/reconstructed/projweap.hpp index 2930c96..cb22607 100644 --- a/game/reconstructed/projweap.hpp +++ b/game/reconstructed/projweap.hpp @@ -151,10 +151,14 @@ class NotationFile; activePerformance = (Simulation::Performance)performance; } - // @004bbd04 -- ProjectileWeapon per-frame update (Performance). Body not - // recovered by the decompiler (function prologue confirmed at 0x4bbd04); - // drives UpdateEject / DrawFiringCharge / CheckForJam / ComputeTimeOfFlight - // and triggers FireWeapon via the vtable. + // @004bbd04 -- ProjectileWeapon per-frame update (Performance). FULLY + // RECOVERED (capstone disasm, Gitea #12): base sim -> trigger edge -> + // UpdateEject -> fault gates (destroyed/FailureHeat/mech-disabled; bin + // empty/destroyed -> alarm 7) -> the Loaded/Loading/Jammed/7 state + // machine. The ammo pull (bin->FeedAmmo @4bd4f4), the view/target + // denial blip, the updateModel replication marks and the recoil set all + // live HERE, not in FireWeapon; slot 17 (@004b9c9c) animates the + // recharge dial from the Loading/7 cases. void ProjectileWeaponSimulation(Scalar time_slice); @@ -276,12 +280,11 @@ class NotationFile; Logical LiveFireEnabled() const; // controls "live fire" flag - // Resolve the linked AmmoBin and pull one round (AmmoBin::FeedAmmo). - // Returns True when a round was dispensed; on a dry / not-ready / missing - // bin it raises the NoAmmo alarm and returns False. Shared by the - // ProjectileWeapon and MissileLauncher FireWeapon spawn paths. - Logical - ConsumeRound(); + // (Gitea #12: the old ConsumeRound() helper is RETIRED -- the binary has + // no such method. The ammo pull is bin->FeedAmmo() @4bd4f4, called by + // the recovered ProjectileWeaponSimulation Loaded case @4bbee6; a failed + // pull stays Loaded silently, and the NoAmmo(7) pin comes from the BIN's + // alarm via the sim's gate 2 -- never from the weapon side.) // True when the weapon has recovered its firing charge (recoil counts // down toward 0 in DrawFiringCharge; 0 == ready) and is not mid-eject.