From d35a8c8309dac9b0738922ab97113473b3b4f608 Mon Sep 17 00:00:00 2001 From: Dipshet <264011288+Dipshet@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:40:18 +0200 Subject: [PATCH] Add native KB+M support: menus, flight, mouse steering, pad-free play Keyboard/mouse state is injected at the guest XamInput wrapper boundary, so every layer of the input stack sees KB+M exactly as a connected pad, and a real pad keeps working alongside. Menu and flight key sets (AC7-style defaults) switch automatically via a flight-sim heartbeat; the mouse steers pitch/roll with a velocity model, or pans the camera while the camera-control key is held. With no controller connected, a synthetic pad is presented on slot 0. Bindings and mouse feel live in ac6_input.toml (auto-created on first run, hot-reloaded ~1x/s). Everything is off unless ac6_kbm_enabled is set. --- CMakeLists.txt | 1 + src/ac6_backend_fixes/ac6_fps_physics_fix.cpp | 7 + src/ac6_backend_fixes/ac6_kbm_input.cpp | 1330 +++++++++++++++++ 3 files changed, 1338 insertions(+) create mode 100644 src/ac6_backend_fixes/ac6_kbm_input.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ccb52f8..f183b6e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,7 @@ set(AC6RECOMP_SOURCES src/ac6_backend_fixes/ac6_backend_hooks.cpp src/ac6_backend_fixes/ac6_backend_pass_classifier.cpp src/ac6_backend_fixes/ac6_fps_physics_fix.cpp + src/ac6_backend_fixes/ac6_kbm_input.cpp ) if(WIN32) diff --git a/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp b/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp index cf0d6dc0..ee818e27 100644 --- a/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp +++ b/src/ac6_backend_fixes/ac6_fps_physics_fix.cpp @@ -164,11 +164,18 @@ void BlendFieldDelta(uint8_t* base, uint32_t ea, float pre, double ratio, const PPC_EXTERN_FUNC(__imp__rex_sub_823046A0); // flight-model force step PPC_EXTERN_FUNC(__imp__rex_sub_82329B40); // flight-model input shaping +// KB+M context switch (ac6_kbm_input.cpp): the force step is the reliable +// "flight sim is stepping" heartbeat (runs per frame for every aircraft, +// halts when paused / in menus). +void ac6KbmNotifyFlightStep(); + // Flight-model core force step (0x823046A0): rescale the per-frame stepped // longitudinal force/speed command at [this+1320]. PPC_FUNC_IMPL(rex_sub_823046A0) { PPC_FUNC_PROLOGUE(); + ac6KbmNotifyFlightStep(); + const uint32_t self = ctx.r3.u32; const double ratio = StepRatio(); // 1.0 is the exact pass-through value (native cadence, or the fix disabled). diff --git a/src/ac6_backend_fixes/ac6_kbm_input.cpp b/src/ac6_backend_fixes/ac6_kbm_input.cpp new file mode 100644 index 00000000..5ebc4f18 --- /dev/null +++ b/src/ac6_backend_fixes/ac6_kbm_input.cpp @@ -0,0 +1,1330 @@ +// TRUE keyboard+mouse input, injected at the engine's action layer (M1: menus). +// +// The engine digests all input into per-context galib::CGaUserInput instances +// living in the static system singleton (ptr at [0x826E4E54], object +// 0x829E6640). Each instance holds 32 abstract action bits + 32 analog action +// floats, rebuilt every frame by CGaUserInput::Update (rex_sub_82211E28, +// called by the pump 0x821CA940 with f1 = frame delta): +// +// +0xE44 level (held action bits) +0xE48 previous level +// +0xE4C pressed-edge +0xE50 released-edge +// +0xE54 pressed-edge + auto-repeat (+0x1058/+0x105C repeat delay/rate) +// +0xE58/+0xED8/+0xF58/+0xFD8 analog action arrays (32 floats each) +// +// Menus consume instance[1] (singleton+0x256F0): confirm/cancel from the +// pressed-edge word, up/down from the repeat word. Flight consumes +// instance[2] (singleton+0x26854). Per-context keymaps therefore need no mode +// detection: menu keys are injected into [1], flight keys (M2+) into [2] - +// the game's own indirection is the context switch. +// +// Injection: strong-override Update, run the original, then OR keyboard- +// derived bits into the words (edges and auto-repeat computed here from our +// own prev-state, using the instance's OWN repeat delay/rate floats so the +// feel matches the pad exactly). The pad path is untouched: we only ever OR +// (plus one masking rule: a key we still hold masks the spurious released- +// edge the game computes because our bit was in prev but not in its rebuilt +// pad-only level). With ac6_kbm_enabled=false the override tail-calls the +// original and nothing else runs. +// +// Bindings come from ac6_input.toml (next to the exe / working directory), +// hot-reloaded ~1x/second. Missing file = built-in defaults (the approved M0 +// table). Full RE map: docs/re/subsystems/input.md. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +REXCVAR_DEFINE_BOOL(ac6_kbm_enabled, false, "AC6/KBM", + "Enable keyboard+mouse input (default off; set true in ac6recomp.toml)"); +REXCVAR_DEFINE_BOOL(ac6_kbm_log, false, "AC6/KBM", + "Log KBM diagnostics to the game log (info level)"); +REXCVAR_DEFINE_STRING(ac6_kbm_config, "ac6_input.toml", "AC6/KBM", + "Path to the keyboard+mouse binding config"); +REXCVAR_DEFINE_BOOL(ac6_kbm_padless, true, "AC6/KBM", + "Present a synthetic pad on slot 0 when no controller is connected"); + +// The SDK's own mnk virtual-pad driver (input/mnk) - forced off while our +// KB+M is enabled so two keyboard mappers never fight over the same pad. +REXCVAR_DECLARE(bool, mnk_mode); + +namespace { + +using rex::ui::ParseVirtualKey; +using rex::ui::VirtualKey; + +// All [KBM] output goes through the game's standard log (ac6recomp.log): +// operational notices at info, config problems at warn. Visibility follows +// the log_level cvar like every other subsystem. +void KbmLog(const std::string& msg) { + REXLOG_INFO("[KBM] {}", msg); +} + +void KbmWarn(const std::string& msg) { + REXLOG_WARN("[KBM] {}", msg); +} + +// ---- Engine addresses (verified, docs/re/subsystems/input.md) -------------- +constexpr uint32_t kSingletonPtrGlobal = 0x826E4E54; +constexpr uint32_t kInstanceOffsets[4] = {0x2458C, 0x256F0, 0x26854, 0x279B8}; +constexpr uint32_t kMenuInstance = 0x256F0; // instance[1] +constexpr uint32_t kFlightInstance = 0x26854; // instance[2] + +constexpr uint32_t kOffLevel = 0xE44; +constexpr uint32_t kOffPressed = 0xE4C; +constexpr uint32_t kOffReleased = 0xE50; +constexpr uint32_t kOffRepeat = 0xE54; +constexpr uint32_t kOffRepeatDelay = 0x1058; // float: initial auto-repeat delay +constexpr uint32_t kOffRepeatRate = 0x105C; // float: auto-repeat interval +constexpr uint32_t kOffAnalogA = 0xE58; // 32 floats +constexpr uint32_t kOffAnalogC = 0xF58; // 32 floats + +// ---- Menu actions as MIRROR bits -------------------------------------------- +// Round-4 pad ground truth: every context instance has its OWN action-bit +// layout, derived from per-instance binding masks (inst+4+8+a*4, one u32 of +// MIRROR-space button bits per action slot). So keyboard keys are expressed +// as mirror-bit presses (exactly what the pad produces after the remap in the +// mirror refresh) and translated per instance through its live mask table. +// Mirror bits observed: dpad_down=1, dpad_left=2 (so up=0, right=3), A=5, +// X=6, B=7, START=10, BACK=11. +struct ActionDef { + const char* name; + int mirror_bit; +}; +constexpr ActionDef kMenuActions[] = { + {"up", 0}, {"down", 1}, {"left", 2}, {"right", 3}, + {"confirm", 5}, {"cancel", 7}, {"start", 10}, {"back", 11}, +}; +constexpr size_t kNumMenuActions = std::size(kMenuActions); + +// ---- Flight actions (device-level XINPUT effects, active only in flight) ---- +struct FlightActionDef { + const char* name; + uint16_t buttons; // XINPUT wButtons bits to assert + uint8_t lt, rt; // trigger values to assert (max-combined) + int16_t lx, ly; // left-stick deflection to assert (kb overrides pad axis) + int16_t rx, ry; // right-stick (camera) deflection +}; +// Button identities VERIFIED by the M2 round-2 field test: +// A=missile/fire, B=machine gun, X=map/radar, Y=switch target, +// RS-click=change view, LS-click=gear, BACK=change weapon, START=pause. +// Names and order follow AC7's PC keyboard screen (user request); AC6-only +// actions (pause, wingman, gear) append at the end. AC7 actions with no AC6 +// equivalent (flare, radio, highlight-target) are omitted. +constexpr FlightActionDef kFlightActions[] = { + {"pitch_down", 0, 0, 0, 0, 32767, 0, 0}, // Descend (kb fallback; mouse steers) + {"pitch_up", 0, 0, 0, 0, -32767, 0, 0}, // Ascend - stick pull + {"roll_left", 0, 0, 0, -32767, 0, 0, 0}, // Turn Left + {"roll_right", 0, 0, 0, 32767, 0, 0, 0}, // Turn Right + {"yaw_left", 0x0100, 0, 0, 0, 0, 0, 0}, // LB + {"yaw_right", 0x0200, 0, 0, 0, 0, 0, 0}, // RB + {"accelerate", 0, 0, 255, 0, 0, 0, 0}, // RT (was: throttle) + {"decelerate", 0, 255, 0, 0, 0, 0, 0}, // LT (was: brake) + {"fire_machine_gun", 0x2000, 0, 0, 0, 0, 0, 0}, // B + {"fire_missile", 0x1000, 0, 0, 0, 0, 0, 0}, // A - missile / sp weapon + {"change_weapon", 0x0020, 0, 0, 0, 0, 0, 0}, // BACK + // Mode key, no pad effect: while held the mouse drives the camera (right + // stick) instead of pitch/roll - AC7's "Camera Control Key". + {"camera_control", 0, 0, 0, 0, 0, 0, 0}, + {"camera_up", 0, 0, 0, 0, 0, 0, 32767}, + {"camera_down", 0, 0, 0, 0, 0, 0, -32767}, + {"camera_left", 0, 0, 0, 0, 0, -32767, 0}, + {"camera_right", 0, 0, 0, 0, 0, 32767, 0}, + {"autopilot", 0x0300, 0, 0, 0, 0, 0, 0}, // LB+RB held = AC6 autopilot + {"high_g", 0, 255, 255, 0, 0, 0, 0}, // = AC7 "Accelerate + Decelerate" + {"change_view", 0x0080, 0, 0, 0, 0, 0, 0}, // RS click + {"switch_radar_map", 0x4000, 0, 0, 0, 0, 0, 0}, // X - map + {"switch_targets", 0x8000, 0, 0, 0, 0, 0, 0}, // Y + {"pause", 0x0010, 0, 0, 0, 0, 0, 0}, // START + {"wingman_up", 0x0001, 0, 0, 0, 0, 0, 0}, + {"wingman_down", 0x0002, 0, 0, 0, 0, 0, 0}, + {"wingman_left", 0x0004, 0, 0, 0, 0, 0, 0}, + {"wingman_right", 0x0008, 0, 0, 0, 0, 0, 0}, + {"gear", 0x0040, 0, 0, 0, 0, 0, 0}, // LS click - landing gear +}; +constexpr size_t kNumFlightActions = std::size(kFlightActions); + +size_t CameraControlAction() { + static const size_t idx = [] { + for (size_t i = 0; i < kNumFlightActions; ++i) { + if (std::strcmp(kFlightActions[i].name, "camera_control") == 0) return i; + } + return size_t{0}; + }(); + return idx; +} + +struct MouseConfig { + // "steer" = mouse drives the left stick (pitch/roll) while flying; + // "off" disables. ("camera" reserved: right-stick drive, later.) + std::string mode = "steer"; + // "velocity" (ACAH/AC7 style): deflection tracks current mouse SPEED and + // snaps back the moment the mouse stops - self-damping. + // "position": mouse displacement accumulates into a held deflection + // (the M3 round-1/2 model; uses `recenter`). + std::string steer_model = "velocity"; + double sensitivity_x = 1.5; // velocity: full stick at (1000/sens) px/s + double sensitivity_y = 1.5; // position: 1.0 ~= 500px for full deflection + bool invert_x = false; + bool invert_y = true; // true = mouse DOWN pitches DOWN (M3 field feedback); + // false = pull-down-to-pitch-up, flight-sim style + double smoothing = 0.035; // velocity model: EMA time constant, seconds + // (jitter filter; 0 = raw, higher = floatier) + double curve_exponent = 1.0; // 1.0 linear; >1 softer center + double deadzone = 0.0; // 0..1 on the virtual stick + double anti_deadzone = 0.15; // jump output past the ENGINE's own stick + // deadzone so small motions bite immediately + double recenter = 3.0; // position model only: return-to-center rate + // (full deflections/second); 0 = stick stays +}; + +struct Config { + std::vector menu_keys[kNumMenuActions]; + std::vector flight_keys[kNumFlightActions]; + MouseConfig mouse; + // Which CGaUserInput instances receive the [menu] mirror-bit presses. + // Translation through each instance's own masks makes this bit-exact with + // real pad presses, so all menu/system contexts are safe targets. [2] is + // the flight context - refused until M2 gives it its own key set. + std::vector menu_instances = {0, 1, 3, 4}; +}; + +Config g_config; +std::filesystem::file_time_type g_config_mtime{}; +bool g_config_file_seen = false; +uint32_t g_hook_calls = 0; + +int64_t NowMs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +// Scroll wheel: not pollable like keys, so a low-level mouse hook (own +// message-pump thread) stamps short pulses that the binding layer reads as +// two pseudo-keys. VK codes 0x0E/0x0F are unassigned in Win32 - safe IDs. +constexpr VirtualKey kVkWheelUp = static_cast(0x0E); +constexpr VirtualKey kVkWheelDown = static_cast(0x0F); +std::atomic g_wheel_up_until{0}; +std::atomic g_wheel_down_until{0}; + +#if defined(_WIN32) +LRESULT CALLBACK MouseLLProc(int nCode, WPARAM wParam, LPARAM lParam) { + if (nCode == HC_ACTION && wParam == WM_MOUSEWHEEL) { + auto* info = reinterpret_cast(lParam); + const int16_t delta = static_cast(HIWORD(info->mouseData)); + const int64_t until = NowMs() + 90; // ~5 frames: enough for an edge + if (delta > 0) { + g_wheel_up_until.store(until, std::memory_order_relaxed); + } else if (delta < 0) { + g_wheel_down_until.store(until, std::memory_order_relaxed); + } + } + return CallNextHookEx(nullptr, nCode, wParam, lParam); +} + +void EnsureWheelThread() { + static bool s_started = false; + if (s_started) return; + s_started = true; + std::thread([] { + if (!SetWindowsHookExW(WH_MOUSE_LL, MouseLLProc, GetModuleHandleW(nullptr), 0)) { + return; + } + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + }).detach(); +} +#else +void EnsureWheelThread() {} +#endif + +// Accept a few aliases on top of the SDK's canonical key names. The SDK only +// knows generic Control/Alt; the side-specific VKs work directly with +// GetAsyncKeyState, so expose them as pseudo-keys. +VirtualKey ParseKeyName(std::string name) { + if (name == "Enter") name = "Return"; + else if (name == "Esc") name = "Escape"; + else if (name == "Mouse1") name = "LMB"; + else if (name == "Mouse2") name = "RMB"; + else if (name == "Mouse3") name = "MMB"; + else if (name == "MouseWheelUp") return kVkWheelUp; + else if (name == "MouseWheelDown") return kVkWheelDown; + else if (name == "LeftControl" || name == "LeftCtrl") + return static_cast(0xA2); // VK_LCONTROL + else if (name == "RightControl" || name == "RightCtrl") + return static_cast(0xA3); // VK_RCONTROL + else if (name == "LeftAlt") return static_cast(0xA4); // VK_LMENU + else if (name == "RightAlt") return static_cast(0xA5); // VK_RMENU + return ParseVirtualKey(name); +} + +void SetDefaultBindings(Config& c) { + auto set = [&](const char* action, std::initializer_list keys) { + for (size_t i = 0; i < kNumMenuActions; ++i) { + if (std::strcmp(kMenuActions[i].name, action) == 0) { + c.menu_keys[i].clear(); + for (const char* k : keys) { + VirtualKey vk = ParseKeyName(k); + if (vk != VirtualKey::kNone) c.menu_keys[i].push_back(vk); + } + } + } + }; + auto setf = [&](const char* action, std::initializer_list keys) { + for (size_t i = 0; i < kNumFlightActions; ++i) { + if (std::strcmp(kFlightActions[i].name, action) == 0) { + c.flight_keys[i].clear(); + for (const char* k : keys) { + VirtualKey vk = ParseKeyName(k); + if (vk != VirtualKey::kNone) c.flight_keys[i].push_back(vk); + } + } + } + }; + // AC7-style defaults (user request): 1/2/3 = descend / high-G / ascend as on + // AC7's keyboard screen, WASD roll/turn + QE yaw, wingman (D-pad) on the + // arrow keys, camera on the numpad. + setf("pitch_down", {"1"}); + setf("pitch_up", {"3"}); + setf("roll_left", {"A"}); + setf("roll_right", {"D"}); + setf("yaw_left", {"Q"}); + setf("yaw_right", {"E"}); + setf("accelerate", {"W"}); + setf("decelerate", {"S"}); + setf("fire_machine_gun", {"Mouse1", "LeftControl"}); + setf("fire_missile", {"Mouse2", "Space"}); + setf("change_weapon", {"MouseWheelUp", "MouseWheelDown", "C"}); + setf("camera_control", {"LeftAlt"}); + setf("camera_up", {"Numpad8"}); + setf("camera_down", {"Numpad2"}); + setf("camera_left", {"Numpad4"}); + setf("camera_right", {"Numpad6"}); + setf("autopilot", {"Z", "X"}); + setf("high_g", {"2"}); // AC7's "Accelerate + Decelerate" key (W+S also works) + setf("change_view", {"V"}); + setf("switch_radar_map", {"R"}); + setf("switch_targets", {"Tab"}); + setf("pause", {"Escape"}); + setf("wingman_up", {"Up"}); + setf("wingman_down", {"Down"}); + setf("wingman_left", {"Left"}); + setf("wingman_right", {"Right"}); + setf("gear", {"G"}); + + set("up", {"W", "Up"}); + set("down", {"S", "Down"}); + set("left", {"A", "Left"}); + set("right", {"D", "Right"}); + set("confirm", {"Space", "Mouse1"}); // Space=A (user swap); LMB = confirm + set("cancel", {"Escape", "Backspace", "Mouse2"}); // RMB = back out + set("start", {"Enter"}); + set("back", {"Tab"}); +} + +// Written next to the exe on first run when no config exists. KEEP IN SYNC +// with SetDefaultBindings/MouseConfig - the values here must equal the +// built-in defaults (the file is parsed right after being written, so a +// mismatch or typo shows up as WARN lines in the log). +constexpr const char kDefaultConfigToml[] = + R"TOML([mouse] +# mode "steer": the mouse flies the plane; "off": no mouse steering. +mode = "steer" +# steer_model "velocity": the stick follows the current mouse SPEED and +# self-centers the moment the mouse stops (AC7/ACAH feel). +# steer_model "position": mouse displacement sets and HOLDS the stick; +# recenter (full deflections/second, 0 = never) eases it back to center. +steer_model = "velocity" +sensitivity_x = 1.5 +sensitivity_y = 1.5 +invert_x = false +invert_y = true +smoothing = 0.035 +curve_exponent = 1.0 +deadzone = 0.0 +anti_deadzone = 0.15 +recenter = 3.0 + +[menu] +instances = [0, 1, 3, 4] +up = ["W", "Up"] +down = ["S", "Down"] +left = ["A", "Left"] +right = ["D", "Right"] +confirm = ["Space", "Mouse1"] +cancel = ["Escape", "Backspace", "Mouse2"] +start = ["Enter"] +back = ["Tab"] + +[flight] +pitch_down = ["1"] +pitch_up = ["3"] +roll_left = ["A"] +roll_right = ["D"] +yaw_left = ["Q"] +yaw_right = ["E"] +accelerate = ["W"] +decelerate = ["S"] +fire_machine_gun = ["Mouse1", "LeftControl"] +fire_missile = ["Mouse2", "Space"] +change_weapon = ["MouseWheelUp", "MouseWheelDown", "C"] +camera_control = ["LeftAlt"] +camera_up = ["Numpad8"] +camera_down = ["Numpad2"] +camera_left = ["Numpad4"] +camera_right = ["Numpad6"] +autopilot = ["Z", "X"] +high_g = ["2"] +change_view = ["V"] +switch_radar_map = ["R"] +switch_targets = ["Tab"] +pause = ["Escape"] +wingman_up = ["Up"] +wingman_down = ["Down"] +wingman_left = ["Left"] +wingman_right = ["Right"] +gear = ["G"] +)TOML"; + +void LoadConfig() { + Config c; + SetDefaultBindings(c); + const std::string path = REXCVAR_GET(ac6_kbm_config); + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + // First run: materialize the default config so users have a commented + // file to edit (the game runs identically without one). + bool created = false; + if (FILE* f = std::fopen(path.c_str(), "wb")) { + created = std::fwrite(kDefaultConfigToml, 1, sizeof(kDefaultConfigToml) - 1, f) == + sizeof(kDefaultConfigToml) - 1; + std::fclose(f); + } + if (created) { + KbmLog(fmt::format("config '{}' not found - created it with the default bindings", path)); + // Fall through and parse the file we just wrote. + } else { + KbmWarn(fmt::format("config '{}' not found and could not be created - using built-in " + "default bindings", path)); + g_config = c; + g_config_file_seen = false; + return; + } + } + try { + toml::table tbl = toml::parse_file(path); + if (auto menu = tbl["menu"].as_table()) { + if (auto insts = (*menu)["instances"].as_array()) { + c.menu_instances.clear(); + for (auto& el : *insts) { + if (auto v = el.value()) { + const int idx = static_cast(*v); + if (idx == 2) { + KbmWarn("menu.instances: 2 is the flight context, refused"); + } else if (idx >= 0 && idx <= 4) { + c.menu_instances.push_back(idx); + } + } + } + } + for (size_t i = 0; i < kNumMenuActions; ++i) { + auto node = (*menu)[kMenuActions[i].name]; + if (auto arr = node.as_array()) { + c.menu_keys[i].clear(); + for (auto& el : *arr) { + if (auto s = el.value()) { + VirtualKey vk = ParseKeyName(*s); + if (vk != VirtualKey::kNone) { + c.menu_keys[i].push_back(vk); + } else { + KbmWarn(fmt::format("menu.{}: unknown key name '{}'", kMenuActions[i].name, + *s)); + } + } + } + } + } + } + if (auto flight = tbl["flight"].as_table()) { + for (size_t i = 0; i < kNumFlightActions; ++i) { + auto node = (*flight)[kFlightActions[i].name]; + if (auto arr = node.as_array()) { + c.flight_keys[i].clear(); + for (auto& el : *arr) { + if (auto s = el.value()) { + VirtualKey vk = ParseKeyName(*s); + if (vk != VirtualKey::kNone) { + c.flight_keys[i].push_back(vk); + } else { + KbmWarn(fmt::format("flight.{}: unknown key name '{}'", + kFlightActions[i].name, *s)); + } + } + } + } + } + } + if (auto mouse = tbl["mouse"].as_table()) { + c.mouse.mode = (*mouse)["mode"].value_or(std::string("steer")); + c.mouse.steer_model = (*mouse)["steer_model"].value_or(std::string("velocity")); + c.mouse.sensitivity_x = (*mouse)["sensitivity_x"].value_or(1.5); + c.mouse.sensitivity_y = (*mouse)["sensitivity_y"].value_or(1.5); + c.mouse.invert_x = (*mouse)["invert_x"].value_or(false); + c.mouse.invert_y = (*mouse)["invert_y"].value_or(true); + c.mouse.smoothing = (*mouse)["smoothing"].value_or(0.035); + c.mouse.curve_exponent = (*mouse)["curve_exponent"].value_or(1.0); + c.mouse.deadzone = (*mouse)["deadzone"].value_or(0.0); + c.mouse.anti_deadzone = (*mouse)["anti_deadzone"].value_or(0.15); + c.mouse.recenter = (*mouse)["recenter"].value_or(3.0); + } + g_config = c; + g_config_mtime = std::filesystem::last_write_time(path, ec); + g_config_file_seen = true; + std::string binds; + for (size_t i = 0; i < kNumMenuActions; ++i) { + binds += kMenuActions[i].name; + binds += "=["; + for (VirtualKey vk : g_config.menu_keys[i]) { + binds += rex::ui::VirtualKeyToString(vk); + binds += ' '; + } + binds += "] "; + } + KbmLog(fmt::format("config '{}' loaded: instances={} {}", path, + fmt::join(g_config.menu_instances, ","), binds)); + } catch (const toml::parse_error& e) { + KbmWarn(fmt::format("config '{}' parse error: {} - keeping previous bindings", path, + std::string(e.description()))); + } +} + +void MaybeReloadConfig() { + // Called from the hook; rate-limited by the caller (~1x/second). + const std::string path = REXCVAR_GET(ac6_kbm_config); + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + return; // keep whatever we have (defaults or last good load) + } + const auto mtime = std::filesystem::last_write_time(path, ec); + if (ec) return; + if (!g_config_file_seen || mtime != g_config_mtime) { + LoadConfig(); + } +} + +// The SDK ships its own mnk virtual-pad driver behind the mnk_mode cvar. Two +// keyboard mappers injecting into the same pad would fight, so while our +// KB+M is enabled that cvar is forced off (re-checked on the reload tick in +// case the settings overlay or a toml reload turns it back on). +void EnforceMnkOff() { + if (REXCVAR_GET(mnk_mode)) { + REXCVAR_SET(mnk_mode, false); + KbmLog("mnk_mode was on - forced off (ac6_kbm handles keyboard+mouse)"); + } +} + +// ---- Host keyboard state ---------------------------------------------------- +struct GateState { + bool fg_ok = false; + bool imgui_ctx = false; + bool want_text = false; + bool want_capture = false; + // Block only on an active text field: WantCaptureKeyboard proved too broad + // a signal to gate on (it can be latched by overlay focus), so it is logged + // for diagnosis but does not gate. + bool open() const { return fg_ok && !want_text; } +}; + +GateState QueryGate() { + GateState g; +#if defined(_WIN32) + HWND fg = GetForegroundWindow(); + if (fg) { + DWORD pid = 0; + GetWindowThreadProcessId(fg, &pid); + g.fg_ok = (pid == GetCurrentProcessId()); + } +#else + g.fg_ok = true; +#endif + if (ImGui::GetCurrentContext() != nullptr) { + g.imgui_ctx = true; + const ImGuiIO& io = ImGui::GetIO(); + g.want_text = io.WantTextInput; + g.want_capture = io.WantCaptureKeyboard; + } + return g; +} + +bool KeyHeld(VirtualKey vk) { + if (vk == kVkWheelUp) { + return NowMs() < g_wheel_up_until.load(std::memory_order_relaxed); + } + if (vk == kVkWheelDown) { + return NowMs() < g_wheel_down_until.load(std::memory_order_relaxed); + } +#if defined(_WIN32) + return (GetAsyncKeyState(static_cast(vk)) & 0x8000) != 0; +#else + (void)vk; + return false; +#endif +} + +// ---- Flight-context detection ------------------------------------------------ +// The player flight-control sampler (0x82191AE8, sole caller = the player +// update 0x82191480) runs only while the flight sim is actually stepping. +// Its pass-through hook below timestamps each run; "in flight" = it ran +// within the last 300ms. Menus/pause (sim halted) fall back to the menu set. +std::atomic g_last_flight_ms{-1000000}; + +bool FlightActive() { + return NowMs() - g_last_flight_ms.load(std::memory_order_relaxed) < 300; +} + +// ---- Cursor hiding while steering ------------------------------------------- +// The OS cursor is pinned to the window center during capture, so it must be +// hidden. Cursor visibility is decided by the window's WM_SETCURSOR handling +// on ITS thread, so the game window is subclassed (same-process, legal) and +// WM_SETCURSOR is answered with SetCursor(NULL) while capture is active. +#if defined(_WIN32) +std::atomic g_hide_cursor{false}; +WNDPROC g_orig_wndproc = nullptr; +HWND g_subclassed_hwnd = nullptr; + +LRESULT CALLBACK KbmWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_SETCURSOR && g_hide_cursor.load(std::memory_order_relaxed)) { + SetCursor(nullptr); + return TRUE; + } + return CallWindowProcW(g_orig_wndproc, hwnd, msg, wp, lp); +} + +void EnsureCursorSubclass(HWND hwnd) { + if (hwnd == g_subclassed_hwnd || hwnd == nullptr) return; + WNDPROC prev = reinterpret_cast( + SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast(&KbmWndProc))); + if (prev != nullptr) { + g_orig_wndproc = prev; + g_subclassed_hwnd = hwnd; + } +} + +void SetCursorHidden(bool hide, HWND hwnd) { + if (hide) EnsureCursorSubclass(hwnd); + if (g_hide_cursor.exchange(hide, std::memory_order_relaxed) != hide && g_subclassed_hwnd) { + // Nudge the window to re-evaluate the cursor immediately. + PostMessageW(g_subclassed_hwnd, WM_SETCURSOR, reinterpret_cast(g_subclassed_hwnd), + HTCLIENT); + if (hide) SetCursor(nullptr); + } +} +#else +void SetCursorHidden(bool, void*) {} +#endif + +// ---- Mouse steering (M3) ----------------------------------------------------- +// Virtual left stick fed by raw cursor deltas. While flying with mode=steer +// and the input gate open, the OS cursor is pinned to the game window's +// center each poll and the deltas accumulate into a clamped stick position +// with config-driven feel. Keyboard pitch/roll overrides its axis. +struct MouseSteer { + double x = 0.0, y = 0.0; // position model: virtual stick, -1..1 + double rate_x = 0.0, rate_y = 0.0; // velocity model: filtered px/s + double cam_x = 0.0, cam_y = 0.0; // camera-control mode: held RS deflection + bool capturing = false; + int64_t last_ms = 0; +}; +MouseSteer g_mouse; + +void MouseSteerRelease() { + g_mouse.capturing = false; + g_mouse.x = g_mouse.y = 0.0; + g_mouse.rate_x = g_mouse.rate_y = 0.0; + g_mouse.cam_x = g_mouse.cam_y = 0.0; +#if defined(_WIN32) + SetCursorHidden(false, nullptr); +#endif +} + +// Returns stick values in [-1,1]; false = steering inactive this poll. +// cam_mode (the held camera-control key) reroutes the mouse to a virtual +// RIGHT stick with absolute-position behavior: deflection accumulates and +// HOLDS while the mouse rests (the camera stays where you put it), and +// resets to neutral when the key is released. +bool MouseSteerPoll(bool cam_mode, double& out_x, double& out_y) { +#if defined(_WIN32) + const MouseConfig& mc = g_config.mouse; + if (mc.mode != "steer") { + MouseSteerRelease(); + return false; + } + HWND fg = GetForegroundWindow(); + if (!fg) { + MouseSteerRelease(); + return false; + } + RECT rc; + if (!GetClientRect(fg, &rc)) { + MouseSteerRelease(); + return false; + } + POINT center{(rc.right - rc.left) / 2, (rc.bottom - rc.top) / 2}; + ClientToScreen(fg, ¢er); + + POINT cur; + GetCursorPos(&cur); + const int64_t now = NowMs(); + if (g_mouse.capturing && now - g_mouse.last_ms > 250) { + g_mouse.capturing = false; // stale anchor (gate was closed) - re-anchor + } + if (g_mouse.capturing) { + const double dt = std::max(0.001, std::min(0.1, (now - g_mouse.last_ms) / 1000.0)); + double dx_px = static_cast(cur.x - center.x); + double dy_px = static_cast(cur.y - center.y); + if (mc.invert_x) dx_px = -dx_px; + if (mc.invert_y) dy_px = -dy_px; + + if (cam_mode) { + // Camera vertical is steering-invert-independent: mouse down always + // looks down (negative = camera_down), whatever invert_y says. + g_mouse.cam_x = std::max( + -1.0, std::min(1.0, g_mouse.cam_x + dx_px * mc.sensitivity_x / 500.0)); + g_mouse.cam_y = std::max( + -1.0, std::min(1.0, g_mouse.cam_y + (mc.invert_y ? dy_px : -dy_px) * + mc.sensitivity_y / 500.0)); + // Freeze the steering models while panning so releasing the camera key + // never produces a stale steering kick. + g_mouse.rate_x = g_mouse.rate_y = 0.0; + } else if (mc.steer_model == "position") { + const double dx = dx_px * mc.sensitivity_x / 500.0; + const double dy = dy_px * mc.sensitivity_y / 500.0; + g_mouse.x += dx; + g_mouse.y += dy; // screen-down positive = pull + if (mc.recenter > 0.0 && dx == 0.0 && dy == 0.0) { + const double decay = mc.recenter * dt; + auto toward_zero = [&](double v) { + if (v > decay) return v - decay; + if (v < -decay) return v + decay; + return 0.0; + }; + g_mouse.x = toward_zero(g_mouse.x); + g_mouse.y = toward_zero(g_mouse.y); + } + g_mouse.x = std::max(-1.0, std::min(1.0, g_mouse.x)); + g_mouse.y = std::max(-1.0, std::min(1.0, g_mouse.y)); + } else { + // Velocity model (ACAH/AC7 style): deflection follows the CURRENT mouse + // speed through a short EMA jitter filter, so it responds within a + // frame and self-damps to zero the moment the mouse rests. + const double inst_x = dx_px / dt; // px/s this poll + const double inst_y = dy_px / dt; + const double alpha = + mc.smoothing <= 0.0 ? 1.0 : 1.0 - std::exp(-dt / mc.smoothing); + g_mouse.rate_x += (inst_x - g_mouse.rate_x) * alpha; + g_mouse.rate_y += (inst_y - g_mouse.rate_y) * alpha; + // The EMA decays asymptotically and never mathematically reaches zero, + // so after one mouse twitch the residual would keep ownership of the + // stick forever (the anti-deadzone re-expands even 1e-6 to a real + // deflection, clobbering the physical pad's left stick). Below a few + // px/s the mouse is at rest: snap to exact zero so the pad passes + // through. Real motion is tens to hundreds of px/s. + if (std::abs(g_mouse.rate_x) < 2.0) g_mouse.rate_x = 0.0; + if (std::abs(g_mouse.rate_y) < 2.0) g_mouse.rate_y = 0.0; + g_mouse.x = std::max(-1.0, std::min(1.0, g_mouse.rate_x * mc.sensitivity_x / 1000.0)); + g_mouse.y = std::max(-1.0, std::min(1.0, g_mouse.rate_y * mc.sensitivity_y / 1000.0)); + } + } else { + g_mouse.capturing = true; // first poll: no delta, just take the anchor + } + if (!cam_mode) { + // Releasing the camera-control key returns the camera to neutral. + g_mouse.cam_x = g_mouse.cam_y = 0.0; + } + g_mouse.last_ms = now; + SetCursorPos(center.x, center.y); + SetCursorHidden(true, fg); + + auto shape = [&](double v) { + double a = std::abs(v); + if (a <= mc.deadzone) return 0.0; + a = (a - mc.deadzone) / (1.0 - mc.deadzone); + if (mc.curve_exponent != 1.0) a = std::pow(a, mc.curve_exponent); + // Anti-deadzone: skip past the engine's own stick deadzone so small + // motions respond immediately. + if (mc.anti_deadzone > 0.0) a = mc.anti_deadzone + a * (1.0 - mc.anti_deadzone); + if (a > 1.0) a = 1.0; + return v < 0 ? -a : a; + }; + out_x = shape(cam_mode ? g_mouse.cam_x : g_mouse.x); + out_y = shape(cam_mode ? g_mouse.cam_y : g_mouse.y); + return true; +#else + (void)cam_mode; + (void)out_x; + (void)out_y; + return false; +#endif +} + +// ---- Per-instance injection state ------------------------------------------- +struct InstanceState { + uint32_t prev = 0; // our injected level bits last frame + float elapsed[32] = {}; // auto-repeat timers (game-dt units) + float threshold[32] = {}; // current repeat threshold per bit +}; +std::unordered_map g_state; // guest inst addr -> state + +uint32_t LoadWord(uint8_t* base, uint32_t ea) { + return rex::memory::load_and_swap(base + ea); +} +void StoreWord(uint8_t* base, uint32_t ea, uint32_t v) { + rex::memory::store_and_swap(base + ea, v); +} + +// One u32 mask of mirror-space button bits per action slot, per device block. +// Device block 0 (pad 0) is representative; the game applies the same layout +// to all four. +uint32_t ActionMask(uint8_t* base, uint32_t inst, int action) { + return LoadWord(base, inst + 4 + 8 + static_cast(action) * 4); +} + +// One-time dump of every instance's nonzero binding masks: the definitive +// per-context action tables (also the M2/M3 planning data). +void DumpMaskTables(uint8_t* base, uint32_t singleton, uint32_t fifth) { + if (!REXCVAR_GET(ac6_kbm_log)) return; + for (int k = 0; k < 5; ++k) { + const uint32_t inst = (k < 4) ? singleton + kInstanceOffsets[k] : fifth; + if (inst == 0) continue; + std::string s; + for (int a = 0; a < 32; ++a) { + const uint32_t m = ActionMask(base, inst, a); + if (m) s += fmt::format("a{}=0x{:X} ", a, m); + } + const uint32_t analog_en = LoadWord(base, inst + 4 + 0x88); + const uint32_t invert = LoadWord(base, inst + 4 + 0x8C); + KbmLog(fmt::format("masks inst[{}]@0x{:08X}: {} analogEn=0x{:X} invert=0x{:X}", k, inst, s, + analog_en, invert)); + } +} + +uint32_t GatherMirrorBits() { + uint32_t mirror = 0; + for (size_t i = 0; i < kNumMenuActions; ++i) { + for (VirtualKey vk : g_config.menu_keys[i]) { + if (KeyHeld(vk)) { + mirror |= 1u << kMenuActions[i].mirror_bit; + break; + } + } + } + return mirror; +} + +void InjectMenu(uint8_t* base, uint32_t inst, double dt, bool gate_open, + uint32_t kb_mirror) { + // Translate mirror-space presses into THIS instance's action bits via its + // own binding masks - bit-exact with what a real pad press produces here. + uint32_t level = 0; + if (gate_open && kb_mirror) { + for (int a = 0; a < 32; ++a) { + if (ActionMask(base, inst, a) & kb_mirror) { + level |= 1u << a; + } + } + } + + InstanceState& st = g_state[inst]; + const uint32_t pressed = level & ~st.prev; + const uint32_t released = st.prev & ~level; + uint32_t repeat_fire = pressed; + + if (level != 0 || st.prev != 0) { + // Auto-repeat with the instance's own tuning (identical feel to the pad). + const float delay = rex::memory::load_and_swap(base + inst + kOffRepeatDelay); + const float rate = rex::memory::load_and_swap(base + inst + kOffRepeatRate); + float fdt = static_cast(dt); + if (!(fdt >= 0.0f)) fdt = 0.0f; // also catches NaN + for (int b = 0; b < 32; ++b) { + const uint32_t bit = 1u << b; + if (pressed & bit) { + st.elapsed[b] = 0.0f; + st.threshold[b] = delay; + } else if (level & bit) { + st.elapsed[b] += fdt; + if (st.elapsed[b] >= st.threshold[b]) { + repeat_fire |= bit; + st.elapsed[b] = 0.0f; + st.threshold[b] = rate; + } + } + } + } + + if (level | pressed | released | repeat_fire) { + StoreWord(base, inst + kOffLevel, LoadWord(base, inst + kOffLevel) | level); + StoreWord(base, inst + kOffPressed, LoadWord(base, inst + kOffPressed) | pressed); + // Mask the spurious released-edge the game computes while our key is + // still held (our bit was in its prev but not in its pad-only level), + // then add our genuine releases. + uint32_t rel = LoadWord(base, inst + kOffReleased); + rel = (rel & ~level) | released; + StoreWord(base, inst + kOffReleased, rel); + StoreWord(base, inst + kOffRepeat, LoadWord(base, inst + kOffRepeat) | repeat_fire); + + if (pressed && REXCVAR_GET(ac6_kbm_log)) { + static int s_lines = 0; + if (s_lines < 200) { + ++s_lines; + KbmLog(fmt::format("inject: mirror 0x{:X} -> level 0x{:X} inst=0x{:08X}", kb_mirror, + level, inst)); + } + } + } + st.prev = level; +} + +// ---- Layer watch: trace pad data bottom-up through the input stack ---------- +// Round-3 finding: real pad presses (which visibly drove the front-end) never +// reached ANY CGaUserInput instance. Watch each layer's buttons word so one +// run shows where the data stops per screen: +// NU devices (fixed): 0x8290DDBC/0x8290DE44/0x8290DECC/0x8290DF54, +0x1C level +// galib mirror: 0x826EDB18 + i*0xA0, +8 buttons +constexpr uint32_t kDeviceAddrs[4] = {0x8290DDBC, 0x8290DE44, 0x8290DECC, 0x8290DF54}; +constexpr uint32_t kMirrorBase = 0x826EDB18; + +void WatchLayers(uint8_t* base) { + if (!REXCVAR_GET(ac6_kbm_log)) return; + static uint32_t s_dev[4] = {}; + static uint32_t s_mir[4] = {}; + static int s_lines = 0; + if (s_lines >= 600) return; + for (int i = 0; i < 4; ++i) { + const uint32_t dev = LoadWord(base, kDeviceAddrs[i] + 0x1C); + if (dev != s_dev[i]) { + s_dev[i] = dev; + ++s_lines; + KbmLog(fmt::format("watch dev[{}] buttons 0x{:08X}", i, dev)); + } + const uint32_t mir = LoadWord(base, kMirrorBase + i * 0xA0 + 8); + if (mir != s_mir[i]) { + s_mir[i] = mir; + ++s_lines; + KbmLog(fmt::format("watch mirror[{}] buttons 0x{:08X}", i, mir)); + } + } +} + +// ---- Probe: log game-side word changes + flight analog activity ------------- +// Answers the M0 open questions from one normal play session: +// - which bits the pad sets per action (press pad buttons -> bit map) +// - which instances screens consume (navigate while watching) +// - which analog slots the sticks drive (move sticks in flight) +void Probe(uint8_t* base, uint32_t singleton, uint32_t inst) { + if (!REXCVAR_GET(ac6_kbm_log)) return; + + int idx = 4; // non-singleton = the pump's fifth instance + for (int i = 0; i < 4; ++i) { + if (inst == singleton + kInstanceOffsets[i]) idx = i; + } + + static std::unordered_map s_last_level; + const uint32_t level = LoadWord(base, inst + kOffLevel); + auto it = s_last_level.find(inst); + if (it == s_last_level.end() || it->second != level) { + s_last_level[inst] = level; + static int s_lines = 0; + if (s_lines < 400) { // hard cap so a stuck bit can't flood the log + ++s_lines; + KbmLog(fmt::format("probe inst[{}] level 0x{:08X}", idx, level)); + } + } + + // Analog probe: flight instance only, at most 2 lines/second. + if (idx == 2) { + using clock = std::chrono::steady_clock; + static clock::time_point s_next = clock::now(); + const auto now = clock::now(); + if (now >= s_next) { + std::string a, c; + for (int i = 0; i < 32; ++i) { + const float va = rex::memory::load_and_swap(base + inst + kOffAnalogA + i * 4); + const float vc = rex::memory::load_and_swap(base + inst + kOffAnalogC + i * 4); + if (va > 0.2f || va < -0.2f) a += fmt::format("{}:{:+.2f} ", i, va); + if (vc > 0.2f || vc < -0.2f) c += fmt::format("{}:{:+.2f} ", i, vc); + } + if (!a.empty() || !c.empty()) { + s_next = now + std::chrono::milliseconds(500); + KbmLog(fmt::format("probe inst[2] analogA[{}] analogC[{}]", a, c)); + } + } + } +} + +} // namespace + +PPC_EXTERN_FUNC(__imp__rex_sub_82390CE0); // guest XamInputGetState(user,0,state) wrapper + +// Round-5 verdict: action-word injection is bit-exact with the pad's values +// yet front-end screens ignore it - they consume a LOWER layer (the NU device +// state the pad poll fills). So the menu key set is additionally injected +// right here at the XamInputGetState boundary as XINPUT button bits: every +// downstream layer then sees keyboard presses exactly as pad presses. +// (Flight/M2 will gate this by game mode so flight keys never collide.) +uint32_t GatherXInputBits() { + static const uint16_t kXBits[kNumMenuActions] = { + 0x0001, // up -> DPAD_UP + 0x0002, // down -> DPAD_DOWN + 0x0004, // left -> DPAD_LEFT + 0x0008, // right -> DPAD_RIGHT + 0x1000, // confirm -> A + 0x2000, // cancel -> B + 0x0010, // start -> START + 0x0020, // back -> BACK + }; + uint32_t bits = 0; + for (size_t i = 0; i < kNumMenuActions; ++i) { + for (VirtualKey vk : g_config.menu_keys[i]) { + if (KeyHeld(vk)) { + bits |= kXBits[i]; + break; + } + } + } + return bits; +} + +PPC_FUNC_IMPL(rex_sub_82390CE0) { + PPC_FUNC_PROLOGUE(); + + const uint32_t lr = static_cast(ctx.lr); + const uint32_t user = ctx.r3.u32; + const uint32_t state_ptr = ctx.r4.u32; + __imp__rex_sub_82390CE0(ctx, base); + + // Pad-less operation: when no controller is connected (0x48F), present a + // neutral synthetic pad on slot 0; the injection below then supplies the + // keyboard/mouse state exactly as if a pad were plugged in. + if (REXCVAR_GET(ac6_kbm_enabled) && REXCVAR_GET(ac6_kbm_padless) && user == 0 && + state_ptr != 0 && ctx.r3.u32 == 0x48F) { + static uint32_t s_packet = 0; + for (uint32_t i = 0; i < 16; ++i) { + *(base + state_ptr + i) = 0; + } + rex::memory::store_and_swap(base + state_ptr + 0, ++s_packet); + ctx.r3.u64 = 0; + } + + // Device-level keyboard injection (user 0 only, successful polls only). + // Context switch: flying -> [flight] key set; everything else -> [menu] set. + if (REXCVAR_GET(ac6_kbm_enabled) && user == 0 && state_ptr != 0 && + ctx.r3.u32 == 0) { + const GateState gate = QueryGate(); + if (gate.open()) { + if (FlightActive()) { + uint16_t btn = 0; + uint8_t lt = 0, rt = 0; + int32_t lx = 0, ly = 0, rx = 0, ry = 0; + for (size_t i = 0; i < kNumFlightActions; ++i) { + for (VirtualKey vk : g_config.flight_keys[i]) { + if (KeyHeld(vk)) { + const FlightActionDef& d = kFlightActions[i]; + btn |= d.buttons; + if (d.lt > lt) lt = d.lt; + if (d.rt > rt) rt = d.rt; + lx += d.lx; + ly += d.ly; + rx += d.rx; + ry += d.ry; + break; + } + } + } + // AC7-style camera control key: while held, the mouse drives the + // camera (right stick) instead of pitch/roll. + bool cam_mode = false; + for (VirtualKey vk : g_config.flight_keys[CameraControlAction()]) { + if (KeyHeld(vk)) { + cam_mode = true; + break; + } + } + + // Mouse steering: runs every flight poll (maintains capture/recenter); + // keyboard keys override the mouse per axis, pad stick wins when both idle. + // With the camera key held, mx/my are the held camera deflection instead. + double mx = 0.0, my = 0.0; + const bool mouse_on = MouseSteerPoll(cam_mode, mx, my); + + const bool any = btn || lt || rt || lx != 0 || ly != 0 || rx != 0 || ry != 0 || + (mouse_on && (mx != 0.0 || my != 0.0)); + if (any) { + const uint16_t cur = rex::memory::load_and_swap(base + state_ptr + 4); + rex::memory::store_and_swap(base + state_ptr + 4, + static_cast(cur | btn)); + auto max_u8 = [&](uint32_t off, uint8_t v) { + uint8_t c = *(base + state_ptr + off); + if (v > c) *(base + state_ptr + off) = v; + }; + max_u8(6, lt); + max_u8(7, rt); + auto clamp16 = [](int32_t v) { + return static_cast(v > 32767 ? 32767 : (v < -32767 ? -32767 : v)); + }; + if (lx != 0) { + rex::memory::store_and_swap(base + state_ptr + 8, clamp16(lx)); + } else if (mouse_on && !cam_mode && mx != 0.0) { + rex::memory::store_and_swap(base + state_ptr + 8, + clamp16(static_cast(mx * 32767.0))); + } + // Mouse "pull" (my positive) = stick pulled = negative LY, matching + // the pitch_up fallback key. + if (ly != 0) { + rex::memory::store_and_swap(base + state_ptr + 10, clamp16(ly)); + } else if (mouse_on && !cam_mode && my != 0.0) { + rex::memory::store_and_swap(base + state_ptr + 10, + clamp16(static_cast(-my * 32767.0))); + } + if (rx != 0) { + rex::memory::store_and_swap(base + state_ptr + 12, clamp16(rx)); + } else if (mouse_on && cam_mode && mx != 0.0) { + rex::memory::store_and_swap(base + state_ptr + 12, + clamp16(static_cast(mx * 32767.0))); + } + if (ry != 0) { + rex::memory::store_and_swap(base + state_ptr + 14, clamp16(ry)); + } else if (mouse_on && cam_mode && my != 0.0) { + rex::memory::store_and_swap(base + state_ptr + 14, + clamp16(static_cast(my * 32767.0))); + } + static int s_lines = 0; + static uint32_t s_last = 0; + const uint32_t sig = btn | (lt << 16) | (rt << 24) | ((lx != 0) << 30) | + ((ly != 0) << 31); + if (REXCVAR_GET(ac6_kbm_log) && sig != s_last && s_lines < 100) { + s_last = sig; + ++s_lines; + KbmLog(fmt::format("xinput inject FLIGHT btn=0x{:04X} lt={} rt={} lx={} ly={} " + "mouse={} cam={} mx={:.2f} my={:.2f}", + btn, lt, rt, lx, ly, mouse_on, cam_mode, mx, my)); + } + } + } else { + MouseSteerRelease(); + const uint32_t kb = GatherXInputBits(); + if (kb != 0) { + const uint16_t cur = rex::memory::load_and_swap(base + state_ptr + 4); + rex::memory::store_and_swap(base + state_ptr + 4, + static_cast(cur | kb)); + static int s_lines = 0; + static uint32_t s_last = 0; + if (REXCVAR_GET(ac6_kbm_log) && kb != s_last && s_lines < 100) { + s_last = kb; + ++s_lines; + KbmLog(fmt::format("xinput inject wButtons 0x{:04X}", kb)); + } + } + } + } + } + + if (!REXCVAR_GET(ac6_kbm_log)) { + return; + } + static uint32_t s_callers[8] = {}; + static int s_ncallers = 0; + bool known = false; + for (int i = 0; i < s_ncallers; ++i) { + if (s_callers[i] == lr) known = true; + } + if (!known && s_ncallers < 8) { + s_callers[s_ncallers++] = lr; + KbmLog(fmt::format("xinput poll site lr=0x{:08X} user={} ret=0x{:X}", lr, user, ctx.r3.u32)); + } + if (state_ptr != 0 && ctx.r3.u32 == 0) { + const uint16_t buttons = rex::memory::load_and_swap(base + state_ptr + 4); + static uint16_t s_last[4] = {}; + static int s_lines = 0; + if (user < 4 && buttons != s_last[user] && s_lines < 300) { + s_last[user] = buttons; + ++s_lines; + KbmLog(fmt::format("xinput user={} wButtons 0x{:04X} (lr=0x{:08X})", user, buttons, lr)); + } + } +} + +// Primary flight heartbeat, called from the fps-physics force-step wrapper +// (rex_sub_823046A0 in ac6_fps_physics_fix.cpp) - proven to run per frame for +// every aircraft in a mission and to halt when the sim halts. The sampler +// hook below turned out to be control-mode dependent (never fired in the +// M2 round-1 field test), so it stays only as a secondary signal. +void ac6KbmNotifyFlightStep() { + g_last_flight_ms.store(NowMs(), std::memory_order_relaxed); +} + +PPC_EXTERN_FUNC(__imp__sub_82390CD8); // guest XamInputGetCapabilities wrapper + +// Pad-less: report a standard gamepad on slot 0 when the real query fails, +// so the NU device layer completes its connect handshake without hardware. +PPC_FUNC_IMPL(sub_82390CD8) { + PPC_FUNC_PROLOGUE(); + const uint32_t user = ctx.r3.u32; + const uint32_t caps = ctx.r5.u32; + __imp__sub_82390CD8(ctx, base); + if (REXCVAR_GET(ac6_kbm_enabled) && REXCVAR_GET(ac6_kbm_padless) && user == 0 && caps != 0 && + ctx.r3.u32 != 0) { + *(base + caps + 0) = 1; // XINPUT_DEVTYPE_GAMEPAD + *(base + caps + 1) = 1; // XINPUT_DEVSUBTYPE_GAMEPAD + rex::memory::store_and_swap(base + caps + 2, 0); + for (uint32_t i = 4; i < 20; ++i) { + *(base + caps + i) = 0xFF; // full gamepad + vibration capability bits + } + ctx.r3.u64 = 0; + if (REXCVAR_GET(ac6_kbm_log)) { + static bool s_logged = false; + if (!s_logged) { + s_logged = true; + KbmLog("padless: synthetic gamepad reported on slot 0"); + } + } + } +} + +PPC_EXTERN_FUNC(__imp__rex_sub_82390CF0); // guest XamInputSetState (vibration) wrapper + +// Pad-less: swallow vibration failures for slot 0 so the synthetic pad never +// churns through reconnects. +PPC_FUNC_IMPL(rex_sub_82390CF0) { + PPC_FUNC_PROLOGUE(); + const uint32_t user = ctx.r3.u32; + __imp__rex_sub_82390CF0(ctx, base); + if (REXCVAR_GET(ac6_kbm_enabled) && REXCVAR_GET(ac6_kbm_padless) && user == 0 && + ctx.r3.u32 != 0) { + ctx.r3.u64 = 0; + } +} + +PPC_EXTERN_FUNC(__imp__rex_sub_82191AE8); // player flight-control sampler + +// Pass-through timestamp: secondary proof-of-flight for the context switch. +PPC_FUNC_IMPL(rex_sub_82191AE8) { + PPC_FUNC_PROLOGUE(); + g_last_flight_ms.store(NowMs(), std::memory_order_relaxed); + __imp__rex_sub_82191AE8(ctx, base); +} + +PPC_EXTERN_FUNC(__imp__rex_sub_82211E28); // galib::CGaUserInput::Update + +PPC_FUNC_IMPL(rex_sub_82211E28) { + PPC_FUNC_PROLOGUE(); + + const uint32_t inst = ctx.r3.u32; + const double dt = ctx.f1.f64; + __imp__rex_sub_82211E28(ctx, base); + + if (!REXCVAR_GET(ac6_kbm_enabled) || inst == 0) { + return; + } + + // One-time init + ~1x/second hot reload (the pump calls this 5x per frame). + const uint32_t call = g_hook_calls++; + if (call == 0) { + LoadConfig(); + EnsureWheelThread(); + EnforceMnkOff(); + } else if ((call % 300) == 0) { + MaybeReloadConfig(); + EnforceMnkOff(); + } + + const uint32_t singleton = LoadWord(base, kSingletonPtrGlobal); + if (singleton == 0) { + return; + } + + const GateState gate = QueryGate(); + + // The pump updates the 4 singleton instances plus one it owns itself; any + // Update call on a non-singleton instance is that fifth one ("instance 4"). + static uint32_t s_fifth_inst = 0; + int idx = -1; + for (int i = 0; i < 4; ++i) { + if (inst == singleton + kInstanceOffsets[i]) idx = i; + } + if (idx < 0) { + s_fifth_inst = inst; + idx = 4; + } + + // One-time mask-table dump once the fifth instance is known. + static bool s_dumped = false; + if (!s_dumped && s_fifth_inst != 0 && call > 5) { + s_dumped = true; + DumpMaskTables(base, singleton, s_fifth_inst); + } + + const uint32_t kb_mirror = GatherMirrorBits(); + + // Heartbeat: proves the hook runs, shows the gate, and shows raw key + // detection INDEPENDENT of the gate (so a closed gate is visible too). + if (REXCVAR_GET(ac6_kbm_log) && (call == 5 || (call % 3000) == 0)) { + KbmLog(fmt::format( + "alive call={} inst=0x{:08X} singleton=0x{:08X} fifth@0x{:08X} dt={:.4f} " + "gate[fg={} imgui={} text={} capture={} open={}] targets=[{}] rawMirror=0x{:X} " + "flight={}", + call, inst, singleton, s_fifth_inst, dt, gate.fg_ok, gate.imgui_ctx, gate.want_text, + gate.want_capture, gate.open(), fmt::join(g_config.menu_instances, ","), kb_mirror, + FlightActive())); + } + + if (!FlightActive()) { + for (int target : g_config.menu_instances) { + if (target == idx && idx != 2) { + InjectMenu(base, inst, dt, gate.open(), kb_mirror); + break; + } + } + } + // Flight (instance[2]) injection lands in M2/M3. + + if (idx == 0) { + WatchLayers(base); // once per pump pass, not 5x + } + Probe(base, singleton, inst); +}