Skills, auto-attack, aura and chain lightning rework, Esc, cache fix

- Skill bar: no art selection; slot 0 is the right-click art, keys 0-6 cast
  slots directly; cooldown labels in exact seconds; slot 0 set apart.
- Arts regenerate on their own (casting no longer refills the group).
- Auto-attack after targeting or casting at a hostile creature, at the speed
  of a held button.
- Auras can be recast while active (renew, no second visual).
- Blicke wie Klingen hits every enemy in range, physical, with life leech.
- Blitzschlag steers the game's own chain: 3 + level/5 hits, 1 + level/10
  chains, re-hits a lone enemy.
- Weapon sets: click selects, drag only from the selected set; the leftmost
  set is clickable (signed hit tests).
- Esc closes open windows before opening the game menu.
- HUD: standalone buttons and texts are centred.
- Record cache sized for widened views (town lag while walking).
- Shared game code: orders, fx events, text drawing, cooldowns, effects.

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 00:11:38 +02:00
1 parent f721d2a486
commit 1fd89524d3
41 files changed
+2347 -214

No files matched your search

+263
View File
@@ -0,0 +1,263 @@
// Blitzschlag as chain lightning (rebalancing request of 2026-10-15).
//
// The game's Blitzschlag already chains (measured, docs/chain-lightning.md):
// the caster's art code creates a bolt object (type 872) at the target and
// sets it up with a cEventFX 0x60 whose [14] is the number of hits left in the
// chain (3 at level 1). The bolt hits its target; then, with hits left, its
// update (0x5b4640..) looks for the next target (0x6005d0 at 0x5b469f: near
// the current target, not the caster, not the current target), draws the arc
// (segment objects) and creates the next bolt with [14] - 1. Without a next
// target the chain ends, and between two enemies it bounces back and forth.
//
// 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
// 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,
// each with a bolt at its own first jump target (created while the first
// bolt is set up, as copies of its event; no arc for that first hop).
#include <cstdint>
#include <cstring>
#include <map>
#include <memory>
#include <vector>
#include <windows.h>
#include "game/engine/arts.h"
#include "game/engine/fx.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::chainlightning {
uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, uint32_t range, uint32_t caster,
uint32_t current, uint8_t* bolt);
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)
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;
constexpr DWORD kChainLifetimeMs = 10000;
struct Chain {
std::vector<uint32_t> hit; // targets in order
DWORD started = 0;
};
struct Bolt {
std::shared_ptr<Chain> chain;
uint32_t hitsLeft = 0;
};
// The art the hero cast last (cast orders), for its level; a cast order arms
// the next bolt setup as a chain start.
uint16_t g_heroArt = 0;
bool g_armed = false;
// Bolt objects of our chains, by object id; and the chain the next bolt set
// up at a given target belongs to (filled by NextTarget and branch spawning).
std::map<uint32_t, Bolt> g_bolts;
struct Pending {
std::shared_ptr<Chain> chain;
uint32_t target;
};
std::vector<Pending> g_pending;
void OnOrder(void* creature, const game::CreatureOrder& order, void*) {
if (order.command == game::kOrderUseArt && creature == game::GetPlayerHero()) {
g_heroArt = static_cast<uint16_t>(order.art);
g_armed = true;
}
}
unsigned ArtLevel(void* creature, uint16_t art) {
uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, 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;
}
void Forget() {
const DWORD now = GetTickCount();
for (auto it = g_bolts.begin(); it != g_bolts.end();) {
it = now - it->second.chain->started > kChainLifetimeMs ? g_bolts.erase(it) : std::next(it);
}
g_pending.clear();
}
// A bolt object at `target`, set up with a copy of `event` (while its strings
// are alive) with `hitsLeft`.
void SpawnBolt(const uint8_t* original, const uint32_t* event, uint32_t target, uint32_t hitsLeft) {
auto* creature = static_cast<uint8_t*>(game::GetCreature(target));
if (!creature) {
return;
}
auto* bolt = static_cast<uint8_t*>(
game::GetObjectById(game::CreateObjectAt(kBoltObjectType, creature + kCreaturePosition)));
if (!bolt) {
return;
}
bolt[kObjectOwnerPlayer] = original[kObjectOwnerPlayer];
uint32_t copy[game::kFxEventDwords];
std::memcpy(copy, event, sizeof(copy));
copy[3] = target;
std::memcpy(&copy[4], creature + kCreaturePosition, 16);
copy[kEventHitsLeft] = hitsLeft;
game::SendEvent(bolt, copy);
}
void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
if (event[1] != game::kFxEventBoltHit || *reinterpret_cast<uint32_t*>(fx + game::kObjectType) != kBoltObjectType ||
!game::IsPlayerHeroId(event[2])) {
return;
}
const uint32_t id = *reinterpret_cast<uint32_t*>(fx + game::kCreatureId);
const uint32_t target = event[3];
if (g_bolts.count(id)) {
return; // set up again later: already ours
}
// A jump of one of our chains (NextTarget or a branch said where it goes).
for (auto it = g_pending.begin(); it != g_pending.end(); ++it) {
if (it->target == target) {
g_bolts[id] = {it->chain, event[kEventHitsLeft]};
it->chain->hit.push_back(target);
g_pending.erase(it);
return;
}
}
if (!g_armed) {
return;
}
// The first bolt of a cast.
g_armed = false;
void* caster = game::GetCreature(event[2]);
void* first = game::GetCreature(target);
if (!caster || !first) {
return;
}
Forget();
const unsigned level = ArtLevel(caster, g_heroArt);
const uint32_t hits = 3 + level / 5;
const unsigned chains = 1 + level / 10;
event[kEventHitsLeft] = hits;
auto chain = std::make_shared<Chain>();
chain->hit.push_back(target);
chain->started = GetTickCount();
g_bolts[id] = {chain, hits};
// 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 branchTarget = next ? next : target;
taken.push_back(branchTarget);
auto branch = std::make_shared<Chain>();
branch->hit.push_back(target);
branch->started = chain->started;
g_pending.push_back({branch, branchTarget});
SpawnBolt(fx, event, branchTarget, hits - 1);
}
re::Log("chainlightning: art %x level %u: %u chain(s) of %u hits from %x", g_heroArt, level, chains, hits,
target);
}
// Called instead of the jump search (ecx = the object manager, four stack
// arguments); the bolt is in ebp.
__declspec(naked) void NextTargetThunk() {
__asm {
push ebp // the bolt
push dword ptr [esp+14h] // current target
push dword ptr [esp+14h] // caster
push dword ptr [esp+14h] // range
push dword ptr [esp+14h] // position
call NextTarget
ret 10h
}
}
void Startup() {
const bool ok = re::RedirectCall(kJumpSearchCall, kFindJumpTarget, reinterpret_cast<const void*>(&NextTargetThunk));
re::Log("chainlightning: jump targets%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(ChainLightningStartup, Startup);
struct Registration {
Registration() {
game::OnCreatureOrder(&OnOrder);
game::BeforeFxEvent(&BeforeFxEvent);
}
} g_registration;
} // namespace
// The next target of `bolt`'s chain (the game's search for bolts that are
// not ours).
uint32_t __fastcall NextTarget(void* manager, void*, const int32_t* position, uint32_t range, uint32_t caster,
uint32_t current, uint8_t* bolt) {
const uint32_t id = bolt ? *reinterpret_cast<uint32_t*>(bolt + game::kCreatureId) : 0;
const auto it = g_bolts.find(id);
if (it == g_bolts.end()) {
using FindFn = uint32_t(__fastcall*)(void*, void*, const int32_t*, uint32_t, uint32_t, uint32_t);
return reinterpret_cast<FindFn>(kFindJumpTarget)(manager, nullptr, position, range, caster, current);
}
void* casterCreature = game::GetCreature(caster);
void* from = game::GetCreature(current);
if (!casterCreature || !from || it->second.hitsLeft <= 1) {
return 0;
}
const std::shared_ptr<Chain> chain = it->second.chain;
uint32_t next = Nearest(casterCreature, from, range, current, chain->hit);
if (!next) {
next = current; // nobody else: hit the same enemy again
}
g_pending.push_back({chain, next});
return next;
}
} // namespace mods::chainlightning