// 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 #include #include #include #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 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 g_artRanged; bool IsEnemy(void* hero, uint32_t id) { if (id == 0 || id == static_cast(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(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(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(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(kIsButtonHeld)(cursor, nullptr)) { return 1; } const uint32_t id = g_target.load(); void* hero = game::GetPlayerHero(); const uint32_t targetId = target ? static_cast(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(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(&SwingContinuesThunk)); re::RedirectCall(kSwingButtonCheck, kIsButtonHeld, reinterpret_cast(&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(order.a))) { g_target = static_cast(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(order.art)); if (launch == game::LaunchType::SelfOrAlly || launch == game::LaunchType::Corpse) { break; } g_cast = {static_cast(order.art), IsEnemy(hero, order.target) ? order.target : 0u, false, GetTickCount()}; break; } } } struct OrderRegistration { OrderRegistration() { game::OnCreatureOrder(&OnOrder); } } g_orderRegistration; } // namespace } // namespace mods::autoattack