Auto-attack: bow shots chain at full speed, arts start by their cooldown; hang watchdog

- The shot end's button check (0x4def52) answers "held" for the auto-attack
  target, like the melee swing end.
- An art order counts as started when the art's cooldown rises, so bow arts
  and arts ordered mid-swing hand over to auto-attack.
- re/crash.cpp: a watchdog writes sacred_re_hang.dmp when the engine tick
  stops for 10 s while the game is in front.

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 01:29:29 +02:00
1 parent 432cac2ccf
commit 184c7058fd
6 files changed
+125 -13

No files matched your search

+25
View File
@@ -85,3 +85,28 @@ Rules (`autoattack.cpp`):
nearest enemy close by. Ranged: the same with a ranged weapon; with a melee nearest enemy close by. Ranged: the same with a ranged weapon; with a melee
weapon only an enemy already in close range (the art's target if it is, weapon only an enemy already in close range (the art's target if it is,
else the nearest), otherwise auto-attack stops. Until the art starts, no attack orders are sent. else the nearest), otherwise auto-attack stops. Until the art starts, no attack orders are sent.
## Bow speed and arts that start differently (2026-10-18)
User: auto-attack after spells not fully reliable; with a ranged weapon it
does not reach full speed; bow arts (Spinnenpfeil) do not start it.
Measured (order log + a log of every `cMouseCursor::IsButtonHeld` caller):
- A shot with a bow is action 5. Before the next shot the game checks the
button at `0x4def52` (shooter esi, target creature edi): held and the target
hostile -> it shoots again. A held button fired 2 s without new orders;
auto-attack sent one order per 0.67 s. `0x4def89` right after retargets to
the creature under the cursor when held.
- Several arts (0x42f, 0x3ee) never showed action 4 or 8, so the cast watch
waited its 5 s and auto-attack stopped. Arts ordered during a swing or cast
were taken as started by that action.
Now:
- `0x4def52` is redirected like the melee swing end (`SwingContinues`):
"held" for the auto-attack target. `0x4def89` is left alone.
- An art counts as started when its cooldown rises (`GetArtCooldown`
remaining above the value at the order, which follows regeneration). Arts
without a cooldown entry: the hero went into a new action other than
walking or idle.
+3
View File
@@ -2,6 +2,7 @@
#include <vector> #include <vector>
#include "re/crash.h"
#include "re/hook.h" #include "re/hook.h"
namespace game { namespace game {
@@ -57,6 +58,7 @@ void __fastcall Engine_Tick(void* self, void* edx, void* event);
RE_HOOK(0x0060f1d0, Engine_Tick); RE_HOOK(0x0060f1d0, Engine_Tick);
void __fastcall Engine_Tick(void* self, void* edx, void* event) { void __fastcall Engine_Tick(void* self, void* edx, void* event) {
Engine_Tick_original(self, edx, event); Engine_Tick_original(self, edx, event);
re::EngineHeartbeat(true);
if (self) { if (self) {
for (EngineTickFn callback : TickCallbacks()) { for (EngineTickFn callback : TickCallbacks()) {
callback(static_cast<uint8_t*>(self)); callback(static_cast<uint8_t*>(self));
@@ -80,6 +82,7 @@ void* __fastcall Engine_Construct(void* self, void* edx, uint32_t arg) {
void __fastcall Engine_Destruct(void* self, void* edx); void __fastcall Engine_Destruct(void* self, void* edx);
RE_HOOK(0x0060d5e0, Engine_Destruct); RE_HOOK(0x0060d5e0, Engine_Destruct);
void __fastcall Engine_Destruct(void* self, void* edx) { void __fastcall Engine_Destruct(void* self, void* edx) {
re::EngineHeartbeat(false);
for (GameEndFn callback : GameEndCallbacks()) { for (GameEndFn callback : GameEndCallbacks()) {
callback(); callback();
} }
+3 -1
View File
@@ -70,13 +70,15 @@ constexpr size_t kCreatureY = 0x20;
// +0x150: low word the current action, high word its phase; 0x10000 when // +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), // standing idle. Measured on the hero: 1 walking (also up to a target),
// 3 weapon attack, 4 casting a spell (2026-10-16), 6 dying, 8 an art seen // 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). // after walking up (2026-10-15, presumably a combat move), 5 a shot with a
// projectile weapon (bow, 2026-10-18).
constexpr size_t kCreatureAction = 0x150; constexpr size_t kCreatureAction = 0x150;
enum CreatureAction : int32_t { enum CreatureAction : int32_t {
kActionIdle = 0, kActionIdle = 0,
kActionWalk = 1, kActionWalk = 1,
kActionAttack = 3, kActionAttack = 3,
kActionCastSpell = 4, kActionCastSpell = 4,
kActionShoot = 5,
kActionDying = 6, kActionDying = 6,
kActionCombatMove = 8, kActionCombatMove = 8,
}; };
+38 -11
View File
@@ -58,6 +58,8 @@ struct CastWatch {
uint32_t target = 0; // an enemy, or 0 uint32_t target = 0; // an enemy, or 0
bool walked = false; bool walked = false;
DWORD since = 0; DWORD since = 0;
float cooldown = 0.0f; // the art's remaining cooldown when ordered
int32_t action = 0; // the hero's action when ordered (and while it lasts)
}; };
CastWatch g_cast; CastWatch g_cast;
std::unordered_map<uint16_t, bool> g_artRanged; std::unordered_map<uint16_t, bool> g_artRanged;
@@ -118,17 +120,24 @@ void WatchCast(void* hero) {
g_cast = {}; g_cast = {};
return; return;
} }
switch (game::CurrentAction(hero)) { // Started: the art's cooldown began (spells, combat moves and bow arts
case game::kActionWalk: // alike), or for an art without one, the hero went into a new action
g_cast.walked = true; // other than walking (arts ordered during an attack or a cast were taken
break; // as started by that one, measured 2026-10-18).
case game::kActionCastSpell: const int32_t action = game::CurrentAction(hero);
case game::kActionCombatMove: const game::ArtCooldown cooldown = game::GetArtCooldown(hero, g_cast.art);
if (cooldown.remaining > g_cast.cooldown) {
OnCastStarted(hero); OnCastStarted(hero);
break; return;
default:
break;
} }
g_cast.cooldown = cooldown.remaining; // regenerating meanwhile
if (action == game::kActionWalk) {
g_cast.walked = true;
} else if (action != g_cast.action && action != game::kActionIdle && cooldown.total <= 0.0f) {
OnCastStarted(hero);
return;
}
g_cast.action = action;
} }
void OnTick(uint8_t* engine) { void OnTick(uint8_t* engine) {
@@ -233,8 +242,26 @@ char __fastcall AttackHeld(void* cursor, void*) {
return target && game::IsAlive(target) ? 1 : 0; return target && game::IsAlive(target) ? 1 : 0;
} }
// A shot with a projectile weapon (action 5) checks the button the same way
// before the next shot (call at 0x4def52; shooter in esi, target creature in
// edi): held -> the next shot at a hostile target follows right away
// (measured: a held button fired for 2 s without new orders, auto-attack
// orders one shot per 0.67 s). The check after it (0x4def89) retargets to the
// creature under the cursor and is left alone.
constexpr uint32_t kShotEndButtonCheck = 0x004def52;
__declspec(naked) void ShotContinuesThunk() {
__asm {
push edi
push esi
call SwingContinues
ret
}
}
void Startup() { void Startup() {
re::RedirectCall(kSwingEndButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&SwingContinuesThunk)); re::RedirectCall(kSwingEndButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&SwingContinuesThunk));
re::RedirectCall(kShotEndButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&ShotContinuesThunk));
re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&AttackHeld)); re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&AttackHeld));
} }
RE_STARTUP(AutoAttackStartup, Startup); RE_STARTUP(AutoAttackStartup, Startup);
@@ -265,7 +292,8 @@ void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
break; break;
} }
g_cast = {static_cast<uint16_t>(order.art), IsEnemy(hero, order.target) ? order.target : 0u, false, g_cast = {static_cast<uint16_t>(order.art), IsEnemy(hero, order.target) ? order.target : 0u, false,
GetTickCount()}; GetTickCount(), game::GetArtCooldown(hero, static_cast<uint16_t>(order.art)).remaining,
game::CurrentAction(hero)};
break; break;
} }
} }
@@ -278,4 +306,3 @@ struct OrderRegistration {
} // namespace } // namespace
} // namespace mods::autoattack } // namespace mods::autoattack
+51
View File
@@ -3,6 +3,11 @@
// and sacred_re_crash.dmp next to the DLL. The game may install its own // and sacred_re_crash.dmp next to the DLL. The game may install its own
// filter later; SetUnhandledExceptionFilter is hooked so ours stays first and // filter later; SetUnhandledExceptionFilter is hooked so ours stays first and
// passes the crash on to the game's afterwards. // passes the crash on to the game's afterwards.
//
// Hangs: a watchdog thread writes sacred_re_hang.dmp (all threads) when the
// engine tick (re::EngineHeartbeat) has stopped for kHangMs while the game
// window is in front; once per hang. The game freezing on F2 while walking
// (2026-10-18) left no report.
#include "re/crash.h" #include "re/crash.h"
@@ -105,6 +110,45 @@ void WriteReport(EXCEPTION_POINTERS* info) {
} }
} }
constexpr DWORD kHangMs = 10000;
volatile LONG g_lastBeat = 0; // GetTickCount of the last engine tick, 0: no game running
bool GameInFront() {
DWORD process = 0;
GetWindowThreadProcessId(GetForegroundWindow(), &process);
return process == GetCurrentProcessId();
}
void WriteHangDump() {
HANDLE dump = CreateFileW((g_dir + L"sacred_re_hang.dmp").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (dump != INVALID_HANDLE_VALUE) {
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump,
static_cast<MINIDUMP_TYPE>(MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo),
nullptr, nullptr, nullptr);
CloseHandle(dump);
}
}
DWORD WINAPI Watchdog(void*) {
bool reported = false;
for (;;) {
Sleep(1000);
const DWORD beat = static_cast<DWORD>(g_lastBeat);
// Signed: a tick may land between reading the beat and the clock.
if (beat == 0 || static_cast<LONG>(GetTickCount() - beat) < static_cast<LONG>(kHangMs)) {
reported = false;
continue;
}
if (!reported && GameInFront()) {
reported = true;
Log("hang: no engine tick for %lu ms, writing sacred_re_hang.dmp", GetTickCount() - beat);
WriteHangDump();
Log("hang: dump written");
}
}
}
LONG WINAPI Filter(EXCEPTION_POINTERS* info) { LONG WINAPI Filter(EXCEPTION_POINTERS* info) {
if (InterlockedExchange(&g_reported, 1) == 0) { if (InterlockedExchange(&g_reported, 1) == 0) {
WriteReport(info); WriteReport(info);
@@ -117,6 +161,13 @@ LONG WINAPI Filter(EXCEPTION_POINTERS* info) {
void InstallCrashHandler(const std::wstring& dir) { void InstallCrashHandler(const std::wstring& dir) {
g_dir = dir; g_dir = dir;
g_gameFilter = SetUnhandledExceptionFilter(&Filter); g_gameFilter = SetUnhandledExceptionFilter(&Filter);
if (HANDLE thread = CreateThread(nullptr, 0, &Watchdog, nullptr, 0, nullptr)) {
CloseHandle(thread);
}
}
void EngineHeartbeat(bool running) {
InterlockedExchange(&g_lastBeat, running ? static_cast<LONG>(GetTickCount() | 1) : 0);
} }
} // namespace re } // namespace re
+5 -1
View File
@@ -4,7 +4,11 @@
namespace re { namespace re {
// Writes sacred_re_crash.txt / .dmp into `dir` when the game crashes. // Writes sacred_re_crash.txt / .dmp into `dir` when the game crashes, and
// sacred_re_hang.dmp when the engine stops ticking (see EngineHeartbeat).
void InstallCrashHandler(const std::wstring& dir); void InstallCrashHandler(const std::wstring& dir);
// The engine ticked (running), or the game ended (not running).
void EngineHeartbeat(bool running);
} // namespace re } // namespace re