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:
1 parent
52db0f79f9
commit
aa7d49409c
6 files changed
+425
No files matched your search
@@ -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})
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "re/config.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <iterator>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
@@ -34,4 +35,16 @@ int ConfigInt(const char* section, const char* key, int fallback) {
|
||||
return static_cast<int>(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<DWORD>(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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user