From 33ca99eb6976d74419ce8b6d3b9e49b39e23db29 Mon Sep 17 00:00:00 2001 From: arcattack Date: Sat, 25 Jul 2026 10:05:57 -0500 Subject: [PATCH] Input-audit cause #1: revive the Avionics power bank + add the unhandled-message trace THE CLASS OF BUG. A default-constructed Receiver::MessageHandlerSet is a total blackhole: entryCount 0 and NO parent chain, so Find() returns NullHandler for every id and Receiver::Receive dropped the message with no log, no counter, nothing. docs/INPUT_PATH_AUDIT.md ranked this systemic cause #1; running its own lint finds SEVEN such sets (Sensor, Searchlight, ThermalSight, Gyroscope, MechTech, SubsystemMessageManager, Torso). It is also why generators cannot be switched off (#53) -- same shape. FIX 1 -- Sensor / Avionics (the big one). The streamed mappings aim 108 records across the 18 mechs at it: the MFD2 ENG1 power bank, 6 clickable buttons plus keys F5-F9 (route Avionics power to generators A-D, gen mode, coolant cut). All silently swallowed. Sensor needed NO handlers of its own -- ids 4-8 already live on PoweredSubsystem, which chains HeatSink for id 3, covering the whole 3-8 range those buttons send. It only ever needed to CHAIN, which is exactly why the byte-identical bank on ENG2 works (Myomers chains, myomers.cpp:88-100; Sensor did not). Added Sensor::GetMessageHandlers() with the same function-local-static idiom and pointed DefaultData at it instead of the empty set. VERIFIED LIVE (scratchpad/avionics.py -- pages MFD2 and presses the bank): [gensel] Avionics -> GeneratorA (tapped) ... B, C, D [gensel] Avionics mode -> 2 i.e. Avionics now behaves exactly like Myomers. 24 handler hits, 0 unhandled. FIX 2 -- the standing guard. Receiver::Receive now logs once per (class, message id) pair when no handler is found: '[msg] UNHANDLED: has no handler for message id N -- silently dropped (is its MessageHandlerSet chained?)'. Once-per-pair on purpose, since some ids broadcast every frame. This one line would have printed Avionics, Searchlight, ThermalSight, crouch and all four generator buttons on the first boot instead of costing us months of player reports. Uses SharedData::derivedClasses -> Derivation::className. DEFERRED, with reasons (not guessed at): Searchlight's ToggleLamp body exists but is unreachable -- it needs a correctly-typed forwarder (Receiver::Handler is void(const Message*); ToggleLamp is Logical(Message&), so a cast would be UB that happens to work on x86) AND an id check, because its id 3 collides with HeatSink's ToggleCooling and I have not traced whether PowerWatcher's chain reaches HeatSink. #53 needs more than chaining: ToggleGeneratorOnOff @004b1ed0 has no body at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166KTsC7ADm7VXEi1HF1jNg --- engine/MUNGA/RECEIVER.cpp | 48 ++++++++++++++++++ game/reconstructed/sensor.cpp | 29 ++++++++++- game/reconstructed/sensor.hpp | 4 ++ scratchpad/avionics.py | 92 +++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 scratchpad/avionics.py diff --git a/engine/MUNGA/RECEIVER.cpp b/engine/MUNGA/RECEIVER.cpp index 009a3d6..d2d8aba 100644 --- a/engine/MUNGA/RECEIVER.cpp +++ b/engine/MUNGA/RECEIVER.cpp @@ -102,6 +102,54 @@ void { (this->*handler)(what); } + else + { + // + // UNHANDLED-MESSAGE TRACE (input-path audit, systemic cause #1). + // + // A message with no handler was dropped here in complete silence -- no log, + // no counter -- and a DEFAULT-CONSTRUCTED `MessageHandlerSet` has + // entryCount 0 and no parent chain, so it swallows EVERY id. That is how + // the Avionics power bank (108 streamed records: 6 cockpit buttons + keys + // F5-F9), the searchlight toggle, the thermal sight, the crouch button and + // all four generator ON/OFF buttons were dead for months while looking + // perfectly wired. This trace is the standing guard for the whole class: + // one line per (class, message id) pair, first occurrence only, so a newly + // dead control announces itself on the first boot instead of waiting for a + // player report. + // + // Deliberately once-per-pair: some ids are broadcast every frame, and this + // must never become a per-frame log. + // + static const int kMaxSeen = 64; + static struct { const char *name; int id; } seen[kMaxSeen]; + static int seenCount = 0; + + Derivation *derivation = sharedData->derivedClasses; + const char *class_name = (derivation != 0 && derivation->className != 0) + ? derivation->className : ""; + const int id = (int)what->messageID; + + int known = 0; + for (int i = 0; i < seenCount; ++i) + { + if (seen[i].id == id && seen[i].name == class_name) + { + known = 1; + break; + } + } + if (!known && seenCount < kMaxSeen) + { + seen[seenCount].name = class_name; + seen[seenCount].id = id; + ++seenCount; + DEBUG_STREAM << "[msg] UNHANDLED: " << class_name + << " has no handler for message id " << id + << " -- silently dropped (is its MessageHandlerSet chained?)" + << std::endl; + } + } } //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/game/reconstructed/sensor.cpp b/game/reconstructed/sensor.cpp index 7910fc8..e720756 100644 --- a/game/reconstructed/sensor.cpp +++ b/game/reconstructed/sensor.cpp @@ -100,6 +100,33 @@ Derivation "Sensor" ); +// +// MESSAGE HANDLERS (Gitea #53 family / input-path audit systemic cause #1). +// +// This was a DEFAULT-CONSTRUCTED `MessageHandlerSet`, which is a total blackhole: +// it has `entryCount = 0` and **no parent chain**, so `Find()` returns +// `NullHandler` for every id (`engine/MUNGA/RECEIVER.h`), and `Receiver::Receive` +// drops the message with no log, no warning and no counter. The Sensor is the +// cockpit's **"Avionics"** subsystem, and the streamed control mappings aim +// **108 records across the 18 mechs** at it -- the MFD2 ENG1 page's power bank +// (6 clickable buttons, keys F5-F9): route Avionics power to generator A-D, gen +// mode, coolant cut. Every one of them was silently swallowed. +// +// Sensor needs NO handlers of its own -- those ids (4-8) already exist on +// `PoweredSubsystem`. It only ever needed to CHAIN, which is exactly why the +// byte-identical bank on ENG2 works: Myomers chains (myomers.cpp:88-100) and +// Sensor did not. Same idiom, function-local static so construction order is +// safe. +// +Receiver::MessageHandlerSet& + Sensor::GetMessageHandlers() +{ + static Receiver::MessageHandlerSet messageHandlers( + 0, 0, // no ids of its own + PoweredSubsystem::GetMessageHandlers()); // copy-inherit ids 4-8 + return messageHandlers; +} + Receiver::MessageHandlerSet Sensor::MessageHandlers; @@ -130,7 +157,7 @@ Sensor::AttributeIndexSet Sensor::SharedData Sensor::DefaultData( &Sensor::ClassDerivations, - Sensor::MessageHandlers, + Sensor::GetMessageHandlers(), // was the EMPTY MessageHandlers (blackhole) Sensor::AttributeIndex, Sensor::StateCount ); diff --git a/game/reconstructed/sensor.hpp b/game/reconstructed/sensor.hpp index 8d69969..36c3a99 100644 --- a/game/reconstructed/sensor.hpp +++ b/game/reconstructed/sensor.hpp @@ -90,6 +90,10 @@ static Derivation ClassDerivations; static SharedData DefaultData; static Receiver::MessageHandlerSet MessageHandlers; + // The set the DefaultData actually hands out. Chains + // PoweredSubsystem's ids 4-8 -- the bare `MessageHandlers` above is + // default-constructed and swallows every message (see sensor.cpp). + static Receiver::MessageHandlerSet& GetMessageHandlers(); //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // BT segment / base-state compatibility shims. diff --git a/scratchpad/avionics.py b/scratchpad/avionics.py new file mode 100644 index 0000000..5262329 --- /dev/null +++ b/scratchpad/avionics.py @@ -0,0 +1,92 @@ +"""Verify the Sensor/Avionics handler-set fix (input-path audit systemic cause #1). + +The MFD2 ENG1 page's power bank -- 6 clickable buttons, keys F5-F9 -- sends +message ids 3-8 to subsystem 14 ("Avionics", class Sensor). Sensor's +MessageHandlerSet was DEFAULT-CONSTRUCTED (entryCount 0, no parent chain), so +Find() returned NullHandler for every id and Receiver::Receive dropped them in +silence: 108 streamed records across the 18 mechs, all dead. Sensor now chains +PoweredSubsystem::GetMessageHandlers() (ids 4-8) which chains HeatSink (id 3). + +PASS = a [gensel] line naming Avionics after an F-key press. +Also prints any [msg] UNHANDLED pairs the new trace catches. + +Kills only the PID it spawns. +""" +import ctypes +import os +import re +import subprocess +import sys +import time + +KEYUP = 0x0002 +VK = {"K": 0x4B, "F5": 0x74, "F6": 0x75, "F7": 0x76, "F8": 0x77, "F9": 0x78, + "C": 0x43, "H": 0x48, "N": 0x4E} +user32 = ctypes.windll.user32 + +REPO = r"C:\git\bt411" +LOG = os.path.join(REPO, "scratchpad_avionics.log") +if os.path.exists(LOG): + os.remove(LOG) + +env = dict(os.environ) +env.update({ + "BT_START_INSIDE": "1", + "BT_KEY_NOFOCUS": "1", + "BT_DEV_GAUGES": "1", + "BT_FIRE_LOG": "1", # [gensel] lines + "BT_HEAT_LOG": "1", + "BT_MODE_LOG": "1", # [mode] preset page changes + "BT_LOG": LOG, +}) +proc = subprocess.Popen( + [os.path.join(REPO, "build", "Release", "btl4.exe"), "-egg", "LAST.EGG"], + cwd=os.path.join(REPO, "content"), env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +print("pid", proc.pid) + +def tap(name, hold=0.10): + vk = VK[name] + user32.keybd_event(vk, 0, 0, 0) + time.sleep(hold) + user32.keybd_event(vk, 0, KEYUP, 0) + time.sleep(0.25) + +try: + deadline = time.time() + 200 + while time.time() < deadline: + if os.path.exists(LOG) and "first frame" in open(LOG, errors="replace").read(): + break + time.sleep(2) + else: + print("FAIL: never reached the mission"); sys.exit(2) + time.sleep(6) + + # page MFD2 through its preset pages, pressing the bank on each + for page in range(5): + tap("K") + time.sleep(1.0) + for k in ("F5", "F6", "F7", "F8", "F9"): + tap(k) + time.sleep(1.5) + print(" page %d: bank pressed" % page) + # and the always-active coolant controls for comparison + tap("C"); tap("H") + time.sleep(3) +finally: + proc.terminate() + time.sleep(1) + +t = open(LOG, errors="replace").read() if os.path.exists(LOG) else "" +gensel = re.findall(r"^\[gensel\].*$", t, re.M) +unh = re.findall(r"^\[msg\] UNHANDLED.*$", t, re.M) +print("\n=============== RESULT ===============") +print("[gensel] lines (Sensor fix working):", len(gensel)) +for l in gensel[:10]: + print(" ", l[:130]) +print("\n[msg] UNHANDLED pairs caught by the new trace:", len(unh)) +for l in unh[:14]: + print(" ", l[:130]) +print("\nVERDICT:", + "PASS -- Avionics messages now reach a handler" if any("Avionics" in l or "Sensor" in l for l in gensel) + else ("handlers ran but not on Avionics" if gensel else "no [gensel] -- still not reaching a handler"))