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
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.
## 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 "re/crash.h"
#include "re/hook.h"
namespace game {
@@ -57,6 +58,7 @@ 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);
re::EngineHeartbeat(true);
if (self) {
for (EngineTickFn callback : TickCallbacks()) {
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);
RE_HOOK(0x0060d5e0, Engine_Destruct);
void __fastcall Engine_Destruct(void* self, void* edx) {
re::EngineHeartbeat(false);
for (GameEndFn callback : GameEndCallbacks()) {
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
// 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
// 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;
enum CreatureAction : int32_t {
kActionIdle = 0,
kActionWalk = 1,
kActionAttack = 3,
kActionCastSpell = 4,
kActionShoot = 5,
kActionDying = 6,
kActionCombatMove = 8,
};
+38 -11
View File
@@ -58,6 +58,8 @@ struct CastWatch {
uint32_t target = 0; // an enemy, or 0
bool walked = false;
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;
std::unordered_map<uint16_t, bool> g_artRanged;
@@ -118,17 +120,24 @@ void WatchCast(void* hero) {
g_cast = {};
return;
}
switch (game::CurrentAction(hero)) {
case game::kActionWalk:
g_cast.walked = true;
break;
case game::kActionCastSpell:
case game::kActionCombatMove:
// Started: the art's cooldown began (spells, combat moves and bow arts
// alike), or for an art without one, the hero went into a new action
// other than walking (arts ordered during an attack or a cast were taken
// as started by that one, measured 2026-10-18).
const int32_t action = game::CurrentAction(hero);
const game::ArtCooldown cooldown = game::GetArtCooldown(hero, g_cast.art);
if (cooldown.remaining > g_cast.cooldown) {
OnCastStarted(hero);
break;
default:
break;
return;
}
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) {
@@ -233,8 +242,26 @@ char __fastcall AttackHeld(void* cursor, void*) {
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() {
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_STARTUP(AutoAttackStartup, Startup);
@@ -265,7 +292,8 @@ void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
break;
}
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;
}
}
@@ -278,4 +306,3 @@ struct OrderRegistration {
} // namespace
} // 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
// filter later; SetUnhandledExceptionFilter is hooked so ours stays first and
// 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"
@@ -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) {
if (InterlockedExchange(&g_reported, 1) == 0) {
WriteReport(info);
@@ -117,6 +161,13 @@ LONG WINAPI Filter(EXCEPTION_POINTERS* info) {
void InstallCrashHandler(const std::wstring& dir) {
g_dir = dir;
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
+5 -1
View File
@@ -4,7 +4,11 @@
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);
// The engine ticked (running), or the game ended (not running).
void EngineHeartbeat(bool running);
} // namespace re