From 16c2844400abec6fff6b295eb77b6b8b3b583abf Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Fri, 16 Oct 2026 09:16:47 +0200 Subject: [PATCH] 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) --- docs/auto-attack.md | 33 ++++++++++ docs/chain-lightning.md | 12 ++++ src/game/engine/orders.cpp | 30 +++++++++ src/game/engine/orders.h | 48 +++++++++++++- src/mods/autoattack.cpp | 124 ++++++++++++++++++++++++++++++++---- src/mods/chainlightning.cpp | 60 ++++++----------- src/mods/guards.cpp | 10 ++- src/re/crash.cpp | 18 +++++- 8 files changed, 275 insertions(+), 60 deletions(-) diff --git a/docs/auto-attack.md b/docs/auto-attack.md index 0fc4133..1f460e8 100644 --- a/docs/auto-attack.md +++ b/docs/auto-attack.md @@ -52,3 +52,36 @@ wind-up, 0.64 s attack, 0.43 s recovery). with a held button; the tick only restarts the attack after casts. - Self / ally arts pointed at an enemy lose their target (`src/mods/arttarget.cpp`) and therefore do not start auto-attack. + +## Next target, and auto-attack after arts (2026-10-16) + +User requests: when the target dies, go on with the next enemy close by; +after a melee art always auto-attack, after a ranged art only with a ranged +weapon. + +Measured with a temporary log (distance hero → target when the hero started +the swing or cast, battlemage with a staff): + +- +0x150 low word: 1 walking (also walking up to a target), 3 weapon attack, + 4 casting a spell. The "1 art" above was the walk up; 8 (seen after it on + 2026-10-15) is presumably a combat move. Names in `game/engine/orders.h`. +- Weapon swings started at 117..149 after walking up from up to 1382: the + game's contact distance (`0x4dbaa0`, mean of both body radii by object + type; heroes 110 with a weapon, 80 without). +- Spells (Blitzschlag 0x21, art 5) started at once from 647..803: no walk-up. + Their cast limit, if any, is beyond that. + +Rules (`autoattack.cpp`): + +- Close range = `ContactDistance(hero, hero) + 100` (≈ 210) with a melee + weapon; 600 with a ranged one (TODO: measure a ranged hero). Ranged weapon = + weapon class (`0x546df0`) 6 throwing, 7 bow, 12 crossbow, 13 pistol. +- The target died: the nearest living hostile creature within close range + (`game::NearestHostile`) becomes the target, else auto-attack stops. +- An art order (not a buff / ally / corpse art) is watched until the hero + starts it (action 4 or 8). It is melee if the hero walked first, ranged if + it starts farther away than contact distance + 100, else as remembered for + that art (default melee). Melee: auto-attack its enemy target, or the + 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. diff --git a/docs/chain-lightning.md b/docs/chain-lightning.md index a0f8fdf..416c83e 100644 --- a/docs/chain-lightning.md +++ b/docs/chain-lightning.md @@ -55,3 +55,15 @@ ends (a lone enemy is hit once). Two enemies: it bounces between them. First version (bolts copied for every jump, not using the game's chain) replaced the same day: it doubled the jumps. + +## Jump range = cast range (2026-10-16) + +User request: a jump reaches as far as a cast. Blitzschlag's hit check is in +the art code (cCreature art switch `0x5222c0`, case 0x21): the ray from the +caster towards the target is laid out in segments of 30, at most 20, each on +passable ground (`0x636b60`); the bolt is created on the target only if the +ray reaches it, otherwise the visible ray just stops. So the cast range is +20 × 30 = 600. `CastRange()` in `chainlightning.cpp` returns it for every jump +(also the first hop of extra chains), instead of the game's jump search radius +(squared 100000, about 316). It is the one place to make the range dynamic +later. Jumps do not check the ground in between. diff --git a/src/game/engine/orders.cpp b/src/game/engine/orders.cpp index 2f541b8..13f8465 100644 --- a/src/game/engine/orders.cpp +++ b/src/game/engine/orders.cpp @@ -4,6 +4,7 @@ #include +#include "game/engine/objects.h" #include "re/hook.h" namespace game { @@ -33,4 +34,33 @@ void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* orde Creature_ReceiveOrder_original(self, edx, order); } +uint32_t NearestHostile(void* attacker, void* from, double maxDistance, uint32_t except, const uint32_t* avoid, + size_t avoidCount) { + uint32_t best = 0; + uint32_t bestDistance = 0; + bool bestFresh = false; + const int count = NearbyCreatureCount(); + for (int i = 0; i < count; ++i) { + const uint32_t id = NearbyCreatureId(i); + if (id == except || IsPlayerHeroId(id) || !IsHostileTo(attacker, GetCreature(id))) { + continue; + } + const uint32_t distance = DistanceTo(from, id); + if (distance > maxDistance) { + continue; + } + bool fresh = true; + for (size_t k = 0; k < avoidCount; ++k) { + fresh = fresh && avoid[k] != id; + } + if (best && ((bestFresh && !fresh) || (bestFresh == fresh && distance >= bestDistance))) { + continue; + } + best = id; + bestDistance = distance; + bestFresh = fresh; + } + return best; +} + } // namespace game diff --git a/src/game/engine/orders.h b/src/game/engine/orders.h index 720fcf9..d6f397e 100644 --- a/src/game/engine/orders.h +++ b/src/game/engine/orders.h @@ -67,9 +67,20 @@ void OnCreatureOrder(CreatureOrderFn callback); constexpr size_t kCreatureId = 0xc; constexpr size_t kCreatureX = 0x1c; constexpr size_t kCreatureY = 0x20; -// +0x150: low word the current action (0 none, 1 art, 3 attack, 8 ...), high -// word its phase; 0x10000 when standing idle. +// +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). constexpr size_t kCreatureAction = 0x150; +enum CreatureAction : int32_t { + kActionIdle = 0, + kActionWalk = 1, + kActionAttack = 3, + kActionCastSpell = 4, + kActionDying = 6, + kActionCombatMove = 8, +}; +inline int32_t CurrentAction(const void* creature); // cCreature::IsAlive (0x548ef0): not dying (+0x150 == 6), not dead (+0xfc == // 9), health (+0x4d8) left. @@ -96,4 +107,37 @@ inline int32_t CreatureField(const void* creature, size_t offset) { return *reinterpret_cast(static_cast(creature) + offset); } +inline int32_t CurrentAction(const void* creature) { + return CreatureField(creature, kCreatureAction) & 0xffff; +} + +// The distance at which two creatures touch (0x4dbaa0 on one, (the other)): +// the mean of their body radii by object type (heroes 110 with a weapon, 80 +// without; monsters 70..480). A melee attacker swings from about there +// (measured: heroes started swings at 117..149). +inline int ContactDistance(void* creature, void* other) { + using Fn = int(__fastcall*)(void*, void*, void*); + return reinterpret_cast(0x004dbaa0)(creature, nullptr, other); +} + +// The creature's weapon class (0x546df0, (0)): from the weapons in its hands +// (+0x1d4 / +0x1d8, item type +0x1e), in the order of the motion sets: +// 0 bare hands, 1 one-handed, 2 two-handed, 3 two-handed axe, 4 staff, +// 5 dagger, 6 throwing, 7 bow, 8 two weapons, 9 / 10 blades, 11 whip, +// 12 crossbow, 13 pistol. +inline int WeaponClass(void* creature) { + using Fn = short(__fastcall*)(void*, void*, int); + return reinterpret_cast(0x00546df0)(creature, nullptr, 0); +} +inline bool HasRangedWeapon(void* creature) { + const int c = WeaponClass(creature); + return c == 6 || c == 7 || c == 12 || c == 13; +} + +// The nearest living creature hostile to `attacker` within `maxDistance` of +// `from` (a creature), other than `except` and other player heroes; one not +// in `avoid` if there is any. 0 if none. +uint32_t NearestHostile(void* attacker, void* from, double maxDistance, uint32_t except = 0, + const uint32_t* avoid = nullptr, size_t avoidCount = 0); + } // namespace game diff --git a/src/mods/autoattack.cpp b/src/mods/autoattack.cpp index ce50a03..2cc5531 100644 --- a/src/mods/autoattack.cpp +++ b/src/mods/autoattack.cpp @@ -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 #include +#include + +#include #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 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; @@ -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(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(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(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 { diff --git a/src/mods/chainlightning.cpp b/src/mods/chainlightning.cpp index 77f0197..ecf84ab 100644 --- a/src/mods/chainlightning.cpp +++ b/src/mods/chainlightning.cpp @@ -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& 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& 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(game::DistanceTo(from, id)); - if (d * d > static_cast(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& 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 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(); @@ -252,7 +228,7 @@ uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, ui return 0; } const std::shared_ptr 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 } diff --git a/src/mods/guards.cpp b/src/mods/guards.cpp index f51c594..0357dd8 100644 --- a/src/mods/guards.cpp +++ b/src/mods/guards.cpp @@ -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 diff --git a/src/re/crash.cpp b/src/re/crash.cpp index 296c07b..53d0304 100644 --- a/src/re/crash.cpp +++ b/src/re/crash.cpp @@ -62,7 +62,23 @@ void WriteReport(EXCEPTION_POINTERS* info) { } fprintf(file, "eax %08lx ebx %08lx ecx %08lx edx %08lx esi %08lx edi %08lx ebp %08lx esp %08lx\n", c.Eax, c.Ebx, c.Ecx, c.Edx, c.Esi, c.Edi, c.Ebp, c.Esp); - fprintf(file, "return addresses on the stack (innermost first):\n"); + // Frame pointer chain (our debug build and most of sacred.exe keep ebp). + fprintf(file, "frames (ebp chain):\n"); + uintptr_t frame = c.Ebp; + for (int i = 0; i < 64 && frame; ++i) { + MEMORY_BASIC_INFORMATION mem; + if (!VirtualQuery(reinterpret_cast(frame), &mem, sizeof(mem)) || mem.State != MEM_COMMIT) { + break; + } + const auto* slots = reinterpret_cast(frame); + DescribeAddress(slots[1], where, sizeof(where)); + fprintf(file, " %s\n", where); + if (slots[0] <= frame) { + break; + } + frame = slots[0]; + } + fprintf(file, "code addresses on the stack (innermost first; may include data):\n"); const auto* stack = reinterpret_cast(c.Esp); int found = 0; for (int i = 0; i < 4096 && found < 48; ++i) {