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) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-14 09:08:53 +02:00
1 parent 52db0f79f9
commit aa7d49409c
6 files changed
+425

No files matched your search

+320
View File
@@ -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 <cctype>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
#include <windows.h>
#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<GetObjectFn>(0x005fe060);
const auto DynamicCast = reinterpret_cast<DynamicCastFn>(0x00849701);
const auto SendOrder = reinterpret_cast<SendOrderFn>(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<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;
}
// --- 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<unsigned char>(value[0]))) {
return static_cast<char>(toupper(static_cast<unsigned char>(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<uint16_t>(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<uint32_t*>(event) != kEventKeyVtable) {
return;
}
auto* bytes = static_cast<uint8_t*>(event);
const uint16_t modifiers = *reinterpret_cast<uint16_t*>(bytes + kKeyModifiers);
if ((modifiers & kModifiersCtrlAlt) != 0 || TypingIntoTextField()) {
return;
}
key_ = reinterpret_cast<uint16_t*>(bytes + kKey);
raw_ = reinterpret_cast<uint16_t*>(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<void**>(kObjectManager);
if (!manager) {
return nullptr;
}
void* object = GetObjectById(manager, nullptr, *reinterpret_cast<uint32_t*>(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<uint32_t*>(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<const uint8_t*>(creature);
const double length = std::sqrt(static_cast<double>(dx * dx + dy * dy));
const int x = *reinterpret_cast<const int32_t*>(c + 0x1c) + static_cast<int>(dx / length * kTargetDistance);
const int y = *reinterpret_cast<const int32_t*>(c + 0x20) + static_cast<int>(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<uint8_t*>(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