From aa7d49409c306a5a37c8d4b04b3d7ffd36d4d5b0 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Wed, 14 Oct 2026 09:08:53 +0200 Subject: [PATCH] WASD movement for the player While W/A/S/D are held, the controlled creature gets a plain walk order every engine tick toward its position plus the pressed screen direction, as the mouse does while its button is held (every mouse event re-sends a walk to the cursor point); releasing sends the target-less stop order of a released mouse button. W/A/S/D key events are swallowed before the hotkeys see them; their old actions move to G (action slot 3), V (pick up nearby) and K (save game window) for now, configurable in [wasd]. docs/wasd.md: order format, measured mouse behaviour, hotkey map. docs/todo.md: better pathfinder; final bindings for the moved hotkeys. re::ConfigString for string settings. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + docs/todo.md | 30 +++++ docs/wasd.md | 60 +++++++++ src/mods/wasd.cpp | 320 ++++++++++++++++++++++++++++++++++++++++++++++ src/re/config.cpp | 13 ++ src/re/config.h | 1 + 6 files changed, 425 insertions(+) create mode 100644 docs/todo.md create mode 100644 docs/wasd.md create mode 100644 src/mods/wasd.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 37c3ad9..60cfd07 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,7 @@ add_library(sacred_re SHARED src/mods/borderless.cpp src/mods/widescreen.cpp src/mods/hud.cpp + src/mods/wasd.cpp ) target_include_directories(sacred_re PRIVATE src) target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..ce739a7 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,30 @@ +# To do + +## Better pathfinder (mouse clicks and monsters) + +Replace the game's pathfinding with a more reliable and efficient one, for +click-to-move and for monster AI. What we know so far: + +- Walk orders are `cEvent_creature` command 4 with a ground target (+0x18/+0x1c), + sent through `cEngine::SendCreatureOrder` (`0x6170d0`), which snaps the + target to a walkable point (`0x616b70`) and hands it to + `cCreature::ReceiveOrder` (`0x52e400`, vf18, ~6400 decompiled lines). +- Far targets (~2000 world units) are rejected or followed badly. +- Still to map: the path search itself, the sector/tile collision data it + reads, and how monster AI issues its moves. + +## Key bindings moved for WASD movement (temporary) + +W, A and S were game hotkeys. With WASD movement (`src/mods/wasd.cpp`) their +actions moved to other keys for now; choose final bindings (and update the +in-game texts/tooltips that name the old keys): + +| Key | Action | Temporary key (`[wasd]` in sacred_re.ini) | +|---|---|---| +| W | action slot 3 (`cEngine::UseActionSlot`, like Q/E/R/B/Space) | G (`ActionKeyW`) | +| A | pick up items nearby (creature event 0x113) | V (`ActionKeyA`) | +| S | save game window (`UI_WND_SAVEGAME`) | K (`ActionKeyS`) | +| D | unbound | - | + +Globally bound letters (engine `0x618130`, UI manager `0x757120`): A B C E F +H I L M N O P Q R S T W, Space, ^, digits. Individual windows may use more. diff --git a/docs/wasd.md b/docs/wasd.md new file mode 100644 index 0000000..d6bf12e --- /dev/null +++ b/docs/wasd.md @@ -0,0 +1,60 @@ +# WASD movement (`src/mods/wasd.cpp`) + +## How the game moves the hero + +Movement is a creature order: `cEvent_creature` (vtable `0x88e95c`, 0x44 +bytes), command 4 = walk, sent with `cEngine::SendCreatureOrder` (`0x6170d0`, +`(creature, event)`), which snaps a ground target to a walkable point and +passes the order to `creature->vf18` (`cCreature::ReceiveOrder`, `0x52e400`). + +| Offset | Field | +|---|---| +| +4 | command (4 walk, 0x105 ...) | +| +0x10 | target object id (attacks) | +| +0x14 | 2: x, y are a ground point; 0: no target | +| +0x18 / +0x1c | ground point (world units, same as creature +0x1c / +0x20) | +| +0x20 | 1: re-aim while a mouse button is held; 2: skip target snapping | + +The controlled creature's object id is at `cEngine+0x13c`; objects come from +`cObjectManager::GetObject` (`0x5fe060`, manager `*0xad3bc0`), cast with +`__RTDynamicCast` to `cCreature`. Hero state: `+0xfc` action mode (2 while +following a held mouse button), `+0x150` movement (1 walking, 2 arriving, +0 standing). + +Mouse, measured with an order log: + +- click (`cEngine::HandleMouseButton` `0x617360`): one walk order to the + ground point, then engine flag `+0x54 |= 0x10` (button held); +- while held: every mouse event re-sends a plain walk order (+0x20 = 0) to the + point under the cursor, dozens per tick; the hero stays in mode 2 and walks + smoothly. `cEngine::Tick` (`0x60f1d0`, 10 Hz) additionally sends a re-aim + order (+0x20 = 1) per tick after 0.4 s of walking; +- release after > 0.5 s: a walk order without target (+0x14 = 0), which stops + the hero in place. + +## What the mod does + +Every `cEngine::Tick`, while W/A/S/D are held (and no text field has the +focus, no mouse button is held): a plain walk order to hero position + +direction x 300 world units (about a cursor's distance). Screen directions in +world axes: up (-1,-1), down (1,1), left (-1,1), right (1,-1); combinations are +summed and normalised. All keys released: the target-less stop order. + +Dead ends, for the record: re-aim orders (+0x20 = 1) on their own stop the +hero after two orders; far targets (2000 units) are rejected or followed +badly; targets near the stepwise-updated position make it arrive and stop. + +## Hotkeys + +Keys reach the game as `cEvent_key` (category 4; key at +6/+8, modifiers at ++0xa) through the kernel queue to `cEngine::ReceiveEvent` (`0x618130`), which +offers them to `cUI_Manager::ReceiveEvent` (`0x757120`) first. Both compare +`toupper(key)` (`0x84aa86`). The mod swallows W/A/S/D there (auto-repeat events +would trigger the hotkeys) and rewrites the replacement keys into the old +letters; the temporary bindings are listed in `docs/todo.md`. + +Hotkeys found: engine A (pick up nearby), P (pause), Q/W/E/R/B/Space +(`cEngine::UseActionSlot`), ^ (console), Esc, Ctrl+B/F/X/Z (Z quicksave); UI +manager C F H I L M N O S T (S save game window). Arrow keys in +`cEngine::ScrollInput` (`0x610650`) walk object #2 or scroll the camera; not +reachable in normal play. diff --git a/src/mods/wasd.cpp b/src/mods/wasd.cpp new file mode 100644 index 0000000..766f105 --- /dev/null +++ b/src/mods/wasd.cpp @@ -0,0 +1,320 @@ +// WASD movement for the player character. +// +// While W, A, S or D is held, the controlled creature gets a plain walk order +// (cEvent_creature 4 to a ground point) every engine tick, aimed a cursor's +// distance from it in the pressed screen direction. That is what the mouse +// does while its button is held: every mouse event re-sends a walk to the point +// under the cursor (cEngine::HandleMouseButton), which keeps the creature +// walking smoothly. (The "held" re-aim orders cEngine::Tick sends as well stop +// it when sent on their own.) Releasing the keys sends the target-less walk +// order a released mouse button sends, which stops it in place. +// +// W, A and S are game hotkeys: W = action slot 3 (like Q/E/R/B/Space, +// 0x613040), A = pick up items nearby, S = save game window. Key events are +// filtered before the engine and UI see them: W/A/S/D are swallowed (key +// repeat would otherwise reach the hotkeys), and replacement keys are rewritten +// into the old letters (temporary choices, see docs/todo.md). Typing into a +// text field (chat, console, save names) and Ctrl/Alt combinations are left +// alone. See docs/wasd.md. +// +// sacred_re.ini: +// [wasd] +// Enabled=1 +// ActionKeyW=G ; key that now does what W did +// ActionKeyA=V ; ... A (pick up nearby) +// ActionKeyS=K ; ... S (save game window) + +#include +#include +#include +#include +#include + +#include + +#include "re/config.h" +#include "re/hook.h" +#include "re/log.h" +#include "re/patch.h" + +namespace mods::wasd { +namespace { + +// --- sacred.exe --------------------------------------------------------------- +constexpr uint32_t kObjectManager = 0x00ad3bc0; // object manager** +constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager*: focused control at +0x148 +constexpr uint32_t kEventKeyVtable = 0x00895568; +constexpr uint32_t kEventCreatureVtable = 0x0088e95c; +constexpr uint32_t kTypeObject = 0x008e9648; // RTTI type descriptors +constexpr uint32_t kTypeCreature = 0x008e9660; +constexpr uint32_t kTypeControl = 0x009de168; +constexpr uint32_t kTypeEditControl = 0x009e3608; + +using GetObjectFn = void*(__fastcall*)(void* manager, void* edx, uint32_t id); +using DynamicCastFn = void*(__cdecl*)(void* object, int vfDelta, uint32_t source, uint32_t target, int isReference); +using SendOrderFn = void(__fastcall*)(void* engine, void* edx, void* creature, void* event); +const auto GetObjectById = reinterpret_cast(0x005fe060); +const auto DynamicCast = reinterpret_cast(0x00849701); +const auto SendOrder = reinterpret_cast(0x006170d0); // adjusts ground targets, then vf18 + +// cEngine fields. +constexpr size_t kEngineFlags = 0x54; // 0x10 mouse button held (hold-to-move), 0x4000 input blocked +constexpr size_t kEngineControlledId = 0x13c; // object id of the controlled creature + +// cEvent_creature, command 4 = walk. +struct CreatureEvent { + uint32_t vtable; + uint32_t command; + uint32_t unknown08[3]; + uint32_t groundTarget; // 2: x, y are a point on the ground + int32_t x; + int32_t y; + uint32_t held; // 1: re-aim while a mouse button is held (cEngine::Tick) + uint32_t unknown24[8]; +}; +static_assert(sizeof(CreatureEvent) == 0x44); + +// cEvent_key: +4 1 = pressed / character, 0 = released; +6 and +8 the key +// (character for WM_CHAR, virtual key for WM_KEYUP); +0xa modifiers +// (0x3 Shift, 0xc Ctrl, 0x30 Alt). +constexpr size_t kKeyRaw = 0x6; +constexpr size_t kKey = 0x8; +constexpr size_t kKeyModifiers = 0xa; +constexpr uint16_t kModifiersCtrlAlt = 0x3c; + +// Walk orders aim this far (world units) from the creature, about where the +// cursor would be. +constexpr double kTargetDistance = 300.0; + +bool Enabled() { + static const bool enabled = re::ConfigInt("wasd", "Enabled", 1) != 0; + return enabled; +} + +bool KeyDown(int key) { + return (GetAsyncKeyState(key) & 0x8000) != 0; +} + +// Whether a text field has the keyboard (as the window procedure checks it). +bool TypingIntoTextField() { + auto* ui = *reinterpret_cast(kUiManager); + if (!ui) { + return false; + } + void* focus = *reinterpret_cast(ui + 0x148); + if (!focus) { + return false; + } + auto* edit = static_cast(DynamicCast(focus, 0, kTypeControl, kTypeEditControl, 0)); + return edit && ((*reinterpret_cast(edit + 0x10) >> 16) & 1) == 0; +} + +// --- hotkey remapping ---------------------------------------------------------- + +struct Remap { + char from; // replacement key the player presses + char to; // original hotkey letter +}; +Remap g_remaps[3]; + +char ConfigKey(const char* key, char fallback) { + const std::string value = re::ConfigString("wasd", key, ""); + if (value.size() == 1 && isalpha(static_cast(value[0]))) { + return static_cast(toupper(static_cast(value[0]))); + } + return fallback; +} + +void LoadRemaps() { + g_remaps[0] = {ConfigKey("ActionKeyW", 'G'), 'W'}; + g_remaps[1] = {ConfigKey("ActionKeyA", 'V'), 'A'}; + g_remaps[2] = {ConfigKey("ActionKeyS", 'K'), 'S'}; +} + +bool IsMovementKey(int upper) { + return upper == 'W' || upper == 'A' || upper == 'S' || upper == 'D'; +} + +// Returns the key the game should see instead of `key` (0: none). +uint16_t RemapKey(uint16_t key) { + if (key >= 0x80) { + return key; + } + const int upper = toupper(key); + const bool lower = upper != key; + if (IsMovementKey(upper)) { + return 0; + } + for (const Remap& remap : g_remaps) { + if (upper == remap.from) { + return static_cast(lower ? tolower(remap.to) : remap.to); + } + } + return key; +} + +thread_local int g_keyEventDepth = 0; + +// RAII: rewrite a key event for the scope of the outermost receiver. +class KeyEventRemap { +public: + explicit KeyEventRemap(void* event) { + if (g_keyEventDepth++ > 0 || !Enabled() || !event || + *static_cast(event) != kEventKeyVtable) { + return; + } + auto* bytes = static_cast(event); + const uint16_t modifiers = *reinterpret_cast(bytes + kKeyModifiers); + if ((modifiers & kModifiersCtrlAlt) != 0 || TypingIntoTextField()) { + return; + } + key_ = reinterpret_cast(bytes + kKey); + raw_ = reinterpret_cast(bytes + kKeyRaw); + originalKey_ = *key_; + originalRaw_ = *raw_; + *key_ = RemapKey(originalKey_); + *raw_ = RemapKey(originalRaw_); + // W/A/S/D: the game must not see these at all (key repeat while held + // interrupts walking); movement polls the keys itself. + swallow_ = *key_ == 0; + } + bool Swallow() const { return swallow_; } + ~KeyEventRemap() { + --g_keyEventDepth; + if (key_) { + *key_ = originalKey_; + *raw_ = originalRaw_; + } + } + KeyEventRemap(const KeyEventRemap&) = delete; + KeyEventRemap& operator=(const KeyEventRemap&) = delete; + +private: + uint16_t* key_ = nullptr; + uint16_t* raw_ = nullptr; + uint16_t originalKey_ = 0; + uint16_t originalRaw_ = 0; + bool swallow_ = false; +}; + +// --- movement ------------------------------------------------------------------ + +void* ControlledCreature(uint8_t* engine) { + void* manager = *reinterpret_cast(kObjectManager); + if (!manager) { + return nullptr; + } + void* object = GetObjectById(manager, nullptr, *reinterpret_cast(engine + kEngineControlledId)); + return object ? DynamicCast(object, 0, kTypeObject, kTypeCreature, 0) : nullptr; +} + +void SendWalk(uint8_t* engine, void* creature, int x, int y) { + CreatureEvent event = {}; + event.vtable = kEventCreatureVtable; + event.command = 4; + event.groundTarget = 2; + event.x = x; + event.y = y; + SendOrder(engine, nullptr, creature, &event); +} + +// A walk order without a target stops the creature where it is (what +// releasing a held mouse button sends, cEngine::HandleMouseButton). +void SendStop(uint8_t* engine, void* creature) { + CreatureEvent event = {}; + event.vtable = kEventCreatureVtable; + event.command = 4; + SendOrder(engine, nullptr, creature, &event); +} + +struct MoveState { + bool moving = false; +}; +MoveState g_move; + +// Called once per engine tick (10 Hz), after the game's own input handling. +void Update(uint8_t* engine) { + // World axes on screen: (-1,-1) up, (1,1) down, (-1,1) left, (1,-1) right. + int dx = 0; + int dy = 0; + const uint32_t flags = *reinterpret_cast(engine + kEngineFlags); + const bool mouseMoving = (flags & 0x10) != 0; + if (!mouseMoving && (flags & 0x4000) == 0 && !TypingIntoTextField()) { + if (KeyDown('W')) { dx -= 1; dy -= 1; } + if (KeyDown('S')) { dx += 1; dy += 1; } + if (KeyDown('A')) { dx -= 1; dy += 1; } + if (KeyDown('D')) { dx += 1; dy -= 1; } + } + if (dx == 0 && dy == 0) { + if (g_move.moving && !mouseMoving) { + if (void* creature = ControlledCreature(engine)) { + SendStop(engine, creature); + } + } + g_move.moving = false; + return; + } + void* creature = ControlledCreature(engine); + if (!creature) { + return; + } + const auto* c = static_cast(creature); + const double length = std::sqrt(static_cast(dx * dx + dy * dy)); + const int x = *reinterpret_cast(c + 0x1c) + static_cast(dx / length * kTargetDistance); + const int y = *reinterpret_cast(c + 0x20) + static_cast(dy / length * kTargetDistance); + SendWalk(engine, creature, x, y); + g_move.moving = true; +} + +void Startup() { + if (!Enabled()) { + return; + } + LoadRemaps(); + re::Log("wasd: movement on W/A/S/D; old W/A/S actions on %c/%c/%c", g_remaps[0].from, g_remaps[1].from, + g_remaps[2].from); +} +RE_STARTUP(WasdStartup, Startup); + +} // namespace +} // namespace mods::wasd + +namespace game { + +// cEngine::Tick (0x60f1d0, 10 Hz): processes queued events, then the mouse's +// hold-to-move re-aim orders. +void __fastcall Engine_Tick(void* self, void* edx, void* event); +RE_HOOK(0x0060f1d0, Engine_Tick); +void __fastcall Engine_Tick(void* self, void* edx, void* event) { + Engine_Tick_original(self, edx, event); + if (mods::wasd::Enabled() && self) { + mods::wasd::Update(static_cast(self)); + } +} + +// cEngine::vf00 (0x618130), the engine's event receiver: hotkeys, after +// offering every event to the UI manager. +char __fastcall Engine_ReceiveEvent(void* self, void* edx, void* event); +RE_HOOK(0x00618130, Engine_ReceiveEvent); +char __fastcall Engine_ReceiveEvent(void* self, void* edx, void* event) { + mods::wasd::KeyEventRemap remap(event); + if (remap.Swallow()) { + return 1; + } + return Engine_ReceiveEvent_original(self, edx, event); +} + +// cUI_Manager::vf00 (0x757120): UI hotkeys (S = save game window), also +// reached without the engine. +char __fastcall UiManager_ReceiveEvent(void* self, void* edx, void* event); +RE_HOOK(0x00757120, UiManager_ReceiveEvent); +char __fastcall UiManager_ReceiveEvent(void* self, void* edx, void* event) { + mods::wasd::KeyEventRemap remap(event); + if (remap.Swallow()) { + return 1; + } + return UiManager_ReceiveEvent_original(self, edx, event); +} + +} // namespace game + diff --git a/src/re/config.cpp b/src/re/config.cpp index 23114b7..f0af6cd 100644 --- a/src/re/config.cpp +++ b/src/re/config.cpp @@ -1,6 +1,7 @@ #include "re/config.h" #include +#include #include @@ -34,4 +35,16 @@ int ConfigInt(const char* section, const char* key, int fallback) { return static_cast(GetPrivateProfileIntW(Widen(section).c_str(), Widen(key).c_str(), fallback, g_path.c_str())); } +std::string ConfigString(const char* section, const char* key, const char* fallback) { + if (g_path.empty()) { + return fallback; + } + wchar_t value[256]; + GetPrivateProfileStringW(Widen(section).c_str(), Widen(key).c_str(), Widen(fallback).c_str(), value, + static_cast(std::size(value)), g_path.c_str()); + char narrow[256]; + WideCharToMultiByte(CP_UTF8, 0, value, -1, narrow, sizeof(narrow), nullptr, nullptr); + return narrow; +} + } // namespace re diff --git a/src/re/config.h b/src/re/config.h index 24eb048..31d8ace 100644 --- a/src/re/config.h +++ b/src/re/config.h @@ -10,5 +10,6 @@ const std::wstring& ConfigPath(); // Folder of sacred_re.dll (with trailing separator), derived from the config path. std::wstring ModuleDirectory(); int ConfigInt(const char* section, const char* key, int fallback); +std::string ConfigString(const char* section, const char* key, const char* fallback); } // namespace re