Key bindings layer: skills on 1-7, weapon sets on F1-F7

Raw WM_KEYDOWN/WM_KEYUP are handled in both window procedures (the game
swaps 0x8110d0 and 0x8122a0 with SetWindowLong; 0x8110d0 is the in-game
one). Bound keys are swallowed and post the original hotkey's key event
to the kernel, so F-keys work and bindings are layout independent
([keys] in sacred_re.ini). Replaces the WASD hotkey remap and the digit
swap; W/A/S/D and the digit row no longer reach the game. Skill keys
still cast through the right mouse button. Enabled switches removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-15 00:11:55 +02:00
1 parent 9fb6c6b406
commit cec2556fa3
11 files changed
+409 -279

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
// Key bindings.
//
// The game only sees key presses as characters (WM_CHAR -> cEvent_key), so it
// cannot see keys without one (F-keys), and its hotkeys are hardcoded in
// several event receivers (engine 0x618130, UI manager 0x757120, taskbar
// 0x6e1970). This mod reads the raw key messages instead (virtual keys,
// independent of the keyboard layout): a bound key's messages are swallowed
// and its action runs, usually by posting the key event of the original
// hotkey to the game's event kernel, the way the window procedure does (the
// kernel dispatches it right away, on the window thread).
//
// While a text field has the keyboard, or Ctrl / Alt is held, keys pass
// through unchanged.
//
// sacred_re.ini (key names: A-Z, 0-9, F1-F24, Space, None):
// [keys]
// SkillSlot1=1 ... SkillSlot7=7 ; select and cast combat-art slot
// WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set
// ActionSlot3=G ; what W did (W is movement)
// PickUp=V ; what A did (pick up items nearby)
// SaveGame=K ; what S did (save game window)
#include "mods/input.h"
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <string>
#include "mods/skillkeys.h"
#include "mods/wasd.h"
#include "re/config.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::input {
namespace {
// --- sacred.exe ---------------------------------------------------------------
constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager*: focused control at +0x148
constexpr uint32_t kKeyModifiers = 0x0182cdec; // u16, as the in-game window procedure keeps it
constexpr uint32_t kEventKeyVtable = 0x00895568;
constexpr uint32_t kTypeControl = 0x009de168;
constexpr uint32_t kTypeEditControl = 0x009e3608;
using DynamicCastFn = void*(__cdecl*)(void* object, int vfDelta, uint32_t source, uint32_t target, int isReference);
using KernelInstanceFn = void*(__cdecl*)();
using PostEventFn = void(__fastcall*)(void* kernel, void* edx, void* event, int a, int b);
const auto DynamicCast = reinterpret_cast<DynamicCastFn>(0x00849701);
const auto KernelInstance = reinterpret_cast<KernelInstanceFn>(0x00808490);
const auto PostEvent = reinterpret_cast<PostEventFn>(0x00808930); // copies the event
// cEvent_key: +4 1 = pressed, 0 = released; +6 / +8 the key (character when
// pressed, virtual key when released); +0xa modifiers.
struct KeyEvent {
uint32_t vtable;
uint16_t pressed;
uint16_t raw;
uint16_t key;
uint16_t modifiers;
};
static_assert(sizeof(KeyEvent) == 0xc);
// Posts the key event the window procedure would post for `key` (an original
// hotkey letter or digit).
void PostGameKey(char key, bool pressed) {
KeyEvent event = {};
event.vtable = kEventKeyVtable;
event.pressed = pressed ? 1 : 0;
// Pressed: the character (lower case, as without Shift); released: the
// virtual key (upper case).
const auto code = static_cast<uint16_t>(pressed ? tolower(key) : toupper(key));
event.raw = code;
event.key = code;
event.modifiers = *reinterpret_cast<uint16_t*>(kKeyModifiers);
PostEvent(KernelInstance(), nullptr, &event, 1, 0);
}
// --- bindings ------------------------------------------------------------------
enum class Kind { SkillSlot, WeaponSet, GameKey };
struct Binding {
const char* name;
const char* fallback;
Kind kind;
int index; // slot / set (0-based)
char gameKey; // GameKey: the original hotkey
int vk = 0;
};
Binding g_bindings[] = {
{"SkillSlot1", "1", Kind::SkillSlot, 0, 0},
{"SkillSlot2", "2", Kind::SkillSlot, 1, 0},
{"SkillSlot3", "3", Kind::SkillSlot, 2, 0},
{"SkillSlot4", "4", Kind::SkillSlot, 3, 0},
{"SkillSlot5", "5", Kind::SkillSlot, 4, 0},
{"SkillSlot6", "6", Kind::SkillSlot, 5, 0},
{"SkillSlot7", "7", Kind::SkillSlot, 6, 0},
{"WeaponSet1", "F1", Kind::WeaponSet, 0, 0},
{"WeaponSet2", "F2", Kind::WeaponSet, 1, 0},
{"WeaponSet3", "F3", Kind::WeaponSet, 2, 0},
{"WeaponSet4", "F4", Kind::WeaponSet, 3, 0},
{"WeaponSet5", "F5", Kind::WeaponSet, 4, 0},
{"WeaponSet6", "F6", Kind::WeaponSet, 5, 0},
{"WeaponSet7", "F7", Kind::WeaponSet, 6, 0},
{"ActionSlot3", "G", Kind::GameKey, 0, 'W'},
{"PickUp", "V", Kind::GameKey, 0, 'A'},
{"SaveGame", "K", Kind::GameKey, 0, 'S'},
};
// Taskbar digits: weapon sets 1-5 on '1'-'5', combat-art slots 1-5 on
// '6'-'9', '0'.
constexpr unsigned kGameSlots = 5;
char WeaponSetDigit(int set) { return static_cast<char>('1' + set); }
char SkillSlotDigit(int slot) { return slot < 4 ? static_cast<char>('6' + slot) : '0'; }
int ParseKey(const std::string& text) {
if (text.size() == 1 && isalnum(static_cast<unsigned char>(text[0]))) {
return toupper(static_cast<unsigned char>(text[0])); // VK of A-Z / 0-9
}
if ((text[0] == 'F' || text[0] == 'f') && text.size() <= 3) {
const int n = atoi(text.c_str() + 1);
if (n >= 1 && n <= 24) {
return VK_F1 + n - 1;
}
}
if (_stricmp(text.c_str(), "Space") == 0) {
return VK_SPACE;
}
return 0; // None / unknown
}
void LoadBindings() {
for (Binding& binding : g_bindings) {
const std::string text = re::ConfigString("keys", binding.name, binding.fallback);
binding.vk = text.empty() ? 0 : ParseKey(text);
if (binding.vk == 0 && !text.empty() && _stricmp(text.c_str(), "None") != 0) {
re::Log("input: [keys] %s=%s: unknown key name", binding.name, text.c_str());
}
}
}
const Binding* BindingFor(int vk) {
for (const Binding& binding : g_bindings) {
if (binding.vk == vk) {
return &binding;
}
}
return nullptr;
}
// Keys taken away from the game although nothing is bound to them: the digit
// row (the game's slot keys) and the movement keys.
bool Reserved(int vk) {
return (vk >= '0' && vk <= '9') || mods::wasd::IsMovementKey(vk);
}
void Press(const Binding& binding, int vk) {
switch (binding.kind) {
case Kind::SkillSlot:
if (binding.index < static_cast<int>(kGameSlots)) {
mods::skillkeys::CastOnSelect(static_cast<unsigned>(binding.index), vk);
PostGameKey(SkillSlotDigit(binding.index), true);
}
break;
case Kind::WeaponSet:
if (binding.index < static_cast<int>(kGameSlots)) {
PostGameKey(WeaponSetDigit(binding.index), true);
}
break;
case Kind::GameKey:
PostGameKey(binding.gameKey, true);
break;
}
}
void Release(const Binding& binding, int vk) {
switch (binding.kind) {
case Kind::SkillSlot:
mods::skillkeys::KeyReleased(vk);
if (binding.index < static_cast<int>(kGameSlots)) {
PostGameKey(SkillSlotDigit(binding.index), false);
}
break;
case Kind::WeaponSet:
if (binding.index < static_cast<int>(kGameSlots)) {
PostGameKey(WeaponSetDigit(binding.index), false);
}
break;
case Kind::GameKey:
PostGameKey(binding.gameKey, false);
break;
}
}
bool ModifierHeld() {
return (GetKeyState(VK_CONTROL) & 0x8000) != 0 || (GetKeyState(VK_MENU) & 0x8000) != 0;
}
// Virtual keys whose WM_KEYDOWN was taken: their WM_CHAR / WM_KEYUP are
// swallowed too, even if the state changed in between.
bool g_taken[256] = {};
// The character of the last taken key, to swallow the WM_CHAR TranslateMessage
// makes of it.
bool g_swallowChar = false;
void Startup() {
LoadBindings();
re::Log("input: key bindings active");
}
RE_STARTUP(InputStartup, Startup);
} // namespace
bool TypingIntoTextField() {
auto* ui = *reinterpret_cast<uint8_t**>(kUiManager);
if (!ui) {
return false;
}
void* focus = *reinterpret_cast<void**>(ui + 0x148);
if (!focus) {
return false;
}
auto* edit = static_cast<uint8_t*>(DynamicCast(focus, 0, kTypeControl, kTypeEditControl, 0));
return edit && ((*reinterpret_cast<uint32_t*>(edit + 0x10) >> 16) & 1) == 0;
}
bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) {
switch (message) {
case WM_KEYDOWN: {
const int vk = static_cast<int>(wParam & 0xff);
const bool repeat = (lParam & (1 << 30)) != 0;
if (!repeat) {
g_taken[vk] = false;
if (ModifierHeld() || TypingIntoTextField()) {
return false;
}
const Binding* binding = BindingFor(vk);
if (!binding && !Reserved(vk)) {
return false;
}
g_taken[vk] = true;
if (binding) {
Press(*binding, vk);
}
}
if (!g_taken[vk]) {
return false;
}
g_swallowChar = true;
return true;
}
case WM_CHAR:
if (g_swallowChar) {
g_swallowChar = false;
return true;
}
return false;
case WM_KEYUP: {
g_swallowChar = false;
const int vk = static_cast<int>(wParam & 0xff);
if (!g_taken[vk]) {
return false;
}
g_taken[vk] = false;
if (const Binding* binding = BindingFor(vk)) {
Release(*binding, vk);
}
return true;
}
}
return false;
}
} // namespace mods::input
namespace game {
// The game switches its window between two procedures (SetWindowLong at
// 0x811fbc / 0x81326f): 0x8110d0, active in-game, translates keys itself (dead
// keys, modifiers at 0x182cdec); 0x8122a0 (hooked by the borderless mod, which
// calls HandleWindowMessage) is the other one.
LRESULT CALLBACK GameWindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam);
RE_HOOK(0x008110d0, GameWindowProc);
LRESULT CALLBACK GameWindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam) {
if (mods::input::HandleWindowMessage(window, message, wParam, lParam)) {
return 0;
}
return GameWindowProc_original(window, message, wParam, lParam);
}
} // namespace game