Files
sacred_k/src/mods/input.cpp
T
khallaandClaude Opus 5.5 6ca96d54b4 Shared chest tabs, rune stacks and rune master, Q pickup, Kettenblitz tooltip and range
- Shared chest: tabs "Truhe"/"Gemeinsam" as a column left of the grid
  (game tab sheet), grids placed every tick, persistent across heroes
- Inventory tab texts capitalized (RegisterText / CapitalizeText)
- Combat-art runes stack like potions (itemstack, renamed from potionstack)
- Rune master: any rune for any number of runes, price by rune count
  (4 runes = 1,000, x10 per missing rune), bought rune goes into the
  backpack, Buy disabled without runes, gold or room, last rune remembered
  per hero (saved), the chosen rune's name below its icon, dropping a
  stack gives up one rune
- Keys: the old key handling no longer takes over after the save screen
  (text field focus only counts while its dialog is shown)
- Esc crash: tooltips forgotten when the game destroys them
- Pick up (Q) sends the pickup order directly; search reach doubled
- Kettenblitz: tooltip shows hits per chain, chains, cast and jump range;
  cast range 900 (longer ray segments), jumps half of that

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 03:40:42 +02:00

331 lines
11 KiB
C++

// 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 <atomic>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <string>
#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<uint16_t>(pressed ? tolower(key) : toupper(key));
event.raw = code;
event.key = code;
event.modifiers = *reinterpret_cast<uint16_t*>(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<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);
}
// 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<bool> 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<unsigned>(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<uint8_t**>(kUiManager);
auto* dialog = ui ? *reinterpret_cast<uint8_t**>(ui + kUiDialog) : nullptr;
if (!dialog || (*reinterpret_cast<uint32_t*>(dialog + kControlFlags) & kControlVisible) == 0) {
return nullptr;
}
void* focus = *reinterpret_cast<void**>(ui + kUiFocus);
auto* edit = static_cast<uint8_t*>(focus ? game::DynamicCast(focus, game::kTypeControl, game::kTypeEditControl)
: nullptr);
if (!edit || (*reinterpret_cast<uint32_t*>(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<int>(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<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