mirror of
https://github.com/sal063/AC6_recomp
synced 2026-08-24 16:00:56 -04:00
1564 lines
62 KiB
C++
1564 lines
62 KiB
C++
// 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 <algorithm>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <iterator>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
#include <fmt/format.h>
|
|
#include <fmt/ranges.h>
|
|
#include <toml++/toml.hpp>
|
|
|
|
#include <rex/cvar.h>
|
|
#include <rex/logging.h>
|
|
#include <rex/memory/utils.h>
|
|
#include <rex/ppc.h>
|
|
#include <rex/ui/imgui_drawer.h>
|
|
#include <rex/ui/keybinds.h>
|
|
#include <rex/ui/virtual_key.h>
|
|
|
|
#if defined(_WIN32)
|
|
#ifndef WIN32_LEAN_AND_MEAN
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#endif
|
|
#ifndef NOMINMAX
|
|
#define NOMINMAX
|
|
#endif
|
|
#include <windows.h>
|
|
#endif
|
|
|
|
REXCVAR_DEFINE_BOOL(ac6_kbm_enabled, false, "AC6/Enhancements",
|
|
"Enable keyboard and mouse controls.");
|
|
REXCVAR_DEFINE_BOOL(ac6_kbm_log, false, "AC6/Enhancements",
|
|
"Log keyboard and mouse input diagnostics.")
|
|
.debug_only();
|
|
REXCVAR_DEFINE_STRING(ac6_kbm_config, "ac6_input.toml", "AC6/Enhancements",
|
|
"Path to the keyboard and mouse bindings file. Edits to the "
|
|
"file are picked up live.");
|
|
REXCVAR_DEFINE_BOOL(ac6_kbm_padless, true, "AC6/Enhancements",
|
|
"Provide a virtual controller when none is connected, so "
|
|
"keyboard and mouse work on their own.");
|
|
REXCVAR_DEFINE_DOUBLE(ac6_cursor_hide_seconds, 3.0, "AC6/Enhancements",
|
|
"Hide the free mouse cursor after this many seconds without "
|
|
"motion (0 = never hide).")
|
|
.range(0.0, 300.0);
|
|
|
|
// 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 from field testing. A/B corrected 2026-08-09: the
|
|
// M2 round-2 report had these two the wrong way round, which swapped the
|
|
// mouse buttons in game (LMB fired missiles, RMB the gun).
|
|
// A=machine gun, B=missile/fire, 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", 0x1000, 0, 0, 0, 0, 0, 0}, // A
|
|
{"fire_missile", 0x2000, 0, 0, 0, 0, 0, 0}, // B - 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<VirtualKey> menu_keys[kNumMenuActions];
|
|
std::vector<VirtualKey> 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<int> 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::milliseconds>(
|
|
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<VirtualKey>(0x0E);
|
|
constexpr VirtualKey kVkWheelDown = static_cast<VirtualKey>(0x0F);
|
|
std::atomic<int64_t> g_wheel_up_until{0};
|
|
std::atomic<int64_t> 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<MSLLHOOKSTRUCT*>(lParam);
|
|
const int16_t delta = static_cast<int16_t>(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 == "Mouse4" || name == "MouseBack")
|
|
return static_cast<VirtualKey>(0x05); // VK_XBUTTON1 (side button, "back")
|
|
else if (name == "Mouse5" || name == "MouseForward")
|
|
return static_cast<VirtualKey>(0x06); // VK_XBUTTON2 (side button, "forward")
|
|
else if (name == "LeftControl" || name == "LeftCtrl")
|
|
return static_cast<VirtualKey>(0xA2); // VK_LCONTROL
|
|
else if (name == "RightControl" || name == "RightCtrl")
|
|
return static_cast<VirtualKey>(0xA3); // VK_RCONTROL
|
|
else if (name == "LeftAlt") return static_cast<VirtualKey>(0xA4); // VK_LMENU
|
|
else if (name == "RightAlt") return static_cast<VirtualKey>(0xA5); // VK_RMENU
|
|
return ParseVirtualKey(name);
|
|
}
|
|
|
|
void SetDefaultBindings(Config& c) {
|
|
auto set = [&](const char* action, std::initializer_list<const char*> 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<const char*> 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<int64_t>()) {
|
|
const int idx = static_cast<int>(*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<std::string>()) {
|
|
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<std::string>()) {
|
|
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)");
|
|
}
|
|
}
|
|
|
|
// Mouse-steering state (used by MouseSteerPoll below). Declared here because
|
|
// the input gate consults `capturing`: while steering pins the cursor it OWNS
|
|
// the pointer, and overlay mouse ownership is suspended.
|
|
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;
|
|
|
|
// ---- Host input gates -------------------------------------------------------
|
|
// Input ownership follows desktop window-manager rules: the MOUSE belongs to
|
|
// whatever is under the CURSOR, the KEYBOARD to whatever holds FOCUS. The
|
|
// ---- Stale-key trust latch --------------------------------------------------
|
|
// GetAsyncKeyState reports OS-level async state, which the game cannot clear
|
|
// and which can wedge "down" without any real press: the classic case is
|
|
// Alt-Tab residue, where Tab's key-UP is swallowed during the focus /
|
|
// fullscreen transition and the OS reports Tab held until its next physical
|
|
// press. Field-confirmed: a session-long phantom Tab forwarded as held
|
|
// BACK/Y, engaging the target-focus camera - "camera dead on keyboard AND
|
|
// pad". So a key only counts as held after a fresh up->down transition has
|
|
// been observed by us: anything already down at module start, or down at the
|
|
// moment the window regains foreground, is ignored (once per offender, with
|
|
// a log line naming it) until released. The deliberate flip side: a key
|
|
// genuinely held ACROSS an Alt-Tab is eaten until re-pressed - standard
|
|
// game-input behavior. Applies to the GetAsyncKeyState path only; the wheel
|
|
// pseudo-keys are synthetic and never wedge.
|
|
#if defined(_WIN32)
|
|
// bit0 = a fresh "up" was observed, the key is trusted; bit1 = wedge logged.
|
|
constexpr uint8_t kKeyTrustUpSeen = 1;
|
|
constexpr uint8_t kKeyTrustLogged = 2;
|
|
std::atomic<uint8_t> g_key_trust[256] = {};
|
|
std::atomic<bool> g_focus_was_ok{false};
|
|
std::atomic<bool> g_focus_seen{false}; // picks the wedge log wording only
|
|
|
|
// Called from QueryGate on every gate evaluation; on the unfocused->focused
|
|
// edge, drop trust for every key physically down at that instant (their
|
|
// up->down did not happen inside the focused session).
|
|
void NoteFocusForKeyTrust(bool fg_ok) {
|
|
if (!g_focus_was_ok.exchange(fg_ok, std::memory_order_relaxed) && fg_ok) {
|
|
g_focus_seen.store(true, std::memory_order_relaxed);
|
|
for (int k = 1; k < 256; ++k) {
|
|
if (GetAsyncKeyState(k) & 0x8000) {
|
|
g_key_trust[k].store(0, std::memory_order_relaxed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// ImGuiDrawer publishes once per frame whether any visible overlay
|
|
// window owns the pointer / the keyboard / an active text field - its ImGui
|
|
// capture flags conjoined with overlay visibility, so a CLOSED overlay can
|
|
// never own input by construction (the focus latch that got a raw
|
|
// WantCaptureKeyboard gate rejected here previously cannot form).
|
|
// One rule on top, also window-manager semantics (pointer capture): while
|
|
// mouse steering holds the pointer - pinned to the window center and hidden -
|
|
// there is no cursor to hover an overlay with, so the mouse stays with the
|
|
// game until steering releases it (menus, pause, mode "off", focus loss).
|
|
// Pad input is deliberately NOT gated: the overlays are not pad-navigable
|
|
// (the drawer feeds ImGui keyboard/mouse/touch only), so no overlay can own
|
|
// pad input and gating it would change behaviour with no owner on the other
|
|
// side.
|
|
struct GateState {
|
|
bool fg_ok = false;
|
|
bool capture_mouse = false; // a visible overlay owns the pointer
|
|
bool capture_keyboard = false; // a visible overlay owns the keyboard
|
|
bool want_text = false; // an overlay text field is active
|
|
bool steer_owns_pointer = false; // mouse steering is pinning the cursor
|
|
bool keys_ok() const { return fg_ok && !capture_keyboard && !want_text; }
|
|
bool mouse_ok() const { return fg_ok && (steer_owns_pointer || !capture_mouse); }
|
|
};
|
|
|
|
GateState QueryGate() {
|
|
GateState g;
|
|
#if defined(_WIN32)
|
|
HWND fg = GetForegroundWindow();
|
|
if (fg) {
|
|
DWORD pid = 0;
|
|
GetWindowThreadProcessId(fg, &pid);
|
|
g.fg_ok = (pid == GetCurrentProcessId());
|
|
}
|
|
NoteFocusForKeyTrust(g.fg_ok);
|
|
#else
|
|
g.fg_ok = true;
|
|
#endif
|
|
g.capture_mouse = rex::ui::ImGuiDrawer::DialogsCaptureMouse();
|
|
g.capture_keyboard = rex::ui::ImGuiDrawer::DialogsCaptureKeyboard();
|
|
g.want_text = rex::ui::ImGuiDrawer::DialogsWantTextInput();
|
|
g.steer_owns_pointer = g_mouse.capturing;
|
|
return g;
|
|
}
|
|
|
|
// LMB/RMB/MMB/X1/X2 and the synthetic wheel keys are MOUSE input (ownership
|
|
// follows the cursor); every other key is KEYBOARD input (ownership follows
|
|
// focus). The two kinds gate independently, like windows on a desktop.
|
|
bool IsMouseKey(VirtualKey vk) {
|
|
switch (vk) {
|
|
case VirtualKey::kLButton:
|
|
case VirtualKey::kRButton:
|
|
case VirtualKey::kMButton:
|
|
case VirtualKey::kXButton1:
|
|
case VirtualKey::kXButton2:
|
|
return true;
|
|
default:
|
|
return vk == kVkWheelUp || vk == kVkWheelDown;
|
|
}
|
|
}
|
|
|
|
bool KeyAllowed(const GateState& gate, VirtualKey vk) {
|
|
return IsMouseKey(vk) ? gate.mouse_ok() : gate.keys_ok();
|
|
}
|
|
|
|
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)
|
|
const bool down = (GetAsyncKeyState(static_cast<int>(vk)) & 0x8000) != 0;
|
|
std::atomic<uint8_t>& trust = g_key_trust[static_cast<int>(vk) & 0xFF];
|
|
if (!down) {
|
|
// Fresh up: trusted from here on, and the wedge log is rearmed so a
|
|
// NEW wedge episode for the same key gets named again.
|
|
trust.store(kKeyTrustUpSeen, std::memory_order_relaxed);
|
|
return false;
|
|
}
|
|
const uint8_t t = trust.load(std::memory_order_relaxed);
|
|
if (t & kKeyTrustUpSeen) {
|
|
return true;
|
|
}
|
|
if (!(t & kKeyTrustLogged)) {
|
|
trust.store(t | kKeyTrustLogged, std::memory_order_relaxed);
|
|
KbmLog(fmt::format("key 0x{:02X} held {} - ignored until released",
|
|
static_cast<int>(vk),
|
|
g_focus_seen.load(std::memory_order_relaxed) ? "across focus gain"
|
|
: "at startup"));
|
|
}
|
|
return false;
|
|
#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<int64_t> 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<bool> g_hide_cursor{false};
|
|
WNDPROC g_orig_wndproc = nullptr;
|
|
HWND g_subclassed_hwnd = nullptr;
|
|
|
|
// Free-cursor idle hide (see CursorIdleHideTick below): hidden after
|
|
// ac6_cursor_hide_seconds without motion, revealed on any real mouse input.
|
|
std::atomic<bool> g_idle_hidden{false};
|
|
std::atomic<int64_t> g_idle_last_motion_ms{0};
|
|
|
|
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;
|
|
}
|
|
if (msg == WM_MOUSEMOVE) {
|
|
// WM_MOUSEMOVE is also sent for window-management reasons with the
|
|
// cursor unmoved (see the SDK's note in window_win.cpp), so only a
|
|
// POSITION CHANGE counts as real motion. Window-thread-only state.
|
|
static LPARAM s_last_move_lp = -1;
|
|
if (lp != s_last_move_lp) {
|
|
s_last_move_lp = lp;
|
|
if (g_idle_hidden.load(std::memory_order_relaxed)) {
|
|
// Restamp the idle clock so the poll tick cannot immediately
|
|
// re-hide. A motion event's own WM_SETCURSOR is sent BEFORE its
|
|
// WM_MOUSEMOVE lands (still hidden then), so nudge a fresh one -
|
|
// the arrow returns on THIS motion, not the next.
|
|
g_idle_last_motion_ms.store(NowMs(), std::memory_order_relaxed);
|
|
g_idle_hidden.store(false, std::memory_order_relaxed);
|
|
PostMessageW(hwnd, WM_SETCURSOR, reinterpret_cast<WPARAM>(hwnd), HTCLIENT);
|
|
}
|
|
}
|
|
} else if (msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN || msg == WM_MBUTTONDOWN ||
|
|
msg == WM_XBUTTONDOWN || msg == WM_MOUSEWHEEL) {
|
|
if (g_idle_hidden.load(std::memory_order_relaxed)) {
|
|
g_idle_last_motion_ms.store(NowMs(), std::memory_order_relaxed);
|
|
g_idle_hidden.store(false, std::memory_order_relaxed);
|
|
PostMessageW(hwnd, WM_SETCURSOR, reinterpret_cast<WPARAM>(hwnd), HTCLIENT);
|
|
}
|
|
} else if (msg == WM_SETCURSOR && g_idle_hidden.load(std::memory_order_relaxed) &&
|
|
LOWORD(lp) == HTCLIENT) {
|
|
// Keep the cursor away over the client area while idle-hidden (window
|
|
// management re-sets it otherwise). Non-client keeps normal arrows.
|
|
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<WNDPROC>(
|
|
SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&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<WPARAM>(g_subclassed_hwnd),
|
|
HTCLIENT);
|
|
if (hide) SetCursor(nullptr);
|
|
}
|
|
}
|
|
|
|
// ---- Free-cursor idle hide --------------------------------------------------
|
|
// The FREE cursor (no steering capture) disappears after
|
|
// ac6_cursor_hide_seconds without motion and returns instantly on motion or
|
|
// a button - menus and desktop-style contexts; the capture hiding above is
|
|
// untouched. Never hides while an overlay is visible under the free cursor
|
|
// (a vanishing pointer over the F4 menu is an anti-feature), while the
|
|
// window is unfocused, or while capture owns the cursor. Runs on the input
|
|
// poll; the subclass proc above answers WM_SETCURSOR while hidden and
|
|
// reveals on real mouse input. Works for pad users too - not gated on
|
|
// ac6_kbm_enabled.
|
|
void CursorIdleHideTick() {
|
|
const double secs = REXCVAR_GET(ac6_cursor_hide_seconds);
|
|
const int64_t now = NowMs();
|
|
bool want_hide = false;
|
|
if (secs > 0.0 && !g_hide_cursor.load(std::memory_order_relaxed)) {
|
|
HWND fg = GetForegroundWindow();
|
|
DWORD pid = 0;
|
|
if (fg) GetWindowThreadProcessId(fg, &pid);
|
|
if (fg && pid == GetCurrentProcessId()) {
|
|
EnsureCursorSubclass(fg);
|
|
POINT p{};
|
|
if (GetCursorPos(&p)) {
|
|
static POINT s_last{}; // poll-thread only
|
|
static bool s_have_last = false;
|
|
if (!s_have_last || p.x != s_last.x || p.y != s_last.y) {
|
|
s_have_last = true;
|
|
s_last = p;
|
|
g_idle_last_motion_ms.store(now, std::memory_order_relaxed);
|
|
} else if (!rex::ui::ImGuiDrawer::DialogsVisible() &&
|
|
now - g_idle_last_motion_ms.load(std::memory_order_relaxed) >=
|
|
static_cast<int64_t>(secs * 1000.0)) {
|
|
want_hide = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (g_idle_hidden.exchange(want_hide, std::memory_order_relaxed) != want_hide &&
|
|
g_subclassed_hwnd) {
|
|
// Nudge the window thread to apply the new state (hide, or re-arrow
|
|
// after an overlay opened / the cvar changed - no mouse motion needed).
|
|
PostMessageW(g_subclassed_hwnd, WM_SETCURSOR, reinterpret_cast<WPARAM>(g_subclassed_hwnd),
|
|
HTCLIENT);
|
|
}
|
|
}
|
|
#else
|
|
void SetCursorHidden(bool, void*) {}
|
|
void CursorIdleHideTick() {}
|
|
#endif
|
|
|
|
// ---- Mouse steering (M3) -----------------------------------------------------
|
|
// Virtual left stick fed by raw cursor deltas (state in MouseSteer above).
|
|
// While flying with mode=steer and the mouse owned by the game, 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.
|
|
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<double>(cur.x - center.x);
|
|
double dy_px = static_cast<double>(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<uint32_t, InstanceState> g_state; // guest inst addr -> state
|
|
|
|
uint32_t LoadWord(uint8_t* base, uint32_t ea) {
|
|
return rex::memory::load_and_swap<uint32_t>(base + ea);
|
|
}
|
|
void StoreWord(uint8_t* base, uint32_t ea, uint32_t v) {
|
|
rex::memory::store_and_swap<uint32_t>(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<uint32_t>(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(const GateState& gate) {
|
|
uint32_t mirror = 0;
|
|
for (size_t i = 0; i < kNumMenuActions; ++i) {
|
|
for (VirtualKey vk : g_config.menu_keys[i]) {
|
|
if (KeyAllowed(gate, vk) && KeyHeld(vk)) {
|
|
mirror |= 1u << kMenuActions[i].mirror_bit;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return mirror;
|
|
}
|
|
|
|
void InjectMenu(uint8_t* base, uint32_t inst, double dt, 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.
|
|
// kb_mirror is already input-ownership-gated by GatherMirrorBits; a gate
|
|
// closing mid-hold arrives as kb_mirror dropping to 0, which produces the
|
|
// released-edge below - keys never stick when an overlay takes the input.
|
|
uint32_t level = 0;
|
|
if (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<float>(base + inst + kOffRepeatDelay);
|
|
const float rate = rex::memory::load_and_swap<float>(base + inst + kOffRepeatRate);
|
|
float fdt = static_cast<float>(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<uint32_t, uint32_t> 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<float>(base + inst + kOffAnalogA + i * 4);
|
|
const float vc = rex::memory::load_and_swap<float>(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(const GateState& gate) {
|
|
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 (KeyAllowed(gate, vk) && KeyHeld(vk)) {
|
|
bits |= kXBits[i];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return bits;
|
|
}
|
|
|
|
PPC_FUNC_IMPL(rex_sub_82390CE0) {
|
|
PPC_FUNC_PROLOGUE();
|
|
|
|
const uint32_t lr = static_cast<uint32_t>(ctx.lr);
|
|
const uint32_t user = ctx.r3.u32;
|
|
const uint32_t state_ptr = ctx.r4.u32;
|
|
__imp__rex_sub_82390CE0(ctx, base);
|
|
|
|
// Free-cursor idle hide rides the input poll (user 0 = once per poll
|
|
// round). Deliberately outside the ac6_kbm_enabled gate: pad users get
|
|
// the timeout too.
|
|
if (user == 0) {
|
|
CursorIdleHideTick();
|
|
}
|
|
|
|
// 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<uint32_t>(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.
|
|
// Ownership gating is per input KIND (KeyAllowed): keyboard keys drop out
|
|
// while an overlay holds keyboard focus, mouse buttons and the wheel while
|
|
// the cursor is over an overlay. A gate closing mid-hold simply stops
|
|
// asserting the bits - downstream edge detection sees a clean release.
|
|
if (REXCVAR_GET(ac6_kbm_enabled) && user == 0 && state_ptr != 0 &&
|
|
ctx.r3.u32 == 0) {
|
|
const GateState gate = QueryGate();
|
|
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 (KeyAllowed(gate, vk) && 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 (KeyAllowed(gate, vk) && KeyHeld(vk)) {
|
|
cam_mode = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Mouse steering: runs every flight poll while the game owns the
|
|
// pointer (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. If a visible
|
|
// overlay owns the pointer (cursor hovering it while free), steering
|
|
// does not acquire - the cursor stays live for the overlay until it
|
|
// moves off, exactly like a desktop window under the pointer.
|
|
double mx = 0.0, my = 0.0;
|
|
bool mouse_on = false;
|
|
if (gate.mouse_ok()) {
|
|
mouse_on = MouseSteerPoll(cam_mode, mx, my);
|
|
} else {
|
|
MouseSteerRelease();
|
|
}
|
|
|
|
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<uint16_t>(base + state_ptr + 4);
|
|
rex::memory::store_and_swap<uint16_t>(base + state_ptr + 4,
|
|
static_cast<uint16_t>(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<int16_t>(v > 32767 ? 32767 : (v < -32767 ? -32767 : v));
|
|
};
|
|
if (lx != 0) {
|
|
rex::memory::store_and_swap<int16_t>(base + state_ptr + 8, clamp16(lx));
|
|
} else if (mouse_on && !cam_mode && mx != 0.0) {
|
|
rex::memory::store_and_swap<int16_t>(base + state_ptr + 8,
|
|
clamp16(static_cast<int32_t>(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<int16_t>(base + state_ptr + 10, clamp16(ly));
|
|
} else if (mouse_on && !cam_mode && my != 0.0) {
|
|
rex::memory::store_and_swap<int16_t>(base + state_ptr + 10,
|
|
clamp16(static_cast<int32_t>(-my * 32767.0)));
|
|
}
|
|
if (rx != 0) {
|
|
rex::memory::store_and_swap<int16_t>(base + state_ptr + 12, clamp16(rx));
|
|
} else if (mouse_on && cam_mode && mx != 0.0) {
|
|
rex::memory::store_and_swap<int16_t>(base + state_ptr + 12,
|
|
clamp16(static_cast<int32_t>(mx * 32767.0)));
|
|
}
|
|
if (ry != 0) {
|
|
rex::memory::store_and_swap<int16_t>(base + state_ptr + 14, clamp16(ry));
|
|
} else if (mouse_on && cam_mode && my != 0.0) {
|
|
rex::memory::store_and_swap<int16_t>(base + state_ptr + 14,
|
|
clamp16(static_cast<int32_t>(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(gate);
|
|
if (kb != 0) {
|
|
const uint16_t cur = rex::memory::load_and_swap<uint16_t>(base + state_ptr + 4);
|
|
rex::memory::store_and_swap<uint16_t>(base + state_ptr + 4,
|
|
static_cast<uint16_t>(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<uint16_t>(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 wrappers in
|
|
// ac6_fps_physics_fix.cpp: the main force step rex_sub_823046A0 (airborne),
|
|
// the alternate step rex_sub_82305278 (rolling on the ground, mode bit 0x40),
|
|
// and - player-scoped - the master updates plus the ground-state maintainer,
|
|
// which still tick at a full standstill where the deactivated model (bit
|
|
// 0x80) steps neither. All of them halt when the sim halts, so menus and
|
|
// pause still fall back to the menu key set. 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<uint16_t>(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(gate);
|
|
|
|
// Gate transitions (capped): one line whenever input ownership changes -
|
|
// the direct log evidence for the overlay open/close and hover checks.
|
|
if (REXCVAR_GET(ac6_kbm_log)) {
|
|
static int s_gate_lines = 0;
|
|
static uint32_t s_gate_last = 0xFF;
|
|
const uint32_t sig = (gate.fg_ok << 0) | (gate.capture_mouse << 1) |
|
|
(gate.capture_keyboard << 2) | (gate.want_text << 3) |
|
|
(gate.steer_owns_pointer << 4);
|
|
if (sig != s_gate_last && s_gate_lines < 200) {
|
|
s_gate_last = sig;
|
|
++s_gate_lines;
|
|
KbmLog(fmt::format(
|
|
"gate change: fg={} capMouse={} capKb={} text={} steerOwn={} -> keys={} mouse={}",
|
|
gate.fg_ok, gate.capture_mouse, gate.capture_keyboard, gate.want_text,
|
|
gate.steer_owns_pointer, gate.keys_ok(), gate.mouse_ok()));
|
|
}
|
|
}
|
|
|
|
// 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)) {
|
|
GateState raw; // all-open gate: rawMirror = physical key detection
|
|
raw.fg_ok = true;
|
|
KbmLog(fmt::format(
|
|
"alive call={} inst=0x{:08X} singleton=0x{:08X} fifth@0x{:08X} dt={:.4f} "
|
|
"gate[fg={} capMouse={} capKb={} text={} steerOwn={} keys={} mouse={}] targets=[{}] "
|
|
"rawMirror=0x{:X} gatedMirror=0x{:X} flight={}",
|
|
call, inst, singleton, s_fifth_inst, dt, gate.fg_ok, gate.capture_mouse,
|
|
gate.capture_keyboard, gate.want_text, gate.steer_owns_pointer, gate.keys_ok(),
|
|
gate.mouse_ok(), fmt::join(g_config.menu_instances, ","), GatherMirrorBits(raw),
|
|
kb_mirror, FlightActive()));
|
|
}
|
|
|
|
if (!FlightActive()) {
|
|
for (int target : g_config.menu_instances) {
|
|
if (target == idx && idx != 2) {
|
|
InjectMenu(base, inst, dt, 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);
|
|
}
|