Merge origin/master: the D1 relay/operator line + input remap meet the glass layer

33 master commits in (relay TCP/UDP + PySide6 operator console, CONTROLS.MAP
+XInput binding engine, camera seats, torso pitch aim, sign fixes, the 1995
manual, version stamping, 18-mech certification).  Conflicts: .gitignore +
CLAUDE.md router rows (combined).

SEMANTIC RECONCILIATION (the one real overlap): masters btinput binding
engine (ungated, CONTROLS.MAP) and the glass PadRIO (gated, bindings.txt)
would both read the keyboard/pad in a glass+PAD session.  btinput now joins
the stand-down convention: BTInputPoll yields (and BTInputSuppressKey claims
NOTHING, so authentic hotkeys flow) when an operational cockpit device owns
the input path -- BTRIODevicePresent, BT_KEY_BRIDGE force-override honored,
forced harness exempt.  One input system per mode: btinput on pod/dev
desktops, PadRIO on glass.  The mechmppr/mech4 bridge merges composed clean
(masters negate-once sign fix inside our device-gated bridge).  The D1 relay
keeps its own raw sockets by design (an alternative LAN wire; Steam and
relay are separate modes).

Verified post-merge: all 3 configs build; glass boots with [input] binding
engine standing down + PadRIO owning input (30 ticks); pod forced-walk
speedDemand=61.501 with btinput ACTIVE; 2-node loopback MP full 31/31
mission, 76/76 ticks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-18 17:46:41 -05:00
co-authored by Claude Fable 5
48 changed files with 7234 additions and 141 deletions
+9 -3
View File
@@ -18,6 +18,7 @@
// #define BT_HEAPCHECK // enable to validate the heap on every alloc (heap-corruption hunt)
#include <windows.h>
#include <shellapi.h> // CommandLineToArgvW
#include <btversion.h> // generated: 4.11.<commit count> (<hash>)
#include <crtdbg.h> // _CrtSetDbgFlag (heap validation, gated by BT_HEAPCHECK)
#include <fstream>
#include <iostream>
@@ -448,7 +449,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
fclose(file);
}
std::cout << "BattleTech v4.10 (reconstructed port)" << std::endl << std::flush;
// Version scheme: 4.10 = the 1995 arcade release; 4.11 = this win32
// reconstruction; build = git commit count, hash pins exact source
// ('+' = built from an uncommitted tree). Stamped by tools/btversion.cmake.
std::cout << "BattleTech " << BT_VERSION_FULL
<< " (win32 reconstruction of Tesla 4.10)" << std::endl << std::flush;
// CPU pin (timing stability). BT_AFFINITY overrides the mask; =0 disables --
// required for multi-instance runs (two instances pinned to core 0 starve
@@ -550,9 +555,10 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
if (np != 0)
sscanf(np + 4, " %d", &netPort);
if (netPort > 0)
swprintf(winTitle, 64, L"BattleTech \x2014 node %d", netPort);
swprintf(winTitle, 64, L"BattleTech %S \x2014 node %d",
BT_VERSION_STRING, netPort);
else
wcscpy(winTitle, L"BattleTech");
swprintf(winTitle, 64, L"BattleTech %S", BT_VERSION_STRING);
}
hWnd = CreateWindowEx(0, L"MainWndClass", winTitle, WS_OVERLAPPEDWINDOW,
0, 0, wr.right - wr.left, wr.bottom - wr.top,
+33
View File
@@ -247,6 +247,39 @@ void
}
}
// BT port diag (BT_CAM_LOG, 1 Hz): why is/isn't the director tracking
if (getenv("BT_CAM_LOG"))
{
static float s_acc = 0.0f; s_acc += time_slice;
if (s_acc >= 1.0f)
{
s_acc = 0.0f;
DEBUG_STREAM << "[cam] director pick: player=" << (void*)player
<< " vehicle=" << (void*)vehicle
<< " goal=" << (void*)GetGoalEntity()
<< " timer=" << timeLeftOnPlayer;
// who IS in the Players group here?
EntityGroup *pg =
application->GetEntityManager()->FindGroup("Players");
if (pg)
{
DEBUG_STREAM << " group:[";
Player *p;
ChainIteratorOf<Node*> it(pg->groupMembers);
while ((p = (Player *)it.ReadAndNext()) != NULL)
{
DEBUG_STREAM << " bmp=" << p->playerBitmapIndex
<< (p->GetPlayerVehicle() ? "+veh" : "-veh");
}
DEBUG_STREAM << " ]";
}
else
{
DEBUG_STREAM << " group:MISSING";
}
DEBUG_STREAM << "\n" << std::flush;
}
}
if (player == 0 || vehicle == 0)
{
return;
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
//#############################################################################
// btinput.hpp -- the desktop input binding engine (CONTROLS.MAP + XInput).
//
// The pod's controls are a fixed physical device set (stick, throttle lever,
// pedals, ~44 lamp buttons, keypads) reaching the game through the
// LBE4ControlsManager push model. On a desktop there is no RIO, so this
// engine maps PC keys and an XInput gamepad onto the SAME channels:
//
// axes -> the virtual-controls integrators in mech4.cpp (lever / turn
// stick / torso twist), exactly where the BT_KEY_BRIDGE writes
// the analog values the RIO would have produced
// buttons -> real buttonGroup[addr] ForceUpdates (aux banks, hotbox,
// panic, throttle-head...) so the AUTHENTIC handlers fire;
// the four joystick fire buttons (0x40/45/46/47) drive the
// bring-up fire channels instead (see btinput.cpp note)
// keypads -> keyboardGroup[KeyboardPilot/KeyboardExternal] key values
// pckey -> keyboardGroup[KeyboardPC] (any authentic '+'-style hotkey)
// actions -> the port-side dev controls (view toggle, all-stop, ...)
//
// Bindings load from content/CONTROLS.MAP at first poll; if the file is
// absent the compiled-in WASD-classic profile (identical text) applies.
// Keys claimed by a binding are SUPPRESSED from the legacy WM_CHAR/-KEYUP
// keyboard feed (BTInputSuppressKey), ending the historic double-dispatch
// (W = drive AND pilot-select, F5 keyup aliasing to 't' = pilot 3, ...).
//#############################################################################
#ifndef BTINPUT_HPP
#define BTINPUT_HPP
struct BTInputState
{
// analog demands (consumed by the mech4 virtual-controls block)
float leverRate; // throttle lever sweep, per second (+fwd/-back)
float turnTarget; // turn-stick deflection target [-1,1]
int turnActive; // any turn input held (else spring-center)
float twistTarget; // torso-twist deflection target [-1,1]
int twistActive;
float elevTarget; // torso-elevation (pitch) target [-1,1]
int elevActive;
int turnAbsolute; // pad stick overrides the integrator outright
int twistAbsolute;
int elevAbsolute;
// the four pod joystick fire buttons (levels)
int fireTrigger; // 0x40 Main
int firePinky; // 0x45
int fireThumbLow; // 0x46 Middle
int fireThumbHigh; // 0x47 Upper
// port-side actions (levels; consumers keep their own edge detectors)
int viewToggle;
int lookBehind;
int allStop;
int modeCycle;
int valve;
int configHold;
int genSel; // 0 = none, 4..7 = Generator A..D, 8 = reconnect
};
extern BTInputState gBTInput;
// Poll everything (keys + pad), evaluate the binding table, fire button /
// keypad edges into the controls manager. Call ONCE per sim frame from the
// player drive block. Handles the foreground-focus guard (BT_KEY_NOFOCUS)
// internally.
void BTInputPoll(float dt);
// The legacy keyboard feed asks before dispatching a typed character /
// key-up value: nonzero = this key is claimed by a binding, swallow it.
extern "C" int BTInputSuppressKey(unsigned int key_value, int is_char);
#endif // BTINPUT_HPP
+4
View File
@@ -1074,6 +1074,10 @@ void
playerVehicle->Dispatch(&player_link_message);
playerVehicle->DispatchToReplicants(&player_link_message);
if (getenv("BT_CAM_LOG"))
DEBUG_STREAM << "[cam] InitializePlayerLink dispatched (player "
<< GetEntityID() << " -> vehicle "
<< playerVehicle->GetEntityID() << ")\n" << std::flush;
Check_Fpu();
}
+33
View File
@@ -546,6 +546,14 @@ Reservoir::Reservoir(
// *this = &PTR_LAB_0050ecd4;
squirtEfficiency = 0.5f; // this[0x8b] @0x22C
thermalCapacity = subsystem_resource->coolantCapacity; // this[0x4a] @0x128 (coolantCapacity reuses thermalCapacity)
// SPEC AUDIT (BT_SPEC_LOG): streamed CoolantCapacity vs the manual's
// "Reservoir Size (Liters)".
if (getenv("BT_SPEC_LOG"))
{
DEBUG_STREAM << "[spec] reservoir: coolantCapacity="
<< subsystem_resource->coolantCapacity << "\n" << std::flush;
}
coolantSquirtMass = subsystem_resource->coolantSquirtMass; // this[0x89] @0x224 = res +0x100
coolantLevel = thermalCapacity; // this[0x4b] @0x12C
coolantFlowScale = 0.0f; // this[0x57] @0x15C (== "word57")
@@ -765,6 +773,23 @@ AggregateHeatSink::AttributeIndexSet&
return attributeIndex;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// BTSetBankAmbientTemperature -- bridge for Mech::PlayerLinkMessageHandler
// (@0049f624 writes bank+0x1d4 = mission temperature): mech.cpp cannot see
// AggregateHeatSink (local-stub collision), so the write lands here where the
// type is complete. This is the AUTHENTIC ambientTemperature writer -- the
// old "frozen 300" note stands corrected (the ctor default only holds until
// the mission's PlayerLink pass runs on the master).
//
void
BTSetBankAmbientTemperature(Subsystem *bank, Scalar temperature)
{
if (bank != NULL)
{
((AggregateHeatSink *)bank)->ambientTemperature = temperature;
}
}
//#############################################################################
// Shared Data Support
//
@@ -806,6 +831,14 @@ AggregateHeatSink::AggregateHeatSink(
heatSinkCount = subsystem_resource->heatSinkCount; // this[0x74] @0x1D0 = res +0xFC
ambientTemperature = 300.0f; // this[0x75] @0x1D4 (_DAT_004ae89c)
// SPEC AUDIT (BT_SPEC_LOG): the streamed HeatSinkCount vs the manual's
// "Number of Heat Sinks" per-mech stat.
if (getenv("BT_SPEC_LOG"))
{
DEBUG_STREAM << "[spec] heatsinks: count=" << heatSinkCount
<< "\n" << std::flush;
}
// task #9 (the ambient radiator lands): the binary ctor @4ae8d0 scales the
// bank's conductance by 0.1 x HeatSinkCount (_DAT_004ae974 float80 = 0.1
// byte-verified; madcat count 14 -> x1.4) and installs the RADIATOR
+53
View File
@@ -80,6 +80,12 @@
// for the same reason.
#include "dmgtable.hpp"
extern Scalar BTGetTorsoTwist(Subsystem *torso); // torso.cpp (Torso complete there)
// heat-bank ambient bridge (heatfamily_reslice.cpp, AggregateHeatSink complete
// there) -- mech.cpp cannot include the subsystem headers (local-stub collision)
extern void BTSetBankAmbientTemperature(Subsystem *bank, Scalar temperature);
#if !defined(BTMSSN_HPP)
# include <btmssn.hpp> // BTMission::GetMissionTemperature (PlayerLink)
#endif
#if !defined(APP_HPP)
# include <app.hpp>
#endif
@@ -454,6 +460,7 @@ const Receiver::HandlerEntry
Mech::MessageHandlerEntries[] =
{
MESSAGE_ENTRY(Mech, TakeDamage),
MESSAGE_ENTRY(Mech, PlayerLink),
};
Receiver::MessageHandlerSet
@@ -609,6 +616,52 @@ Scalar
return tmpl ? tmpl->maxY : 0.0f; // ResolveHit guards <= 0
}
//
// Mech override of Entity::PlayerLinkMessageHandler (binary @0049f624). The
// engine base resolves the FORWARD link (mech->playerLink @0x190 from the
// message's playerID); BT then:
// 1. sets the REVERSE link player->playerVehicle = this -- on EVERY node,
// replicants included. This is how the 1995 camera director
// (BeABTDirector's GetPlayerByIndex -> GetPlayerVehicle) and the
// scoreboard find a REMOTE player's mech; without it a spectator seat
// parks forever ("no goal entity", camera-seat bring-up 2026-07-18).
// 2. clears the player's NonScoringPlayerFlag (0x4000 == bit 14): a pilot
// with a vehicle is a SCORING player -- this is what admits them to the
// ranking pass (Player::IsScoringPlayer gates RankPlayers).
// 3. master only: seeds the heat bank's ambientTemperature (@0x1d4 on the
// 0xBBE AggregateHeatSink at mech+0x7dc) from the mission's [mission]
// temperature (BTMission+0xf4) -- resolving the heat family's
// "frozen 300" deviation note: it was never frozen, THIS writes it.
//
void
Mech::PlayerLinkMessageHandler(PlayerLinkMessage *message)
{
Check(message);
Entity::PlayerLinkMessageHandler(message); // FUN_0041fd18: mech+0x190
Player *owner = GetPlayerLink();
if (getenv("BT_CAM_LOG"))
DEBUG_STREAM << "[cam] Mech::PlayerLink inst="
<< (int)(GetInstance() == ReplicantInstance)
<< " owner=" << (void*)owner << "\n" << std::flush;
if (owner != NULL)
{
owner->SetPlayerVehicle(this); // player+0x1fc = this
owner->simulationFlags &= ~Player::NonScoringPlayerFlag; // &= ~0x4000
if (GetInstance() != ReplicantInstance && sensorSubsystem != NULL)
{
Mission *mission = owner->GetMission();
if (mission != NULL)
{
BTSetBankAmbientTemperature(sensorSubsystem,
((BTMission *)mission)->GetMissionTemperature());
}
}
}
Check_Fpu();
}
//
// Mech override of Entity::TakeDamageMessageHandler (binary @0x4a037a, the two
// call sites into the glue @0x49ed0c). An unaimed hit arrives with
+8
View File
@@ -1165,6 +1165,14 @@ protected:
// Mech override of Entity::TakeDamageMessageHandler: resolve an unaimed
// (invalidDamageZone) hit's zone via the cylinder table, then base-route.
void TakeDamageMessageHandler(TakeDamageMessage *message);
// Mech override of Entity::PlayerLinkMessageHandler (@0049f624): the
// base resolves mech->player; BT adds the REVERSE link
// player->playerVehicle = this on EVERY node (replicants included --
// this is how the camera director / scoreboard find a remote player's
// mech), clears the player's NonScoringPlayerFlag (a pilot with a
// vehicle SCORES/ranks), and on the master seeds the heat bank's
// ambientTemperature from the mission's [mission] temperature.
void PlayerLinkMessageHandler(PlayerLinkMessage *message);
// --- damage-routing support (mechdmg / mech4) -----------------------
// Typed access to the inherited Entity::damageZones[] (engine stores DamageZone*;
+144 -84
View File
@@ -155,6 +155,7 @@
#include <messmgr.hpp> // SubsystemMessageManager (task #7 consolidated delivery)
#include <mechweap.hpp> // MechWeapon::GetExplosionResourceID (per-round detonation)
#include <mislanch.hpp> // MissileLauncher::ClassDerivations (splash-radius gate)
#include "btinput.hpp" // the CONTROLS.MAP + XInput binding engine
#if !defined(PLAYER_HPP)
# include <player.hpp> // Player::VehicleDeadMessage -- the death->respawn notification (task #52)
#endif
@@ -653,6 +654,8 @@ static int gBTPinkyKey = 0; // key '4' = the pod's 4th fire butto
int gBTModeCycle = 0; // 'M' edge: cycle the control mode (mapper consumes)
int gBTLookBehind = 0; // 'V' held: the pod's rear-view button (task #68)
float gBTTwistAxis = 0.0f; // Q/E torso-twist deflection (assisted-mode stick X)
float gBTElevAxis = 0.0f; // R/F torso-elevation (pitch aim, stick Y)
int gBTElevRecenter = 0; // X edge: zero the pitch axis
int gBTTorsoRecenter = 0; // 'X' edge: pulse the authentic torso recenter (mapper consumes)
static int gBTConfigKey = 0; // task #6: HOLD 'G' = the weapon-configure button
static int gBTGenSelKey = 0; // task #12: F5..F8 = SelectGeneratorA..D, F9 = mode toggle
@@ -2480,7 +2483,11 @@ void
if (s_replLog >= 1.0f)
{
s_replLog = 0.0f;
DEBUG_STREAM << "[repl] mech " << (long)GetEntityID()
// print host:local -- the (long) cast collapsed distinct
// hosts' mechs onto one number (EntityID::operator int
// returns localID only) and faked "missing replicants"
// during the first 4-pod session analysis (2026-07-18)
DEBUG_STREAM << "[repl] mech " << GetEntityID()
<< " pos=(" << localOrigin.linearPosition.x << ", "
<< localOrigin.linearPosition.y << ", "
<< localOrigin.linearPosition.z << ")"
@@ -2507,6 +2514,28 @@ void
AuthenticGroundAndCollide(dt, preMovePos);
}
// SPEC AUDIT (BT_SPEC_LOG): once, after all subsystems are built + linked,
// dump each subsystem -> coolant loop (BTCoolingLoopFrame resolves the
// linked condenser's number, the manual's "Loop N"). Diffs the per-mech
// COOLANT LOOPS table vs the 1995 manual (structural, less tuning-prone).
if (isPlayerMech && getenv("BT_SPEC_LOG"))
{
static int s_specDumped = 0;
if (!s_specDumped && dt > 0.0001f)
{
s_specDumped = 1;
extern int BTCoolingLoopFrame(void *subsystem);
for (int i = 0; i < GetSubsystemCount(); ++i)
{
Subsystem *s = GetSubsystem(i);
if (s == 0) continue;
DEBUG_STREAM << "[spec] loop " << BTCoolingLoopFrame(s)
<< " <- " << (s->GetName() ? s->GetName() : "?")
<< "\n" << std::flush;
}
}
}
if (isPlayerMech && dt > 0.0001f && dt < 0.5f) // ignore zero / huge (stall) slices
{
// --- VIRTUAL CONTROLS (dev keyboard -> the pod's analog inputs) --------
@@ -2527,76 +2556,45 @@ void
const float kStickRate = 2.5f; // stick deflect per second
const float kStickCenterRate = 5.0f; // stick auto-center per second
// POLL the real key state. WndProc key messages are unusable: the
// POLL the input state. WndProc key messages are unusable: the
// engine keyboard reader (L4CTRL.cpp:1506) GetMessage()s every
// WM_KEYUP/WM_CHAR out of the queue for its key-command channel, so
// only KEYDOWNs ever reached the WndProc and key state latched on
// forever. GetAsyncKeyState is immune; the foreground guard keeps
// background typing from driving the mech.
// WM_KEYUP/WM_CHAR out of the queue for its key-command channel,
// so the binding engine polls GetAsyncKeyState (+ XInput); the
// foreground guard (BT_KEY_NOFOCUS override) lives inside it.
{
typedef short (__stdcall *AsyncFn)(int);
typedef void *(__stdcall *FgFn)(void);
typedef unsigned long (__stdcall *WtpFn)(void *, unsigned long *);
typedef unsigned long (__stdcall *PidFn)(void);
static AsyncFn pAsync = 0; static FgFn pFg = 0;
static WtpFn pWtp = 0; static PidFn pPid = 0;
if (pAsync == 0)
{
HMODULE u = GetModuleHandleA("user32.dll");
HMODULE k = GetModuleHandleA("kernel32.dll");
pAsync = (AsyncFn)GetProcAddress(u, "GetAsyncKeyState");
pFg = (FgFn)GetProcAddress(u, "GetForegroundWindow");
pWtp = (WtpFn)GetProcAddress(u, "GetWindowThreadProcessId");
pPid = (PidFn)GetProcAddress(k, "GetCurrentProcessId");
}
int focused = 0;
if (pFg && pWtp && pPid)
{
void *fg = pFg();
unsigned long fgPid = 0;
if (fg) pWtp(fg, &fgPid);
focused = (fgPid == pPid());
}
// TEST HOOK (BT_KEY_NOFOCUS=1): accept keys without foreground focus --
// automation harnesses (SendInput from a background shell) can't grant
// real foreground; interactive play never needs this.
static int sNoFocus = -1;
if (sNoFocus < 0) { const char *nf = getenv("BT_KEY_NOFOCUS"); sNoFocus = (nf && *nf == '1') ? 1 : 0; }
if (sNoFocus) focused = 1;
if (pAsync)
{
const int dn = 0x8000;
gBTDrive.keyFwd = focused && ((pAsync('W') | pAsync(0x26 /*VK_UP*/)) & dn) ? 1 : 0;
gBTDrive.keyBack = focused && ((pAsync('S') | pAsync(0x28 /*VK_DOWN*/)) & dn) ? 1 : 0;
gBTDrive.keyLeft = focused && ((pAsync('A') | pAsync(0x25 /*VK_LEFT*/)) & dn) ? 1 : 0;
gBTDrive.keyRight = focused && ((pAsync('D') | pAsync(0x27 /*VK_RIGHT*/)) & dn) ? 1 : 0;
// WEAPON GROUPS (task #43, KEYBOARD only per user): three fire
// channels like three pod buttons -- 1/SPACE = lasers, 2 = PPCs,
// 3/CTRL = missiles. (Interim; the authentic system is the
// ConfigureMappables/ChooseButton mapper channels.)
gBTLaserKey = focused && ((pAsync('1') | pAsync(0x20 /*VK_SPACE*/)) & dn) ? 1 : 0;
gBTPPCKey = focused && (pAsync('2') & dn) ? 1 : 0;
gBTMissileKey = focused && ((pAsync('3') | pAsync(0x11 /*VK_CONTROL*/)) & dn) ? 1 : 0;
// The pod's 4TH fire button (Pinky, 0x45) -- previously
// unmapped on desktop, so any weapon the authored groups
// put there (the Avatar's NARC etc.) was unreachable.
gBTPinkyKey = focused && (pAsync('4') & dn) ? 1 : 0;
// task #6: HOLD 'G' opens the config session on the selected
// weapon (BT_CONFIG_SLOT, default: the first weapon); while
// held, the fire keys TOGGLE its group membership.
gBTConfigKey = focused && (pAsync('G') & dn) ? 1 : 0;
// task #12: F5..F8 assign the selected weapon to Generator
// A..D; F9 toggles Manual/Auto reconnect.
gBTGenSelKey =
(focused && (pAsync(0x74 /*F5*/) & dn)) ? 4
: (focused && (pAsync(0x75 /*F6*/) & dn)) ? 5
: (focused && (pAsync(0x76 /*F7*/) & dn)) ? 6
: (focused && (pAsync(0x77 /*F8*/) & dn)) ? 7
: (focused && (pAsync(0x78 /*F9*/) & dn)) ? 8
: 0;
// task #13: 'C' cycles the coolant valve (BT_VALVE_SLOT
// picks the condenser roster slot; default = Condenser1).
gBTValveKey = focused && (pAsync('C') & dn) ? 1 : 0;
// INPUT REMAP (2026-07-18): the raw GetAsyncKeyState reads
// moved behind the binding engine (btinput.cpp) -- keys +
// XInput pad resolve through content/CONTROLS.MAP (built-in
// WASD-classic profile when the file is absent). RIO lamp
// buttons / keypads / authentic pckey hotkeys are emitted
// into the controls manager inside the poll; the fire
// channels, actions, and analog demands publish through
// gBTInput. Everything below the reads (the lever/stick/
// twist integrators, detents, edge latches) is UNCHANGED.
BTInputPoll(dt);
gBTDrive.keyFwd = gBTInput.leverRate > 0.0001f ? 1 : 0;
gBTDrive.keyBack = gBTInput.leverRate < -0.0001f ? 1 : 0;
gBTDrive.keyLeft = (gBTInput.turnActive && gBTInput.turnTarget < 0.0f) ? 1 : 0;
gBTDrive.keyRight = (gBTInput.turnActive && gBTInput.turnTarget > 0.0f) ? 1 : 0;
// WEAPON GROUPS (task #43): the four pod joystick fire
// buttons -> the bring-up fire channels (0x40 Main = laser
// group, 0x46 ThumbLow = PPCs, 0x47 ThumbHigh = missiles,
// 0x45 Pinky = the 4th group). (Interim; the authentic
// system is the ConfigureMappables/ChooseButton mapper.)
gBTLaserKey = gBTInput.fireTrigger;
gBTPPCKey = gBTInput.fireThumbLow;
gBTMissileKey = gBTInput.fireThumbHigh;
gBTPinkyKey = gBTInput.firePinky;
// task #6: HOLD the ConfigHold action opens the config
// session on the selected weapon; while held, the fire
// keys TOGGLE its group membership.
gBTConfigKey = gBTInput.configHold;
// task #12: Generator1-4 actions assign the selected weapon
// to Generator A..D; Reconnect toggles Manual/Auto.
gBTGenSelKey = gBTInput.genSel;
// task #13: the Valve action cycles the coolant valve.
gBTValveKey = gBTInput.valve;
// TORSO CONTROLS (2026-07-13): 'M' cycles the control mode
// (Basic -> Standard -> Veteran -- the pod console button,
// CycleControlModeMessageHandler); Q/E deflect the torso
@@ -2604,19 +2602,23 @@ void
// become the pedals). Ramped like the turn stick.
{
static int sPrevM = 0;
const int mNow = focused && (pAsync('M') & dn) ? 1 : 0;
const int mNow = gBTInput.modeCycle;
if (mNow && !sPrevM) gBTModeCycle = 1; // edge -> one cycle
sPrevM = mNow;
// (task #68) 'V' HELD = look behind (the pod's rear-view
// button; releases back to the forward view). Rear-mounted
// (task #68) LookBehind action HELD = the pod's rear-view
// button (releases back to the forward view). Rear-mounted
// weapons (blackhawk/owens back racks) fire only in it.
gBTLookBehind = focused && (pAsync('V') & dn) ? 1 : 0;
const int tw = (focused && (pAsync('E') & dn) ? 1 : 0)
- (focused && (pAsync('Q') & dn) ? 1 : 0);
gBTLookBehind = gBTInput.lookBehind;
static float sTwist = 0.0f;
if (tw != 0)
if (gBTInput.twistAbsolute)
{
sTwist += tw * kStickRate * dt;
// pad stick: the axis IS the stick position (the
// spring is in the physical stick, not software)
sTwist = gBTInput.twistTarget;
}
else if (gBTInput.twistActive)
{
sTwist += gBTInput.twistTarget * kStickRate * dt;
if (sTwist > 1.0f) sTwist = 1.0f;
if (sTwist < -1.0f) sTwist = -1.0f;
}
@@ -2642,26 +2644,76 @@ void
// edge detector from the drive all-stop below -- both
// fire on the same press.
static int sPrevXT = 0;
const int xtNow = focused && (pAsync('X') & dn) ? 1 : 0;
const int xtNow = gBTInput.allStop;
if (xtNow && !sPrevXT)
{
sTwist = 0.0f;
gBTTorsoRecenter = 1; // mapper consumes -> CommandRecenter()
{
extern int gBTElevRecenter;
gBTElevRecenter = 1; // pitch axis zeroes below
}
}
sPrevXT = xtNow;
gBTTwistAxis = sTwist;
// TORSO ELEVATION (pitch aim -- the pod stick's Y
// axis; every 1995 control mode routes it to
// Torso::SetAnalogElevationAxis). Same integrator
// model as the twist: keys walk the axis, release
// springs the RATE to 0 (position holds, the sim's
// vertical limits clamp); a pad stick is absolute.
{
extern float gBTElevAxis;
static float sElev = 0.0f;
if (gBTInput.elevAbsolute)
{
sElev = gBTInput.elevTarget;
}
else if (gBTInput.elevActive)
{
sElev += gBTInput.elevTarget * kStickRate * dt;
if (sElev > 1.0f) sElev = 1.0f;
if (sElev < -1.0f) sElev = -1.0f;
}
else if (sElev != 0.0f)
{
const float estep = kStickCenterRate * dt;
if (sElev > estep) sElev -= estep;
else if (sElev < -estep) sElev += estep;
else sElev = 0.0f;
}
extern int gBTElevRecenter; // set by the X edge above
if (gBTElevRecenter)
{
gBTElevRecenter = 0;
sElev = 0.0f; // X recenters pitch too
}
gBTElevAxis = sElev;
if (getenv("BT_INPUT_LOG"))
{
static float s_eacc = 0.0f; s_eacc += dt;
if (s_eacc >= 1.0f) { s_eacc = 0.0f;
DEBUG_STREAM << "[input] elev tgt="
<< gBTInput.elevTarget << " act="
<< gBTInput.elevActive << " axis="
<< sElev << "\n" << std::flush; }
}
}
}
// gBTDrive.fire = "any weapon trigger down" (feeds the bring-up
// damage dispatcher + the beam-visual keepalive)
gBTDrive.fire = (gBTLaserKey || gBTPPCKey || gBTMissileKey || gBTPinkyKey) ? 1 : 0;
static int sPrevX = 0;
const int xNow = focused && (pAsync('X') & dn) ? 1 : 0;
const int xNow = gBTInput.allStop;
if (xNow && !sPrevX) gBTDrive.allStop = 1; // edge -> one all-stop
sPrevX = xNow;
// V: toggle the view between the authentic COCKPIT eyepoint
// (the pod's only view) and the external chase camera.
// ViewToggle action: the authentic COCKPIT eyepoint (the
// pod's only view) <-> the external chase camera. Split
// from LookBehind (2026-07-18): the old shim had BOTH on
// 'V', so holding rear-view also flipped the camera.
static int sPrevV = 0, sViewInside = 0;
const int vNow = focused && (pAsync('V') & dn) ? 1 : 0;
const int vNow = gBTInput.viewToggle;
if (vNow && !sPrevV)
{
sViewInside = !sViewInside;
@@ -2849,7 +2901,9 @@ void
}
}
}
float sweep = ((fwd ? 1.0f : 0.0f) - (back ? 1.0f : 0.0f)) * kLeverRate * dt;
// the configured rate (CONTROLS.MAP `axis Throttle rate n`) IS the
// sweep speed; kLeverRate above stays as the documented default
float sweep = gBTInput.leverRate * dt;
if (sweep != 0.0f && !sDetent)
{
const float prev = sLever;
@@ -2866,8 +2920,13 @@ void
if (sLever < -1.0f) sLever = -1.0f;
}
const float want = (gBTDrive.keyRight ? 1.0f : 0.0f) - (gBTDrive.keyLeft ? 1.0f : 0.0f);
if (want != 0.0f)
const float want = gBTInput.turnActive ? gBTInput.turnTarget : 0.0f;
if (gBTInput.turnAbsolute)
{
// pad stick: the axis IS the stick position (physically sprung)
sStick = want;
}
else if (want != 0.0f)
{
sStick += want * kStickRate * dt;
if (sStick > 1.0f) sStick = 1.0f;
@@ -3184,6 +3243,7 @@ void
<< " pre=" << preThrottle
<< " rev=" << mppr->reverseThrust
<< " stickX=" << mppr->stickPosition.x
<< " stickY=" << mppr->stickPosition.y
<< " -> speedDemand=" << mppr->speedDemand
<< " turnDemand=" << mppr->turnDemand
<< " mode=" << mppr->controlMode
+48 -4
View File
@@ -641,7 +641,13 @@ void
{
float key_throttle = gBTDrive.forced
? gBTDrive.forcedThrottle : gBTDrive.throttle;
float key_turn = gBTDrive.forced ? 0.0f : gBTDrive.turn;
// PORT SIGN (user live-verified 2026-07-18: left arrow turned the
// mech RIGHT): the sim's positive turnDemand is CCW (LEFT) -- math
// convention, same as the torso twist. On the pod the RIO Ranger
// owned the hardware sign; the desktop bridge must negate so that
// input-right (positive) = turn RIGHT. Forced/BT_GOTO harness
// demands are sim-frame already and stay un-negated.
float key_turn = gBTDrive.forced ? 0.0f : -gBTDrive.turn;
// Headless harness (forced mode only): BT_FORCE_TURN holds a steering
// demand; BT_FORCE_SECONDS releases the forced throttle after n
// sim-seconds while KEEPING the turn (the turn-in-place repro).
@@ -760,6 +766,14 @@ void
gBTModeCycle = 0;
CycleControlModeNow();
}
// TORSO ELEVATION (pitch): the pod stick's Y axis -- every
// control mode routes stickPosition.y into
// Torso::SetAnalogElevationAxis below, so the bridge feeds
// it in BOTH branches (keys R/F or a pad stick via btinput).
{
extern float gBTElevAxis;
stickPosition.y = gBTElevAxis;
}
if (controlMode == BasicMode)
{
stickPosition.x = key_turn;
@@ -767,7 +781,14 @@ void
}
else
{
stickPosition.x = gBTTwistAxis; // the torso axis
// PORT SIGN: positive twist is CCW (left) like the turn;
// negate so stick/E-key right = torso right (manual p8:
// "pulling your joystick to the right torso twists your
// 'Mech to the right"). The earlier "MadCat twist was
// correct" call was based on the same misread screenshot
// forensics as the turn -- the user's live left-arrow
// test invalidated both.
stickPosition.x = -gBTTwistAxis; // the torso axis
pedalsPosition = key_turn; // A/D = the pedals
// 'X' recenter pulse (2026-07-13): one-frame centerCommand
// -> the sim arms recenterActive and Recenter (@004b6918)
@@ -792,7 +813,8 @@ void
}
}
}
stickPosition.y = 0.0f;
// (stickPosition.y no longer zeroed here -- the bridge above
// feeds the elevation axis every bridged frame, 2026-07-18)
}
}
@@ -863,6 +885,18 @@ void
{
torso->SetAnalogElevationAxis(stick_y); // raw torso+0x1f4 (500)
torso->SetAnalogTwistAxis(0.0f); // raw torso+0x1f0
if (getenv("BT_INPUT_LOG"))
{
static float s_eacc2 = 0.0f; s_eacc2 += time_slice;
if (s_eacc2 >= 0.5f) { s_eacc2 = 0.0f;
YawPitchRoll ypr;
ypr = mech->localOrigin.angularPosition;
DEBUG_STREAM << "[input] mppr elev in=" << stick_y
<< " torsoElev=" << torso->CurrentElevation()
<< " vel=" << torso->ElevationVelocity()
<< " mechYaw=" << (Scalar)ypr.yaw
<< "\n" << std::flush; }
}
}
if (cockpit)
{
@@ -887,6 +921,8 @@ void
//
if (torso == 0 || !torso->GetHorizontalEnabled()) // raw torso+0x250 == 0
{
// (sign handled ONCE at the key bridge: positive sim twist/slew
// = CCW/left; the bridge negates so input-right = right)
if (cockpit) cockpit->SetFreeAimSlew(stick_x); // raw cockpit+0x28c
}
else
@@ -894,6 +930,14 @@ void
torso->SetAnalogTwistAxis(stick_x); // raw torso+0x1f0
}
if (torso) torso->SetAnalogElevationAxis(stick_y); // raw torso+0x1f4
if (torso && getenv("BT_INPUT_LOG"))
{
static float s_tacc = 0.0f; s_tacc += time_slice;
if (s_tacc >= 0.5f) { s_tacc = 0.0f;
DEBUG_STREAM << "[input] twist in=" << stick_x
<< " currentTwist=" << torso->CurrentTwist()
<< "\n" << std::flush; }
}
turnDemand = (pedal_3 == 0.0f) ? 0.0f : pedal_3;
@@ -914,7 +958,7 @@ void
//
if (torso == 0 || !torso->GetHorizontalEnabled())
{
if (cockpit) cockpit->SetFreeAimSlew(stick_x);
if (cockpit) cockpit->SetFreeAimSlew(stick_x); // (sign at the bridge)
}
else
{
+18
View File
@@ -244,6 +244,24 @@ Torso::Torso(
elevationCenter = verticalLimitTop; // @0x228 = @0x220
elevationHalfBottom = verticalLimitBottom * 0.5f; // @0x22C (_DAT_004b6fbc)
// SPEC AUDIT (BT_SPEC_LOG, 2026-07-18): dump the streamed torso config so
// it can be diffed against the 1995 manual's per-mech stat sheet
// ("Torso speed (deg/sec)" / "Torso Limit (degrees)"). Values printed in
// DEGREES (the manual's units); horizontalRotationPerSecond IS deg/sec in
// the resource, the limits are stored in degrees pre-DegToRad.
if (getenv("BT_SPEC_LOG"))
{
DEBUG_STREAM << "[spec] torso: speed=" << r->horizontalRotationPerSecond
<< " deg/s limitL=" << r->horizontalLimitLeft
<< " limitR=" << r->horizontalLimitRight
<< " (arc=" << (r->horizontalLimitLeft - r->horizontalLimitRight)
<< ") vRate=" << r->verticalRotationPerSecond
<< " vTop=" << r->verticalLimitTop
<< " vBot=" << r->verticalLimitBottom
<< " hEnabled=" << (int)r->torsoHorizontalEnabled
<< "\n" << std::flush;
}
buttonRampActive = 0; // @0x268
buttonRamp = 0.0f;// @0x26C
+4
View File
@@ -171,6 +171,10 @@ class Joint; // engine skeleton node (JOINT.h); the twist target
// Address of the twist scalar (task #56: the binary bt_mech stream tail
// re-points gyro+0x258 to torso+0x1D8; the gyro damage-response reads it).
Scalar* CurrentTwistAddr() { return &currentTwist; }
// Current vertical (pitch) torso aim in radians (binary torso+0x1e4) --
// elevation-axis bring-up diagnostics (2026-07-18).
Scalar CurrentElevation() const { return currentElevation; }
Scalar ElevationVelocity() const { return elevationVelocity; }
protected:
// Resolve a named skeleton node to its live Joint* via the owning Mech
// (the ctor @004b6b0c inlines Mech::ResolveJoint == FUN_00424b60). Defined