- autoattack: when the target dies, the nearest enemy within close range (contact distance + a step) takes over; after an art the hero auto-attacks if the art was melee (walked up / cast close by), or ranged with a ranged weapon, or ranged with an enemy already in melee range; measured action codes and weapon classes in game/engine/orders.h - chainlightning: jumps reach Blitzschlag's cast range (20 ray segments of 30, the art code's hit check) via the shared NearestHostile search - guards: burning bone render hook had the wrong signature (device plus a 4x4 matrix by value, ret 0x44) and corrupted the stack when fire arrows hit - crash reports follow the ebp chain before the raw stack scan Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
280 lines
9.7 KiB
C++
280 lines
9.7 KiB
C++
// Auto-attack: once the hero attacked or cast an art at an enemy, it keeps
|
|
// attacking that enemy with its weapon whenever it would stand idle.
|
|
//
|
|
// A left click on a creature sends the hero one attack order (cEvent_creature
|
|
// 7, target id at +0x18, its position at +0x1c / +0x20); the hero swings once
|
|
// and stands again (+0x150 back to 0x10000). Arts are orders 0x105 with the
|
|
// target at +0x10. Both are seen in cCreature::ReceiveOrder; their target
|
|
// becomes the hero's combat target if it is a living creature and not an ally.
|
|
// Every engine tick in which the hero is idle, no mouse button and no skill
|
|
// key is held, the attack order a left click sends goes to the target again.
|
|
// A walk order (mouse move, WASD, stop) ends it. When the target dies the
|
|
// hero takes the nearest enemy within close range (weapon reach and a step).
|
|
// After an art: a melee art (the hero walked up to cast it, or casts it from
|
|
// close by) always leads to auto-attacking (its target, else the nearest
|
|
// enemy close by); a ranged art (cast from beyond close range) only with a
|
|
// ranged weapon, or with a melee weapon when an enemy is in melee range. Which arts are ranged is seen when the hero starts them and
|
|
// remembered per art. User requests of 2026-10-16.
|
|
// Between swings the hero does not go idle at all: the end-of-swing check for
|
|
// a held mouse button is answered "held" for the auto-attack target.
|
|
// Measured with an order log, see docs/auto-attack.md.
|
|
|
|
#include <atomic>
|
|
#include <cstdint>
|
|
#include <unordered_map>
|
|
|
|
#include <windows.h>
|
|
|
|
#include "game/engine/arts.h"
|
|
#include "game/engine/events.h"
|
|
#include "game/engine/objects.h"
|
|
#include "game/engine/orders.h"
|
|
#include "mods/skillkeys.h"
|
|
#include "re/hook.h"
|
|
#include "re/patch.h"
|
|
|
|
namespace mods::autoattack {
|
|
namespace {
|
|
|
|
// After an attack order, wait this many ticks for the hero to start it before
|
|
// sending another one (it takes about 2 ticks).
|
|
constexpr int kStartTicks = 5;
|
|
// "Close range": where the hero touches a creature (game::ContactDistance,
|
|
// heroes swing from 117..149) plus a step.
|
|
constexpr int kStep = 100;
|
|
// TODO(measure): the reach of ranged weapons (no ranged hero measured yet).
|
|
constexpr int kRangedWeaponReach = 600;
|
|
// An art order that has not started after this long is forgotten.
|
|
constexpr DWORD kCastStartMs = 5000;
|
|
|
|
std::atomic<uint32_t> g_target{0};
|
|
int g_waitTicks = 0;
|
|
|
|
// The hero's last art order until the hero starts it.
|
|
struct CastWatch {
|
|
uint16_t art = 0;
|
|
uint32_t target = 0; // an enemy, or 0
|
|
bool walked = false;
|
|
DWORD since = 0;
|
|
};
|
|
CastWatch g_cast;
|
|
std::unordered_map<uint16_t, bool> g_artRanged;
|
|
|
|
bool IsEnemy(void* hero, uint32_t id) {
|
|
if (id == 0 || id == static_cast<uint32_t>(game::CreatureField(hero, game::kCreatureId))) {
|
|
return false;
|
|
}
|
|
return game::IsHostileTo(hero, game::GetCreature(id));
|
|
}
|
|
|
|
bool Idle(void* creature) {
|
|
return game::CurrentAction(creature) == game::kActionIdle;
|
|
}
|
|
|
|
// How far the hero looks for its next target.
|
|
double CloseRange(void* hero) {
|
|
if (game::HasRangedWeapon(hero)) {
|
|
return kRangedWeaponReach;
|
|
}
|
|
return game::ContactDistance(hero, hero) + kStep;
|
|
}
|
|
|
|
uint32_t NearestCloseEnemy(void* hero, uint32_t except) {
|
|
return game::NearestHostile(hero, hero, CloseRange(hero), except);
|
|
}
|
|
|
|
// The hero started the watched art: decide what it auto-attacks afterwards.
|
|
void OnCastStarted(void* hero) {
|
|
const CastWatch cast = g_cast;
|
|
g_cast = {};
|
|
void* target = cast.target ? game::GetCreature(cast.target) : nullptr;
|
|
bool ranged = false;
|
|
if (cast.walked) {
|
|
g_artRanged[cast.art] = false;
|
|
} else if (target && static_cast<int>(game::DistanceTo(hero, cast.target)) >
|
|
game::ContactDistance(hero, target) + kStep) {
|
|
g_artRanged[cast.art] = ranged = true;
|
|
} else {
|
|
const auto known = g_artRanged.find(cast.art);
|
|
ranged = known != g_artRanged.end() && known->second;
|
|
}
|
|
if (ranged && !game::HasRangedWeapon(hero)) {
|
|
// A ranged art with a melee weapon: auto-attack only what is in melee
|
|
// range already (its target if that close, else the nearest enemy).
|
|
const bool targetClose = target && game::DistanceTo(hero, cast.target) <= CloseRange(hero);
|
|
g_target = targetClose ? cast.target : NearestCloseEnemy(hero, 0);
|
|
return;
|
|
}
|
|
g_target = target ? cast.target : NearestCloseEnemy(hero, 0);
|
|
}
|
|
|
|
void WatchCast(void* hero) {
|
|
if (!g_cast.since) {
|
|
return;
|
|
}
|
|
if (GetTickCount() - g_cast.since > kCastStartMs) {
|
|
g_cast = {};
|
|
return;
|
|
}
|
|
switch (game::CurrentAction(hero)) {
|
|
case game::kActionWalk:
|
|
g_cast.walked = true;
|
|
break;
|
|
case game::kActionCastSpell:
|
|
case game::kActionCombatMove:
|
|
OnCastStarted(hero);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void OnTick(uint8_t* engine) {
|
|
void* hero = game::ControlledCreature(engine);
|
|
if (!hero) {
|
|
g_target = 0;
|
|
return;
|
|
}
|
|
WatchCast(hero);
|
|
if (g_cast.since) {
|
|
return; // the art decides once it starts
|
|
}
|
|
uint32_t id = g_target.load();
|
|
if (!id) {
|
|
return;
|
|
}
|
|
void* target = game::GetCreature(id);
|
|
if (!target || !game::IsAlive(target)) {
|
|
id = NearestCloseEnemy(hero, id);
|
|
g_target = id;
|
|
target = id ? game::GetCreature(id) : nullptr;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
}
|
|
if (!Idle(hero)) {
|
|
g_waitTicks = 0;
|
|
return;
|
|
}
|
|
const uint32_t flags = *reinterpret_cast<uint32_t*>(engine + game::kEngineFlags);
|
|
if (g_waitTicks > 0 && --g_waitTicks > 0) {
|
|
return;
|
|
}
|
|
if ((flags & 0x4010) != 0 || mods::skillkeys::KeyHeld()) {
|
|
return; // mouse button held / input blocked / a skill key casts
|
|
}
|
|
game::CreatureOrder order;
|
|
order.command = game::kOrderAttack;
|
|
order.a = static_cast<int32_t>(id);
|
|
order.b = game::CreatureField(target, game::kCreatureX);
|
|
order.c = game::CreatureField(target, game::kCreatureY);
|
|
game::SendCreatureOrder(engine, hero, order);
|
|
g_waitTicks = kStartTicks;
|
|
}
|
|
|
|
struct Registration {
|
|
Registration() { game::OnEngineTick(&OnTick); }
|
|
} g_registration;
|
|
|
|
// The end of a swing (0x4e6584, after the hit) asks the mouse cursor whether
|
|
// the left button is still held (cMouseCursor::IsButtonHeld, call at
|
|
// 0x4e65c6; attacker in esi, target creature in ebp): if so the attacker
|
|
// swings again right away, otherwise it recovers (+0x152 = 2) and stands.
|
|
// While the hero auto-attacks its target, the answer is yes, so swings follow
|
|
// each other at the speed of a held button.
|
|
constexpr uint32_t kSwingEndButtonCheck = 0x004e65c6;
|
|
constexpr uint32_t kIsButtonHeld = 0x00655ab0;
|
|
|
|
char __fastcall SwingContinues(void* cursor, void*, void* attacker, void* target) {
|
|
using IsButtonHeldFn = char(__fastcall*)(void*, void*);
|
|
if (reinterpret_cast<IsButtonHeldFn>(kIsButtonHeld)(cursor, nullptr)) {
|
|
return 1;
|
|
}
|
|
const uint32_t id = g_target.load();
|
|
void* hero = game::GetPlayerHero();
|
|
const uint32_t targetId = target ? static_cast<uint32_t>(game::CreatureField(target, game::kCreatureId)) : 0;
|
|
if (!id || !target || attacker != hero || mods::skillkeys::KeyHeld() || targetId != id) {
|
|
return 0;
|
|
}
|
|
return game::IsAlive(target) ? 1 : 0;
|
|
}
|
|
|
|
// Called instead of IsButtonHeld (ecx = the cursor, attacker in esi, target
|
|
// in ebp).
|
|
__declspec(naked) void SwingContinuesThunk() {
|
|
__asm {
|
|
push ebp
|
|
push esi
|
|
call SwingContinues
|
|
ret
|
|
}
|
|
}
|
|
|
|
// During a swing the attack code also checks the button (call at 0x4e7101)
|
|
// and clears the global "attack held" flag (0xab6030) when it is up; the swing
|
|
// end only chains while that flag is set (0x4e6641). The flag exists for the
|
|
// local player only, so the answer is "held" whenever the hero auto-attacks.
|
|
constexpr uint32_t kSwingButtonCheck = 0x004e7101;
|
|
|
|
char __fastcall AttackHeld(void* cursor, void*) {
|
|
using IsButtonHeldFn = char(__fastcall*)(void*, void*);
|
|
if (reinterpret_cast<IsButtonHeldFn>(kIsButtonHeld)(cursor, nullptr)) {
|
|
return 1;
|
|
}
|
|
const uint32_t id = g_target.load();
|
|
void* hero = game::GetPlayerHero();
|
|
if (!id || !hero || mods::skillkeys::KeyHeld() ||
|
|
game::CurrentAction(hero) != game::kActionAttack) {
|
|
return 0;
|
|
}
|
|
void* target = game::GetCreature(id);
|
|
return target && game::IsAlive(target) ? 1 : 0;
|
|
}
|
|
|
|
void Startup() {
|
|
re::RedirectCall(kSwingEndButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&SwingContinuesThunk));
|
|
re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast<const void*>(&AttackHeld));
|
|
}
|
|
RE_STARTUP(AutoAttackStartup, Startup);
|
|
|
|
} // namespace
|
|
|
|
namespace {
|
|
|
|
// An order for the player's hero.
|
|
void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
|
|
void* hero = game::GetPlayerHero();
|
|
if (creature != hero) {
|
|
return;
|
|
}
|
|
switch (order.command) {
|
|
case game::kOrderWalk:
|
|
g_target = 0;
|
|
break;
|
|
case game::kOrderAttack:
|
|
if (IsEnemy(hero, static_cast<uint32_t>(order.a))) {
|
|
g_target = static_cast<uint32_t>(order.a);
|
|
}
|
|
break;
|
|
case game::kOrderUseArt: {
|
|
// Buffs and the like (aimed at the hero or an ally) leave the target.
|
|
const auto launch = game::ArtLaunchType(static_cast<uint16_t>(order.art));
|
|
if (launch == game::LaunchType::SelfOrAlly || launch == game::LaunchType::Corpse) {
|
|
break;
|
|
}
|
|
g_cast = {static_cast<uint16_t>(order.art), IsEnemy(hero, order.target) ? order.target : 0u, false,
|
|
GetTickCount()};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
struct OrderRegistration {
|
|
OrderRegistration() { game::OnCreatureOrder(&OnOrder); }
|
|
} g_orderRegistration;
|
|
|
|
} // namespace
|
|
|
|
} // namespace mods::autoattack
|
|
|