From 2215932016e060c3fff25b30b7fa5e21ad810d11 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 22:46:10 -0500 Subject: [PATCH 1/6] KB: Gitea #12 incident findings -- @004bbd04 firing machine recovered, gauge-freeze root cause, dirty-bit gotcha - decomp-reference: ProjectileWeaponSimulation @004bbd04 FULLY RECOVERED (capstone): fault gate reads simulationState@0x40==1 (not simulationFlags); ammo-bin gates; Loaded-case view/target denial = SetLevel(4);SetLevel(2) blip BEFORE FeedAmmo; updateModel@0x18|=1 is the replication dirty (== ForceUpdate); slot 17 @004b9c9c = rechargeLevel writer; FireWeapon @004bcc60 carries no gating. - gauges-hud: CORRECTION -- launcher recharge dial is NOT authentically static (slot 17 @004b9c9c animates it); MP gauge-window freeze root-caused = lobby-built gauge tree dangling over the launch mech re-stream + permanent SEH disable. - reconstruction-gotchas #20: updateModel@0x18 vs simulationFlags@0x28 bit 0 (DelayWatchersFlag) mis-mapping class (Emitter::SetDirty, mislanch salvo mark). - open-questions: #12 status ledger -- fixes pending a live-verify window; J/K/L preset hypothesis refuted for solo (scripted A/B); symptom-4 pip set unresolved. - GAUGE_COMPOSITE row 13 verdict corrected (WRONG-STATIC, fix pending). Co-Authored-By: Claude Fable 5 --- context/decomp-reference.md | 30 +++++++++++++++++-- context/gauges-hud.md | 49 ++++++++++++++++++++++--------- context/open-questions.md | 33 +++++++++++++++++++++ context/reconstruction-gotchas.md | 20 +++++++++++++ docs/GAUGE_COMPOSITE.md | 2 +- 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 8188bf9..6906b40 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -199,8 +199,34 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a GENERATOR's `pendingHeat@0x1c8` (~3.5e8/full PPC charge). `ComputeOutputVoltage` @004ba738 snaps rechargeLevel@0x320 to 1.0 within 0.01. **FailureHeat consumers:** `this+0x184` (own heatAlarm level) == 2 -> emitter @004baa88 resets firing + holds charge 0; ballistic - @004bbd36 pins `recoil=rechargeRate` + alarm 7. @004bbd04 (ProjectileWeaponSimulation) - opens with `call 0x4b0bd0` (the powered/heat step), like @004baa88. Cockpit power-routing + @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: + 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**; + gate 2 @4bbd71: bin=Resolve(this+0x43c)@417ab4; bin alarm LEVEL@+0x1A8 ∈{2,3} || bin + simulationState@0x40==1 → alarm 7 (re-pinned per frame while dry). switch(alarm@0x364): + **Loaded(2) @4bbec2**: on trigger — `viewFireEnable@0x3E0==0 || ownerTarget@[owner+0x388]==0 + → SetLevel(4);SetLevel(2)` (denial BLIP, stays Loaded, NO ammo pull); else + `bin->FeedAmmo@4bd4f4` (fail → stays Loaded silently) → SetLevel(0 Firing) → + **`updateModel@0x18 |= 1`** → vtbl+0x48 FireWeapon → (bin level==2 → SetLevel(7)) else + (CheckForJam@4bbfcc → SetLevel(5)) else SetLevel(3) → `updateModel|=1` → + recoil=rechargeRate. **Loading(3) @4bbdd2**: trigger → blip 4→3; if + electricalState@0x278==4 { DrawFiringCharge@4bbc78; recoil<0 → clamp 0 + (bin level==1 → + SetLevel(2)) }; always vtbl+0x44. **Jammed(5) @4bbe8e**: trigger → 6→5; + recoil=rechargeRate. **7 (unavailable/NoAmmo) @4bbe4d**: trigger → blip 1; + re-SetLevel(7) unconditionally (roach motel — permanent once entered); + recoil=rechargeRate; vtbl+0x44. States 0/1/4/6 = transient audio blips (no case body). + **vtbl+0x44 (slot 17) @004b9c9c = `rechargeLevel@0x320 = (rechargeRate@0x3DC − + recoil@0x3E8)/rechargeRate`** — the projectile recharge-dial writer (Emitter overrides this + slot with ComputeOutputVoltage@4ba738). **MissileLauncher::FireWeapon @004bcc60 carries NO + view/target/ammo gating and NO recoil set** (part_013.c:8741+: heat add + spawn descriptor + only) — ALL gating lives in the caller's Loaded case. ⚠ The DIRTY-for-replication bit is + `Simulation::updateModel@0x18 |= 1` (== engine `ForceUpdate()`), NOT + `simulationFlags@0x28 |= 1` — bit 0 of simulationFlags is the engine **DelayWatchersFlag** + (SIMULATE.h): setting it permanently skips the subsystem's ExecuteWatchers (audio watchers). + Cockpit power-routing handlers @004b099c/@004b09e4/@004b0a2c/@004b0a74 (bind generator by authored id 1-4 via @004b0b18) + @004b0abc (auto/manual, modeAlarm@0x2CC); attach/detach @004b0dd8/@004b0e30 (generator client capacity @0x1e4/count @0x1e8). diff --git a/context/gauges-hud.md b/context/gauges-hud.md index f96a0de..01df692 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -77,6 +77,24 @@ over one shared `SVGA16` pixelBuffer; `SVGA16::DrawDevSurface`). On the POD they intact). The `overlay` port (SectorDisplay lives there) shares the `sec` physical surface via a different bit-plane (0x00C0). [T2] +## MP gauge-window FREEZE = dangling bindings + permanent SEH disable (Gitea #12, 2026-07-19) [T2 log-convicted] +The live-MP "dev-gauges window froze entirely mid-session" incident (issue #12) is NOT a +swap-chain/present bug: `scratchpad/incident_2157/operator_1.log` shows a burst of +`[gauge-fault] '' Execute FAULTED -> gauge DISABLED` (PilotList, HeadingPointer, +MapDisplay, SectorDisplay, TwoPartBars, NumericDisplayScalar …) exactly while ALL THREE mechs +were being RE-CREATED at mission launch (second `[zonebuild]`/`[cyl]` set at new addresses — +the session had a pre-launch host drop/rejoin; a normal 2-player LAN launch on the same build +did not re-stream and did not freeze). The gauge tree had been built (lazily) during the +LOBBY phase against the FIRST-stream mechs; the re-stream freed them; every gauge Execute then +AV'd on its dangling [[attribute-pointer]]s and the `BT_DEV_GAUGES` SEH guard +(`Gauge::GuardedExecute`, GAUGE.cpp:618) DISABLED each one — `Disable(True)` sets `rate=0` +PERMANENTLY, nothing re-enables or rebinds → the whole window static for the rest of the +session (= symptoms 1+3). The AUTHENTIC engine flow handles mission transitions by +`Application::Shutdown → gaugeRenderer->Shutdown() → ShutdownImplementation → Remove(0)` (all +gauges deleted, tree lazily rebuilt next mission, APP.cpp:787/GAUGREND.cpp:3264) — the port's +in-session re-stream path bypasses it. OPEN: rebuild/rebind the gauge tree on a viewpoint-mech +re-stream (or route the rejoin through the engine Shutdown flow); see [[open-questions]]. + ## The secondary screen's THREE views (Damage / Critical / Heat) — mode-gated [T0/T1/T2] The `sec` port stacks three mode-gated mech-schematic layers at offset (50,0) over the always-on radar/heading/speed/messageBoard (`Secondary1`): **Damage** (`ModeSecondaryDamage`, @@ -176,16 +194,18 @@ locked**. **RESOLVED 2026-07-12 [T2]:** deaths now tally per node from LOCALLY O own VehicleDead path counts its node), and `BTPilotDeaths` clamps the −2/−1 pre-acquire seed to 0 for display. Own row counts correctly as before. -## Launcher-panel recharge dial — authentically STATIC [T1, writer census] -The weapon panels' SegmentArc270 tick ring reads MechWeapon::rechargeLevel (+0x320). A FULL -writer census of the binary (all decomp spellings: `0x320) =`, `+ 800) =`, `[200] =`) finds -exactly four writers in the weapon family: the ctor (@004b99a8, 1.0), the stream init -(@004b8fec), ResetToInitialState (@004b96ec, 1.0), and the ONE live writer — -`Emitter::ComputeOutputVoltage` @004ba738, reached only through the Emitter vtable. Projectile -weapons (MissileLauncher/autocannon) never write it: their dial draws full ONCE and never moves -on the pod. The launcher panel's live indicators are the AMMO DIGITS (AmmoBin::ammoCount via -the complete-type bridges, 2026-07-12 — the old raw bin+0x180/0x18c reads were layout garbage), -the jam/fire lamps and the eject wipe. Do NOT "fix" the static ring — it is the authentic look. +## Launcher-panel recharge dial — CORRECTION 2026-07-19: it IS live (slot 17 @004b9c9c) [T1] +The weapon panels' SegmentArc270 tick ring reads MechWeapon::rechargeLevel (+0x320). +**The old "authentically STATIC on projectile weapons" claim was WRONG** — the writer census +missed the anonymous vtable slot 17 (vtbl+0x44) body **@004b9c9c**: +`rechargeLevel = (rechargeRate@0x3DC − recoil@0x3E8) / rechargeRate` (capstone disasm, issue +#12; Ghidra never emitted it). The recovered `ProjectileWeaponSimulation` @004bbd04 calls it +every frame in the Loading(3) and unavailable(7) alarm cases — so the launcher dial +authentically ANIMATES 0→1 through each reload. (The census's four other writers stand: ctor +@004b99a8, stream init @004b8fec, ResetToInitialState @004b96ec, and +`Emitter::ComputeOutputVoltage` @004ba738 — slot 17 is the Emitter override of the SAME slot; +@004b9c9c is the MechWeapon/projectile base body.) The launcher panel's other live indicators: +AMMO DIGITS (AmmoBin::ammoCount via the complete-type bridges), jam/fire lamps, eject wipe. ## ConfigMapGauge (the weapon panel's regroup lamp column) — authentically DORMANT The per-weapon btjoy.pcc joystick image + 4 cm_* state lamps (off/other/only/both) showing, @@ -329,14 +349,15 @@ and every instrument is now live [T2]:** (the weapon-GROUP bits Front/Rear/Left/Right). **Range plays NO part** — Execute never reads the stored TargetWithinRange slots. **FIX 2026-07-18 [T2]:** the pip's "loaded" was a port approximation reading `rechargeLevel ≥ 1` — correct for emitters (charge-driven) but - **statically 1.0 on projectile weapons** (MissileLauncher/autocannon never write it — see - §Launcher-panel above), so missile/AC pips were **permanently lit and never blinked on fire**. + **statically 1.0 on projectile weapons in the PORT** (the port sim never called the slot-17 + updater @004b9c9c — see the §Launcher-panel CORRECTION), so missile/AC pips were + **permanently lit and never blinked on fire**. Now reads the AUTHENTIC attr 0x1C = the weaponAlarm StateIndicator level (`WeaponStatePtr` → `GaugeAlarm54::LevelPtr`), compared `== stateConst2` (the const the binary itself stores in AddWeapon). The state cycles Loaded(2)↔Firing(0)/Loading(3)/Jammed(5) for BOTH families, so a missile pip now momentarily drops when fired, exactly like the emitter's. Verified live - (Blackhawk, `BT_AUTOFIRE=1 BT_AF_MISSILE=1`): both SRM6 pips toggle 2↔0/5 on each salvo. The - authentically-static `rechargeLevel` recharge DIAL (§Launcher-panel) is untouched. + (Blackhawk, `BT_AUTOFIRE=1 BT_AF_MISSILE=1`): both SRM6 pips toggle 2↔0/5 on each salvo. + (The recharge DIAL is driven by slot 17 @004b9c9c — §Launcher-panel CORRECTION.) - **Lock ring** = subB9 (ring+cross) at frame centre, **SPINNING 4°/frame** while the Lock attr (0xA) is up. The Lock PRODUCER is the authentic HudSimulation rule (part_013.c:5619-5634 [T1], wired task #38): lock requires a target AND your own HUD's host zone damage < **0.75** (a diff --git a/context/open-questions.md b/context/open-questions.md index 2baea80..709598a 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -629,6 +629,39 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR numbers no longer point at that code). ## Multiplayer (Phase 7 / P6) +- **Gitea #12 MP incident (2026-07-19) — root causes found, FIXES NOT YET LANDED (blocked on a + live-verify window; the user was playing).** Findings [T1/T2 — see the #12 comments + + `scratchpad/incident_2157/`]: + (1) *Gauge-window freeze* = lobby-built gauge tree dangling over a mission-launch mech + RE-STREAM after a pre-launch host drop/rejoin; SEH guard disables each faulted gauge + permanently — see [[gauges-hud]] §MP gauge-window FREEZE. FIX TODO: rebuild/rebind the gauge + tree on a viewpoint re-stream (engine analog: `Application::Shutdown` → + `gaugeRenderer->Shutdown()`); also consider re-enabling (not just disabling) a faulted gauge + after a rebind. + (2) *Missiles cycle-but-never-launch*: the port's ProjectileWeaponSimulation is a RivetGun + approximation — the AUTHENTIC @004bbd04 machine is now fully recovered ([[decomp-reference]] + §5). Port defects to fix faithfully: fault gate reads `simulationFlags==1` (binary: + `simulationState@0x40==1`); `simulationFlags|=0x1` on every salvo/beam = engine + DelayWatchersFlag poisoning ([[reconstruction-gotchas]] §20; binary = `updateModel|=1` == + ForceUpdate); view/target gating + ConsumeRound live in the port's FireWeapon (binary: in the + caller, with a 4-blip stay-Loaded denial — the port instead fakes a full Firing→Loading pip + cycle on a denied shot, which is exactly the reported symptom); recoil never clamped at 0 + (runs to −114); slot-17 @004b9c9c (rechargeLevel) never called. Solo repro on 4.11.387 + (pre-stop-order): SRM6s latch alarm 7 PERMANENTLY mid-session after a heat-failure episode + (recoil pinned → released → state 7 forever) — missiles stop while lasers keep working, with + or WITHOUT J/K/L presses. The binary's state 7 is also permanent once entered [T1] — open + fidelity question: is launcher FailureHeat reachable at authentic heat economy, or does our + launcher self-heat run hot? (Emitters never trip it — task #11 duty-cycle measurement.) + (3) *J/K/L preset hypothesis REFUTED for solo* [T2]: BT_PRESET_TEST cycling on 387 — missiles + kept launching after presets (36 PUSH before / 24 after), pip count unchanged, gauges live, + [mode] masks touch ONLY ModeMFD bits 0-14 (`reticleElementMask` is a different object). + Corroborating: a 2-player LAN session on the same build (no J/K/L) had working missiles + + normal pips. UNTESTED: J/K/L during a live RELAY MP session. + (4) *"WAY MORE pips" (BLH, symptom 4)* — UNRESOLVED: the incident registered 10 pips + (8×3016 @750/500×5/225×2 + 2×3024 @800) which matches NO stock arsenal (solo dumps: bhk1=7, + madcat=6, thor=7); the reticle built identically TWICE (lobby + launch re-stream). Needs the + incident egg / a live 3-seat repro to identify the mech that owned those 10 weapons + (custom operator-egg loadout? viewpoint mis-assignment at build time?). - **✅ Console-death peer-replication freeze — NOT REPRODUCIBLE + the one real bug FIXED (2026-07-15) [T2].** The 2026-07-14 report (btconsole.py died mid-match → peer↔peer replication froze both ways) did NOT reproduce in the current build under EITHER failure mode, tested directly diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index bb79724..9b17942 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -405,6 +405,26 @@ The name-keyed samplers (`PowerSourceConnection`/`GeneratorVoltageConnection`, w --- +## 20. Dirty-bit mis-mapping: `updateModel@0x18` (replication) vs `simulationFlags@0x28` bit 0 (DelayWatchersFlag) + +**Symptom class (Gitea #12):** subsystem watchers (the AUDIO watchers — `ExecuteWatchers`, +SIMULATE.cpp:461) silently stop for a weapon after its FIRST shot; and any "flags == 1" state +test misfires. **Cause:** the binary's "needs replication" mark is +**`Simulation::updateModel@0x18 |= 1`** — in the disasm it reads `or word ptr [this+0x18], 1` +(i.e. "this+6" in dword-speak) and is EXACTLY what the engine `ForceUpdate()` does. Several +port reconstructions transcribed it onto **`simulationFlags@0x28 |= 0x1`** (`Emitter::SetDirty` +emitter.cpp:215, the MissileLauncher salvo mark mislanch.cpp:337) — but simulationFlags bit 0 +is the engine **DelayWatchersFlag** [T0 SIMULATE.h:165]: once set (nothing clears it), +`PerformAndWatch` skips `ExecuteWatchers()` for that subsystem FOREVER. Bit 1 is +DontExecuteFlag; bits 2-3 the replicant-copy mask; bit 8 the master flag. A related misread: +the @004bbd04 fault gate tests **`simulationState@0x40 == 1`** (subsystem DESTROYED), which the +port transcribed as `simulationFlags == 1` — with the spurious `|=1` above, that arms a +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] + +--- + ## Diagnostic recipe (the standard loop) 1. Read the RAW decomp `reference/decomp/all/part_*.c` for the `FUN_xxxx`. 2. Map `FUN_`/`DAT_`/`this+0xNN` to engine symbols via BT headers + WinTesla MUNGA source + diff --git a/docs/GAUGE_COMPOSITE.md b/docs/GAUGE_COMPOSITE.md index fc7864d..4c6e595 100644 --- a/docs/GAUGE_COMPOSITE.md +++ b/docs/GAUGE_COMPOSITE.md @@ -674,7 +674,7 @@ reconfigure/externalConfigure); parse-skip list EMPTY ([gskip]=0), all 50 attr b | 10 | Comm: SELECT-TARGET highlight | BTPilotIsSelected accessors (ex raw-offset AV) | commit 34aaa7d root-cause note | T2 | CORRECT | | 11 | Plasma: rankAndScore | engine primitive → external serial annunciator | not a D3D surface (L4PLASMA=com2) | T0 | CORRECT (n/a on dev composite) | | 12 | Quad mini-panels: temp bars / cooling-loop / power-bus lamps / evolt bar / cmode lamp | SubsystemCluster children (@004c8140 map) reading InputVoltage/HeatSink/temps | VEHICLE_SUBSYSTEMS.md phase 2; LIVE: panels render per-subsystem, [vss] roster matches authored aux screens 1-10 | T2 | CORRECT | -| 13 | Quad: recharge dial (SegmentArc270) | PercentDone → rechargeLevel@0x320 | writer census: Emitter::ComputeOutputVoltage @004ba738 only — projectile dial full-once | T1/T2 | CORRECT (emitters) + AUTH-STATIC (projectile) | +| 13 | Quad: recharge dial (SegmentArc270) | PercentDone → rechargeLevel@0x320 | ~~writer census: Emitter::ComputeOutputVoltage @004ba738 only — projectile dial full-once~~ **CORRECTED (Gitea #12, 2026-07-19): the census missed vtbl slot 17 @004b9c9c** — `rechargeLevel=(rechargeRate−recoil)/rechargeRate`, called per frame by the recovered @004bbd04 Loading/state-7 cases: the projectile dial authentically ANIMATES through reload (port fix pending — the port sim never calls slot 17) | T1 | CORRECT (emitters) + **WRONG-STATIC (projectile; fix pending)** | | 14 | Quad: ammo count (ammoCountA) | AmmoBin::ammoCount via BTAmmoBinCountPtr bridge | AFC100 fix 2026-07-12; LIVE: 24 fresh (audit4) vs 19 after missile autofire (audit3) — counter moves | T2 | CORRECT | | 15 | Quad: ConfigMap regroup lamps | LBE4ControlsManager buttonGroup map state | task #6: NO SetColor caller in the binary → color=0, Execute early-outs | T1 | AUTH-STATIC (dormant; BT_CONFIGMAP dev enable) | | 16 | Eng pages: prepEngr SYSTEM NN + subsystem label + class cells | roster walk, sub+0x1dc auxScreen == screen (BTGetSubsystemAuxScreen) | FUN_004c7e48 matched line-by-line; [vss] dump: scr1/2/4=PPC/Strk6/ERMed, 5-8=Sens/Myo/ERMed/ERMed, 9/10=Strk6/PPC — page headers+labels CORRECT | T1/T2 | CORRECT | From 2edde296714f0bc772b75e2127a939c885fcf9c5 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 23:26:11 -0500 Subject: [PATCH 2/6] 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. From 0d032029a0f29831dcffed2ea16a34f97ab931e7 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sun, 19 Jul 2026 23:30:43 -0500 Subject: [PATCH 3/6] Fix 2 (Gitea #12): gauge tree survives the in-session mech re-stream The pre-launch host drop/rejoin re-stream re-creates every mech at mission launch; the LOBBY-built gauge tree kept attribute pointers into the freed first-stream mechs, every widget Execute AV'd, and the BT_DEV_GAUGES SEH guard (Gauge::GuardedExecute, GAUGE.cpp:618) disabled each one PERMANENTLY (rate=0, no rebind) -- the frozen dev-gauges window of incident #12. The authentic engine flow only tears the tree down at mission transitions (Application::Shutdown -> gaugeRenderer->Shutdown() -> ShutdownImplementation -> Remove(0), APP.cpp:787 / GAUGREND.cpp:3264); the re-stream bypassed it. Fix: BTL4GaugeRenderer::TearDownForViewpointRestream() performs the ENTITY-BOUND half of that same ShutdownImplementation sequence, in order (gaugeAlarmManager->RemoveAllAlarms() -> Remove(0) -> moving/static entity grid Clear), keeping the mission-scoped state (warehouse, graphics ports, interpreter, controls-owned L4Lamps -- RemoveAllLamps would delete them behind the buttonGroup's &lamp->automaticValue registrations). Called from BTL4Application::MakeViewpointEntity before ConfigureForModel("Init") on any viewpoint RE-make -- that handler runs once per stream (the incident log's [ctrlmap] installing x2) -- so the tree rebuilds bound to the NEW mech via the renderer's existing lazy build. Sentinel: "[gauge] viewpoint re-stream: tearing the gauge tree down for rebuild". Verified with the 2-instance relay harness (port 15600, MP_RELAY.EGG, the incident sequence): join A (dev gauges) + B, kill B by exact PID at WAITING FOR OPERATOR LAUNCH, rejoin B, operator launch. Pod A re-streamed (2x ctrlmap install, 4x zonebuild), logged the sentinel, and post-launch the docked composite is fully live (mission clock/heading/radar advancing between BT_SHOT frames) with ZERO [gauge-fault] DISABLED lines (the incident had 11). Co-Authored-By: Claude Fable 5 --- context/gauges-hud.md | 15 +++++++++-- context/open-questions.md | 46 ++++++++++++++++++--------------- game/reconstructed/btl4app.cpp | 25 ++++++++++++++++++ game/reconstructed/btl4grnd.cpp | 44 +++++++++++++++++++++++++++++++ game/reconstructed/btl4grnd.hpp | 17 ++++++++++++ 5 files changed, 124 insertions(+), 23 deletions(-) diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 01df692..4d0c92e 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -92,8 +92,19 @@ PERMANENTLY, nothing re-enables or rebinds → the whole window static for the r session (= symptoms 1+3). The AUTHENTIC engine flow handles mission transitions by `Application::Shutdown → gaugeRenderer->Shutdown() → ShutdownImplementation → Remove(0)` (all gauges deleted, tree lazily rebuilt next mission, APP.cpp:787/GAUGREND.cpp:3264) — the port's -in-session re-stream path bypasses it. OPEN: rebuild/rebind the gauge tree on a viewpoint-mech -re-stream (or route the rejoin through the engine Shutdown flow); see [[open-questions]]. +in-session re-stream path bypasses it. **FIX LANDED (2026-07-19, awaiting human MP verify):** +`BTL4GaugeRenderer::TearDownForViewpointRestream()` (btl4grnd.cpp) performs the ENTITY-BOUND +half of the engine ShutdownImplementation sequence, same order (RemoveAllAlarms → Remove(0) → +entity-grid Clear), deliberately KEEPING warehouse/graphics-ports/interpreter/controls-L4Lamps +(RemoveAllLamps mid-session would delete the CONTROLS-owned L4Lamps behind the buttonGroup's +`&lamp->automaticValue` registrations — a dangling mapping; the mech-state "lamps" like +AnimatedSubsystemLamp are Gauges and go with Remove(0)). Called from +`BTL4Application::MakeViewpointEntity` before `ConfigureForModel("Init", entity)` whenever the +viewpoint is a RE-make (that handler empirically runs once per stream — twice in the incident +log, `[ctrlmap] installing` ×2) — the tree then rebuilds bound to the NEW mech. Log sentinel: +`[gauge] viewpoint re-stream: tearing the gauge tree down for rebuild`. Residual exposure: +`Remove(0)` flushes each gauge (`Update(gaugeRate_A)`, GAUGREND.cpp:3465) before deleting it, so +gauge CONNECTIONS may read the freed mech once more (plain reads; Execute stays SEH-guarded). ## The secondary screen's THREE views (Damage / Critical / Heat) — mode-gated [T0/T1/T2] The `sec` port stacks three mode-gated mech-schematic layers at offset (50,0) over the diff --git a/context/open-questions.md b/context/open-questions.md index 709598a..c0e17a4 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -629,29 +629,33 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR numbers no longer point at that code). ## Multiplayer (Phase 7 / P6) -- **Gitea #12 MP incident (2026-07-19) — root causes found, FIXES NOT YET LANDED (blocked on a - live-verify window; the user was playing).** Findings [T1/T2 — see the #12 comments + - `scratchpad/incident_2157/`]: +- **Gitea #12 MP incident (2026-07-19) — root causes found; BOTH FIXES LANDED 2026-07-19 + (awaiting the human MP death-and-survive verification).** Findings [T1/T2 — see the #12 + comments + `scratchpad/incident_2157/`]: (1) *Gauge-window freeze* = lobby-built gauge tree dangling over a mission-launch mech RE-STREAM after a pre-launch host drop/rejoin; SEH guard disables each faulted gauge - permanently — see [[gauges-hud]] §MP gauge-window FREEZE. FIX TODO: rebuild/rebind the gauge - tree on a viewpoint re-stream (engine analog: `Application::Shutdown` → - `gaugeRenderer->Shutdown()`); also consider re-enabling (not just disabling) a faulted gauge - after a rebind. - (2) *Missiles cycle-but-never-launch*: the port's ProjectileWeaponSimulation is a RivetGun - approximation — the AUTHENTIC @004bbd04 machine is now fully recovered ([[decomp-reference]] - §5). Port defects to fix faithfully: fault gate reads `simulationFlags==1` (binary: - `simulationState@0x40==1`); `simulationFlags|=0x1` on every salvo/beam = engine - DelayWatchersFlag poisoning ([[reconstruction-gotchas]] §20; binary = `updateModel|=1` == - ForceUpdate); view/target gating + ConsumeRound live in the port's FireWeapon (binary: in the - caller, with a 4-blip stay-Loaded denial — the port instead fakes a full Firing→Loading pip - cycle on a denied shot, which is exactly the reported symptom); recoil never clamped at 0 - (runs to −114); slot-17 @004b9c9c (rechargeLevel) never called. Solo repro on 4.11.387 - (pre-stop-order): SRM6s latch alarm 7 PERMANENTLY mid-session after a heat-failure episode - (recoil pinned → released → state 7 forever) — missiles stop while lasers keep working, with - or WITHOUT J/K/L presses. The binary's state 7 is also permanent once entered [T1] — open - fidelity question: is launcher FailureHeat reachable at authentic heat economy, or does our - launcher self-heat run hot? (Emitters never trip it — task #11 duty-cycle measurement.) + permanently — see [[gauges-hud]] §MP gauge-window FREEZE. ✅ FIX LANDED: + `BTL4GaugeRenderer::TearDownForViewpointRestream()` (alarms + Remove(0) + entity grids — the + entity-bound half of the engine `ShutdownImplementation`), called from + `MakeViewpointEntity` before `ConfigureForModel` on a viewpoint RE-make; the tree rebuilds + bound to the new mech. Sentinel: `[gauge] viewpoint re-stream: tearing the gauge tree down`. + (2) *Missiles cycle-but-never-launch*: ✅ FIX LANDED — the port's RivetGun-approximation + ProjectileWeaponSimulation replaced with the disasm-exact @004bbd04 machine + ([[decomp-reference]] §5; projweap.cpp): fault gate reads `simulationState@0x40==1`; the + `simulationFlags|=0x1` DelayWatchersFlag poisoning removed everywhere + ([[reconstruction-gotchas]] §20 — emitter SetDirty split into ForceUpdate/ExecuteOnUpdate per + site); view/target gating + the `bin->FeedAmmo` pull + recoil set moved into the caller's + Loaded case with the authentic 4-blip stay-Loaded denial; recoil bleeds ONLY in Loading at + electrical-Ready and clamps at 0; slot-17 @004b9c9c called from Loading/7 (the launcher + recharge dial now ANIMATES). State 7 kept binary-faithful (re-asserted unconditionally — + permanent once entered; recovery only via ResetToInitialState/mission reset). Solo-verified + on 4.11.388+: 54-60 `[projectile] PUSH` + impact smoke; denial window (BT_LOOK_TEST + look-back) = trig pulses, state STAYS 2, zero launches, resumes on release; disabled-mech + gate pins enemy launchers at 7+full recoil; STREAK-6 dial sweeps across screenshots; lasers + fire throughout. Open fidelity question REMAINS: is launcher FailureHeat/jam-rate at + sustained autofire authentic-intensity? (both SRM6s jam within ~2 min of held autofire via + the bring-up CheckForJam heat gate; emitters never trip it — task #11 duty-cycle + measurement.) (3) *J/K/L preset hypothesis REFUTED for solo* [T2]: BT_PRESET_TEST cycling on 387 — missiles kept launching after presets (36 PUSH before / 24 after), pip count unchanged, gauges live, [mode] masks touch ONLY ModeMFD bits 0-14 (`reticleElementMask` is a different object). diff --git a/game/reconstructed/btl4app.cpp b/game/reconstructed/btl4app.cpp index 5254d9b..3f23292 100644 --- a/game/reconstructed/btl4app.cpp +++ b/game/reconstructed/btl4app.cpp @@ -697,6 +697,31 @@ BTL4Application::SharedData << (int)(gauge_renderer != NULL) << " -> ConfigureForModel(Init)\n" << std::flush; if (gauge_renderer != NULL) { + // + // GAUGE-TREE SURVIVAL ACROSS THE IN-SESSION RE-STREAM (Gitea #12 fix): + // when the mechs are RE-CREATED pre-launch (host drop/rejoin re-stream), + // this handler runs AGAIN for the new viewpoint mech -- but the + // lobby-built gauge tree still holds attribute pointers into the FREED + // first-stream mech; every widget then faults and the BT_DEV_GAUGES SEH + // guard disables it permanently (GAUGE.cpp:618) = the frozen dev-gauges + // window. The engine's own transition path is the Shutdown teardown + // (Application::Shutdown -> gaugeRenderer->Shutdown() -> Remove(0), + // APP.cpp:787 / GAUGREND.cpp:3264) followed by a lazy rebuild; the + // re-stream bypasses it. So: tear the entity-bound tree down HERE + // (alarms + gauges + entity grids -- see TearDownForViewpointRestream) + // and let the ConfigureForModel below rebuild it against THIS mech. + // A no-op on the first (lobby) build. + // + static int s_gaugeTreeBuilt = 0; + if (s_gaugeTreeBuilt) + { + DEBUG_STREAM << "[gauge] viewpoint re-stream: tearing the gauge tree " + "down for rebuild on entity " << (void *)viewing_entity + << "\n" << std::flush; + gauge_renderer->TearDownForViewpointRestream(); + } + s_gaugeTreeBuilt = 1; + gauge_renderer->ConfigureForModel("Init", viewing_entity); // FUN_0046ff64 } diff --git a/game/reconstructed/btl4grnd.cpp b/game/reconstructed/btl4grnd.cpp index f80cc0e..eba2878 100644 --- a/game/reconstructed/btl4grnd.cpp +++ b/game/reconstructed/btl4grnd.cpp @@ -73,6 +73,7 @@ #if !defined(L4LAMP_HPP) # include #endif +#include // GaugeAlarmManager (TearDownForViewpointRestream, Gitea #12) #if !defined(L4WARE_HPP) # include #endif @@ -269,6 +270,49 @@ Logical return GaugeRenderer::TestInstance(); // FUN_00447b60 } +// +//############################################################################# +// TearDownForViewpointRestream (PORT FIX -- Gitea #12) +//############################################################################# +// +// The in-session mech RE-STREAM (a pre-launch host drop/rejoin re-creates all +// mechs at mission launch) frees the mech the LOBBY-built gauge tree was bound +// to; every gauge Execute then AVs on its dangling attribute pointers and the +// BT_DEV_GAUGES SEH guard (Gauge::GuardedExecute, GAUGE.cpp:618) disables each +// one PERMANENTLY (rate=0, nothing rebinds) -- the frozen dev-gauges window of +// incident #12. The AUTHENTIC engine flow only tears the tree down at mission +// transitions (Application::Shutdown -> gaugeRenderer->Shutdown() -> +// ShutdownImplementation -> Remove(0), APP.cpp:787 / GAUGREND.cpp:3264); the +// re-stream path bypasses it. This method performs the ENTITY-BOUND half of +// that same ShutdownImplementation sequence, in the same order: +// gaugeAlarmManager->RemoveAllAlarms() (alarms hold entity/subsystem ptrs) +// Remove(0) (delete every gauge, active+inactive) +// movingEntities/staticEntities.Clear() (raw Entity* grids for radar/map) +// and deliberately SKIPS the mission-scoped steps -- warehouse purge, graphics +// port destruction, lampManager->RemoveAllLamps() (the L4Lamps are owned by the +// CONTROLS layer, which registers &lamp->automaticValue into its buttonGroup -- +// deleting them mid-session would dangle the button mappings; the mech-state +// "lamps" like AnimatedSubsystemLamp are Gauges and go with Remove(0)). The +// caller (MakeViewpointEntity) immediately re-runs ConfigureForModel("Init", +// ) against the still-initialized interpreter/warehouse, so the tree +// rebuilds bound to the NEW mech -- the gauge renderer builds lazily by design. +// +void + BTL4GaugeRenderer::TearDownForViewpointRestream() +{ + Check(this); + + if (gaugeAlarmManager != NULL) + { + Check(gaugeAlarmManager); + gaugeAlarmManager->RemoveAllAlarms(); + } + Remove(0); // FUN_00447fc0(this, 0) -- all gauges + movingEntities.Clear(); + staticEntities.Clear(); + Check_Fpu(); +} + // //############################################################################# // NotifyOfNewInterestingEntity diff --git a/game/reconstructed/btl4grnd.hpp b/game/reconstructed/btl4grnd.hpp index 56acab4..d32dd82 100644 --- a/game/reconstructed/btl4grnd.hpp +++ b/game/reconstructed/btl4grnd.hpp @@ -54,6 +54,23 @@ NotifyOfNewInterestingEntity(Entity *entity); void NotifyOfBecomingUninterestingEntity(Entity *entity); + + // + // PORT FIX (Gitea #12 -- NOT in the surviving 1995 header): tear the + // built gauge tree down when the viewpoint mech is RE-STREAMED + // in-session (pre-launch host drop/rejoin re-creates every mech; the + // lobby-built tree's attribute pointers dangle and each widget is + // permanently DISABLED by the BT_DEV_GAUGES SEH guard, GAUGE.cpp:618). + // Performs the entity-bound half of the engine's mission-transition + // teardown (GaugeRenderer::ShutdownImplementation, GAUGREND.cpp:3264): + // alarms + all gauges + the entity grids. Warehouse / graphics ports / + // interpreter / controls lamps stay -- entity-independent, reused by + // the immediate ConfigureForModel rebuild. Called from + // BTL4Application::MakeViewpointEntity before ConfigureForModel; + // a no-op on the first (lobby) build. + // + void + TearDownForViewpointRestream(); }; From aa2432be7e28368f4f288ed6159e06e80e8e66c8 Mon Sep 17 00:00:00 2001 From: arcattack Date: Mon, 20 Jul 2026 07:45:43 -0500 Subject: [PATCH 4/6] Glass panel: double-buffer PaintPanel + suppress WM_ERASEBKGND (issue #13 partial) The panel repainted control-by-control straight onto the window DC on a 100ms timer. Compose to a memory bitmap + single BitBlt; erase suppressed. NOT the whole fix: the reporter's flicker is monitor-dependent (clean on a display without the game window) = D3D-vs-GDI presentation contention, plus a 100ms-timer-vs-125ms-lamp-flash sampling beat -- directions filed on #13. Co-Authored-By: Claude Fable 5 --- engine/MUNGA_L4/L4PADPANEL.cpp | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/engine/MUNGA_L4/L4PADPANEL.cpp b/engine/MUNGA_L4/L4PADPANEL.cpp index 6bc021d..0f35165 100644 --- a/engine/MUNGA_L4/L4PADPANEL.cpp +++ b/engine/MUNGA_L4/L4PADPANEL.cpp @@ -243,11 +243,21 @@ static void PaintPanel(HWND window) { PAINTSTRUCT paint; - HDC dc = BeginPaint(window, &paint); + HDC winDC = BeginPaint(window, &paint); unsigned long tick = GetTickCount(); RECT client; GetClientRect(window, &client); + + // Issue #13 (flicker): the 100ms repaint timer redrew the panel + // control-by-control straight onto the window DC -- visible progressive + // flicker at 10 Hz. Compose the whole frame into a memory bitmap and + // blit it once (plus WM_ERASEBKGND suppressed in the wndproc). The + // drawing body below is untouched: `dc` is now the memory DC. + HDC dc = CreateCompatibleDC(winDC); + HBITMAP backing = CreateCompatibleBitmap(winDC, + client.right - client.left, client.bottom - client.top); + HBITMAP oldBacking = (HBITMAP)SelectObject(dc, backing); HBRUSH background = CreateSolidBrush(RGB(28, 28, 28)); FillRect(dc, &client, background); DeleteObject(background); @@ -381,6 +391,13 @@ static void } } + // single blit of the composed frame (issue #13) + BitBlt(winDC, 0, 0, client.right - client.left, client.bottom - client.top, + dc, 0, 0, SRCCOPY); + SelectObject(dc, oldBacking); + DeleteObject(backing); + DeleteDC(dc); + EndPaint(window, &paint); } @@ -395,6 +412,9 @@ static LRESULT CALLBACK PaintPanel(window); return 0; + case WM_ERASEBKGND: + return 1; // issue #13: PaintPanel fills the frame itself (double-buffered) + case WM_TIMER: if (wparam == RepaintTimerId) { From cb190aa00a902c888697e9998b97f92fc69ca4f2 Mon Sep 17 00:00:00 2001 From: arcattack Date: Mon, 20 Jul 2026 07:50:44 -0500 Subject: [PATCH 5/6] Glass panel flicker FIXED: layered window + lamp-period repaint timer (issue #13) Human-verified (epilectrik, live): the panel is steady on the SAME monitor as the game. Two root causes, both cured: 1. D3D-vs-GDI presentation contention -- the game's ~60Hz presents fought the panel's GDI redraws on a shared display (single-monitor users had no escape; moving the panel to another monitor cleaned it, the tell). WS_EX_LAYERED + opaque SetLayeredWindowAttributes: DWM now composites the panel independently of the D3D swap chain. 2. Sampling beat -- the 100ms repaint timer sampled the authentic RIO lamp flash half-periods (125/250/500ms) into an irregular 'broken lamp' strobe. Timer -> 62ms (~half the fastest half-period): flashing lamps now blink at their even, authored tempo. (Stacked on the earlier double-buffer/erase-suppression cleanup.) Co-Authored-By: Claude Fable 5 --- engine/MUNGA_L4/L4PADPANEL.cpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/engine/MUNGA_L4/L4PADPANEL.cpp b/engine/MUNGA_L4/L4PADPANEL.cpp index 0f35165..36d49f2 100644 --- a/engine/MUNGA_L4/L4PADPANEL.cpp +++ b/engine/MUNGA_L4/L4PADPANEL.cpp @@ -37,7 +37,9 @@ enum PanelMargin = 6, FirstRow = 1, // the shared layout leaves grid row 0 empty RepaintTimerId = 1, - RepaintMilliseconds = 100 + RepaintMilliseconds = 62 // issue #13: ~half the fastest lamp-flash half-period + // (125ms) -- the old 100ms SAMPLED the 125/250/500ms + // flash rhythms into an irregular "broken lamp" beat }; enum GroupKind { KindMfd, KindColumn, KindKeypad }; @@ -551,8 +553,13 @@ void panel_x = 0; } + // Issue #13: WS_EX_LAYERED -- a layered window is composited by DWM + // independently of the game's D3D swap chain, ending the D3D-vs-GDI + // presentation contention that strobed the panel on the game's monitor + // (single-monitor setups had no escape). Fully opaque; behavior is + // otherwise identical. panelWindow = CreateWindowExW( - WS_EX_TOPMOST, + WS_EX_TOPMOST | WS_EX_LAYERED, L"BTPadPanelWnd", L"BattleTech - Cockpit Buttons", style, panel_x, 12, @@ -563,6 +570,7 @@ void DEBUG_STREAM << "[padpanel] CreateWindow failed\n" << std::flush; return; } + SetLayeredWindowAttributes(panelWindow, 0, 255, LWA_ALPHA); // opaque layer SetTimer(panelWindow, RepaintTimerId, RepaintMilliseconds, NULL); ShowWindow(panelWindow, SW_SHOWNOACTIVATE); DEBUG_STREAM << "[padpanel] cockpit button panel up (" From 5dd35365c748a443e9c5f0718d7e53d172c69345 Mon Sep 17 00:00:00 2001 From: arcattack Date: Mon, 20 Jul 2026 08:35:55 -0500 Subject: [PATCH 6/6] Glass input audit: RIO mapper id fix (panel banks live), keyboard reconciliation Root cause of the dead on-screen panel: MechRIOMapper's message-id enum chained off L4MechControlsMapper::NextMessageID (=0x19), registering all 18 RIO override handlers at 0x19-0x2a while the streamed .CTL EventMapping records carry the binary's ids -- the binary RIO table @0051dd30 re-registers the BASE aux/zoom ids 3..0x13 (Hotbox 0x1a @0051de98). Every MFD-bank/zoom press hit the base ConfigureMappableMessageHandler FAIL trap (26/72 buttons dead; an abort() on a trap-armed pod). Ids now pinned to the binary numbering + static_assert-locked (btl4mppr.hpp) -- panel goes 26 -> 52 working buttons (8 await handler reconstruction, filed on Gitea; 12 have no streamed mapping authored = authentically inert). Keyboard reconciliation (glass-only): the merged map -- keyboard hosts the ~20 core gameplay actions on the CONTROLS.MAP keys, the panel covers every pod address by click (right-click = hold latch): - W/S throttle lever, A/D pedals, Q/E twist, R/F elevation, X all-stop, arrows drive; numpad flight cluster kept (Cyd) - 1/2/3/4/Space/LCTRL/RCTRL fire, LALT reverse, B look-behind (0x41) - M=0x18 mode cycle, N=0x15 display cycle, H=0x2C coolant flush (hold), C=0x2F Condenser1 valve, G=0x0E weapon-1 configure (Mfd1-Quad-gated) - F5-F9 = Mfd2 bank 0x27-0x24 + 0x22 (page-gated gen A-D / gen mode) - hardcoded: ` or V = view toggle, J/K/L = Mfd1/2/3 preset-page cycle (PadRIO edges -> gBTPresetCycle; no pod "cycle" button exists) - PadRIO::SuppressKey: bound keys are swallowed from the typed 1995 channel (the btinput suppression pattern, glass side) -- ends the glass double dispatch ('w'=pilot select 0, 'a'..'g'=MFD2 presets, NUMPAD/F-key key-up VK aliases like VK_F5=0x74='t'). Unbound keys keep their authentic meanings (5/z presets, t-o pilot select, +/- zoom, F1/F2 align). DISPLACED from Cyd's default bindings (each reachable on the panel or by a bindings.txt edit -- flagging for Cyd's veto): number row -> secondary panel (1-4 now fire), QWERTY row -> pilot keypad (dropped), arrows -> hat looks (now drive), V/C/B -> fire 0x47/0x46/0x45 (now view/valve/look-behind), LCTRL -> throttle-down (now fire). XInput pad section untouched. Audit by-products (KB updated): the always-active msg-4 records identified (0x2C = Reservoir flush, 0x29-0x2F = condenser valves, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff @004b1ed0 unreconstructed); 0x13 = Mech DuckRequest (crouch), 0x28 = BalanceCoolant; Searchlight/ThermalSight ToggleLamp handler-sets are default-constructed EMPTY; weapon Eng-page msgs 0x3/0xb = ToggleCooling / ToggleSeekVoltage / EjectAmmo, all unwired; unhandled messages are SILENT (no [FAIL] -- census = BT_CTRLMAP_LOG dump x handler tables). Verified live (build-glass2, ARENA1): panel presets/zoom/display/flush; W drive (speedDemand 61.5, gait advances), Q turn (BAS), M -> MID, A turn (MID), Space fires, N display, J/K/L presets, H flush drain, C valve 1->5; suppression ('w' swallowed, 't' flows, F5-keyup swallowed); BT_SHOT frame. Un-regression: pod build (gates OFF) compiles 0 errors, forced-walk drives, streamed ids unchanged; CONTROLS.MAP/btinput untouched. Co-Authored-By: Claude Fable 5 --- context/decomp-reference.md | 23 +++++ context/gauges-hud.md | 3 +- context/glass-cockpit.md | 15 +++ context/open-questions.md | 11 +++ context/pod-hardware.md | 22 ++++- docs/GLASS_COCKPIT.md | 81 ++++++++++++++++ engine/MUNGA_L4/L4CTRL.cpp | 9 ++ engine/MUNGA_L4/L4PADBINDINGS.cpp | 94 +++++++++++------- engine/MUNGA_L4/L4PADRIO.cpp | 156 +++++++++++++++++++++++++++++- engine/MUNGA_L4/L4PADRIO.h | 27 ++++++ game/reconstructed/btl4mppr.hpp | 29 +++++- 11 files changed, 422 insertions(+), 48 deletions(-) diff --git a/context/decomp-reference.md b/context/decomp-reference.md index 09f6803..f03f039 100644 --- a/context/decomp-reference.md +++ b/context/decomp-reference.md @@ -239,6 +239,29 @@ From the weapon `.SUB` records + the charge-curve `.data` constants (PE-parsed a - MechWeapon handler table @0x511860: {9,"ConfigureMappables"→@004b9550}, {10,"ChooseButton"→@004b95b8} (strings @0x511994/@0x5119A7). PoweredSubsystem table @0x50F4EC: ids 4-8 = SelectGeneratorA-D / ToggleGeneratorMode → @004b099c/@004b09e4/@004b0a2c/@004b0a74/@004b0abc. + +### Binary message tables decoded 2026-07-20 (glass input audit) [T1] +16-byte HandlerEntry {id, namePtr, fnPtr, 0} rows in section_dump. Per-receiver-class id spaces. +- **Mech (entity)** @0x50BDF8..: 0x12 TakeDamage@004a0230, 0x14 PlayerLink@0049f624, 0x15 + RealMaxSpeed@0049f604, **0x16 BalanceCoolant@0049f728**, 0x17 SetBurningState@0049f674, 0x18 + ClearBurningState@0049f700, **0x19 EjectPilot@0049f854**, **0x1a DuckRequest@0049fa00** (the + manual's CROUCH; streamed button 0x13 sends it — unreconstructed). +- **HeatableSubsystem** @0x50E41C: {3, "ToggleCooling"→@004ad6f8} (the Eng-page msg-3 button on + every weapon/avionics — unwired in the reconstruction). +- **Generator** @0x50FB90: {4, "ToggleGeneratorOnOff"→@004b1ed0} (streamed buttons 0x1A-0x1D → + GeneratorA-D — unreconstructed). +- **Searchlight** @0x51120C: {3, "ToggleLamp"→@004b860c}; PowerWatcher/ThermalSight-side variant + @0x51117C: {3, "ToggleLamp"→@004b838c}. (Reconstruction has Searchlight::ToggleLamp but the + MessageHandlers set is default-constructed EMPTY — unwired.) +- **Myomers-class** @0x51158C: {9, "ToggleSeekVoltage"→@004b8a48}; **energy-weapon** variant + @0x511DB8: {0xb, "ToggleSeekVoltage"→@004ba478}; **ammo-weapon** @0x512210: {0xb, + "EjectAmmo"→@004bb9b8}. +- **MechRIOMapper** @0x51DD30: RE-REGISTERS the base ids 3..0x13 (Aux1Quad..ZoomOut) with the + RIO override handlers @004d22fc..@004d24f8, + {0x19, Keypress→@004d2514} (RIO's OWN keypress, + unreconstructed — we use the shared L4 0x17 @004d1bf0) + {0x1a, Hotbox→@004d2574} @0x51DE98. + The reconstruction's enum chained these ids to 0x19-0x2a until 2026-07-20 — every streamed + MFD-bank/zoom press hit the base FAIL trap; now pinned + static_assert-locked (btl4mppr.hpp). +- Unhandled ids are SILENT: `Receiver::Receive` Find() returns NullHandler and does nothing. - MechWeapon ATTRIBUTE table @0x511890: ELEVEN entries — 0x12 PercentDone@0x320, 0x13 TriggerState@0x31C, 0x14 DistanceToTarget@0x324, 0x15 TargetWithinRange@0x34C, 0x16 WeaponRange@0x328, 0x17 PipPosition, 0x18 PipColor, 0x19 PipExtendedRange, 0x1A EstimatedReadyTime@0x330, 0x1B RearFiring@0x334, diff --git a/context/gauges-hud.md b/context/gauges-hud.md index 4d0c92e..d5d4892 100644 --- a/context/gauges-hud.md +++ b/context/gauges-hud.md @@ -10,7 +10,8 @@ open_questions: - "HUD attr table RESOLVED 2026-07-19 (Gitea #10 audit): re-dumped @5110b8 -- the table starts one record earlier (id 3 FlickerRate@0x1D8), every hud.hpp offset was shifted one slot; hud.hpp/CLASSMAP corrected, no behavioral impact (the reticle feeds via port globals). See GAUGE_COMPOSITE.md AUDIT finding B" - "SeekVoltageGraph RECONSTRUCTED 2026-07-19 (Gitea #11, was #10 finding A): full widget landed (see §SeekVoltageGraph below) -- ghosts gone steady-state (BT_PRESET_HOLD verification); remaining polish: a 1-frame transition artifact when a BT_SHOT lands on the exact page-switch frame (label BecameActive vs the graph's next rated Execute -- same lag class as the binary; self-heals next frame)" - "Secondary-view cycling RESOLVED 2026-07-19 (Gitea #6): the selector is the DISPLAY mode (CycleDisplayMode -> vtbl+0x4C override @4d1ae4), NOT CycleControlMode; desktop 'N' / pad RightThumb wired; pixel-verified dama->crit->heat" - - "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired; remaining: the always-active 0x28-0x2F / 0x1A-0x1D per-subsystem msg-4 records (subs 3-13 -- likely the condenser-valve/coolant buttons) unidentified" + - "Upper-MFD PRESET pages RESOLVED 2026-07-19 (Gitea #9): SetPresetMode table @0051dbf0 re-decoded (little-endian -> ModeMFD bits 0-14), per-MFD pod button banks identified from the .CTL dump, desktop J/K/L cycle wired" + - "Always-active msg-4 records IDENTIFIED 2026-07-20 (glass input audit): 0x2C = Reservoir InjectCoolant (the flush button), 0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = Condenser1-6 MoveValve, 0x1A-0x1D = GeneratorA-D ToggleGeneratorOnOff (@0050fb90, unreconstructed); plus 0x13 = Mech DuckRequest (crouch), 0x28 = Mech BalanceCoolant, 0x12/0x14 = ThermalSight/Searchlight toggles -- see pod-hardware.md + docs/GLASS_COCKPIT.md 2026-07-20" - "MP DEATHS resolved 2026-07-12 (observed-death tally + display clamp); remaining: verify multi-death tallies stay in sync across a long session (GAUGE_COMPOSITE.md)" --- diff --git a/context/glass-cockpit.md b/context/glass-cockpit.md index 2d7498e..2b16ac4 100644 --- a/context/glass-cockpit.md +++ b/context/glass-cockpit.md @@ -70,6 +70,21 @@ copied from them. - **Step 4 (Steam): CODE COMPLETE 2026-07-18 [T2 single-machine].** See [[steam-networking]] — the wire seam + `L4STEAMNET` identity-token transport + `btl4lobby`. Live 2-account cross-machine session pending (`docs/STEAM_TEST.md`). +- **Input-coverage audit + keyboard reconciliation: DONE 2026-07-20 [T2].** Root cause of the + dead panel: `MechRIOMapper`'s message ids were chained one enum too high (0x19-0x2a) — the + binary RIO table @0051dd30 re-registers the BASE aux/zoom ids 3..0x13 (Hotbox 0x1a); fixed + ungated + static_assert-locked (`btl4mppr.hpp`) → all 24 MFD-bank + 2 zoom buttons live + (52/72 panel buttons now work; 8 dead await handler reconstruction — Gitea backlog issue; + 12 have no streamed mapping authored, authentically inert). **Keyboard = the ~20 core + gameplay actions on the CONTROLS.MAP keys; the PANEL covers every pod address by click** + (right-click = hold latch) — the merged default profile lives in `L4PADBINDINGS.cpp` + (auto-writes `content\bindings.txt`; W/S/A/D/Q/E/R/F/X drive, 1-4/Space/Ctrl fire, M/N/H/C = + pod buttons 0x18/0x15/0x2C/0x2F, `/V view toggle + J/K/L preset cycles hardcoded in PadRIO). + Bound keys are SUPPRESSED from the typed 1995 channel (`PadRIO::SuppressKey`, the btinput + pattern — ended the glass double-dispatch: 'w'=pilot-select, numpad/F-key key-up aliases); + unbound keys keep their authentic meanings (5/z presets, t-o pilot select, +/- zoom). + In BAS control mode the pod turns with the STICK (Q/E) and pedals are inactive — authentic; + A/D turn in MID/ADV. Full audit + census: `docs/GLASS_COCKPIT.md` §2026-07-20. ## Key Relationships - Extends: [[pod-hardware]] (RIO input path) · Uses: [[gauges-hud]] dev-gauge modes · diff --git a/context/open-questions.md b/context/open-questions.md index c0e17a4..4d77979 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -137,6 +137,17 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR (task #8 "landed" vs the kShotDamage=12 bring-up residue) — audit before reworking. ## Deferred subsystems / feeds (authentic path scoped, marked in code) +- **Glass panel dead-button backlog (input-coverage audit 2026-07-20) [T1 census].** 8 of the 72 + panel buttons dispatch a streamed message with NO reconstructed handler (silently ignored): + 0x13 → Mech `DuckRequest` @0049fa00 (CROUCH — `duckState` attr + SQUAT clips exist!), 0x28 → + Mech `BalanceCoolant` @0049f728, 0x1A-0x1D → Generator A-D `ToggleGeneratorOnOff` @004b1ed0, + 0x12 → ThermalSight toggle (pvision unported), 0x14 → Searchlight `ToggleLamp` (body + reconstructed, handler-set unwired — note the 1995 latent bug below: even wired, the light + never turns on in the original). Plus page-gated Eng-page meanings: `ToggleCooling` @004ad6f8 + (msg 3), `ToggleSeekVoltage` @004ba478/@004b8a48 (msg 0xb energy / 9 Myomers), `EjectAmmo` + @004bb9b8 (msg 0xb ammo weapons), and MechRIOMapper's own Keypress @004d2514 (id 0x19). + Filed as the Gitea "glass panel: unreconstructed button handlers backlog" issue; tables in + [[decomp-reference]] §2026-07-20; census in `docs/GLASS_COCKPIT.md`. - **Searchlight-driven fog swap — DEFERRED, and it is an ORIGINAL 1995 LATENT BUG (task #63, 2026-07-13) [T1 decomp].** The arcade swaps fog between `fog=` (lights on) and `nosearchlightfog=` (off) via `PullFogRenderable` watching the Searchlight's `lightState`. It never fires — in the PORT diff --git a/context/pod-hardware.md b/context/pod-hardware.md index f417963..b9e16a1 100644 --- a/context/pod-hardware.md +++ b/context/pod-hardware.md @@ -42,11 +42,23 @@ preset-able MFD owns the 8-button bank AROUND it, mode-mask-gated per page — * left) = 0x08-0x0F (AuxLowerLeft), Mfd2 (upper center) = 0x20-0x27 (AuxUpperCenter), Mfd3 (lower right) = 0x00-0x07 (AuxLowerRight)**: Quad page → the outer buttons direct-select the populated Eng pages; Eng page → one button back to Quad, the rest drive the shown subsystem -(generator select A-D, gen mode, weapon config/unjam). Always-active: **0x28-0x2F -(AuxUpperLeft) → per-subsystem msg 0x4 to roster subs 3-9** and secondary-panel 0x1A-0x1D → -subs 10-13 msg 0x4 (identities unverified — likely the condenser-valve / coolant buttons -[T4]); 0x10/0x11 = map ZoomIn/Out, 0x15 = CycleDisplayMode, 0x18 = CycleControlMode (#6). -Full decode: [[gauges-hud]] §preset pages. +(generator select A-D msg 4-7, gen mode msg 8, weapon configure msg 9; also msg 0x3 = +ToggleCooling and msg 0xb = ToggleSeekVoltage (energy) / EjectAmmo (ammo weapons) — +unreconstructed, silently dead). **Always-active records IDENTIFIED 2026-07-20 [T1]** (roster: +sub 3 = Reservoir, 4-9 = Condenser1-6, 10-13 = GeneratorA-D): **0x2C = the COOLANT FLUSH +button** (Reservoir InjectCoolant, hold-to-flush — works), **0x2F/0x2E/0x2D/0x2B/0x2A/0x29 = +the per-condenser VALVE buttons** (MoveValve, Cond1-6 — work), **0x1A-0x1D = Generator A-D +ON/OFF** (`ToggleGeneratorOnOff` id 4, binary table @0050fb90 fn @004b1ed0 — DEAD until +reconstructed). Newly decoded from the binary message tables: **0x13 → Mech `DuckRequest` +(0x1a @0049fa00 — the manual's CROUCH button)**, **0x28 → Mech `BalanceCoolant` (0x16 +@0049f728)**, 0x12 → ThermalSight toggle (id 3), **0x14 → Searchlight + Searchlight2 +`ToggleLamp` (id 3 @004b860c; body reconstructed but the handler-set is default-constructed +EMPTY — unwired)** — all currently dead; an unhandled message is SILENTLY ignored +(Receiver::Receive finds no handler and does nothing — dead buttons produce NO log). +0x10/0x11 = map ZoomIn/Out, 0x15 = CycleDisplayMode, 0x18 = CycleControlMode (#6). Buttons +with NO streamed mapping authored (authentically inert): 0x16/0x17/0x19/0x1E/0x1F/0x38-0x3E. +Full decode: [[gauges-hud]] §preset pages; the complete 72-button census + verification: +`docs/GLASS_COCKPIT.md` §2026-07-20. ### Desktop input remap — CONTROLS.MAP + XInput (2026-07-18) [T2 live] `game/reconstructed/btinput.cpp` + `content/CONTROLS.MAP` (WASD-classic default, compiled-in twin; diff --git a/docs/GLASS_COCKPIT.md b/docs/GLASS_COCKPIT.md index 39d18f2..b87ec93 100644 --- a/docs/GLASS_COCKPIT.md +++ b/docs/GLASS_COCKPIT.md @@ -162,6 +162,87 @@ map `169.254.77.1=` → egg roster carries the token → mission proc mission RUNS (46 ticks). 3-config build matrix green throughout. **Remaining (user hardware):** the live 2-account cross-machine session — procedure in `docs/STEAM_TEST.md`. +## 2026-07-20 — Glass input-coverage audit + keyboard reconciliation + +**Trigger:** making BT_GLASS the permanent player experience — familiar desktop keys dead, "most +panel buttons do nothing". Full audit of every input surface (bindings.txt keys, the 72-button +panel, the streamed .CTL EventMapping records, the typed 1995 channel), then the fix set. + +### Finding 1 — THE panel-dead-buttons root cause: MechRIOMapper message ids shifted [T1→T2 fixed] +`MechRIOMapper`'s enum chained `Aux1QuadMessageID = L4MechControlsMapper::NextMessageID` (=0x19), +registering all 18 RIO handlers at ids 0x19-0x2a. The binary RIO table @0051dd30 RE-REGISTERS the +base aux/zoom ids **3..0x13** with the override handlers (Hotbox = 0x1a @0051de98) — that +re-registration over the base FAIL-trap ids is the whole point of the derived table. Streamed +`.CTL` events carry the binary ids, so every MFD-bank/zoom press found only the base +`ConfigureMappableMessageHandler` → `[FAIL]` (26 of 72 panel buttons dead; on a real pod with the +hard trap, an abort()). Hotbox worked by accident (sender + entry both used the same wrong 0x2a). +Fixed: ids pinned to the binary numbering + static_assert locks (`btl4mppr.hpp`). Verified live: +panel 0x0F/0x08/0x27/0x20 → `[mode] preset` lines, 0x10/0x11 zoom no longer FAIL. (Residual +fidelity gap: the binary RIO table also carries its OWN Keypress id 0x19 → @004d2514, +unreconstructed; we still register the shared L4 Keypress 0x17 — works, logged in the backlog.) + +### Finding 2 — the always-active msg-4 records IDENTIFIED [T1] (closes the #9 open question) +Binary message tables decoded from section_dump (16-byte {id,name,fn,0} entries): per-receiver- +class id spaces. Roster (ARENA1 MadCat): subs 3=Reservoir, 4-9=Condenser1-6, 10-13=GeneratorA-D. +So: **0x2C = the COOLANT FLUSH button** (Reservoir InjectCoolant, WORKS), **0x2F/0x2E/0x2D/0x2B/ +0x2A/0x29 = per-condenser VALVE buttons** (MoveValve, WORK), **0x1A-0x1D = Generator A-D ON/OFF** +(`ToggleGeneratorOnOff` id 4, table @0050fb90 fn @004b1ed0 — DEAD, unreconstructed). Also new: +0x13 → Mech **DuckRequest** (0x1a @0049fa00 — the manual's CROUCH button!), 0x28 → Mech +**BalanceCoolant** (0x16 @0049f728), 0x12 → ThermalSight toggle (id 3), 0x14 → Searchlight + +Searchlight2 **ToggleLamp** (id 3 @004b860c — body reconstructed but `Searchlight:: +MessageHandlers` is default-constructed EMPTY, so it never fires: the quick win). Weapon +Eng-page extras: msg 0x3 = **ToggleCooling** (HeatableSubsystem @0050e41c fn @004ad6f8, unwired), +msg 0xb = **ToggleSeekVoltage** (energy @004ba478) / **EjectAmmo** (ammo weapons @004bb9b8), +Myomers msg 9 = ToggleSeekVoltage (@004b8a48; body exists, unwired). Unhandled messages are +SILENT (Receiver::Receive ignores an unfound id) — the dead-button census cannot come from +[FAIL] logs alone; it comes from the BT_CTRLMAP_LOG record dump × the handler tables. + +### Finding 3 — glass keyboard: stand-down + double dispatch [T2] +ALL btinput actions (drive keys, M/N/J/K/L/H/C/V/B/G/F5-F9) stand down when PadRIO owns the path +— nothing on the game side polls keys directly (BTInputPoll is the only GetAsyncKeyState +consumer). Meanwhile the typed 1995 channel stays LIVE (glass preset = `PAD,KEYBOARD`) with NO +suppression — so Cyd's old bindings double-dispatched: W (keypad row) also typed 'w' = pilot +select 0; C (fire middle) also typed 'c' = MFD3 Eng3; NUMPAD key-UPS alias to letter hotkeys +(VK_NUMPAD2=0x62='b' = MFD3 Quad, VK_F5=0x74='t' = pilot select 3). This is the historic +pre-btinput double-dispatch, back on the glass side. + +### The fix set (landed) +1. `btl4mppr.hpp` — RIO id pinning (ungated, pod-correctness; the step-2a idiom). +2. `L4PADRIO.*` — typed-channel suppression tables built from the loaded bindings (+hardcoded + keys), `PadRIO::SuppressKey` consumed by a `#ifdef BT_GLASS` arm next to the btinput hook in + `L4CTRL.cpp:1506` block; V joins backtick on the view-toggle edge; J/K/L fire + `gBTPresetCycle[3]` (the same seam the dev poll feeds — no binary analog exists for a "cycle" + button; the body is the authentic SetPresetMode). +3. `L4PADBINDINGS.cpp` + `content\bindings.txt` — the MERGED default map (user decision: + keyboard = the ~20 core gameplay actions on the CONTROLS.MAP keys; the PANEL covers all 72 + pod buttons by click/latch). W/S throttle, A/D pedals, Q/E twist, R/F elevation, X all-stop, + 1-4/Space/Ctrl fire + LALT reverse + B look-behind, M=0x18, N=0x15, H=0x2C (flush hold), + C=0x2F (Condenser1 valve), G=0x0E (weapon-1 configure, Mfd1-Quad-gated), F5-F9=Mfd2 bank + 0x27-0x24+0x22 (page-gated gen A-D/mode), numpad flight cluster kept. DISPLACED from Cyd's + map (each reachable on the panel or by editing bindings.txt): number row→secondary panel, + QWERTY row→pilot keypad (dropped), arrows→hat looks (arrows now drive), V/C/B→fire + 0x47/0x46/0x45, LCTRL→throttle-down. XInput pad section untouched. + NOTE (control modes, authentic): in BAS mode the pod turns with the STICK (Q/E here) and the + pedals are inactive; A/D turn in MID/ADV — exactly the manual's behavior, unlike the desktop + bridge which re-routes A/D per mode. +4. Un-touched: CONTROLS.MAP, btinput defaults, pod serial path. `bindings.txt` is read only by + `PadBindingProfile` inside the gated PadRIO TU — the pod build never opens it. + +### Verification (2026-07-20, build-glass2, ARENA1 solo, all logs in-session) +- Panel: 0x0F→preset(0,1), 0x08→(0,0), 0x27→(1,1), 0x20→(1,0), zoom no-FAIL; 0x15 display + 1→2→0; 0x2C hold → `[flush] Reservoir level 6→0`; dead-class buttons (0x13/0x1A/0x12/0x14) + silent as predicted. ZERO [FAIL] lines in the whole session (was 2 per bank press). +- Keyboard: W hold → thr 0→1, speedDemand 61.501, gait advances; Q → turnDemand 0.94 (BAS); + M → `control mode -> 1`; A hold → turnDemand 1.0 (MID); Space → `[emitter] FIRED`; N → + `display notify 1`; J/K/L → presets (0,1)/(1,1)/(2,1); H → reservoir drain; C → condenser#1 + valveState 1→5. BT_SHOT frame confirms visually: MID lit on the control-mode column, CRITICAL + DAMAGE schematic up, MFDs on Eng pages, heading moved. V not re-injected (user took + foreground mid-run) — code-identical to the verified backtick edge [T3]. +- Suppression: typed 'w' swallowed (no [keych]), typed 't' flows ([keych] 0x74), WM_KEYUP + VK_F5 swallowed (the 't'-alias hazard). +- Un-regression: build-pod (gates OFF) compiles 0 errors; forced-walk run drives + (speedDemand=61.501, 32 subsystems, streamed ids 3→+0xdc / 4→+0xe4 unchanged). + ## 2026-07-18 — Merge origin/master (commit f889e24): btinput meets PadRIO Master's 33-commit run landed (D1 relay/operator-console line, CONTROLS.MAP+XInput binding diff --git a/engine/MUNGA_L4/L4CTRL.cpp b/engine/MUNGA_L4/L4CTRL.cpp index f0df062..2fe8c78 100644 --- a/engine/MUNGA_L4/L4CTRL.cpp +++ b/engine/MUNGA_L4/L4CTRL.cpp @@ -1605,6 +1605,15 @@ void { // swallowed: remapped by CONTROLS.MAP } +#ifdef BT_GLASS + else if (PadRIO::SuppressKey((unsigned int)keyValue, + msg.message == WM_CHAR)) + { + // swallowed: claimed by a bindings.txt row (PadRIO) -- + // the glass-side analog of the CONTROLS.MAP suppression + // above (btinput stands down when PadRIO owns the path) + } +#endif else { keyboardGroup[KeyboardPC].ForceUpdate(&keyValue, mode_mask); diff --git a/engine/MUNGA_L4/L4PADBINDINGS.cpp b/engine/MUNGA_L4/L4PADBINDINGS.cpp index 9e32b46..a1d910f 100644 --- a/engine/MUNGA_L4/L4PADBINDINGS.cpp +++ b/engine/MUNGA_L4/L4PADBINDINGS.cpp @@ -139,6 +139,21 @@ static int // right/left), 0x45-0x47 pinky/thumb-low/thumb-high, 0x10-0x15 + 0x18-0x1D // the 12 secondary-panel buttons, 0x00-0x0F the lower aux banks, 0x20-0x37 // the upper aux banks. Keypad unit 0 = the pilot's MFD keypad. +// +// KEYBOARD RECONCILIATION (2026-07-20, input-coverage audit): the keyboard +// carries the ~20 core gameplay actions on the SAME keys the desktop +// CONTROLS.MAP uses (W/S/A/D drive, Q/E twist, R/F elevation, X all-stop, +// 1-4/Space/Ctrl fire, M/N mode/display cycle, H flush, C valve, B look +// behind, G configure, V view); FULL pod-address coverage lives on the +// on-screen panel (every one of the 72 buttons is one click away, +// right-click = hold latch). Keys DISPLACED from the previous default +// (each still reachable by editing bindings.txt or on the panel): +// - number row 1-0/-/= -> secondary-panel 0x10-0x1D (1-4 now fire) +// - QWERTY row Q..P -> pilot MFD keypad unit 0 (dropped) +// - arrow keys -> hat looks 0x41-0x44 (arrows now drive) +// - V/C/B -> fire 0x47/0x46/0x45 (now view/valve/behind) +// - LCTRL -> throttle slew down (now fire 0x47) +// The XInput pad section is unchanged. //########################################################################### static const char *defaultProfileText = @@ -153,8 +168,33 @@ static const char *defaultProfileText = "# padaxis axis [invert] [slew ]\n" "# Channels: Throttle JoystickX JoystickY LeftPedal RightPedal\n" "# Pad buttons: A B X Y LB RB BACK START LS RS DPAD_* Axes: LX LY RX RY LT RT\n" +"#\n" +"# LAYOUT: the keyboard hosts the core gameplay actions on the desktop\n" +"# CONTROLS.MAP keys; the on-screen cockpit panel covers EVERY pod button\n" +"# by click (right-click = press-and-hold latch). A key bound here is\n" +"# REMOVED from the authentic 1995 typed-hotkey channel (no double\n" +"# dispatch); unbound keys keep their 1995 meaning -- 5 = MFD1 Quad page,\n" +"# z = MFD3 Eng1 page, t/y/u/i/o = pilot select 3-7, + / - = target zoom.\n" +"# Hardcoded (not rebindable here): ` or V = view toggle, J/K/L = cycle\n" +"# the Mfd1/Mfd2/Mfd3 preset page.\n" "\n" -"# --- flight: numpad spring stick, pedals, throttle lever ---\n" +"# --- drive: W/S sweep the throttle lever (sticks; X = all-stop detent),\n" +"# --- A/D spring the turn pedals, Q/E twist the torso, R/F aim it up/down\n" +"key W axis Throttle slew + 0.7\n" +"key S axis Throttle slew - 0.7\n" +"key UP axis Throttle slew + 0.7\n" +"key DOWN axis Throttle slew - 0.7\n" +"key A axis LeftPedal deflect + 2.5\n" +"key D axis RightPedal deflect + 2.5\n" +"key LEFT axis LeftPedal deflect + 2.5\n" +"key RIGHT axis RightPedal deflect + 2.5\n" +"key Q axis JoystickX deflect - 2.5\n" +"key E axis JoystickX deflect + 2.5\n" +"key R axis JoystickY deflect + 2.5\n" +"key F axis JoystickY deflect - 2.5\n" +"key X axis Throttle set 0\n" +"\n" +"# --- numpad flight cluster (alternative stick/pedals/lever) ---\n" "key NUMPAD8 axis JoystickY deflect + 2.5\n" "key NUMPAD2 axis JoystickY deflect - 2.5\n" "key NUMPAD4 axis JoystickX deflect - 2.5\n" @@ -162,51 +202,33 @@ static const char *defaultProfileText = "key NUMPAD7 axis LeftPedal deflect + 2.5\n" "key NUMPAD9 axis RightPedal deflect + 2.5\n" "key LSHIFT axis Throttle slew + 0.7\n" -"key LCTRL axis Throttle slew - 0.7\n" "key NUMPAD5 axis Throttle set 0\n" "\n" -"# --- weapons / hat / handle buttons ---\n" +"# --- fire groups (the joystick's four mappable buttons) + reverse + look\n" +"key 1 button 0x40\n" "key SPACE button 0x40\n" +"key 2 button 0x46\n" +"key 3 button 0x47\n" +"key LCTRL button 0x47\n" +"key RCTRL button 0x47\n" +"key 4 button 0x45\n" "key LALT button 0x3F\n" -"key V button 0x47\n" -"key C button 0x46\n" -"key B button 0x45\n" -"key UP button 0x42\n" -"key DOWN button 0x41\n" -"key LEFT button 0x44\n" -"key RIGHT button 0x43\n" +"key B button 0x41\n" "\n" -"# --- secondary panel (12) on the number row ---\n" -"key 1 button 0x10\n" -"key 2 button 0x11\n" -"key 3 button 0x12\n" -"key 4 button 0x13\n" -"key 5 button 0x14\n" -"key 6 button 0x15\n" -"key 7 button 0x18\n" -"key 8 button 0x19\n" -"key 9 button 0x1A\n" -"key 0 button 0x1B\n" -"key MINUS button 0x1C\n" -"key EQUALS button 0x1D\n" +"# --- systems (pod console buttons) ---\n" +"key M button 0x18 # cycle control mode (BAS/MID/ADV)\n" +"key N button 0x15 # cycle secondary schematic (Damage/Critical/Heat)\n" +"key H button 0x2C # HOLD = coolant flush (Reservoir InjectCoolant)\n" +"key C button 0x2F # cycle Condenser1 coolant valve (MoveValve)\n" +"key G button 0x0E # HOLD + fire key = regroup weapon 1 (Mfd1 Quad page)\n" "\n" -"# --- aux banks on F5-F8 (upper center 1-4) ---\n" +"# --- Mfd2 bank on F5-F8 + F9 (page-gated: Quad page = select the Eng\n" +"# --- pages, Eng page = generator A-D select / F9 gen-mode toggle) ---\n" "key F5 button 0x27\n" "key F6 button 0x26\n" "key F7 button 0x25\n" "key F8 button 0x24\n" -"\n" -"# --- pilot MFD keypad on QWERTY row (unit 0, keys 0-9) ---\n" -"key Q keypad 0 1\n" -"key W keypad 0 2\n" -"key E keypad 0 3\n" -"key R keypad 0 4\n" -"key T keypad 0 5\n" -"key Y keypad 0 6\n" -"key U keypad 0 7\n" -"key I keypad 0 8\n" -"key O keypad 0 9\n" -"key P keypad 0 0\n" +"key F9 button 0x22\n" "\n" "# --- XInput pad ---\n" "padaxis LX axis JoystickX\n" diff --git a/engine/MUNGA_L4/L4PADRIO.cpp b/engine/MUNGA_L4/L4PADRIO.cpp index 2ff4d56..0f82e4a 100644 --- a/engine/MUNGA_L4/L4PADRIO.cpp +++ b/engine/MUNGA_L4/L4PADRIO.cpp @@ -21,11 +21,23 @@ PadRIO *PadRIO::activeInstance = NULL; // -// Pending backtick view-toggle edges (set in Poll, consumed by the game's +// Pending backtick/V view-toggle edges (set in Poll, consumed by the game's // view-toggle block through BTPadViewToggleEdge). // int gBTPadViewToggleEdges = 0; +// +// The desktop per-MFD preset-page cycle edges (J/K/L -> Mfd1/2/3), consumed +// by L4MechControlsMapper::InterpretControls (btl4mppr.cpp step 3b -- the +// same seam the dev-build mech4 poll feeds). Defined in mech4.cpp (always +// compiled), so the glass TU externs it -- keyboard reconciliation +// 2026-07-20: J/K/L are the CONTROLS.MAP muscle-memory keys and there is no +// single pod button that "cycles" an MFD (the pod's bank buttons are +// mode-mask-gated direct selects), so the cycle stays a port-side sender +// with the authentic SetPresetMode body. +// +extern int gBTPresetCycle[3]; + int BTPadViewToggleEdge(void) { @@ -118,6 +130,7 @@ PadRIO::PadRIO(): memset(lampState, 0, sizeof(lampState)); bindings.Load(); + BuildKeySuppression(); // // Per-channel spring return rate = the fastest deflect rate bound to @@ -173,6 +186,118 @@ PadRIO::~PadRIO() } } +//########################################################################### +// Typed-channel suppression (the btinput sSuppressChar/sSuppressKeyUp +// pattern -- btinput.cpp AddSuppression -- rebuilt here because btinput +// stands down whenever a cockpit device owns the input path). A bound key +// must NOT also reach the 1995 in-cockpit keyboard dispatcher: 'w' selects +// pilot 0, 'a'/'s'/'d'/'f'/'g' flip MFD2 preset pages, letter/numpad +// KEY-UP VK values alias onto lowercase hotkeys (VK_F5==0x74=='t' = pilot +// select 3, VK_NUMPAD2==0x62=='b' = MFD3 Quad, ...). Unbound keys keep +// their authentic 1995 typed meaning. +//########################################################################### + +void + PadRIO::AddKeySuppression(int virtual_key) +{ + // + // WM_KEYUP delivers the raw VK; every consumer downstream compares + // typed CHARACTERS, so the VK value itself is the alias to swallow. + // + if (virtual_key >= 0 && virtual_key < 256) + { + suppressKeyUp[virtual_key] = 1; + } + // + // WM_CHAR delivers typed characters: both cases of a letter, the digit + // itself (main row AND numpad -- VK_NUMPAD0..9 type '0'..'9'), space, + // and the base punctuation of the Oem keys. + // + if (virtual_key >= 'A' && virtual_key <= 'Z') + { + suppressChar[virtual_key + ('a' - 'A')] = 1; + suppressChar[virtual_key] = 1; + } + else if (virtual_key >= '0' && virtual_key <= '9') + { + suppressChar[virtual_key] = 1; + } + else if (virtual_key >= VK_NUMPAD0 && virtual_key <= VK_NUMPAD9) + { + suppressChar['0' + (virtual_key - VK_NUMPAD0)] = 1; + } + else if (virtual_key == VK_SPACE) + { + suppressChar[' '] = 1; + suppressKeyUp[VK_SPACE] = 1; + } + else + { + static const struct { int vk; char ch; } oem[] = + { + { VK_OEM_MINUS, '-' }, { VK_OEM_PLUS, '=' }, + { VK_OEM_COMMA, ',' }, { VK_OEM_PERIOD, '.' }, + { VK_OEM_2, '/' }, { VK_OEM_3, '`' }, + { VK_OEM_4, '[' }, { VK_OEM_5, '\\' }, + { VK_OEM_6, ']' }, { VK_OEM_1, ';' }, + { VK_OEM_7, '\'' }, { VK_RETURN, '\r' }, + { VK_TAB, '\t' }, { VK_BACK, '\b' }, + }; + for (int i = 0; i < (int)(sizeof(oem) / sizeof(oem[0])); ++i) + { + if (oem[i].vk == virtual_key) + { + suppressChar[(unsigned char)oem[i].ch] = 1; + } + } + } + // + // The generic modifier VK is what WM_KEYUP reports for L/R variants. + // + if (virtual_key == VK_LSHIFT || virtual_key == VK_RSHIFT) + { + suppressKeyUp[VK_SHIFT] = 1; + } + if (virtual_key == VK_LCONTROL || virtual_key == VK_RCONTROL) + { + suppressKeyUp[VK_CONTROL] = 1; + } +} + +void + PadRIO::BuildKeySuppression() +{ + memset(suppressChar, 0, sizeof(suppressChar)); + memset(suppressKeyUp, 0, sizeof(suppressKeyUp)); + + for (int k = 0; k < bindings.keyBindingCount; ++k) + { + AddKeySuppression(bindings.keyBindings[k].virtualKey); + } + + // + // The hardcoded keys (Poll): backtick + V = view toggle, J/K/L = the + // per-MFD preset-page cycle. + // + AddKeySuppression(VK_OEM_3); + AddKeySuppression('V'); + AddKeySuppression('J'); + AddKeySuppression('K'); + AddKeySuppression('L'); +} + +int + PadRIO::SuppressKey(unsigned int key_value, int is_char) +{ + if (activeInstance == NULL || key_value > 255) + { + return 0; // no glass device / ALT_BIT-tagged value + } + return is_char + ? activeInstance->suppressChar[key_value] + : activeInstance->suppressKeyUp[key_value]; +} + //########################################################################### // Event queue //########################################################################### @@ -278,13 +403,16 @@ void // // The backtick view toggle (per Cyd: ` = 1st/3rd person in the glass - // cockpit). Edge-detected here (async poll, message-path-free) and - // consumed by the game's view-toggle block via BTPadViewToggleEdge. + // cockpit) + V (the CONTROLS.MAP ViewToggle key -- keyboard + // reconciliation 2026-07-20). Edge-detected here (async poll, + // message-path-free) and consumed by the game's view-toggle block via + // BTPadViewToggleEdge. // { static int s_backtickWas = 0; int backtick_held = focused && - (GetAsyncKeyState(VK_OEM_3) & 0x8000) != 0; + (((GetAsyncKeyState(VK_OEM_3) & 0x8000) != 0) || + ((GetAsyncKeyState('V') & 0x8000) != 0)); if (backtick_held && !s_backtickWas) { extern int gBTPadViewToggleEdges; @@ -293,6 +421,26 @@ void s_backtickWas = backtick_held; } + // + // J/K/L: cycle the Mfd1/Mfd2/Mfd3 preset page (the CONTROLS.MAP keys; + // the L4 mapper consumes gBTPresetCycle and runs the authentic + // SetPresetMode body -- btl4mppr.cpp CyclePresetModeNow). + // + { + static int s_presetWas[3] = { 0, 0, 0 }; + static const int s_presetKey[3] = { 'J', 'K', 'L' }; + for (int g = 0; g < 3; ++g) + { + int held = focused && + (GetAsyncKeyState(s_presetKey[g]) & 0x8000) != 0; + if (held && !s_presetWas[g]) + { + gBTPresetCycle[g] = 1; + } + s_presetWas[g] = held; + } + } + float slewDelta[PadBindingProfile::ChannelCount]; int deflectHeld[PadBindingProfile::ChannelCount]; memset(slewDelta, 0, sizeof(slewDelta)); diff --git a/engine/MUNGA_L4/L4PADRIO.h b/engine/MUNGA_L4/L4PADRIO.h index 1fc1db6..04dde3b 100644 --- a/engine/MUNGA_L4/L4PADRIO.h +++ b/engine/MUNGA_L4/L4PADRIO.h @@ -58,6 +58,20 @@ public: static int GetLampState(int unit); + // + // Legacy typed-channel suppression (the btinput BTInputSuppressKey + // pattern, glass-side). A key CLAIMED by a bindings.txt row (or by the + // hardcoded `/V view-toggle and J/K/L preset-cycle keys) is swallowed + // from the engine's WM_CHAR/WM_KEYUP key-command feed (L4CTRL.cpp:1506 + // block) -- otherwise every bound letter double-dispatches into the 1995 + // in-cockpit dispatcher ('w' = pilot select 0, 'a'/'s'/'d'/'f' = MFD2 + // preset pages, NUMPAD key-up VKs alias to 'b'/'d'/'e' hotkeys, ...). + // Unbound keys keep their authentic 1995 typed meaning. Returns 0 when + // no PadRIO is active (the feed flows untouched on dev/pod builds). + // + static int + SuppressKey(unsigned int key_value, int is_char); + enum { LampCount = 128 }; protected: @@ -105,6 +119,19 @@ protected: int lampState[LampCount]; + // + // Typed-channel suppression tables (built from the loaded bindings + + // the hardcoded view/preset keys; see SuppressKey). + // + unsigned char + suppressChar[256]; // WM_CHAR values + unsigned char + suppressKeyUp[256]; // WM_KEYUP values (VK aliases) + void + AddKeySuppression(int virtual_key); + void + BuildKeySuppression(); + static PadRIO *activeInstance; }; diff --git a/game/reconstructed/btl4mppr.hpp b/game/reconstructed/btl4mppr.hpp index 5a12fef..e40b6fe 100644 --- a/game/reconstructed/btl4mppr.hpp +++ b/game/reconstructed/btl4mppr.hpp @@ -288,16 +288,41 @@ // Messaging Support // public: + // + // GLASS INPUT-AUDIT FIX (2026-07-20): these ids are PINNED to the + // binary numbering. The old chain started at + // L4MechControlsMapper::NextMessageID (=0x19), which registered every + // RIO handler at 0x19..0x2a -- but the streamed "L4" .CTL EventMapping + // records carry the BINARY's ids, and the binary RIO table @0051dd30 + // (section_dump) RE-REGISTERS the MechControlsMapper aux/zoom ids + // 3..0x13 with the RIO override handlers (that is the whole point of + // the derived table: same message, real body instead of the base FAIL + // trap). With the shifted ids every streamed MFD-bank/zoom button + // press found only the base ConfigureMappableMessageHandler ("[FAIL] + // Unhandled button mapping") -- ~38 of the 72 pod panel buttons dead + // (and, on a real pod with BT_BUTTON_TRAP, an abort()). Hotbox is + // 0x1a in the binary (table entry @0051de98); the ctor/handler pair + // was previously self-consistent at the wrong value (0x2a), so it + // worked by accident -- now it matches the binary. + // (Faithful-fidelity note: the binary RIO table also carries its OWN + // Keypress id 0x19 -> @004d2514; our ctor still registers the shared + // L4 Keypress (0x17, @004d1bf0) -- see the Gitea backlog issue.) + // enum { - Aux1QuadMessageID = L4MechControlsMapper::NextMessageID, // 3 + Aux1QuadMessageID = MechControlsMapper::Aux1QuadMessageID, // 3 Aux1Eng1MessageID, Aux1Eng2MessageID, Aux1Eng3MessageID, Aux1Eng4MessageID, Aux2QuadMessageID, Aux2Eng1MessageID, Aux2Eng2MessageID, Aux2Eng3MessageID, Aux2Eng4MessageID, Aux3QuadMessageID, Aux3Eng1MessageID, Aux3Eng2MessageID, Aux3Eng3MessageID, Aux3Eng4MessageID, ZoomInMessageID, // 0x12 ZoomOutMessageID, // 0x13 - HotboxMessageID, // pilot-target select + HotboxMessageID = 0x1a, // pilot-target select (@0051de98) NextMessageID }; + static_assert(Aux1QuadMessageID == 0x03, "RIO Aux1Quad must be the binary id 3 (table @0051dd30)"); + static_assert(Aux3Eng4MessageID == 0x11, "RIO Aux3Eng4 must be the binary id 0x11"); + static_assert(ZoomInMessageID == 0x12 && ZoomOutMessageID == 0x13, + "RIO ZoomIn/ZoomOut must be the binary ids 0x12/0x13"); + static_assert(HotboxMessageID == 0x1a, "RIO Hotbox must be the binary id 0x1a (@0051de98)"); void Aux1QuadMessageHandler(ReceiverDataMessageOf *message), // @004d22fc