Restore aura visuals after loading a save

Saving marks effect objects dead, so after a load the Heldenmut /
Klingenblick buff was active but its aura effect (cObjectFX 842) was
gone. A few ticks after a save game has loaded, the effect is created
again for each such buff of a player hero (CreateAt + cEventFX 0x42,
as the art's cast effect does), with the buff's remaining time.

Shared game events (game/engine/events): engine tick and "save game
loaded" callbacks; the WASD mod uses the tick through it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-15 01:27:56 +02:00
1 parent 30c93fc87e
commit f721d2a486
8 files changed
+217 -26

No files matched your search

+2
View File
@@ -31,6 +31,7 @@ add_library(sacred_re SHARED
src/re/sacred_re.def src/re/sacred_re.def
src/game/engine/log.cpp src/game/engine/log.cpp
src/game/engine/cmdline.cpp src/game/engine/cmdline.cpp
src/game/engine/events.cpp
src/mods/borderless.cpp src/mods/borderless.cpp
src/mods/widescreen.cpp src/mods/widescreen.cpp
src/mods/hud.cpp src/mods/hud.cpp
@@ -41,6 +42,7 @@ add_library(sacred_re SHARED
src/mods/inventory_save.cpp src/mods/inventory_save.cpp
src/mods/skillbar.cpp src/mods/skillbar.cpp
src/mods/arttarget.cpp src/mods/arttarget.cpp
src/mods/aura_restore.cpp
) )
target_include_directories(sacred_re PRIVATE src) target_include_directories(sacred_re PRIVATE src)
target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS})
+6 -12
View File
@@ -1,18 +1,12 @@
# To do # To do
## Bug: skill "aura" effects are not drawn ## Fixed: aura visuals missing after loading (2026-10-15)
Reported 2026-10-14: the aura effects of some skills are missing. Not Saving marks effect objects (cObjectFX) dead; loading keeps the buff but drops
investigated yet. Things to check first: the aura effect (type 842) that follows the hero. `src/mods/aura_restore.cpp`
recreates it after a load for Heldenmut (0x3f7) and Klingenblick (0x3f8). Spell
- whether vanilla (`[widescreen] Enabled=0`) draws them, i.e. whether the auras (Steinhaut, Lichtschild, ...) use other effects and are not covered
widescreen or HUD patches cause it; yet; check them if their visuals go missing too.
- whether they are missing everywhere or only away from the screen centre;
if the latter, a clip test still using 1024x768 bounds, like the creature
clip (`0x4c8`/`0x3c8`) was;
- effect objects (`cObjectFX`) in the object passes (`cWorldView0::DrawObjects`
`0x62e580` / `DrawObjects2` `0x6300d0`) and the light/effect list
(`+0xce14`, `0x41b1xx`).
## Better pathfinder (mouse clicks and monsters) ## Better pathfinder (mouse clicks and monsters)
+49
View File
@@ -0,0 +1,49 @@
#include "game/engine/events.h"
#include <vector>
#include "re/hook.h"
namespace game {
namespace {
std::vector<EngineTickFn>& TickCallbacks() {
static std::vector<EngineTickFn> callbacks;
return callbacks;
}
std::vector<SaveGameLoadedFn>& LoadedCallbacks() {
static std::vector<SaveGameLoadedFn> callbacks;
return callbacks;
}
} // namespace
void OnEngineTick(EngineTickFn callback) {
TickCallbacks().push_back(callback);
}
void OnSaveGameLoaded(SaveGameLoadedFn callback) {
LoadedCallbacks().push_back(callback);
}
void NotifySaveGameLoaded() {
for (SaveGameLoadedFn callback : LoadedCallbacks()) {
callback();
}
}
// cEngine::Tick (0x60f1d0, 10 Hz): processes queued events, then the mouse's
// hold-to-move re-aim orders.
void __fastcall Engine_Tick(void* self, void* edx, void* event);
RE_HOOK(0x0060f1d0, Engine_Tick);
void __fastcall Engine_Tick(void* self, void* edx, void* event) {
Engine_Tick_original(self, edx, event);
if (self) {
for (EngineTickFn callback : TickCallbacks()) {
callback(static_cast<uint8_t*>(self));
}
}
}
} // namespace game
+19
View File
@@ -0,0 +1,19 @@
#pragma once
// Game events several mods react to. The hooks behind them live in
// events.cpp; mods register callbacks at startup (static initialisation).
#include <cstdint>
namespace game {
// cEngine::Tick (0x60f1d0, 10 Hz), after the game's own handling.
using EngineTickFn = void (*)(uint8_t* engine);
void OnEngineTick(EngineTickFn callback);
// A save game finished loading its objects (cInventoryManager::Serialize
// read, the last part of the load); callbacks run on the loading thread.
using SaveGameLoadedFn = void (*)();
void OnSaveGameLoaded(SaveGameLoadedFn callback);
void NotifySaveGameLoaded();
} // namespace game
+120
View File
@@ -0,0 +1,120 @@
// Aura visuals after loading a save.
//
// Casting a lasting self aura (Heldenmut 0x3f7, Klingenblick 0x3f8) adds a
// buff to the hero's active effects and, separately, creates an aura effect
// object (cObjectFX type 842) that follows the hero: the art's cast effect
// (0x4feef0) creates it with cObjectManager::CreateAt at the hero and sends it
// a cEventFX 0x42 (owner, duration, art). Saving marks effect objects as dead;
// loading restores the buff but drops the effect, so the aura works but is
// invisible. A few ticks after a save game has loaded, the effect is created
// again for every such buff of a player hero, with the buff's remaining time.
#include <cstdint>
#include <cstring>
#include "game/engine/events.h"
#include "game/engine/objects.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 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;
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
auto* effect = static_cast<uint8_t*>(game::GetObjectById(id));
if (!effect) {
return;
}
uint32_t event[0x1f] = {};
event[0] = kEventFXVtable;
event[1] = kFxAttach;
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);
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`.
float RemainingBuff(uint8_t* hero, uint16_t art) {
uint8_t* arts = hero + kCreatureArts;
auto* entry = *reinterpret_cast<uint8_t**>(arts + kEffectsBegin);
auto* end = *reinterpret_cast<uint8_t**>(arts + kEffectsEnd);
float longest = 0.0f;
for (; entry && entry < end; entry += kEffectSize) {
float remaining;
std::memcpy(&remaining, entry + kEffectRemaining, 4);
if (*reinterpret_cast<uint32_t*>(entry + kEffectKind) != kKindCooldown &&
*reinterpret_cast<uint16_t*>(entry + kEffectArt) == art && remaining > longest) {
longest = remaining;
}
}
return longest;
}
void RestoreAuras() {
for (uint32_t id = 1; id < kPlayerIds; ++id) {
auto* hero = static_cast<uint8_t*>(game::GetCreature(id));
if (!hero) {
continue;
}
for (uint16_t art : kAuraArts) {
const float remaining = RemainingBuff(hero, art);
if (remaining > 0.0f) {
AttachAura(hero, art, remaining);
}
}
}
}
void OnLoaded() {
g_ticksUntilRestore = kTicksAfterLoad;
}
void OnTick(uint8_t*) {
if (g_ticksUntilRestore >= 0 && g_ticksUntilRestore-- == 0) {
RestoreAuras();
}
}
struct Registration {
Registration() {
game::OnSaveGameLoaded(&OnLoaded);
game::OnEngineTick(&OnTick);
}
} g_registration;
} // namespace
} // namespace mods::aura_restore
+2
View File
@@ -24,6 +24,7 @@
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "game/engine/events.h"
#include "mods/skillslots.h" #include "mods/skillslots.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -376,6 +377,7 @@ int __fastcall InvManager_Serialize(void* self, void* edx, void* stream, int wri
--g_heroFileDepth; --g_heroFileDepth;
if (!static_cast<char>(write)) { if (!static_cast<char>(write)) {
re::Log("inventory: save game read, %d JSON / %d vanilla inventories", g_readJson, g_readVanilla); re::Log("inventory: save game read, %d JSON / %d vanilla inventories", g_readJson, g_readVanilla);
NotifySaveGameLoaded();
} }
return result; return result;
} }
+7 -14
View File
@@ -20,6 +20,7 @@
#include <windows.h> #include <windows.h>
#include "game/engine/events.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "mods/input.h" #include "mods/input.h"
#include "re/hook.h" #include "re/hook.h"
@@ -126,19 +127,11 @@ void Update(uint8_t* engine) {
g_move.moving = true; g_move.moving = true;
} }
namespace {
struct Registration {
Registration() { game::OnEngineTick(&Update); }
} g_registration;
} // namespace
} // namespace mods::wasd } // namespace mods::wasd
namespace game {
// cEngine::Tick (0x60f1d0, 10 Hz): processes queued events, then the mouse's
// hold-to-move re-aim orders.
void __fastcall Engine_Tick(void* self, void* edx, void* event);
RE_HOOK(0x0060f1d0, Engine_Tick);
void __fastcall Engine_Tick(void* self, void* edx, void* event) {
Engine_Tick_original(self, edx, event);
if (self) {
mods::wasd::Update(static_cast<uint8_t*>(self));
}
}
} // namespace game
+12
View File
@@ -141,3 +141,15 @@
0x00548e70 cCreature::IsEnemy # (object id) 0x00548e70 cCreature::IsEnemy # (object id)
0x00611f10 cEngine::UpdateCursorPick # cursor target for the selected art 0x00611f10 cEngine::UpdateCursorPick # cursor target for the selected art
0x00548060 cCreature::ChooseAiArt # monsters: art per creature class 0x00548060 cCreature::ChooseAiArt # monsters: art per creature class
0x00582c10 cCreatureArts::AddEffect # (kind, value, factor, duration, art, notify, ...): active effects at +0x11a
0x00539ce0 cCreature::StartArtCooldown # AddEffect(0x22, ...)
0x004feef0 cCreature::ArtCastEffect # per-art effect of using an art (switch on art id)
0x005fbaa0 cObjectManager::CreateAt # (type, position, ...): object placed in the world
0x005fb590 cObjectManager::CreateFX
0x005fb040 cObjectManager::Allocate
0x004514d0 ObjectFactory::Create # (class, ...)
0x005fcd10 cObjectManager::Place # (id, position); refuses dead objects (+0x14 & 0x10)
0x00602d70 cObjectManager::Load # objects from a save stream
0x005a0980 cObjectFX::Construct
0x005a37b0 cObjectFX::ReceiveEvent # vf18; cEventFX 0x42 attaches it to an owner
0x0041e0a0 cEventFX::Construct