// 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. Skill keys cast their slot (src/mods/skillkeys.cpp), // weapon set keys select it on the taskbar; keys standing in for an original // hotkey post that hotkey's key event 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, keys pass through unchanged. (Keys // with Ctrl / Alt held used to pass too; GetKeyState reports Alt as held after // Alt+Tab until it is pressed again in the game, which switched the bindings // off. Alt+key arrives as WM_SYSKEYDOWN and is not handled here anyway.) // // sacred_re.ini (key names: A-Z, 0-9, F1-F24, Space, None): // [keys] // SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art) // WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set // ActionSlot3=G ; what W did (W is movement) // PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up) // SaveGame=K ; what S did (save game window) #include "mods/input.h" #include #include #include #include #include #include "game/engine/events.h" #include "game/engine/objects.h" #include "game/engine/orders.h" #include "mods/skillbar.h" #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* constexpr uint32_t kKeyModifiers = 0x0182cdec; // u16, as the in-game window procedure keeps it constexpr uint32_t kEventKeyVtable = 0x00895568; // 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(pressed ? tolower(key) : toupper(key)); event.raw = code; event.key = code; event.modifiers = *reinterpret_cast(kKeyModifiers); game::PostEvent(&event); } // --- bindings ------------------------------------------------------------------ enum class Kind { SkillSlot, WeaponSet, GameKey, PickUp }; 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[] = { {"SkillSlot0", "0", Kind::SkillSlot, 0, 0}, {"SkillSlot1", "1", Kind::SkillSlot, 1, 0}, {"SkillSlot2", "2", Kind::SkillSlot, 2, 0}, {"SkillSlot3", "3", Kind::SkillSlot, 3, 0}, {"SkillSlot4", "4", Kind::SkillSlot, 4, 0}, {"SkillSlot5", "5", Kind::SkillSlot, 5, 0}, {"SkillSlot6", "6", 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", "Q", Kind::PickUp, 0, 0}, {"SaveGame", "K", Kind::GameKey, 0, 'S'}, }; int ParseKey(const std::string& text) { if (text.size() == 1 && isalnum(static_cast(text[0]))) { return toupper(static_cast(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); } // Pick up items nearby: the order the engine's A hotkey sends (creature order // 0x113, cEngine::ReceiveEvent 0x618c41; the hero collects, mode +0xfc 0xc), // given to the controlled creature on the next engine tick. (Posting the A // key event did not reach it: no pickup was logged.) constexpr uint32_t kOrderPickUp = 0x113; std::atomic g_pickUp{false}; // The collect mode (0x4ec3a0, substate 0 without a target) looks for an item // in square rings of map cells around the hero, nearest ring first, while the // ring's low offset is above -20 (cmp edi,-20 at 0x4ec8c6). Twice as far. constexpr uint32_t kCollectRingLimit = 0x004ec8c6; constexpr uint8_t kCollectRingsOriginal = 0xec; // -20 constexpr uint8_t kCollectRings = 0xd8; // -40 void OnTick(uint8_t* engine) { if (!g_pickUp.exchange(false)) { return; } if (void* hero = game::ControlledCreature(engine)) { game::CreatureOrder order; order.command = kOrderPickUp; game::SendCreatureOrder(engine, hero, order); } } struct Registration { Registration() { game::OnEngineTick(&OnTick); } } g_registration; void Press(const Binding& binding, int vk) { const auto slot = static_cast(binding.index); switch (binding.kind) { case Kind::SkillSlot: mods::skillkeys::KeyPressed(slot, vk); break; case Kind::WeaponSet: mods::skillbar::SelectWeaponSet(slot); break; case Kind::GameKey: PostGameKey(binding.gameKey, true); break; case Kind::PickUp: g_pickUp = true; break; } } void Release(const Binding& binding, int vk) { switch (binding.kind) { case Kind::SkillSlot: mods::skillkeys::KeyReleased(vk); break; case Kind::WeaponSet: case Kind::PickUp: break; case Kind::GameKey: PostGameKey(binding.gameKey, false); break; } } // 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(); if (!re::PatchInstructionByte(kCollectRingLimit, 2, kCollectRingsOriginal, kCollectRings)) { re::Log("input: pickup range patch FAILED"); } re::Log("input: key bindings active"); } RE_STARTUP(InputStartup, Startup); // The text field that has the keyboard, as the game decides it: the UI // manager hands key events to its dialog (+0x144) while that is shown // (+0x10 bit 0; cUI_Manager::ReceiveEvent 0x7573ba), and the dialog's focused // control (+0x148) is an edit control without the read-only bit (+0x10 bit // 16). The focus pointer alone stays set after the dialog closed (measured: // the save window's 'DEFAULT_EDIT' after closing it), which switched the // bindings off. constexpr size_t kUiDialog = 0x144; constexpr size_t kUiFocus = 0x148; constexpr size_t kControlFlags = 0x10; constexpr uint32_t kControlVisible = 1; constexpr uint32_t kEditReadOnly = 1u << 16; uint8_t* FocusedTextField() { auto* ui = *reinterpret_cast(kUiManager); auto* dialog = ui ? *reinterpret_cast(ui + kUiDialog) : nullptr; if (!dialog || (*reinterpret_cast(dialog + kControlFlags) & kControlVisible) == 0) { return nullptr; } void* focus = *reinterpret_cast(ui + kUiFocus); auto* edit = static_cast(focus ? game::DynamicCast(focus, game::kTypeControl, game::kTypeEditControl) : nullptr); if (!edit || (*reinterpret_cast(edit + kControlFlags) & kEditReadOnly) != 0) { return nullptr; } return edit; } } // namespace bool TypingIntoTextField() { return FocusedTextField() != nullptr; } bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_KEYDOWN: { const int vk = static_cast(wParam & 0xff); const bool repeat = (lParam & (1 << 30)) != 0; if (!repeat) { g_taken[vk] = false; if (FocusedTextField()) { 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(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