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:
1 parent
432cac2ccf
commit
184c7058fd
6 files changed
+125
-13
No files matched your search
@@ -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.
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
Reference in new issue
Block a user