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

+103
View File
@@ -0,0 +1,103 @@
// Arts regenerate on their own: casting an art no longer puts the other arts
// of its group into regeneration.
//
// After an art is used, the cast code computes its regeneration
// (cCreatureArts::RegenFor 0x582710, (art) -> amount) and passes the amount
// to cCreatureArts::AddGroupRegen (0x562330, (amount, group, filter)), which
// adds it to the remaining time of every cooldown entry of that group (1 or
// 2), capped at each entry's total. For player heroes the amount now goes to
// the cast art's entry only. AddGroupRegen calls that do not directly follow
// a RegenFor (other callers, negative amounts, the combo branch) are left to
// the game. See docs/art-regen.md.
#include <cstdint>
#include "game/engine/arts.h"
#include "game/engine/orders.h"
#include "re/hook.h"
namespace mods::artregen {
namespace {
// The art whose regeneration RegenFor computed last (the cast code calls
// AddGroupRegen right after it, on the same thread).
struct Pending {
void* arts = nullptr;
uint16_t art = 0;
};
thread_local Pending g_pending;
uint8_t* Owner(uint8_t* arts) {
return *reinterpret_cast<uint8_t**>(arts + game::kArtsCreature);
}
bool IsPlayerHero(const uint8_t* creature) {
return game::IsPlayerHeroId(*reinterpret_cast<const uint32_t*>(creature + game::kCreatureId));
}
} // namespace
void OnRegenFor(void* arts, uint32_t art) {
g_pending = {arts, static_cast<uint16_t>(art)};
}
// AddGroupRegen for the cast art only; false to let the game handle the call.
bool AddToCastArt(void* self, float amount, uint32_t group, uint32_t filter) {
const Pending pending = g_pending;
g_pending = {};
auto* arts = static_cast<uint8_t*>(self);
// Only the game's normal branch (no combo running, not the combo group),
// without a filter.
uint8_t* creature = Owner(arts);
if (pending.arts != self || pending.art == 0 || amount < 0.0f || group == game::kRegenGroupCombo ||
filter != 0 || !creature || creature[game::kCreatureComboStep] != 0 || arts[game::kArtsComboRunning] != 0 ||
!IsPlayerHero(creature)) {
return false;
}
uint8_t* entry = game::FindCooldownEntry(arts, pending.art);
if (!entry) {
return true; // nothing to regenerate; the other arts stay untouched
}
// As the game: the flags shorten one group's regeneration to 80 %.
const bool magic = game::InMagicRegenGroup(*reinterpret_cast<uint32_t*>(entry + game::kCooldownGroup),
*reinterpret_cast<uint16_t*>(entry + game::kCooldownArt));
const uint32_t flags = *reinterpret_cast<uint32_t*>(arts + game::kArtsFlags);
if ((magic && (flags & game::kArtsFlagFasterMagicRegen)) ||
(!magic && (flags & game::kArtsFlagFasterCombatRegen))) {
amount *= 0.8f;
}
float& remaining = game::Float(entry, game::kCooldownRemaining);
remaining += amount;
const float total = game::Float(entry, game::kCooldownTotal);
if (remaining > total) {
remaining = total;
}
if (remaining < 0.0f) {
remaining = 0.0f;
}
return true;
}
} // namespace mods::artregen
namespace game {
// cCreatureArts::RegenFor (0x582710, ret 4): the regeneration of art `art`.
float __fastcall Arts_RegenFor(void* self, void* edx, uint32_t art);
RE_HOOK(0x00582710, Arts_RegenFor);
float __fastcall Arts_RegenFor(void* self, void* edx, uint32_t art) {
mods::artregen::OnRegenFor(self, art & 0xffff);
return Arts_RegenFor_original(self, edx, art);
}
// cCreatureArts::AddGroupRegen (0x562330, ret 0xc).
uint32_t __fastcall Arts_AddGroupRegen(void* self, void* edx, float amount, uint32_t group, uint32_t filter);
RE_HOOK(0x00562330, Arts_AddGroupRegen);
uint32_t __fastcall Arts_AddGroupRegen(void* self, void* edx, float amount, uint32_t group, uint32_t filter) {
if (self && mods::artregen::AddToCastArt(self, amount, group, filter)) {
return 0;
}
return Arts_AddGroupRegen_original(self, edx, amount, group, filter);
}
} // namespace game
+11 -24
View File
@@ -11,46 +11,33 @@
#include <cstdint>
#include "game/engine/arts.h"
#include "game/engine/orders.h"
#include "re/hook.h"
namespace game {
namespace {
constexpr uint32_t kOrderUseArt = 0x105;
constexpr uint32_t kPlayerIds = 0x11; // object ids below this are player heroes
// cEvent_creature fields used here.
constexpr size_t kOrderCommand = 0x4;
constexpr size_t kOrderTarget = 0x10; // object id
constexpr size_t kOrderArt = 0x30; // art id (cCreature::ResolveArtId)
uint32_t& Field(void* event, size_t offset) {
return *reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(event) + offset);
}
void RetargetSelfArt(void* creature, void* event) {
if (!creature || !event || Field(event, kOrderCommand) != kOrderUseArt) {
void RetargetSelfArt(void* creature, CreatureOrder* order) {
if (!creature || !order || order->command != kOrderUseArt || order->target == 0) {
return;
}
const uint32_t target = Field(event, kOrderTarget);
const uint32_t casterId = *reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(creature) + 0xc);
if (target == 0 || casterId >= kPlayerIds) {
if (!IsPlayerHeroId(static_cast<uint32_t>(CreatureField(creature, kCreatureId)))) {
return;
}
if (ArtLaunchType(static_cast<uint16_t>(Field(event, kOrderArt))) == LaunchType::SelfOrAlly &&
!IsAlly(creature, target)) {
Field(event, kOrderTarget) = 0;
if (ArtLaunchType(static_cast<uint16_t>(order->art)) == LaunchType::SelfOrAlly &&
!IsAlly(creature, order->target)) {
order->target = 0;
}
}
} // namespace
// cEngine::SendCreatureOrder (0x6170d0): orders for the controlled creature.
void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, void* event);
void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, CreatureOrder* order);
RE_HOOK(0x006170d0, Engine_SendCreatureOrder);
void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, void* event) {
RetargetSelfArt(creature, event);
Engine_SendCreatureOrder_original(self, edx, creature, event);
void __fastcall Engine_SendCreatureOrder(void* self, void* edx, void* creature, CreatureOrder* order) {
RetargetSelfArt(creature, order);
Engine_SendCreatureOrder_original(self, edx, creature, order);
}
} // namespace game
+14 -34
View File
@@ -12,62 +12,42 @@
#include <cstdint>
#include <cstring>
#include "game/engine/arts.h"
#include "game/engine/events.h"
#include "game/engine/fx.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/log.h"
namespace mods::aura_restore {
namespace {
constexpr uint32_t kAuraEffectType = 842; // cObjectFX: aura following its owner
constexpr uint16_t kAuraArts[] = {0x3f7, 0x3f8}; // Heldenmut, Klingenblick
constexpr uint32_t kPlayerIds = 0x11; // object ids below this are player heroes
constexpr uint16_t kAuraArts[] = {game::kArtHeldenmut, game::kArtKlingenblick};
constexpr int kTicksAfterLoad = 5; // wait for the load to finish (0.5 s)
// Active effects of a creature: vector of 0x20-byte entries at
// (creature + 0x3a8) + 0x11a / + 0x11e (cCreatureArts, AddEffect 0x582c10).
constexpr size_t kCreatureArts = 0x3a8;
constexpr size_t kEffectsBegin = 0x11a;
constexpr size_t kEffectsEnd = 0x11e;
constexpr size_t kEffectSize = 0x20;
constexpr size_t kEffectKind = 0x0;
constexpr size_t kEffectRemaining = 0x18; // float, seconds
constexpr size_t kEffectArt = 0x1c; // u16
constexpr uint32_t kKindCooldown = 0x22; // the art's cooldown (0x539ce0), not the buff
// cEventFX (0x7c bytes, built by 0x41e0a0).
constexpr uint32_t kEventFXVtable = 0x0088e9b4;
constexpr uint32_t kFxAttach = 0x42;
using namespace game; // effect layout (arts.h)
int g_ticksUntilRestore = -1;
// cObjectManager::CreateAt (0x5fbaa0): object `type` at `position`; its id.
uint32_t CreateAt(uint32_t type, const void* position) {
using Fn = uint32_t(__fastcall*)(void* manager, void* edx, uint32_t, const void*, uint32_t, uint32_t,
uint32_t);
void* manager = *reinterpret_cast<void**>(0x00ad3bc0);
return manager ? reinterpret_cast<Fn>(0x005fbaa0)(manager, nullptr, type, position, 0, 0, 0) : 0;
}
void AttachAura(uint8_t* hero, uint16_t art, float remaining) {
const uint32_t heroId = *reinterpret_cast<uint32_t*>(hero + 0xc);
const uint32_t id = CreateAt(kAuraEffectType, hero + 0x18); // the hero's position
const uint32_t heroId = *reinterpret_cast<uint32_t*>(hero + kCreatureId);
const uint32_t id = CreateObjectAt(kAuraEffectType, hero + 0x18); // the hero's position
auto* effect = static_cast<uint8_t*>(game::GetObjectById(id));
if (!effect) {
return;
}
uint32_t event[0x1f] = {};
event[0] = kEventFXVtable;
event[1] = kFxAttach;
uint32_t event[kFxEventDwords] = {};
event[0] = kFxEventVtable;
event[1] = kFxEventAttachAura;
event[2] = heroId;
std::memcpy(&event[11], &remaining, 4);
event[15] = art;
using ReceiveFn = int(__fastcall*)(void*, void*, void*);
reinterpret_cast<ReceiveFn>((*reinterpret_cast<void***>(effect))[6])(effect, nullptr, event);
SendEvent(effect, event);
re::Log("aura: restored art %x on hero %u (%.1f s left)", art, heroId, remaining);
}
// Longest remaining time of the buffs (any kind but the cooldown) of `art`.
// Longest remaining time of the buffs (any kind but the lock) of `art`.
float RemainingBuff(uint8_t* hero, uint16_t art) {
uint8_t* arts = hero + kCreatureArts;
auto* entry = *reinterpret_cast<uint8_t**>(arts + kEffectsBegin);
@@ -76,7 +56,7 @@ float RemainingBuff(uint8_t* hero, uint16_t art) {
for (; entry && entry < end; entry += kEffectSize) {
float remaining;
std::memcpy(&remaining, entry + kEffectRemaining, 4);
if (*reinterpret_cast<uint32_t*>(entry + kEffectKind) != kKindCooldown &&
if (*reinterpret_cast<uint32_t*>(entry + kEffectKind) != kEffectKindArtLock &&
*reinterpret_cast<uint16_t*>(entry + kEffectArt) == art && remaining > longest) {
longest = remaining;
}
@@ -85,7 +65,7 @@ float RemainingBuff(uint8_t* hero, uint16_t art) {
}
void RestoreAuras() {
for (uint32_t id = 1; id < kPlayerIds; ++id) {
for (uint32_t id = 1; id < kPlayerHeroIdEnd; ++id) {
auto* hero = static_cast<uint8_t*>(game::GetCreature(id));
if (!hero) {
continue;
+120
View File
@@ -0,0 +1,120 @@
// Periodic aura hits strike every creature in range, not one.
//
// Effects of kind 0x12 (Blicke wie Klingen) are handled in the creature arts
// update (branch at 0x562f08, local player only): every second (timer at
// effect +8) it walks the creatures near the player (g_nearbyCreatures,
// 0xad1c60, 8 bytes each, count g_nearbyCreatureCount 0xad1b48), keeps one of
// those within (cCreatureArts +0x56 + 20) * 10 of the owner (the first,
// replaced by later ones with 1/3 chance) and sends it one hit (cEvent_creature
// 5, the effect's value as one random damage type) through its ReceiveOrder.
// The damage is made physical (user's choice: no random fire/magic/poison),
// and the hits name the owner as their attacker, so the game applies its life
// leech (with beam and sound) as for weapon hits.
//
// That call (`mov eax, [ecx]; call [eax+18h]` at 0x56323b) now goes to
// HitAllInRange: the chosen creature gets its hit, then every other creature
// of the list within the same range gets the same hit. Rebalancing request of
// 2026-10-15, see docs/aura-hits.md.
#include <cstdint>
#include <cstring>
#include "game/engine/arts.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::aurahits {
namespace {
constexpr uint32_t kHitCall = 0x0056323b; // mov eax, [ecx]; call [eax+18h]
constexpr uint32_t kEffectKindPeriodicHit = 0x12;
constexpr size_t kArtsHitRangeLevel = 0x56; // u16: range = (value + 20) * 10
// The hit's damage: four u16 slots at +0x20 (physical, fire, magic,
// poison); the game fills one of them at random.
constexpr size_t kHitDamage = 0x20;
constexpr int kDamageTypes = 4;
void MakePhysical(game::CreatureOrder& hit) {
auto* damage = reinterpret_cast<uint16_t*>(reinterpret_cast<uint8_t*>(&hit) + kHitDamage);
uint32_t total = 0;
for (int i = 0; i < kDamageTypes; ++i) {
total += damage[i];
damage[i] = 0;
}
damage[0] = static_cast<uint16_t>(total > 0xfffe ? 0xfffe : total);
}
void Send(void* creature, game::CreatureOrder* order) {
using ReceiveFn = void(__fastcall*)(void*, void*, void*);
reinterpret_cast<ReceiveFn>((*reinterpret_cast<void***>(creature))[0x18 / 4])(creature, nullptr, order);
}
// Sends the hit with the owner as its attacker (order +0x1c): the game leaves
// it 0 for aura hits, so the damage code (0x56aff0) does not apply the
// attacker's life leech, its beam and sound. With the attacker set, it does.
void Hit(void* owner, void* creature, game::CreatureOrder* order) {
order->b = game::CreatureField(owner, game::kCreatureId);
Send(creature, order);
}
} // namespace
// (ecx = the chosen creature, the hit, cCreatureArts in ebp, the effect's
// offset in its list in ebx)
void __fastcall HitAllInRange(void* chosen, void*, game::CreatureOrder* hit, uint8_t* arts, uint32_t effect) {
auto* effects = *reinterpret_cast<uint8_t**>(arts + game::kEffectsBegin);
void* owner = *reinterpret_cast<void**>(arts + game::kArtsCreature);
if (!effects || !owner ||
*reinterpret_cast<uint32_t*>(effects + effect + game::kEffectKind) != kEffectKindPeriodicHit) {
Send(chosen, hit);
return;
}
MakePhysical(*hit);
const game::CreatureOrder original = *hit; // the receiver may change it
Hit(owner, chosen, hit);
const uint32_t range = (*reinterpret_cast<uint16_t*>(arts + kArtsHitRangeLevel) + 20u) * 10u;
const auto chosenId = static_cast<uint32_t>(game::CreatureField(chosen, game::kCreatureId));
const int count = game::NearbyCreatureCount();
for (int i = 0; i < count; ++i) {
const uint32_t id = game::NearbyCreatureId(i);
// Player heroes need the game's PvP checks (0x4d4be0): left out.
if (id == chosenId || game::IsPlayerHeroId(id) || game::DistanceTo(owner, id) >= range) {
continue;
}
void* creature = game::GetCreature(id);
if (!game::IsHostileTo(owner, creature)) {
continue;
}
game::CreatureOrder copy = original;
Hit(owner, creature, &copy);
}
}
namespace {
__declspec(naked) void HitAllInRangeThunk() {
__asm {
push ebx // effect offset
push ebp // cCreatureArts
push dword ptr [esp+0Ch] // the hit (the call's argument)
call HitAllInRange
ret 4
}
}
void Startup() {
const auto target = reinterpret_cast<uintptr_t>(&HitAllInRangeThunk);
const int32_t rel = static_cast<int32_t>(target - (kHitCall + 5));
uint8_t to[5] = {0xe8};
std::memcpy(to + 1, &rel, 4);
const uint8_t from[5] = {0x8b, 0x01, 0xff, 0x50, 0x18};
const bool ok = re::ReplaceCode(kHitCall, from, to, sizeof(to));
re::Log("aurahits: periodic aura hits strike all creatures in range%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(AuraHitsStartup, Startup);
} // namespace
} // namespace mods::aurahits
+136
View File
@@ -0,0 +1,136 @@
// Auras and buffs can be cast again while they are active; the new cast
// renews their remaining time instead of adding a second effect.
//
// cCreature::IsValidArtTarget (0x51d680) rejects lasting buffs (Heldenmut,
// Klingenblick, ... : the arts that branch to 0x51d8b1) on a target that
// already has an effect of the art (cCreatureArts::HasEffectOfArt 0x5829c0).
// That test is skipped. The cast then adds its effects again through
// cCreatureArts::AddEffect (0x582c10); for a buff art (launch type
// SelfOrAlly) on a player hero, an effect whose kind and art are already
// present only gets its remaining time set to the new duration. The art's
// cooldown applies as before. The aura's visual (an effect object each cast
// creates) is not doubled: the old one is flagged dead when the new one
// attaches. See docs/aura-recast.md.
#include <cstddef>
#include <cstdint>
#include "game/engine/arts.h"
#include "game/engine/fx.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::aurarecast {
namespace {
// IsValidArtTarget: `call HasEffectOfArt; test al, al; je valid` (0x51d8b6),
// the `je` (0f 84 17 02 00 00) made a `jmp` to the same place (e9 18 02 00
// 00 90).
constexpr re::CodeReplacement kSkipActiveCheck[] = {
{0x0051d8bd, {0x0f, 0x84, 0x17, 0x02, 0x00, 0x00}, {0xe9, 0x18, 0x02, 0x00, 0x00, 0x90}, 6},
};
using namespace game; // effect layout (arts.h)
constexpr size_t kFxOwner = 0x68; // set from the attach event
void Startup() {
const bool ok = re::ReplaceAll(kSkipActiveCheck) == 0;
re::Log("aurarecast: active buffs can be recast%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(AuraRecastStartup, Startup);
} // namespace
// An effect of `kind` / `art` that `arts` already has, if it is a buff of a
// player hero: renew it to `duration` and report true.
bool Renew(uint8_t* arts, uint32_t kind, float duration, uint32_t art) {
art &= 0xffff;
const auto heroId = static_cast<uint32_t>(game::CreatureField(arts - game::kCreatureArts, game::kCreatureId));
if (art == game::kNoArt || !game::IsPlayerHeroId(heroId) ||
game::ArtLaunchType(static_cast<uint16_t>(art)) != game::LaunchType::SelfOrAlly) {
return false;
}
auto* entry = *reinterpret_cast<uint8_t**>(arts + kEffectsBegin);
auto* end = *reinterpret_cast<uint8_t**>(arts + kEffectsEnd);
for (; entry && entry < end; entry += kEffectSize) {
if (*reinterpret_cast<uint32_t*>(entry) == kind && *reinterpret_cast<uint16_t*>(entry + kEffectArt) == art) {
*reinterpret_cast<float*>(entry + kEffectRemaining) = duration;
return true;
}
}
return false;
}
// The aura effect object (cObjectFX, attached to its owner by a cEventFX 0x42:
// owner +8, art +0x3c) is created again by every cast. When a new one attaches
// to a player hero for the same art, the old one is flagged dead (object
// +0x14 bit 0x10, as saving does), so the renewed aura shows once.
void OnFxAttach(uint8_t* fx, uint32_t owner, uint32_t art) {
struct Aura {
uint32_t owner;
uint32_t art;
uint32_t fx;
};
static Aura auras[32] = {};
art &= 0xffff;
if (!game::IsPlayerHeroId(owner) || art == 0 || art == game::kNoArt ||
game::ArtLaunchType(static_cast<uint16_t>(art)) != game::LaunchType::SelfOrAlly) {
return;
}
const auto id = *reinterpret_cast<uint32_t*>(fx + game::kCreatureId);
Aura* free = nullptr;
for (Aura& aura : auras) {
if (aura.owner == owner && aura.art == art) {
if (aura.fx != id) {
auto* old = static_cast<uint8_t*>(game::GetObjectById(aura.fx));
// Ids are reused: only the aura object still on this owner.
if (old && *reinterpret_cast<uint32_t*>(old) == game::kObjectFXVtable &&
*reinterpret_cast<uint32_t*>(old + kFxOwner) == owner) {
*reinterpret_cast<uint32_t*>(old + game::kObjectFlags) |= game::kObjectDead;
}
}
aura.fx = id;
return;
}
if (!free && aura.owner == 0) {
free = &aura;
}
}
if (free) {
*free = {owner, art, id};
}
}
namespace {
void OnFxEvent(uint8_t* fx, uint32_t* event) {
if (event[1] == game::kFxEventAttachAura) {
OnFxAttach(fx, event[2], event[15]);
}
}
struct Registration {
Registration() { game::OnFxEvent(&OnFxEvent); }
} g_registration;
} // namespace
} // namespace mods::aurarecast
namespace game {
// cCreatureArts::AddEffect (0x582c10): (kind, ?, value, duration, art,
// network, ?, ?), 1 if added.
uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration,
uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8);
RE_HOOK(0x00582c10, Arts_AddEffect);
uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration,
uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8) {
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
return 1;
}
return Arts_AddEffect_original(self, edx, kind, a2, value, duration, art, a6, a7, a8);
}
} // namespace game
+179
View File
@@ -0,0 +1,179 @@
// 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) or the target's death ends it.
// 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 "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;
constexpr int32_t kActionAttack = 3; // +0x150 low word
std::atomic<uint32_t> g_target{0};
int g_waitTicks = 0;
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::CreatureField(creature, game::kCreatureAction) & 0xffff) == 0;
}
void OnTick(uint8_t* engine) {
const 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 (!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::CreatureField(hero, game::kCreatureAction) & 0xffff) != 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:
if (IsEnemy(hero, order.target)) {
g_target = order.target;
}
break;
}
}
struct OrderRegistration {
OrderRegistration() { game::OnCreatureOrder(&OnOrder); }
} g_orderRegistration;
} // namespace
} // namespace mods::autoattack
+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
+101
View File
@@ -0,0 +1,101 @@
// Esc closes open windows before it opens the game menu.
//
// cUI_Manager::HandleEsc (0x756ba0) closes the ESC menu, the large map, the
// quest book or the inventory, hides two more windows, and otherwise opens
// the ESC menu. With a trader (and others) open it only closed the inventory,
// which the trader window opened again. Now every open window of the table
// below is sent the command the game itself uses to close the quest book: a
// cEventUI2 "hide" (code 0x12, subtype 5, the window's name) to the UI
// manager's event handler. The game's handling stays for the ESC menu itself and when
// none of these windows is open. See docs/escape.md.
#include <cstdint>
#include <cstring>
#include "re/hook.h"
#include "re/log.h"
namespace mods::escape {
namespace {
// cUI_Manager: window pointers by slot (dwords).
struct ClosableWindow {
int slot;
const char* name;
};
constexpr ClosableWindow kClosable[] = {
{0x21, "UI_WND_INVENTORY"}, {0x22, "UI_WND_EQUIPMENT"}, {0x23, "UI_WND_BLACKSMITH"},
{0x24, "UI_WND_MERCHANT"}, {0x25, "UI_WND_MEGAMAP"}, {0x28, "UI_WND_STATS_CHAR"},
{0x2a, "UI_WND_QUESTBOOK"}, {0x2b, "UI_WND_SAVEGAME"}, {0x2d, "UI_WND_PURCHASE"},
{0x2e, "UI_WND_MASTER"}, {0x2f, "UI_WND_OPTIONS"}, {0x30, "UI_WND_CHEST"},
{0x31, "UI_WND_HORSE"}, {0x35, "UI_WND_CUBE"},
};
constexpr int kEscMenuSlot = 0x2c;
constexpr size_t kControlFlags = 0x10; // bit 0: visible
// cEventUI2 as HandleEsc builds it (event at [esp+10h] there).
constexpr uint32_t kEventUiVtable = 0x0088edd0;
constexpr uint32_t kEventUiWindowCommand = 0x12;
constexpr uint32_t kWindowCommandHide = 5;
constexpr size_t kEventUiSize = 0x80;
// Names, written by 0x5904e0 (sender at +0, window at +0x20 of this): measured
// in the UI commands the game sends when a window is closed with its button or
// hotkey (window name at +0x48).
constexpr size_t kEventUiNames = 0x28;
constexpr size_t kEventUiText = 0x68; // a string, 0x423920 / 0x416660
bool Visible(const uint8_t* window) {
return window && (window[kControlFlags] & 1) != 0;
}
// The window command goes to the UI manager's event handler (vf00,
// 0x757120), which routes it by name, as HandleEsc does for the inventory.
// (Sent to the window's own handler it was not understood the same way: the
// trader changed its stock instead of closing.)
void Hide(uint32_t* manager, const char* name) {
alignas(4) uint8_t event[kEventUiSize] = {};
*reinterpret_cast<uint32_t*>(event) = kEventUiVtable;
*reinterpret_cast<uint32_t*>(event + 4) = kEventUiWindowCommand;
*reinterpret_cast<uint32_t*>(event + 8) = kWindowCommandHide;
using SetNamesFn = void(__fastcall*)(void*, void*, const char* window, const char* sender);
reinterpret_cast<SetNamesFn>(0x005904e0)(event + kEventUiNames, nullptr, name, "");
using StringFn = void(__fastcall*)(void*, void*);
reinterpret_cast<StringFn>(0x00423920)(event + kEventUiText, nullptr); // construct
using HandleFn = int(__fastcall*)(void*, void*, void*);
reinterpret_cast<HandleFn>((*reinterpret_cast<void***>(manager))[0])(manager, nullptr, event);
reinterpret_cast<StringFn>(0x00416660)(event + kEventUiText, nullptr); // destroy
}
} // namespace
// True if Esc closed windows (the game's handling is skipped).
bool CloseWindows(uint32_t* manager) {
if (Visible(reinterpret_cast<uint8_t*>(manager[kEscMenuSlot]))) {
return false;
}
bool closed = false;
for (const ClosableWindow& w : kClosable) {
auto* window = reinterpret_cast<uint8_t*>(manager[w.slot]);
if (Visible(window)) {
Hide(manager, w.name);
closed = true;
}
}
return closed;
}
} // namespace mods::escape
namespace game {
// cUI_Manager::HandleEsc (0x756ba0): 1 if handled.
uint32_t __fastcall UiManager_HandleEsc(uint32_t* self, void* edx);
RE_HOOK(0x00756ba0, UiManager_HandleEsc);
uint32_t __fastcall UiManager_HandleEsc(uint32_t* self, void* edx) {
if (self && mods::escape::CloseWindows(self)) {
return 1;
}
return UiManager_HandleEsc_original(self, edx);
}
} // namespace game
+16
View File
@@ -44,6 +44,7 @@ thread_local int g_offsetX = 0;
// Whether the UI event currently being handled has already been shifted.
thread_local bool g_eventShifted = false;
enum class Anchor { Center, Right };
int OffsetFor(Anchor anchor) {
@@ -500,6 +501,21 @@ HUD_RENDER(0x0071dd80, UiSavegame_Render, Anchor::Center)
HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center)
HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center)
// Controls that can be top-level windows of their own, e.g. the quest dialog
// (buttons and texts placed directly in the UI manager). Inside one of the
// windows above they are already in its scope (outermost wins); on their own
// they were drawn and clicked unshifted (or shifted by whichever window was
// active), which made the dialog jump and its buttons miss.
HUD_EVENT(0x0072adf0, UiButton_HandleEvent, Anchor::Center)
HUD_RENDER(0x0072af90, UiButton_Render, Anchor::Center)
HUD_EVENT(0x00753c70, UiStaticText_HandleEvent, Anchor::Center)
HUD_RENDER(0x00753a20, UiStaticText_Render, Anchor::Center)
HUD_EVENT(0x00754610, UiStaticTextFx_HandleEvent, Anchor::Center)
HUD_RENDER(0x007541e0, UiStaticTextFx_Render, Anchor::Center)
HUD_EVENT(0x00753360, UiStatic_HandleEvent, Anchor::Center)
HUD_RENDER(0x007530a0, UiStatic_Render, Anchor::Center)
HUD_RENDER(0x007228b0, UiTextLines_Render, Anchor::Center)
// Tooltips: placed by the hovered window in its own layout (see TrackHover).
int __fastcall UiTooltip_Render(void* self, void* edx, void* device);
RE_HOOK(0x006e7300, UiTooltip_Render);
+12 -13
View File
@@ -5,9 +5,9 @@
// several event receivers (engine 0x618130, UI manager 0x757120, taskbar
// 0x6e1970). This mod reads the raw key messages instead (virtual keys,
// independent of the keyboard layout): a bound key's messages are swallowed
// and its action runs. Slot keys call the taskbar's selection directly
// (src/mods/skillbar.cpp); keys standing in for an original hotkey post that
// hotkey's key event to the game's event kernel, the way the window
// and its action runs. Skill keys cast their slot (src/mods/skillkeys.cpp),
// weapon set keys select it on the taskbar; keys standing in for an original
// hotkey post that hotkey's key event to the game's event kernel, the way the window
// procedure does (the kernel dispatches it right away, on the window thread).
//
// While a text field has the keyboard, or Ctrl / Alt is held, keys pass
@@ -15,7 +15,7 @@
//
// sacred_re.ini (key names: A-Z, 0-9, F1-F24, Space, None):
// [keys]
// SkillSlot1=1 ... SkillSlot7=7 ; select and cast combat-art slot
// SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art)
// WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set
// ActionSlot3=G ; what W did (W is movement)
// PickUp=V ; what A did (pick up items nearby)
@@ -85,13 +85,13 @@ struct Binding {
};
Binding g_bindings[] = {
{"SkillSlot1", "1", Kind::SkillSlot, 0, 0},
{"SkillSlot2", "2", Kind::SkillSlot, 1, 0},
{"SkillSlot3", "3", Kind::SkillSlot, 2, 0},
{"SkillSlot4", "4", Kind::SkillSlot, 3, 0},
{"SkillSlot5", "5", Kind::SkillSlot, 4, 0},
{"SkillSlot6", "6", Kind::SkillSlot, 5, 0},
{"SkillSlot7", "7", Kind::SkillSlot, 6, 0},
{"SkillSlot0", "0", Kind::SkillSlot, 0, 0},
{"SkillSlot1", "1", Kind::SkillSlot, 1, 0},
{"SkillSlot2", "2", Kind::SkillSlot, 2, 0},
{"SkillSlot3", "3", Kind::SkillSlot, 3, 0},
{"SkillSlot4", "4", Kind::SkillSlot, 4, 0},
{"SkillSlot5", "5", Kind::SkillSlot, 5, 0},
{"SkillSlot6", "6", Kind::SkillSlot, 6, 0},
{"WeaponSet1", "F1", Kind::WeaponSet, 0, 0},
{"WeaponSet2", "F2", Kind::WeaponSet, 1, 0},
{"WeaponSet3", "F3", Kind::WeaponSet, 2, 0},
@@ -149,8 +149,7 @@ void Press(const Binding& binding, int vk) {
const auto slot = static_cast<unsigned>(binding.index);
switch (binding.kind) {
case Kind::SkillSlot:
mods::skillkeys::CastOnSelect(slot, vk);
mods::skillbar::SelectSkillSlot(slot);
mods::skillkeys::KeyPressed(slot, vk);
break;
case Kind::WeaponSet:
mods::skillbar::SelectWeaponSet(slot);
+96
View File
@@ -0,0 +1,96 @@
// The game's record cache is sized for the widened view.
//
// FUN_006360e0 (on an object whose record file is at +0x23c) returns a
// 16-byte record by id: from a std::map at +0xde5c (size at +0xde60, value at
// node +0x14, "last used" stamp at node +0x24 = g_frameStamp 0xcd59d0), or
// read from the file and inserted. With 4096 (0x1000) entries or more, every
// insert walks the whole map to erase the single least recently used entry.
// The 1024 view stays below that; the widened view needs more records per
// frame, so they were evicted and reloaded all the time, each with a full map
// walk: 90-130 ms per frame in towns instead of 17 (measured with a sampling
// profiler, 2026-10-15).
//
// The game's limit is raised to 0x8000 so that its per-insert walk never
// runs; instead, once the map holds kSweepAt entries, one pass erases the
// less recently used half. See docs/performance.md.
#include <algorithm>
#include <cstdint>
#include <vector>
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::recordcache {
namespace {
constexpr uint32_t kLimitCompare = 0x00636201; // cmp ecx, 1000h
constexpr uint32_t kGameLimit = 0x1000;
constexpr uint32_t kNewLimit = 0x8000;
constexpr uint32_t kSweepAt = 0x6000;
constexpr size_t kMap = 0xde5c; // std::map: header node pointer
constexpr size_t kMapSize = 0xde60;
constexpr size_t kNodeStamp = 0x24;
constexpr size_t kHeaderFirst = 8; // header node: the first (leftmost) node
using NextFn = uint8_t*(__cdecl*)(uint8_t* node); // tree successor (0x832ba0)
using EraseFn = void(__fastcall*)(void* map, void*, uint8_t* node); // map::erase (0x63da10)
const auto Next = reinterpret_cast<NextFn>(0x00832ba0);
const auto Erase = reinterpret_cast<EraseFn>(0x0063da10);
void Startup() {
const bool ok = re::PatchInstructionDword(kLimitCompare, kGameLimit, kNewLimit);
re::Log("recordcache: limit %x, batch eviction from %x%s", kNewLimit, kSweepAt, ok ? "" : " (patch FAILED)");
}
RE_STARTUP(RecordCacheStartup, Startup);
// Erases the less recently used half of the map.
void Sweep(uint8_t* owner) {
auto* map = owner + kMap;
auto* header = *reinterpret_cast<uint8_t**>(map);
std::vector<uint32_t> stamps;
stamps.reserve(*reinterpret_cast<uint32_t*>(owner + kMapSize));
for (uint8_t* node = *reinterpret_cast<uint8_t**>(header + kHeaderFirst); node != header; node = Next(node)) {
stamps.push_back(*reinterpret_cast<uint32_t*>(node + kNodeStamp));
}
if (stamps.empty()) {
return;
}
const size_t half = stamps.size() / 2;
std::nth_element(stamps.begin(), stamps.begin() + half, stamps.end());
const uint32_t cutoff = stamps[half];
size_t erased = 0;
for (uint8_t* node = *reinterpret_cast<uint8_t**>(header + kHeaderFirst); node != header && erased < half;) {
uint8_t* next = Next(node);
if (*reinterpret_cast<uint32_t*>(node + kNodeStamp) <= cutoff) {
Erase(map, nullptr, node);
++erased;
}
node = next;
}
}
} // namespace
void BeforeLookup(uint8_t* owner) {
if (*reinterpret_cast<uint32_t*>(owner + kMapSize) >= kSweepAt) {
Sweep(owner);
}
}
} // namespace mods::recordcache
namespace game {
// FUN_006360e0 (record by id, ret 4).
void* __fastcall RecordCache_Get(void* self, void* edx, uint32_t id);
RE_HOOK(0x006360e0, RecordCache_Get);
void* __fastcall RecordCache_Get(void* self, void* edx, uint32_t id) {
if (self) {
mods::recordcache::BeforeLookup(static_cast<uint8_t*>(self));
}
return RecordCache_Get_original(self, edx, id);
}
} // namespace game
+194 -15
View File
@@ -12,6 +12,15 @@
// The old arrays keep a copy of the first 5 controls, so code that was missed
// still sees valid controls.
//
// Combat-art slots are not selected any more: slot 0 is always the right
// mouse button's art (the game's "selected" slot), the others are cast with
// their keys (src/mods/skillkeys.cpp). Selecting another slot is refused
// unless a skill key asks for it. Slot 0 is drawn with the selected look and
// set apart from slots 1-6 by a gap; every ready art uses its selected icon.
//
// Recharging arts show their remaining cooldown (seconds, one decimal) on the
// slot, drawn with the game's font after RenderArtSlots.
//
// Also: 7 entries in the slot-count-by-level table (6 at level 45, 7 at 65),
// and cCreature::AssignArtToSlot accepts slots up to 6.
@@ -19,14 +28,21 @@
#include <atomic>
#include <cstring>
#include <cwchar>
#include "game/addresses.h"
#include "game/engine/arts.h"
#include "game/engine/objects.h"
#include "game/ui/text.h"
#include "mods/skillslots.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::skillbar {
void WeaponSetClickThunk();
namespace {
using namespace game::addr;
@@ -39,10 +55,36 @@ constexpr uint32_t kNewTaskbarSize = kSkillSlots + kSlots * 4 + 0x8;
constexpr int8_t kOldDistance = static_cast<int8_t>(kTaskbarSkillSlots - kTaskbarWeaponSlots); // 0x14
constexpr int8_t kNewDistance = static_cast<int8_t>(kSkillSlots - kWeaponSlots); // 0x1c
// cUI_Taskbar2 fields.
constexpr size_t kInventoryIndex = 0x158; // u16: the hero's inventory
constexpr size_t kSlotCount = 0x15c; // u16: slots shown, from SetSlotCount
// UI control: x at +0x24 (relative to the taskbar).
constexpr size_t kControlX = 0x24;
// Gap between slot 0 (right mouse button) and slots 1-6: half a slot.
constexpr int kRightButtonSlotGap = 0x21;
constexpr uint32_t kRightStripStart = 0x23a;
// Weapon set clicks (WeaponSetClickThunk).
constexpr uint32_t kWeaponSetEmptyCursorJump = 0x006e0ca7;
constexpr uint32_t kWeaponSetDrag = 0x006e10d8;
// Cooldown labels on the art slots.
constexpr uint16_t kCooldownFont = 1;
constexpr uint32_t kCooldownColor = 0xffffffff;
constexpr uint32_t kCooldownAlign = game::kTextCenter;
// (level, slots): the game's table plus slots 6 and 7.
constexpr uint16_t kSlotLevels[kSlots] = {0, 2, 8, 16, 30, 45, 65};
std::atomic<uint8_t*> g_taskbar{nullptr};
// A skill key is selecting a slot other than 0 (window thread).
bool g_keySelects = false;
// Where the cooldown labels go: computed on the first frame after the layout
// (render thread), fixed to the slots from then on.
game::UiRect g_labelRects[kSlots] = {};
bool g_labelRectsValid = false;
void Startup() {
int failed = 0;
@@ -53,6 +95,12 @@ void Startup() {
failed += re::PatchAll(kTaskbarSlotDistanceUp, static_cast<uint8_t>(kNewDistance));
failed += re::PatchAll(kTaskbarSlotLoopBounds, static_cast<uint8_t>(kSlots));
failed += re::PatchAll(kAssignArtMaxSlot, static_cast<uint8_t>(kSlots - 1));
failed += re::ReplaceAll(kTaskbarSkillSelectionLook);
failed += re::ReplaceAll(kTaskbarSignedHitTests);
failed += !re::PatchInstructionDword(kTaskbarRightStripStart, kRightStripStart,
kRightStripStart + kRightButtonSlotGap);
failed += !re::RedirectJcc(kWeaponSetEmptyCursorJump, kWeaponSetDrag,
reinterpret_cast<const void*>(&WeaponSetClickThunk));
static_assert(kOldDistance == 0x14);
re::Log("skillbar: %u weapon sets / art slots on the taskbar (%d patches failed)", kSlots, failed);
}
@@ -66,39 +114,143 @@ void Select(uint32_t function, unsigned slot) {
}
}
uint8_t* HeroInventory() {
const uint8_t* taskbar = g_taskbar.load();
return taskbar ? skillslots::Inventory(*reinterpret_cast<const uint16_t*>(taskbar + kInventoryIndex))
: nullptr;
}
} // namespace
void SelectSkillSlot(unsigned slot) {
g_keySelects = slot != 0;
Select(0x006e17d0, slot); // cUI_Taskbar2::SelectSkillSlot
g_keySelects = false;
}
// With the game option COMBINE_SLOTS (Options::CombineSlots 0x81c020) a
// weapon set also selects the art slot with the same number, as the digit
// keys do (cUI_Taskbar2::HandleEvent).
void SelectWeaponSet(unsigned slot) {
Select(0x006e1440, slot); // cUI_Taskbar2::SelectWeaponSet
using CombineSlotsFn = char(__cdecl*)();
if (reinterpret_cast<CombineSlotsFn>(0x0081c020)()) {
SelectSkillSlot(slot);
}
}
bool HasSkillSlot(const void* taskbar, unsigned slot) {
const auto* p = static_cast<const uint8_t*>(taskbar);
constexpr size_t kSlotCount = 0x15c; // u16: slots shown, from SetSlotCount
return slot < kSlots && slot < *reinterpret_cast<const uint16_t*>(p + kSlotCount) &&
reinterpret_cast<void* const*>(p + kSkillSlots)[slot] != nullptr;
unsigned SelectedSkillSlot() {
const uint8_t* inventory = HeroInventory();
return inventory ? reinterpret_cast<const uint16_t*>(inventory + skillslots::kSelected)[1] : 0;
}
bool SkillSlotHasArt(unsigned slot) {
const uint8_t* taskbar = g_taskbar.load();
uint8_t* inventory = HeroInventory();
if (!taskbar || !inventory || slot >= kSlots ||
slot >= *reinterpret_cast<const uint16_t*>(taskbar + kSlotCount) ||
!reinterpret_cast<void* const*>(taskbar + kSkillSlots)[slot]) {
return false;
}
using GetArtSlotRefFn = uint32_t(__fastcall*)(void* inventory, void* edx, uint32_t slot);
return reinterpret_cast<GetArtSlotRefFn>(0x005d9420)(inventory, nullptr, slot) != 0;
}
bool KeySelects() {
return g_keySelects;
}
void OnTaskbar(void* taskbar) {
g_taskbar = static_cast<uint8_t*>(taskbar);
}
// Copy of the first 5 controls of each row into the old arrays.
void MirrorOldArrays(void* taskbar) {
// After the layout: slots 1-6 move right, away from slot 0; the first 5
// controls of each row are copied into the old arrays.
void AfterLayout(void* taskbar) {
auto* p = static_cast<uint8_t*>(taskbar);
auto* const* skills = reinterpret_cast<uint8_t* const*>(p + kSkillSlots);
for (unsigned slot = 1; slot < kSlots; ++slot) {
if (skills[slot]) {
*reinterpret_cast<int*>(skills[slot] + kControlX) += kRightButtonSlotGap;
}
}
std::memcpy(p + kTaskbarWeaponSlots, p + kWeaponSlots, kGameSlots * 4);
std::memcpy(p + kTaskbarSkillSlots, p + kSkillSlots, kGameSlots * 4);
g_labelRectsValid = false;
}
void UpdateLabelRects(uint8_t* const* skills) {
for (unsigned slot = 0; slot < kSlots; ++slot) {
if (skills[slot]) {
g_labelRects[slot] =
game::CenterLineVertically(kCooldownFont, game::ControlScreenRect(skills[slot]));
}
}
g_labelRectsValid = true;
}
// The remaining cooldown in seconds (at the hero's current regeneration
// rate, game::GetArtCooldown), to one decimal, centred on each art slot that
// is recharging (combat moves and spells: slot types 2 / 3).
void RenderCooldowns(void* taskbar, void* renderer) {
auto* p = static_cast<uint8_t*>(taskbar);
uint8_t* inventory = skillslots::Inventory(*reinterpret_cast<const uint16_t*>(p + kInventoryIndex));
void* hero = game::GetPlayerHero();
if (!inventory || !hero) {
return;
}
using SlotFn = uint32_t(__fastcall*)(void* inventory, void* edx, uint32_t slot);
const auto getType = reinterpret_cast<SlotFn>(0x005d93e0); // cInventoryEntry::GetArtSlotType
const auto getRef = reinterpret_cast<SlotFn>(0x005d9420); // cInventoryEntry::GetArtSlotRef
auto* const* skills = reinterpret_cast<uint8_t* const*>(p + kSkillSlots);
if (!g_labelRectsValid) {
UpdateLabelRects(skills);
}
const unsigned shown = *reinterpret_cast<const uint16_t*>(p + kSlotCount);
for (unsigned slot = 0; slot < kSlots && slot < shown; ++slot) {
const uint32_t type = getType(inventory, nullptr, slot);
const uint32_t ref = getRef(inventory, nullptr, slot);
if (!skills[slot] || (type != 2 && type != 3) || !ref) {
continue;
}
const uint16_t art = game::ResolveArtId(hero, ref);
const game::ArtCooldown cooldown = game::GetArtCooldown(hero, art);
if (cooldown.remaining <= game::kArtReadyCooldown) {
continue; // ready: no label, the game's icon shows it
}
wchar_t text[16];
swprintf_s(text, L"%.1f", cooldown.seconds);
game::DrawText(renderer, kCooldownFont, g_labelRects[slot], text, kCooldownColor, kCooldownAlign);
}
}
// A click on a weapon set with an empty cursor (cUI_Taskbar2's mouse handling,
// `je 0x6e10d8` at 0x6e0ca7) takes the set's weapon onto the cursor, i.e.
// starts dragging it. Only a click on the selected set does that now; a click
// on another set just selects it.
bool __fastcall SelectInsteadOfDrag(void* taskbar, void*, const uint8_t* inventory, uint32_t set) {
set &= 0xffff;
const unsigned selected = reinterpret_cast<const uint16_t*>(inventory + skillslots::kSelected)[0];
if (set == selected) {
return false;
}
using SelectFn = void(__fastcall*)(void*, void*, uint32_t, int);
reinterpret_cast<SelectFn>(0x006e1440)(taskbar, nullptr, set, 1); // cUI_Taskbar2::SelectWeaponSet
return true;
}
// Jumped to instead of 0x6e10d8 (taskbar in esi, inventory in ebp, the set
// at [esp+0B8h]).
__declspec(naked) void WeaponSetClickThunk() {
__asm {
mov eax, [esp+0B8h]
push eax
push ebp
mov ecx, esi
call SelectInsteadOfDrag
test al, al
jz drag
mov al, 1
mov edx, 0x006e124d // handler exit, al = handled
jmp edx
drag:
mov edx, 0x006e10d8 // the drag (kWeaponSetDrag)
jmp edx
}
}
uint16_t SlotCountForLevel(unsigned level) {
@@ -122,7 +274,7 @@ RE_HOOK(0x006e4ba0, UiTaskbar_Layout);
void __fastcall UiTaskbar_Layout(void* self, void* edx) {
mods::skillbar::OnTaskbar(self);
UiTaskbar_Layout_original(self, edx);
mods::skillbar::MirrorOldArrays(self);
mods::skillbar::AfterLayout(self);
}
// cUI_Taskbar2::SetSlotCount (0x6dffa0): shows the first n slots of each row.
@@ -133,6 +285,33 @@ void __fastcall UiTaskbar_SetSlotCount(void* self, void* edx, uint32_t count) {
UiTaskbar_SetSlotCount_original(self, edx, count);
}
// cUI_Taskbar2::SelectSkillSlot (0x6e17d0): makes combat-art slot `slot` the
// right mouse button's art. Clicks on the bar, the mouse wheel, COMBINE_SLOTS
// and the slot count check all end up here; only a skill key may select a
// slot other than 0.
void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, uint32_t slot, int notify);
RE_HOOK(0x006e17d0, UiTaskbar_SelectSkillSlot);
void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, uint32_t slot, int notify) {
if (!mods::skillbar::KeySelects()) {
slot = 0;
}
UiTaskbar_SelectSkillSlot_original(self, edx, slot, notify);
// It also moves the selected slot background (orange) to the new slot;
// redo the backgrounds, which mark slot 0 (kTaskbarSkillSelectionLook).
if (self) {
using UpdateSlotHighlightFn = void(__fastcall*)(void*, void*, int force);
reinterpret_cast<UpdateSlotHighlightFn>(0x006df9a0)(self, nullptr, 1);
}
}
// cUI_Taskbar2::RenderArtSlots (0x6e31e0): then the cooldown labels.
void __fastcall UiTaskbar_RenderArtSlots(void* self, void* edx, void* renderer);
RE_HOOK(0x006e31e0, UiTaskbar_RenderArtSlots);
void __fastcall UiTaskbar_RenderArtSlots(void* self, void* edx, void* renderer) {
UiTaskbar_RenderArtSlots_original(self, edx, renderer);
mods::skillbar::RenderCooldowns(self, renderer);
}
uint32_t __stdcall SlotCountForLevel(uint32_t level);
RE_HOOK(addr::SlotCountForLevel, SlotCountForLevel);
uint32_t __stdcall SlotCountForLevel(uint32_t level) {
+9 -5
View File
@@ -2,12 +2,16 @@
namespace mods::skillbar {
// Select weapon set / combat-art slot `slot` (0-6) on the taskbar, as its key
// or a click does.
void SelectSkillSlot(unsigned slot);
// Select weapon set `slot` (0-6) on the taskbar, as its key or a click does.
void SelectWeaponSet(unsigned slot);
// Whether the taskbar shows combat-art slot `slot` (unlocked, control exists).
bool HasSkillSlot(const void* taskbar, unsigned slot);
// Make combat-art slot `slot` (0-6) the right mouse button's art. Only skill
// keys select a slot other than 0, for the duration of their cast; any other
// selection is turned into slot 0.
void SelectSkillSlot(unsigned slot);
unsigned SelectedSkillSlot();
// Whether combat-art slot `slot` is shown (unlocked) and holds an art.
bool SkillSlotHasArt(unsigned slot);
} // namespace mods::skillbar
+49 -77
View File
@@ -1,24 +1,24 @@
// Skill keys cast the skill, not only select it.
// Skill keys cast their combat-art slot.
//
// The taskbar's combat-art slots are selected with a key (cUI_Taskbar2::
// HandleEvent -> SelectSkillSlot 0x6e17d0); the selected art is then used with
// the right mouse button. When a skill key (src/mods/input.cpp) selects a slot,
// this mod also presses the right mouse button: a WM_RBUTTONDOWN is posted to
// the game window, so the game handles it exactly like a real right-click at
// the cursor (UI hit tests, target under the cursor, cEvent_creature 0x105).
// The button is released with the key, so holding the key is holding the
// button. Slots selected by clicking them with the mouse are not cast.
// The game casts one art with the right mouse button: the one in the
// "selected" art slot, which is always slot 0 now (src/mods/skillbar.cpp).
// Slot 0's key presses the right mouse button; the keys of slots 1-6 first
// make their slot the selected one. The button is pressed by posting
// WM_RBUTTONDOWN to the game window, so the game handles it exactly like a
// real right-click at the cursor (UI hit tests, target under the cursor,
// cEvent_creature 0x105), and released with the key: holding the key is
// holding the button. While no skill key is held, slot 0 is selected again
// (checked every engine tick, which also covers saves loaded with another
// selection).
#include "mods/skillkeys.h"
#include "mods/skillbar.h"
#include <cstdint>
#include <mutex>
#include <atomic>
#include <windows.h>
#include "re/hook.h"
#include "game/engine/events.h"
#include "mods/skillbar.h"
namespace mods::skillkeys {
namespace {
@@ -40,77 +40,49 @@ LPARAM CursorClientPos(HWND window) {
return MAKELPARAM(cursor.x, cursor.y);
}
// The key that is to cast slot `slot` when it gets selected, then the key
// holding the right mouse button down. Set on the window thread, used on the
// thread that handles UI events.
struct State {
std::mutex lock;
int pendingKey = 0;
unsigned pendingSlot = 0;
bool keyDown = false;
int heldKey = 0; // right button is down for this key
HWND window = nullptr;
};
State g_state;
// The key holding the right mouse button down, and the window it was pressed
// in. Set on the window thread, read by the engine tick.
std::atomic<int> g_heldKey{0};
HWND g_window = nullptr;
void OnTick(uint8_t*) {
if (!g_heldKey.load() && mods::skillbar::SelectedSkillSlot() != 0) {
mods::skillbar::SelectSkillSlot(0);
}
}
struct Registration {
Registration() { game::OnEngineTick(&OnTick); }
} g_registration;
} // namespace
void CastOnSelect(unsigned slot, int vk) {
std::lock_guard guard(g_state.lock);
g_state.pendingKey = vk;
g_state.pendingSlot = slot;
g_state.keyDown = true;
void KeyPressed(unsigned slot, int vk) {
if (g_heldKey.load() || !mods::skillbar::SkillSlotHasArt(slot)) {
return;
}
HWND window = GameWindow();
if (!window) {
return;
}
g_window = window;
g_heldKey = vk; // before selecting: the tick leaves the selection alone
if (slot != 0) {
mods::skillbar::SelectSkillSlot(slot);
}
PostMessageW(window, WM_RBUTTONDOWN, MK_RBUTTON, CursorClientPos(window));
}
bool KeyHeld() {
return g_heldKey.load() != 0;
}
void KeyReleased(int vk) {
std::lock_guard guard(g_state.lock);
if (g_state.pendingKey == vk) {
g_state.keyDown = false; // released before the slot was selected
}
if (g_state.heldKey == vk) {
g_state.heldKey = 0;
PostMessageW(g_state.window, WM_RBUTTONUP, 0, CursorClientPos(g_state.window));
}
}
// After a slot was selected: cast if a skill key asked for it.
void OnSlotSelected(void* taskbar, unsigned slot) {
std::lock_guard guard(g_state.lock);
if (!g_state.pendingKey || g_state.pendingSlot != slot) {
if (g_heldKey.load() != vk) {
return;
}
const int key = g_state.pendingKey;
g_state.pendingKey = 0;
if (!mods::skillbar::HasSkillSlot(taskbar, slot)) {
return; // empty slot: nothing was selected
}
HWND window = GameWindow();
if (!window || g_state.heldKey) {
return;
}
PostMessageW(window, WM_RBUTTONDOWN, MK_RBUTTON, CursorClientPos(window));
if (g_state.keyDown) {
g_state.heldKey = key;
g_state.window = window;
} else {
PostMessageW(window, WM_RBUTTONUP, 0, CursorClientPos(window));
}
PostMessageW(g_window, WM_RBUTTONUP, 0, CursorClientPos(g_window));
g_heldKey = 0;
}
} // namespace mods::skillkeys
namespace game {
// cUI_Taskbar2::SelectSkillSlot (0x6e17d0): makes combat-art slot `slot` the
// right mouse button's art (keys, clicks on the slot).
void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, unsigned slot, int notify);
RE_HOOK(0x006e17d0, UiTaskbar_SelectSkillSlot);
void __fastcall UiTaskbar_SelectSkillSlot(void* self, void* edx, unsigned slot, int notify) {
UiTaskbar_SelectSkillSlot_original(self, edx, slot, notify);
if (self) {
mods::skillkeys::OnSlotSelected(self, slot & 0xffff);
}
}
} // namespace game
+6 -3
View File
@@ -2,9 +2,12 @@
namespace mods::skillkeys {
// The skill key `vk` was pressed to select combat-art slot `slot`: when the
// game selects it, press the right mouse button until KeyReleased(vk).
void CastOnSelect(unsigned slot, int vk);
// The skill key `vk` of combat-art slot `slot` (0-6) went down / up: casts
// the slot's art with the right mouse button while the key is held.
void KeyPressed(unsigned slot, int vk);
void KeyReleased(int vk);
// Whether a skill key is held (its art is being cast).
bool KeyHeld();
} // namespace mods::skillkeys
+5
View File
@@ -45,6 +45,11 @@ int IndexOf(const void* inventory) {
} // namespace
uint8_t* Inventory(unsigned index) {
auto* base = *reinterpret_cast<uint8_t**>(kInventoryBlock);
return base && index < kInventories ? base + index * kInventorySize : nullptr;
}
uint8_t* ShadowFor(const void* inventory, bool create) {
const int index = IndexOf(inventory);
if (index < 0) {
+3
View File
@@ -20,6 +20,9 @@ constexpr size_t kArtSlotSize = 0x28;
constexpr size_t kArtEntrySize = 0x14;
constexpr size_t kSelected = 0xd58; // u16 [0] weapon set, [1] art slot
// Inventory `index` (0-31) of cInventoryManager's block, or null.
uint8_t* Inventory(unsigned index);
// The shadow inventory holding slots 6-7 (at indices 0-1) of `inventory`, or
// null if it has none yet and `create` is false.
uint8_t* ShadowFor(const void* inventory, bool create);
+15 -40
View File
@@ -22,34 +22,16 @@
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "mods/input.h"
#include "re/hook.h"
namespace mods::wasd {
namespace {
// --- sacred.exe ---------------------------------------------------------------
constexpr uint32_t kEventCreatureVtable = 0x0088e95c;
using SendOrderFn = void(__fastcall*)(void* engine, void* edx, void* creature, void* event);
const auto SendOrder = reinterpret_cast<SendOrderFn>(0x006170d0); // adjusts ground targets, then vf18
// cEngine fields.
constexpr size_t kEngineFlags = 0x54; // 0x10 mouse button held (hold-to-move), 0x4000 input blocked
constexpr size_t kEngineControlledId = 0x13c; // object id of the controlled creature
// cEvent_creature, command 4 = walk.
struct CreatureEvent {
uint32_t vtable;
uint32_t command;
uint32_t unknown08[3];
uint32_t groundTarget; // 2: x, y are a point on the ground
int32_t x;
int32_t y;
uint32_t held; // 1: re-aim while a mouse button is held (cEngine::Tick)
uint32_t unknown24[8];
};
static_assert(sizeof(CreatureEvent) == 0x44);
using game::ControlledCreature;
using game::CreatureOrder;
using game::kEngineFlags;
// Walk orders aim this far (world units) from the creature, about where the
// cursor would be.
@@ -59,27 +41,21 @@ bool KeyDown(int key) {
return (GetAsyncKeyState(key) & 0x8000) != 0;
}
void* ControlledCreature(uint8_t* engine) {
return game::GetCreature(*reinterpret_cast<uint32_t*>(engine + kEngineControlledId));
}
void SendWalk(uint8_t* engine, void* creature, int x, int y) {
CreatureEvent event = {};
event.vtable = kEventCreatureVtable;
event.command = 4;
event.groundTarget = 2;
event.x = x;
event.y = y;
SendOrder(engine, nullptr, creature, &event);
CreatureOrder order;
order.command = game::kOrderWalk;
order.groundTarget = 2;
order.a = x;
order.b = y;
game::SendCreatureOrder(engine, creature, order);
}
// A walk order without a target stops the creature where it is (what
// releasing a held mouse button sends, cEngine::HandleMouseButton).
void SendStop(uint8_t* engine, void* creature) {
CreatureEvent event = {};
event.vtable = kEventCreatureVtable;
event.command = 4;
SendOrder(engine, nullptr, creature, &event);
CreatureOrder order;
order.command = game::kOrderWalk;
game::SendCreatureOrder(engine, creature, order);
}
struct MoveState {
@@ -119,10 +95,9 @@ void Update(uint8_t* engine) {
if (!creature) {
return;
}
const auto* c = static_cast<const uint8_t*>(creature);
const double length = std::sqrt(static_cast<double>(dx * dx + dy * dy));
const int x = *reinterpret_cast<const int32_t*>(c + 0x1c) + static_cast<int>(dx / length * kTargetDistance);
const int y = *reinterpret_cast<const int32_t*>(c + 0x20) + static_cast<int>(dy / length * kTargetDistance);
const int x = game::CreatureField(creature, game::kCreatureX) + static_cast<int>(dx / length * kTargetDistance);
const int y = game::CreatureField(creature, game::kCreatureY) + static_cast<int>(dy / length * kTargetDistance);
SendWalk(engine, creature, x, y);
g_move.moving = true;
}