WASD: direction movement steers the walk path directly

The keys rewrite the hero's path every cCreature::Update: one segment
along the held direction, the facing set to it, sliding along blocked
cells. Starting from idle runs the game's own Think steps at once
(StartPendingWalk, WalkAction) instead of waiting up to 110 ms, so
weapon switches no longer stop the walk. Stops wait 50 ms after the
keys are released, and a stop in progress is taken back when keys are
held again. Names for movement, stats and text-key functions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-18 03:18:51 +02:00
1 parent d296e2850b
commit 84c50d8c33
6 files changed
+422 -104

No files matched your search

+84
View File
@@ -67,8 +67,55 @@ void OnCreatureOrder(CreatureOrderFn callback);
constexpr size_t kCreatureId = 0xc;
constexpr size_t kCreatureX = 0x1c;
constexpr size_t kCreatureY = 0x20;
// The walk path (measured 2026-10-18): waypoints {x, y} at +0x2ed + i * 8,
// the current segment's start index at +0x36d, the count at +0x371, and the
// step's sub-unit remainder (x, y, 1/1024 units) at +0x37d / +0x381.
// cCreature::Update (0x594110, thiscall (u8), ret 4; about 40 times per second
// on the engine thread) moves the creature its step length along the
// segments into g_creatureNewPosition (0xad2368, {u16, x, y, u8 level}),
// writes the reached point back as the segment start, and commits it with
// cObjectManager::Place (0x5fcd10), which refuses blocked spots (the
// creature then stays where it was). At the last waypoint the walk ends.
constexpr size_t kCreaturePath = 0x2ed;
constexpr size_t kPathPointSize = 8;
constexpr size_t kCreaturePathIndex = 0x36d;
constexpr size_t kCreaturePathCount = 0x371;
// Facing: a float unit vector in world axes ({0.707, -0.707} facing right on
// screen). Walking turns it towards the path gradually (measured 2026-10-18:
// about 300 ms for a reversal, swinging through the side).
constexpr size_t kCreatureFacing = 0x70;
// +0xfc: the pending action (u16, the order received but not yet started;
// 2 a walk), +0xfe its phase, +0x100..+0x13f its parameters (walk target at
// +0x104 / +0x108). cCreature::Think (vf04, 0x537430, thiscall (float),
// ret 4; about every 110 ms per creature) starts it and then runs the current
// action's handler.
constexpr size_t kCreaturePendingAction = 0xfc;
constexpr uint16_t kPendingWalk = 2;
// cCreature::StartPendingWalk (0x4ede70, thiscall (), ret): the Think step
// for a pending walk: unless a status effect (+0x1f4) holds the creature or
// the target is over 2000 units away, the pending parameters become the
// action's (+0x154), action 1, phase 0 unless already walking.
inline void StartPendingWalk(void* creature) {
using Fn = void(__fastcall*)(void*, void*);
reinterpret_cast<Fn>(0x004ede70)(creature, nullptr);
}
// cCreature::WalkAction (0x4f45b0, thiscall (), ret): the Think step of the
// walk action; phase 0 plans the path, sets the facing, starts the walk
// animation and goes to phase 1.
inline void WalkAction(void* creature) {
using Fn = void(__fastcall*)(void*, void*);
reinterpret_cast<Fn>(0x004f45b0)(creature, nullptr);
}
// +0x150: low word the current action, high word its phase; 0x10000 when
// standing idle. Measured on the hero: 1 walking (also up to a target),
// 2 stopping (2026-10-18: the target-less walk order sets the walk's phase
// to 0x64 at 0x52f03a, WalkAction then switches to 2 with the stop
// animation at 0x4f6f8b, idle about 110 ms later at 0x546513),
// 3 weapon attack, 4 casting a spell (2026-10-16), 6 dying, 8 an art seen
// after walking up (2026-10-15, presumably a combat move), 5 a shot with a
// projectile weapon (bow, 2026-10-18).
@@ -76,6 +123,7 @@ constexpr size_t kCreatureAction = 0x150;
enum CreatureAction : int32_t {
kActionIdle = 0,
kActionWalk = 1,
kActionStop = 2,
kActionAttack = 3,
kActionCastSpell = 4,
kActionShoot = 5,
@@ -83,6 +131,8 @@ enum CreatureAction : int32_t {
kActionCombatMove = 8,
};
inline int32_t CurrentAction(const void* creature);
constexpr size_t kCreatureActionPhase = 0x152; // u16
constexpr uint16_t kPhaseStopping = 0x64; // walk: stop requested
// cCreature::IsAlive (0x548ef0): not dying (+0x150 == 6), not dead (+0xfc ==
// 9), health (+0x4d8) left.
@@ -113,6 +163,40 @@ inline int32_t CurrentAction(const void* creature) {
return CreatureField(creature, kCreatureAction) & 0xffff;
}
// A map position as objects keep it (object +0x18): area, x, y, level. In
// world units, or in map cells (world / kMapCellSize, truncated; 0x6225c0
// converts an object's position).
struct MapPosition {
uint16_t area = 0;
int32_t x = 0;
int32_t y = 0;
uint8_t level = 0;
};
static_assert(offsetof(MapPosition, x) == 4 && offsetof(MapPosition, level) == 0xc);
constexpr size_t kObjectPosition = 0x18;
constexpr size_t kObjectLevel = 0x24;
constexpr float kMapCellSize = 53.66563f; // 0x88f318 / 0x890044
// The map cell of world point (x, y) on the creature's area and level.
inline MapPosition CellAt(const void* creature, int x, int y) {
const auto* c = static_cast<const uint8_t*>(creature);
MapPosition cell;
cell.area = *reinterpret_cast<const uint16_t*>(c + kObjectPosition);
cell.x = static_cast<int32_t>(x / kMapCellSize);
cell.y = static_cast<int32_t>(y / kMapCellSize);
cell.level = c[kObjectLevel];
return cell;
}
// cCreature::IsCellWalkable (0x4f7b20, thiscall (cell*), ret 4): whether the
// creature may enter map cell `cell` (map sector lookups and its walk area
// +0x2c; reads only). The step test cCreature::CanStepTo (0x593c50) refuses a
// step into another cell that is not walkable.
inline bool IsCellWalkable(void* creature, MapPosition& cell) {
using Fn = char(__fastcall*)(void*, void*, MapPosition*);
return reinterpret_cast<Fn>(0x004f7b20)(creature, nullptr, &cell) != 0;
}
// The distance at which two creatures touch (0x4dbaa0 on one, (the other)):
// the mean of their body radii by object type (heroes 110 with a weapon, 80
// without; monsters 70..480). A melee attacker swings from about there
-13
View File
@@ -36,7 +36,6 @@
#include "game/engine/objects.h"
#include "game/ui/text.h"
#include "mods/skillslots.h"
#include "mods/wasd.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
@@ -308,18 +307,6 @@ void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, uint32_t slot,
}
}
// cUI_Taskbar2::SelectWeaponSet (0x6e1440, ret 8): sends the hero the
// weapon set order (0x114) and, when the weapon class changed, resets its
// animation state (0x54e540), which drops a walk for a tick (measured
// 2026-10-18: action walk -> idle -> walk on switches between melee and bow
// sets, none between melee sets). A WASD walk goes on at once.
void __fastcall UiTaskbar_SelectWeaponSet(void* self, void* edx, uint32_t set, int notify);
RE_HOOK(0x006e1440, UiTaskbar_SelectWeaponSet);
void __fastcall UiTaskbar_SelectWeaponSet(void* self, void* edx, uint32_t set, int notify) {
UiTaskbar_SelectWeaponSet_original(self, edx, set, notify);
mods::wasd::ResumeWalk();
}
// cUI_Taskbar2::RenderArtSlots (0x6e31e0): then the cooldown labels.
void __fastcall UiTaskbar_RenderArtSlots(void* self, void* edx, void* renderer);
RE_HOOK(0x006e31e0, UiTaskbar_RenderArtSlots);
+217 -76
View File
@@ -1,13 +1,29 @@
// WASD movement for the player character.
// WASD movement for the player character: the keys steer the hero directly
// (request of 2026-10-18: direction movement instead of walk orders).
//
// 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.
// The game walks a creature along a path of waypoints in cCreature::Update
// (0x594110, about 40 times per second; path layout in game/engine/orders.h).
// While W, A, S or D is held, each update of the controlled creature first
// replaces its path with one straight segment from where it stands, far into
// the pressed screen direction: the game's own step then moves it at its own
// speed, with its walk animation, and its step test refuses blocked cells. No
// pathfinding, no arrival, and a new direction counts from the next update.
//
// Obstacles: the cell one step ahead is probed with cCreature::IsCellWalkable
// (measured: walking straight into a fence, 27 of 28 steps were refused).
// When it is blocked, the direction turned by up to 90 degrees is tried,
// the side the hero last slid to first, so it slides along walls instead of
// running on the spot; with everything blocked it stands.
//
// The facing is set to the direction too: the game turns it towards the path
// over about 300 ms, which made reversals look like walking backwards.
//
// The path only drives a creature that walks. Standing idle (at the start, or
// after an attack, a cast, a weapon switch, which resets the hero to idle) it
// gets a walk order, and the two steps of cCreature::Think that would start it
// up to 110 ms later run at once: the walk goes on in the same update.
// 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; the key bindings (src/mods/input.cpp) keep
// W/A/S/D away from the game and move those actions to other keys. Typing into
@@ -34,9 +50,25 @@ using game::ControlledCreature;
using game::CreatureOrder;
using game::kEngineFlags;
// Walk orders aim this far (world units) from the creature, about where the
// cursor would be.
constexpr double kTargetDistance = 300.0;
// The walk order that starts walking aims this far (world units).
constexpr double kStartDistance = 300.0;
// Between two start orders while the walk has not begun yet.
constexpr DWORD kStartOrderMs = 150;
// Keys released this long before the hero stops: changing keys often leaves
// a moment without any (measured: a stop order there cost about 125 ms
// standing).
constexpr DWORD kStopGraceMs = 50;
// The steered segment's length: farther than any step, so it never arrives.
constexpr double kSegmentLength = 1000.0;
// How far ahead a direction is probed: a bit more than one map cell.
constexpr double kProbeDistance = game::kMapCellSize * 1.25;
constexpr double kProbeStep = game::kMapCellSize * 0.25;
// Directions tried around a blocked one (degrees, both sides).
constexpr double kSlideAngles[] = {30.0, 60.0, 90.0};
// Engine flags (+0x54): a mouse button is held / input is blocked.
constexpr uint32_t kEngineMouseHeld = 0x10;
constexpr uint32_t kEngineInputBlocked = 0x4000;
bool KeyDown(int key) {
return (GetAsyncKeyState(key) & 0x8000) != 0;
@@ -59,10 +91,125 @@ void SendStop(uint8_t* engine, void* creature) {
game::SendCreatureOrder(engine, creature, order);
}
struct MoveState {
bool moving = false;
std::atomic<uint8_t*> g_engine{nullptr};
bool g_moving = false;
DWORD g_releasedAt = 0; // when the keys were released (0: held)
DWORD g_startOrderAt = 0;
int g_slideSide = 1; // +1 / -1: the side the hero last turned to
struct Direction {
double x;
double y;
};
MoveState g_move;
// The held movement keys as a unit screen direction in world axes: (-1,-1)
// up, (1,1) down, (-1,1) left, (1,-1) right. False when none (or the keys
// belong to the mouse, blocked input or a text field).
bool HeldDirection(const uint8_t* engine, Direction& dir) {
int x = 0;
int y = 0;
const uint32_t flags = *reinterpret_cast<const uint32_t*>(engine + kEngineFlags);
if ((flags & (kEngineMouseHeld | kEngineInputBlocked)) == 0 && !mods::input::TypingIntoTextField()) {
if (KeyDown('W')) { x -= 1; y -= 1; }
if (KeyDown('S')) { x += 1; y += 1; }
if (KeyDown('A')) { x -= 1; y += 1; }
if (KeyDown('D')) { x += 1; y -= 1; }
}
if (x == 0 && y == 0) {
return false;
}
const double length = std::sqrt(static_cast<double>(x * x + y * y));
dir = {x / length, y / length};
return true;
}
Direction Rotated(Direction dir, double degrees) {
const double a = degrees * 3.14159265358979 / 180.0;
return {dir.x * std::cos(a) - dir.y * std::sin(a), dir.x * std::sin(a) + dir.y * std::cos(a)};
}
// Whether the creature can go a probe distance along `dir`: every cell the
// way crosses (sampled at quarter cells, so none is skipped) is walkable.
bool Open(uint8_t* creature, Direction dir) {
const int x = game::CreatureField(creature, game::kCreatureX);
const int y = game::CreatureField(creature, game::kCreatureY);
game::MapPosition last = game::CellAt(creature, x, y);
for (double d = kProbeStep; d <= kProbeDistance; d += kProbeStep) {
game::MapPosition cell =
game::CellAt(creature, x + static_cast<int>(dir.x * d), y + static_cast<int>(dir.y * d));
if (cell.x == last.x && cell.y == last.y) {
continue;
}
if (!game::IsCellWalkable(creature, cell)) {
return false;
}
last = cell;
}
return true;
}
// The direction to go for keys pointing along `dir`: itself, or the nearest
// open one within 90 degrees (the last slide side first). False if blocked.
bool OpenDirection(uint8_t* creature, Direction dir, Direction& out) {
if (Open(creature, dir)) {
out = dir;
return true;
}
const int sides[] = {g_slideSide, -g_slideSide};
for (const double angle : kSlideAngles) {
for (const int side : sides) {
const Direction turned = Rotated(dir, side * angle);
if (Open(creature, turned)) {
g_slideSide = side;
out = turned;
return true;
}
}
}
return false;
}
// One straight segment from the creature's position along `dir`.
void Steer(uint8_t* creature, Direction dir) {
const int x = game::CreatureField(creature, game::kCreatureX);
const int y = game::CreatureField(creature, game::kCreatureY);
auto* path = reinterpret_cast<int32_t*>(creature + game::kCreaturePath);
path[0] = x;
path[1] = y;
path[2] = x + static_cast<int>(dir.x * kSegmentLength);
path[3] = y + static_cast<int>(dir.y * kSegmentLength);
*reinterpret_cast<int32_t*>(creature + game::kCreaturePathIndex) = 0;
*reinterpret_cast<int32_t*>(creature + game::kCreaturePathCount) = 2;
auto* facing = reinterpret_cast<float*>(creature + game::kCreatureFacing);
facing[0] = static_cast<float>(dir.x);
facing[1] = static_cast<float>(dir.y);
}
// Starts walking along `dir` now: the walk order, then the two Think steps
// that would otherwise follow up to 110 ms later (start the pending walk, the
// walk action's first phase). True if the creature walks.
bool StartWalk(uint8_t* engine, uint8_t* creature, Direction dir) {
SendWalk(engine, creature, game::CreatureField(creature, game::kCreatureX) + static_cast<int>(dir.x * kStartDistance),
game::CreatureField(creature, game::kCreatureY) + static_cast<int>(dir.y * kStartDistance));
if (*reinterpret_cast<uint16_t*>(creature + game::kCreaturePendingAction) == game::kPendingWalk) {
game::StartPendingWalk(creature);
}
if (game::CurrentAction(creature) != game::kActionWalk) {
return false; // held by a status effect: Think decides later
}
if (game::CreatureField(creature, game::kCreatureAction) >> 16 == 0) {
game::WalkAction(creature);
}
return true;
}
void OnTick(uint8_t* engine) {
g_engine = engine;
}
struct Registration {
Registration() { game::OnEngineTick(&OnTick); }
} g_registration;
} // namespace
@@ -70,78 +217,72 @@ bool IsMovementKey(int vk) {
return (vk == 'W' || vk == 'A' || vk == 'S' || vk == 'D');
}
namespace {
// The held movement keys as a screen direction in world axes: (-1,-1) up,
// (1,1) down, (-1,1) left, (1,-1) right; 0,0 when none (or the keys belong to
// the mouse, blocked input or a text field).
void HeldDirection(uint8_t* engine, int& dx, int& dy) {
dx = 0;
dy = 0;
const uint32_t flags = *reinterpret_cast<uint32_t*>(engine + kEngineFlags);
if ((flags & 0x10) == 0 && (flags & 0x4000) == 0 && !mods::input::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; }
// Before each update of a creature: steer the controlled one.
void BeforeCreatureUpdate(uint8_t* creature) {
uint8_t* engine = g_engine.load();
if (!engine || creature != ControlledCreature(engine)) {
return;
}
}
void Walk(uint8_t* engine, void* creature, int dx, int dy) {
const double length = std::sqrt(static_cast<double>(dx * dx + dy * dy));
const int x = game::CreatureField(creature, game::kCreatureX) + static_cast<int>(dx / length * kTargetDistance);
const int y = game::CreatureField(creature, game::kCreatureY) + static_cast<int>(dy / length * kTargetDistance);
SendWalk(engine, creature, x, y);
}
std::atomic<uint8_t*> g_engine{nullptr};
// Called once per engine tick (10 Hz), after the game's own input handling.
void Update(uint8_t* engine) {
g_engine = engine;
int dx;
int dy;
HeldDirection(engine, dx, dy);
const bool mouseMoving = (*reinterpret_cast<uint32_t*>(engine + kEngineFlags) & 0x10) != 0;
if (dx == 0 && dy == 0) {
if (g_move.moving && !mouseMoving) {
if (void* creature = ControlledCreature(engine)) {
Direction keys;
if (!HeldDirection(engine, keys)) {
const DWORD now = GetTickCount();
if (g_moving && !g_releasedAt) {
g_releasedAt = now;
}
if (g_moving && now - g_releasedAt >= kStopGraceMs) {
if ((*reinterpret_cast<uint32_t*>(engine + kEngineFlags) & kEngineMouseHeld) == 0) {
SendStop(engine, creature);
}
g_moving = false;
}
g_move.moving = false;
return;
}
void* creature = ControlledCreature(engine);
if (!creature) {
g_moving = true;
g_releasedAt = 0;
int32_t action = game::CurrentAction(creature);
if (action == game::kActionStop) {
action = game::kActionIdle; // stopping: start again like from standing
}
if (action != game::kActionWalk && action != game::kActionIdle) {
return; // attacking, casting, dying: the action finishes first
}
Direction dir;
const bool open = OpenDirection(creature, keys, dir);
if (action == game::kActionWalk) {
auto* phase = reinterpret_cast<uint16_t*>(creature + game::kCreatureActionPhase);
if (open) {
if (*phase == game::kPhaseStopping) {
*phase = 1; // a stop requested but not begun: walk on
}
Steer(creature, dir);
} else if (*phase != game::kPhaseStopping) {
SendStop(engine, creature); // stand instead of running on the spot
}
return;
}
Walk(engine, creature, dx, dy);
g_move.moving = true;
}
} // namespace
void ResumeWalk() {
uint8_t* engine = g_engine.load();
void* creature = engine && g_move.moving ? ControlledCreature(engine) : nullptr;
int dx;
int dy;
if (creature) {
HeldDirection(engine, dx, dy);
if (dx != 0 || dy != 0) {
Walk(engine, creature, dx, dy);
const DWORD now = GetTickCount();
if (open && now - g_startOrderAt >= kStartOrderMs) {
g_startOrderAt = now;
if (StartWalk(engine, creature, dir)) {
g_startOrderAt = 0;
Steer(creature, dir);
}
}
}
namespace {
struct Registration {
Registration() {
game::OnEngineTick(&Update);
}
} g_registration;
} // namespace
} // namespace mods::wasd
namespace game {
// cCreature::Update (0x594110, thiscall (u8), ret 4): moves the creature
// along its path, among others.
void __fastcall Creature_Update(void* self, void* edx, uint32_t arg);
RE_HOOK(0x00594110, Creature_Update);
void __fastcall Creature_Update(void* self, void* edx, uint32_t arg) {
if (self) {
mods::wasd::BeforeCreatureUpdate(static_cast<uint8_t*>(self));
}
Creature_Update_original(self, edx, arg);
}
} // namespace game
-4
View File
@@ -5,8 +5,4 @@ namespace mods::wasd {
// Whether `vk` is one of the movement keys W/A/S/D.
bool IsMovementKey(int vk);
// Sends the walk for the held movement keys now, after something stopped the
// hero mid-walk (a weapon set switch).
void ResumeWalk();
} // namespace mods::wasd