Auto-attack: next target, melee/ranged rule after arts; chain range = cast range

- 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>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-16 09:16:47 +02:00
1 parent 5338527b49
commit 16c2844400
8 files changed
+275 -60

No files matched your search

+112 -12
View File
@@ -8,13 +8,22 @@
// 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) or the target's death ends it.
// 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"
@@ -30,11 +39,27 @@ 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;
constexpr int32_t kActionAttack = 3; // +0x150 low word
// "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;
@@ -43,19 +68,89 @@ bool IsEnemy(void* hero, uint32_t id) {
}
bool Idle(void* creature) {
return (game::CreatureField(creature, game::kCreatureAction) & 0xffff) == 0;
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) {
const uint32_t id = g_target.load();
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* hero = game::ControlledCreature(engine);
void* target = game::GetCreature(id);
if (!hero || !target || !game::IsAlive(target)) {
g_target = 0;
return;
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;
@@ -129,7 +224,7 @@ char __fastcall AttackHeld(void* cursor, void*) {
const uint32_t id = g_target.load();
void* hero = game::GetPlayerHero();
if (!id || !hero || mods::skillkeys::KeyHeld() ||
(game::CreatureField(hero, game::kCreatureAction) & 0xffff) != kActionAttack) {
game::CurrentAction(hero) != game::kActionAttack) {
return 0;
}
void* target = game::GetCreature(id);
@@ -161,12 +256,17 @@ void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
g_target = static_cast<uint32_t>(order.a);
}
break;
case game::kOrderUseArt:
if (IsEnemy(hero, order.target)) {
g_target = order.target;
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 {
+18 -42
View File
@@ -11,7 +11,8 @@
//
// This mod keeps all of that and decides:
// - hits per chain N = 3 + level / 5: the first bolt's [14];
// - the next target: the nearest enemy within the game's range that this
// - the next target: the nearest enemy within Blitzschlag's cast range (see
// below; the game's own jump search used about 316) that this
// chain has not hit yet; else another enemy; else the current target again
// (a lone enemy takes all N hits);
// - chains B = 1 + level / 10: B - 1 more chains start at the first target,
@@ -43,7 +44,16 @@ namespace {
constexpr uint32_t kBoltObjectType = 0x368; // 872
constexpr uint32_t kJumpSearchCall = 0x005b469f; // in the bolt update
constexpr uint32_t kFindJumpTarget = 0x006005d0; // cObjectManager::FindJumpTarget
constexpr uint32_t kJumpRange = 100000; // the game's argument (a squared distance)
// Blitzschlag's cast range: the art code (cCreature art switch 0x5222c0,
// case 0x21) lays the ray from the caster towards the target in segments of
// 30, at most 20, each on passable ground (0x636b60); only if the target is
// reached is the bolt created on it. A jump may go as far as a cast. One place,
// so it can become dynamic (levels, attributes) later.
constexpr double kRaySegmentLength = 30.0;
constexpr int kRaySegments = 20;
double CastRange() {
return kRaySegmentLength * kRaySegments;
}
constexpr size_t kObjectOwnerPlayer = 0x39; // u8, copied from the caster like the game does
constexpr size_t kCreaturePosition = 0x18; // 4 dwords, as effects are placed
constexpr size_t kEventHitsLeft = 14;
@@ -84,44 +94,10 @@ unsigned ArtLevel(void* creature, uint16_t art) {
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
}
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
for (uint32_t i : ids) {
if (i == id) {
return true;
}
}
return false;
}
bool IsTarget(void* caster, uint32_t id) {
return !game::IsPlayerHeroId(id) && game::IsHostileTo(caster, game::GetCreature(id));
}
// The nearest living enemy of `caster` within `range` (squared) of `from`, not
// `current`; one not in `avoid` if there is any. 0 if none.
uint32_t Nearest(void* caster, void* from, uint32_t range, uint32_t current, const std::vector<uint32_t>& avoid) {
uint32_t best = 0;
double bestDistance = 0.0;
bool bestFresh = false;
const int count = game::NearbyCreatureCount();
for (int i = 0; i < count; ++i) {
const uint32_t id = game::NearbyCreatureId(i);
if (id == current || !IsTarget(caster, id)) {
continue;
}
const double d = static_cast<double>(game::DistanceTo(from, id));
if (d * d > static_cast<double>(range)) {
continue;
}
const bool fresh = !Contains(avoid, id);
if (best && ((bestFresh && !fresh) || (bestFresh == fresh && d >= bestDistance))) {
continue;
}
best = id;
bestDistance = d;
bestFresh = fresh;
}
return best;
// The nearest living enemy of `caster` within the cast range of `from`, not
// `current`; one not in `avoid` if there is any.
uint32_t Nearest(void* caster, void* from, uint32_t current, const std::vector<uint32_t>& avoid) {
return game::NearestHostile(caster, from, CastRange(), current, avoid.data(), avoid.size());
}
void Forget() {
@@ -194,7 +170,7 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
// More chains: each starts with a bolt at its own first jump target.
std::vector<uint32_t> taken{target};
for (unsigned c = 1; c < chains && hits > 1; ++c) {
const uint32_t next = Nearest(caster, first, kJumpRange, target, taken);
const uint32_t next = Nearest(caster, first, target, taken);
const uint32_t branchTarget = next ? next : target;
taken.push_back(branchTarget);
auto branch = std::make_shared<Chain>();
@@ -252,7 +228,7 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui
return 0;
}
const std::shared_ptr<Chain> chain = it->second.chain;
uint32_t next = Nearest(casterCreature, from, range, current, chain->hit);
uint32_t next = Nearest(casterCreature, from, current, chain->hit);
if (!next) {
next = current; // nobody else: hit the same enemy again
}
+7 -3
View File
@@ -13,9 +13,13 @@ namespace game {
// pressed while walking; Windows reported sacred.exe+0x374e39). Who renders
// it without a device is not known yet (sacred_re_crash.txt will tell). With
// no device there is nothing to draw.
void __fastcall BurningBone_Render(void* self, void* edx, void* device);
// (device, a 4x4 matrix by value; ret 0x44).
struct Matrix4 {
float m[16];
};
void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4 transform);
RE_HOOK(0x00774e10, BurningBone_Render);
void __fastcall BurningBone_Render(void* self, void* edx, void* device) {
void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4 transform) {
if (!device) {
static bool logged = false;
if (!logged) {
@@ -24,7 +28,7 @@ void __fastcall BurningBone_Render(void* self, void* edx, void* device) {
}
return;
}
BurningBone_Render_original(self, edx, device);
BurningBone_Render_original(self, edx, device, transform);
}
} // namespace game