From 184c7058fd06bec8eb59e1e43308d891e64c5097 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Sun, 18 Oct 2026 01:29:29 +0200 Subject: [PATCH] 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) --- docs/auto-attack.md | 25 +++++++++++++++++++ src/game/engine/events.cpp | 3 +++ src/game/engine/orders.h | 4 ++- src/mods/autoattack.cpp | 49 ++++++++++++++++++++++++++++-------- src/re/crash.cpp | 51 ++++++++++++++++++++++++++++++++++++++ src/re/crash.h | 6 ++++- 6 files changed, 125 insertions(+), 13 deletions(-) diff --git a/docs/auto-attack.md b/docs/auto-attack.md index 1f460e8..9bdda0d 100644 --- a/docs/auto-attack.md +++ b/docs/auto-attack.md @@ -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. diff --git a/src/game/engine/events.cpp b/src/game/engine/events.cpp index 107f307..d588278 100644 --- a/src/game/engine/events.cpp +++ b/src/game/engine/events.cpp @@ -2,6 +2,7 @@ #include +#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(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(); } diff --git a/src/game/engine/orders.h b/src/game/engine/orders.h index d6f397e..84a7888 100644 --- a/src/game/engine/orders.h +++ b/src/game/engine/orders.h @@ -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, }; diff --git a/src/mods/autoattack.cpp b/src/mods/autoattack.cpp index b133776..ad9ec51 100644 --- a/src/mods/autoattack.cpp +++ b/src/mods/autoattack.cpp @@ -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 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(&SwingContinuesThunk)); + re::RedirectCall(kShotEndButtonCheck, kIsButtonHeld, reinterpret_cast(&ShotContinuesThunk)); re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast(&AttackHeld)); } RE_STARTUP(AutoAttackStartup, Startup); @@ -265,7 +292,8 @@ void OnOrder(void* creature, const game::CreatureOrder& order, void*) { break; } g_cast = {static_cast(order.art), IsEnemy(hero, order.target) ? order.target : 0u, false, - GetTickCount()}; + GetTickCount(), game::GetArtCooldown(hero, static_cast(order.art)).remaining, + game::CurrentAction(hero)}; break; } } @@ -278,4 +306,3 @@ struct OrderRegistration { } // namespace } // namespace mods::autoattack - diff --git a/src/re/crash.cpp b/src/re/crash.cpp index 53d0304..725e182 100644 --- a/src/re/crash.cpp +++ b/src/re/crash.cpp @@ -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(MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo), + nullptr, nullptr, nullptr); + CloseHandle(dump); + } +} + +DWORD WINAPI Watchdog(void*) { + bool reported = false; + for (;;) { + Sleep(1000); + const DWORD beat = static_cast(g_lastBeat); + // Signed: a tick may land between reading the beat and the clock. + if (beat == 0 || static_cast(GetTickCount() - beat) < static_cast(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(GetTickCount() | 1) : 0); } } // namespace re diff --git a/src/re/crash.h b/src/re/crash.h index 9fcffda..9585b04 100644 --- a/src/re/crash.h +++ b/src/re/crash.h @@ -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