Files
sacred_k/src/mods/characters.cpp
T
khallaandClaude Opus 5.5 732d9c2abb Characters: Laden in the Esc menu, timed autosave
- Esc menu: "Exportieren" became "Laden"; it opens the load window, always
  in load mode. In a game every world loads (the current one reloads); a
  "(neu)" world stops the game (cCommand_armaStop) and starts the new one
  without the main menu. The character being played cannot be deleted.
- Silent autosave every sacredK.ini [characters] AutosaveMinutes (default
  10, 0 = off) into the character's autosave file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 22:41:20 +02:00

1619 lines
69 KiB
C++

// Characters and difficulties (requests of 2026-10-19/20, docs/characters.md):
// one file per character instead of save rows and exported heroes. The file
// (charfile.h) holds the shared hero (a .pax as cEngine::SaveHero writes it)
// and one world per campaign and difficulty (a GAME.PAK as cEngine::Save
// writes it). The main menu's "Spielen" (mainmenu.cpp) opens the load window,
// which lists every character of both campaigns; "Neuer Charakter" there asks
// for the campaign and opens the game's character creation.
//
// The game keeps doing the work on its own files; only which files changes.
// Its save folder is a work folder (save\characters\_work, via userfolders):
// - the load window (cUI_Savegame) lists GAMEnn.PAK there: before it scans,
// one small preview per character is written (header, thumbnail,
// "Name - Klasse"); row nn of the game is g_rows[nn - 1]. Laden asks for
// campaign and difficulty (worlds, newer autosaves, new worlds), "Umbenennen"
// renames a character, deleting moves it to characters\deleted;
// - loading row nn (cEngine::Load) unpacks the chosen world and the hero into
// the work folder, loads the world and puts the hero onto the world's hero
// with LoadHeroFile (0x61d190), the operation vanilla uses for an exported
// hero in a new game; measured: exact (gear, inventory, chest, class). The
// world's portal bits (hero +0x578) and position stay;
// - a difficulty without a world starts a new game with the shared hero;
// - every save (direct save, quicksave, story autosave) writes the world and
// the hero into the work folder and packs them into the current character;
// story autosaves (engine event 0xf with the autosave flag) go into
// CharacterNN.autosave.sacredK instead, listed when newer than the real save.
// Migration: the first time, GAMEnn.PAK rows and Heronn.pax heroes are grouped
// by hero name and class into character files (the newest world per
// difficulty; the newest of all is the shared hero), after a backup.
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <cstring>
#include <cwctype>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <windows.h>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/ui/draw.h"
#include "game/ui/button.h"
#include "game/ui/control.h"
#include "game/ui/events.h"
#include "game/ui/text.h"
#include "game/ui/texts.h"
#include "game/ui/windows.h"
#include "mods/characters.h"
#include "mods/charfile.h"
#include "mods/hud.h"
#include "mods/input.h"
#include "mods/userfolders.h"
#include "re/config.h"
#include "re/hook.h"
#include "re/patch.h"
#include "re/log.h"
namespace mods::characters {
namespace {
using nlohmann::json;
constexpr char kGameSaveSubfolder[] = "\\characters\\_work";
constexpr char kExtension[] = ".sacredK";
constexpr char kAutosaveExtension[] = ".autosave.sacredK";
constexpr int kMaxRows = 99; // GAME%02d.PAK
// The game's names for the work files (relative: the game's save folder).
constexpr char kGameWorld[] = "./SAVE/world.PAK";
constexpr char kGameHero[] = "Save\\hero.pax";
constexpr char kWorkWorld[] = "world.PAK";
constexpr char kWorkHero[] = "hero.pax";
constexpr const char* kDifficultyTextKeys[kDifficultyCount] = {
"UI_DIFFICULTY_BRONZE", "UI_DIFFICULTY_SILVER", "UI_DIFFICULTY_GOLD", "UI_DIFFICULTY_PLATINUM",
"UI_DIFFICULTY_NIOB"};
// Hero class (hero header +4) -> name text; 7 is not used by the game's heroes.
constexpr const char* kClassTextKeys[] = {
nullptr, "UI_DEFAULT_SERAPHIM", "UI_DEFAULT_GLADIATOR", "UI_DEFAULT_KAMPFMAGIER", "UI_DEFAULT_DUNKELELF",
"UI_DEFAULT_WALDELFIN", "UI_DEFAULT_VAMPIRIN", nullptr, "UI_DEFAULT_ZWERG", "UI_DEFAULT_DAEMONIN"};
constexpr size_t kHeroKnownPortals = 0x578; // u32 bit mask (teleportpotion.cpp)
// Bronze and Silber are always open; completing the story on a difficulty
// opens the next one; every difficulty below the highest with a world stays
// open. One without a world starts a new game.
constexpr int kAlwaysOpenDifficulty = 1;
// Starting a game (as cUI_Character::executeAction IMPORT_DO, 0x70d637, and
// the load window's load, 0x71cc73, do it).
constexpr uintptr_t kMultiplayer = 0x0182cb6c; // byte; the load window loads by network event then, quickload refuses
constexpr uintptr_t kGameLevel = 0x0182cdc4; // g_gameLevel: the difficulty of the game to start / running
// g_campaign (u16): the campaign of the game to start / running. The main menu
// sets it by entry (0x712180..), cEngine::Load from the save header; the load
// window lists only saves of it (0x71e2b9).
constexpr uintptr_t kCampaign = 0x0182cdc8;
constexpr size_t kSessionSinglePlayer = 0x14; // set to 1 by both
constexpr size_t kSessionHeroClass = 0x44; // the start hero's class (Session::SetHeroClass 0x7d7cf0)
// cCommand_armaPlay (vtable 0x894ff4): execute (0x755ac0) posts 0x8002 (mode,
// argument) to the main window, which constructs the engine with the argument:
// mode 1 with bit 31 a new game, bits 16..23 hero file number + 1; cEngine's
// constructor then runs InitGame and LoadHeroFile("Save\Hero%.2d.pax") (0x60bf2c).
constexpr uint16_t kPlayModeStart = 1;
constexpr uint32_t kPlayNewGame = 0x80000000;
constexpr uint32_t kPlayHeroFileShift = 16;
// cEngine fields.
constexpr size_t kEngineFlags = 0x54;
constexpr uint32_t kEngineSaving = 0x40;
// Engine event 0xf, subtype 2: save (row +0xc, autosave flag +0x18).
constexpr uint32_t kEngineEventSaveLoad = 0xf;
constexpr uint32_t kSaveLoadSave = 2;
constexpr size_t kEventCode = 1, kEventSubtype = 2, kEventAutosave = 6; // dwords
// cUI_Savegame: entries (0x310 bytes, file name at +4) at +0x15c..+0x160,
// selected visible row at +0x156 (1..3; 0 the quicksave row), first visible
// entry from 0x71bb40.
constexpr size_t kSavegameEntries = 0x15c;
constexpr size_t kSavegameEntriesEnd = 0x160;
constexpr size_t kSavegameSelected = 0x156;
constexpr size_t kSavegameEntrySize = 0x310;
constexpr size_t kSavegameEntryName = 4;
constexpr int kSavegameActionDelete = 8;
constexpr size_t kSavegameMode = 0x75c; // 1 save, 2 load, 3..5 a confirmation
constexpr uint32_t kSavegameModeLoad = 2;
constexpr size_t kSavegameLoadButton = 0x764; // cUI_Button2 inside the window (action 4)
constexpr char kSaveWindowName[] = "UI_WND_SAVEGAME";
// --- paths and texts -------------------------------------------------------
std::string CharactersDir() {
return userfolders::SaveDir() + "characters\\";
}
std::string WorkDir() {
return CharactersDir() + "_work\\";
}
std::string AutosavePath(const std::string& path) {
return path.substr(0, path.size() - std::strlen(kExtension)) + kAutosaveExtension;
}
bool EndsWith(const std::string& text, const char* suffix) {
const size_t length = std::strlen(suffix);
return text.size() >= length && _stricmp(text.c_str() + text.size() - length, suffix) == 0;
}
std::string Convert(const std::wstring& text, UINT codePage) {
if (text.empty()) {
return {};
}
const int size = WideCharToMultiByte(codePage, 0, text.c_str(), static_cast<int>(text.size()), nullptr, 0,
nullptr, nullptr);
std::string result(size, '\0');
WideCharToMultiByte(codePage, 0, text.c_str(), static_cast<int>(text.size()), result.data(), size, nullptr,
nullptr);
return result;
}
std::wstring FromUtf8(const std::string& text) {
if (text.empty()) {
return {};
}
const int size = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), nullptr, 0);
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), result.data(), size);
return result;
}
std::wstring ClassName(uint32_t heroClass) {
const char* key = heroClass < std::size(kClassTextKeys) ? kClassTextKeys[heroClass] : nullptr;
std::wstring name = key ? game::GameTextByKey(key) : std::wstring();
return name.empty() ? L"?" : name;
}
std::wstring DifficultyName(int difficulty) {
return difficulty >= 0 && difficulty < kDifficultyCount ? game::GameTextByKey(kDifficultyTextKeys[difficulty])
: std::wstring(L"?");
}
uint16_t& GameCampaign() {
return *reinterpret_cast<uint16_t*>(kCampaign);
}
std::wstring CampaignName(int campaign) {
return campaign == kCampaignUnderworld ? L"Underworld" : L"Ancaria";
}
// A file for a new character: CharacterNN.sacredK, the first free number
// (the hero's name is inside, in the meta).
std::string NewCharacterPath() {
for (int n = 1;; ++n) {
char name[32];
wsprintfA(name, "Character%02d%s", n, kExtension);
const std::string path = CharactersDir() + name;
if (GetFileAttributesA(path.c_str()) == INVALID_FILE_ATTRIBUTES &&
GetFileAttributesA(AutosavePath(path).c_str()) == INVALID_FILE_ATTRIBUTES) {
return path;
}
}
}
std::vector<std::string> FilesIn(const std::string& dir, const char* pattern) {
std::vector<std::string> files;
WIN32_FIND_DATAA found;
HANDLE find = FindFirstFileA((dir + pattern).c_str(), &found);
if (find == INVALID_HANDLE_VALUE) {
return files;
}
do {
if (!(found.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) {
files.push_back(found.cFileName);
}
} while (FindNextFileA(find, &found));
FindClose(find);
return files;
}
uint64_t FileTime(const std::string& path) {
WIN32_FILE_ATTRIBUTE_DATA data;
if (!GetFileAttributesExA(path.c_str(), GetFileExInfoStandard, &data)) {
return 0;
}
return (uint64_t(data.ftLastWriteTime.dwHighDateTime) << 32) | data.ftLastWriteTime.dwLowDateTime;
}
void SetHeroMeta(json& meta, const SaveInfo& info) {
meta["format"] = 1;
meta["name"] = Convert(info.name, CP_UTF8);
meta["class"] = info.heroClass;
meta["level"] = info.level;
}
// --- migration ---------------------------------------------------------------
// The old save rows and exported heroes become character files, once.
void Migrate() {
const std::string saveDir = userfolders::SaveDir();
const std::vector<std::string> worlds = FilesIn(saveDir, "GAME*.PAK");
const std::vector<std::string> heroes = FilesIn(saveDir, "Hero*.pax");
// Backup of everything that is read.
SYSTEMTIME now;
GetLocalTime(&now);
char stamp[32];
wsprintfA(stamp, "%04u-%02u-%02u_%02u%02u", now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute);
const std::string sacredDir = saveDir.substr(0, saveDir.find_last_of('\\', saveDir.size() - 2) + 1);
const std::string backupDir = sacredDir + "backup\\" + stamp + "_characters_migration\\";
CreateDirectoryA((sacredDir + "backup").c_str(), nullptr);
CreateDirectoryA(backupDir.c_str(), nullptr);
for (const auto* list : {&worlds, &heroes}) {
for (const std::string& file : *list) {
CopyFileA((saveDir + file).c_str(), (backupDir + file).c_str(), TRUE);
}
}
struct Source {
std::string file;
SaveInfo info;
uint64_t time = 0;
};
struct Group {
std::map<std::pair<int, int>, Source> worlds; // newest per (campaign, difficulty)
std::optional<Source> hero; // newest of all
std::vector<std::string> files;
};
std::map<std::pair<std::wstring, uint32_t>, Group> groups;
auto add = [&](const std::string& file, bool world) {
std::optional<Bytes> bytes = ReadFile(saveDir + file);
std::optional<SaveInfo> info = bytes ? ReadSaveInfo(*bytes) : std::nullopt;
if (!info || info->world != world || info->multiplayer || info->difficulty < 0 ||
info->difficulty >= kDifficultyCount) {
re::Log("characters: migration skips %s", file.c_str());
return;
}
Source source{file, *info, world && info->savedAt ? info->savedAt : FileTime(saveDir + file)};
Group& group = groups[{info->name, info->heroClass}];
group.files.push_back(file);
if (world) {
const std::pair<int, int> key{info->campaign, info->difficulty};
auto it = group.worlds.find(key);
if (it == group.worlds.end() || it->second.time < source.time) {
group.worlds[key] = source;
}
}
if (!group.hero || group.hero->time < source.time) {
group.hero = source;
}
};
for (const std::string& file : worlds) {
add(file, true);
}
for (const std::string& file : heroes) {
add(file, false);
}
for (auto& [key, group] : groups) {
CharacterFile character(NewCharacterPath());
SetHeroMeta(character.meta, group.hero->info);
character.meta["migratedFrom"] = group.files;
for (const auto& [world, source] : group.worlds) {
character.Set(WorldSection(world.first, world.second), *ReadFile(saveDir + source.file));
}
if (group.hero->info.world) {
// The newest hero is in a world: it becomes the shared hero the
// first time that world is loaded (Engine_Load).
character.meta["heroFrom"] = group.hero->info.difficulty;
character.meta["heroFromCampaign"] = group.hero->info.campaign;
character.meta["lastDifficulty"] = group.hero->info.difficulty;
character.meta["lastCampaign"] = group.hero->info.campaign;
} else {
character.Set("hero", *ReadFile(saveDir + group.hero->file));
const std::pair<int, int> last = group.worlds.empty() ? std::pair<int, int>{} : group.worlds.rbegin()->first;
character.meta["lastCampaign"] = last.first;
character.meta["lastDifficulty"] = last.second;
}
const bool saved = character.Save();
re::Log("characters: migrated %s (%zu files, %zu worlds, hero from %s)%s", character.Path().c_str(),
group.files.size(), group.worlds.size(), group.hero->file.c_str(), saved ? "" : " - FAILED");
}
re::Log("characters: migration done, backup in %s", backupDir.c_str());
}
void EnsureFolders() {
static bool done = false;
if (done) {
return;
}
done = true;
const std::string dir = CharactersDir();
const bool migrate = GetFileAttributesA(dir.c_str()) == INVALID_FILE_ATTRIBUTES;
CreateDirectoryA(dir.c_str(), nullptr);
CreateDirectoryA(WorkDir().c_str(), nullptr);
if (migrate) {
Migrate();
}
}
// --- the load window's rows ----------------------------------------------------
// A world a row can load: a difficulty's save or its newer autosave.
struct Choice {
int campaign = kCampaignAncaria;
int difficulty = 0;
bool autosave = false;
bool newWorld = false; // no world yet: a new game with the shared hero
};
// A character in the load window; the difficulty dialog picks `chosen`.
struct Row {
std::string file; // the character file
std::wstring name; // the hero's
std::vector<Choice> choices;
size_t chosen = 0;
uint32_t level = 0; // the shared hero's
bool hasHero = false; // the shared hero is in the file (migrated ones get it at their first load)
};
std::vector<Row> g_rows; // row nn of the game = g_rows[nn - 1]
// The character being played: saves go there (empty file: a new character,
// named at its first save).
struct Current {
std::string file;
} g_current;
// The next cEngine::Save (render thread) is an autosave: a story autosave
// (Engine_ReceiveEvent) or the timed one (SaveOnTick).
std::atomic<bool> g_autosavePending{false};
bool g_inGame = false; // a game runs (the load window opens from the Esc menu too)
std::atomic<uint64_t> g_lastSaveAt{0}; // GetTickCount64 of the last save of any kind
std::string Description(const json& meta) {
return Convert(FromUtf8(meta.value("name", std::string())) + L" - " + ClassName(meta.value("class", 0u)),
CP_ACP);
}
// "Ancaria - Silber", with "(neu)" / "(Autosave)".
std::wstring ChoiceName(const Choice& choice) {
std::wstring name = CampaignName(choice.campaign) + L" - " + DifficultyName(choice.difficulty);
if (choice.newWorld) {
name += L" (neu)";
} else if (choice.autosave) {
name += L" (Autosave)";
}
return name;
}
uint64_t SavedAt(const CharacterFile& file, int campaign, int difficulty) {
std::optional<Bytes> world = file.Read(WorldSection(campaign, difficulty));
std::optional<SaveInfo> info = world ? ReadSaveInfo(*world) : std::nullopt;
return info ? info->savedAt : 0;
}
// Writes one preview per row into the work folder, where the game scans.
void RebuildList() {
EnsureFolders();
const std::string work = WorkDir();
for (const std::string& file : FilesIn(work, "GAME*.PAK")) {
DeleteFileA((work + file).c_str());
}
g_rows.clear();
for (const std::string& name : FilesIn(CharactersDir(), "*.sacredK")) {
if (EndsWith(name, kAutosaveExtension) || g_rows.size() >= kMaxRows) {
continue;
}
CharacterFile character(CharactersDir() + name);
if (!character.Open()) {
continue;
}
CharacterFile autosave(AutosavePath(character.Path()));
const int autosaved = autosave.Open() ? autosave.meta.value("difficulty", -1) : -1;
const int autosavedCampaign = autosave.meta.value("campaign", kCampaignAncaria);
const bool hasHero = character.Has("hero");
const int heroFrom = character.meta.value("heroFrom", -1);
const int heroFromCampaign = character.meta.value("heroFromCampaign", kCampaignAncaria);
Row row{character.Path()};
row.hasHero = hasHero;
row.name = FromUtf8(character.meta.value("name", std::string()));
row.level = character.meta.value("level", 0u);
int highest = kAlwaysOpenDifficulty;
// The story completions are the shared hero's (or, before it exists,
// the world holding it); the game counts them per difficulty only, so
// they open the next difficulty in both campaigns (decided 2026-10-20).
std::optional<Bytes> heroFile =
character.Read(hasHero ? std::string("hero") : WorldSection(heroFromCampaign, heroFrom));
std::optional<SaveInfo> hero = heroFile ? ReadSaveInfo(*heroFile) : std::nullopt;
for (int d = 0; d < kDifficultyCount; ++d) {
for (int c = 0; c < kCampaignCount; ++c) {
if (SavedAt(character, c, d)) {
highest = std::max(highest, d);
}
}
if (hero && hero->storyCompleted[d]) {
highest = std::max(highest, std::min(d + 1, kDifficultyCount - 1));
}
}
// New games need the shared hero (in a game the running one stops
// first, StartNewWorld); multiplayer games are not ours.
const bool canStart = hasHero && !*reinterpret_cast<const uint8_t*>(kMultiplayer);
for (int c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) {
const uint64_t savedAt = SavedAt(character, c, d);
// Without the shared hero yet only the world holding it can be loaded.
if (savedAt && (hasHero || (c == heroFromCampaign && d == heroFrom))) {
row.choices.push_back({c, d});
} else if (!savedAt && d <= highest && canStart) {
row.choices.push_back({c, d, false, true});
}
if (c == autosavedCampaign && d == autosaved && SavedAt(autosave, c, d) > savedAt) {
row.choices.push_back({c, d, true});
}
}
}
// The preview shows the last played world (or any world).
std::optional<size_t> shownChoice;
const int last = character.meta.value("lastDifficulty", 0);
const int lastCampaign = character.meta.value("lastCampaign", kCampaignAncaria);
for (size_t i = 0; i < row.choices.size(); ++i) {
const Choice& choice = row.choices[i];
if (!choice.newWorld &&
(!shownChoice || (choice.campaign == lastCampaign && choice.difficulty == last && !choice.autosave))) {
shownChoice = i;
}
}
if (!shownChoice) {
continue;
}
row.chosen = *shownChoice;
const Choice& shown = row.choices[row.chosen];
const CharacterFile& source = shown.autosave ? autosave : character;
std::optional<Bytes> world = source.Read(WorldSection(shown.campaign, shown.difficulty));
if (!world) {
continue;
}
g_rows.push_back(std::move(row));
char preview[32];
wsprintfA(preview, "GAME%02u.PAK", static_cast<unsigned>(g_rows.size()));
WriteFileReplacing(work + preview, BuildPreview(*world, Description(character.meta), GameCampaign()));
}
re::Log("characters: %zu characters listed", g_rows.size());
}
// Row number nn from the game's "./SAVE/GAMEnn.PAK"; 0 if it isn't one.
int RowFromPath(const char* path) {
if (!path) {
return 0;
}
const std::string_view full(path);
const size_t slash = full.find_last_of("\\/");
const char* name = path + (slash == std::string_view::npos ? 0 : slash + 1);
unsigned row = 0;
char rest[8] = {};
if (_strnicmp(name, "GAME", 4) != 0 || sscanf_s(name + 4, "%2u%7s", &row, rest, unsigned(sizeof(rest))) != 2 ||
_stricmp(rest, ".PAK") != 0) {
return 0;
}
return static_cast<int>(row);
}
// --- game functions ------------------------------------------------------------
// LoadHeroFile (0x61d190, cdecl (path, 1, 0)): a hero file onto the player hero.
uint32_t LoadHeroFile(const char* path) {
using Fn = uint32_t(__cdecl*)(const char*, uint32_t, uint32_t);
return reinterpret_cast<Fn>(0x0061d190)(path, 1, 0);
}
// cEngine::SaveHero (0x61cda0, cdecl (path)): the hero as a hero file.
bool SaveHero(const char* path) {
using Fn = char(__cdecl*)(const char*);
return reinterpret_cast<Fn>(0x0061cda0)(path) != 0;
}
uint32_t& HeroField(void* hero, size_t offset) {
return *reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(hero) + offset);
}
// The hero file holds the whole hero object, its world position too:
// loadHero (0x6059f0..) deserialises it over the hero (vf1c), so the hero
// stood where it was saved, on another difficulty (measured). The position
// belongs to the world: it is put back right after. (cObjectManager::Place
// 0x5fcd10 in loadHero only runs for carried objects, flag 0x2.)
constexpr size_t kObjectPosition = 0x18; // area (u16), x, y, level (byte)
constexpr size_t kObjectPositionSize = 0x10; // as saveHero logs it: +0x18 .. +0x24
// Runs `load` (a LoadHeroFile) keeping the hero where the world has it.
template <typename Load>
uint32_t KeepingHeroPosition(Load load) {
// Measured: the hero stays the same object.
auto* hero = static_cast<uint8_t*>(game::GetPlayerHero());
uint8_t position[kObjectPositionSize] = {};
if (hero) {
std::memcpy(position, hero + kObjectPosition, kObjectPositionSize);
}
const uint32_t result = load();
if (hero && hero == game::GetPlayerHero()) {
std::memcpy(hero + kObjectPosition, position, kObjectPositionSize);
}
return result;
}
// The shared hero onto the loaded world's hero; the world's portals and
// position stay.
void ApplySharedHero() {
void* hero = game::GetPlayerHero();
const uint32_t portals = hero ? HeroField(hero, kHeroKnownPortals) : 0;
const uint32_t loaded = KeepingHeroPosition([] { return LoadHeroFile(kGameHero); });
hero = game::GetPlayerHero();
if (hero) {
HeroField(hero, kHeroKnownPortals) = portals;
}
re::Log("characters: shared hero loaded (%u), portals %08x kept", loaded, portals);
}
// --- hooks -----------------------------------------------------------------------
// cUI_Savegame's scan of the save folder (0x71e330, thiscall (dir, multiplayer),
// ret 8): previews first.
int __fastcall Savegame_Scan(void* self, void* edx, const char* dir, uint32_t multiplayer);
RE_HOOK(0x0071e330, Savegame_Scan);
int __fastcall Savegame_Scan(void* self, void* edx, const char* dir, uint32_t multiplayer) {
RebuildList();
return Savegame_Scan_original(self, edx, dir, multiplayer);
}
// The row of the window's selected entry (0: none, or not a character).
Row* SelectedRow(uint8_t* window) {
const int selected = *reinterpret_cast<int16_t*>(window + kSavegameSelected);
if (selected <= 0) {
return nullptr;
}
using FirstVisibleFn = int16_t(__fastcall*)(void*, void*);
const int index = reinterpret_cast<FirstVisibleFn>(0x0071bb40)(window, nullptr) + selected - 1;
const uint8_t* begin = *reinterpret_cast<uint8_t**>(window + kSavegameEntries);
const uint8_t* end = *reinterpret_cast<uint8_t**>(window + kSavegameEntriesEnd);
if (index < 0 || index >= static_cast<int>((end - begin) / kSavegameEntrySize)) {
return nullptr;
}
const int row = RowFromPath(reinterpret_cast<const char*>(begin + index * kSavegameEntrySize + kSavegameEntryName));
return row >= 1 && row <= static_cast<int>(g_rows.size()) ? &g_rows[row - 1] : nullptr;
}
// Dialogs over the window, in its coordinates (1024x768):
// - difficulty: "Laden" on a character with more than one world to choose
// (difficulties, newer autosaves); a choice runs the window's own load
// action with that world;
// - rename: the "Umbenennen" button below "Löschen" asks for the selected
// character's new name; while it is open every key goes to the name field
// (input key capture, window thread), Enter and Esc act on the next frame.
// Buttons look like the window's load button; the last one is "Abbrechen".
constexpr int kFrameWidth = 1024;
constexpr int kFrameHeight = 768;
constexpr int kDialogWidth = 320;
constexpr int kDialogTitleHeight = 48;
constexpr int kDialogFieldHeight = 32; // rename: the name field
constexpr int kDialogFieldGap = 16;
constexpr int kDialogFieldPadding = 8;
constexpr int kDialogButtonWidth = 220;
constexpr int kDialogButtonHeight = 32;
constexpr int kDialogPitch = 44;
constexpr int kDialogBottom = 20;
constexpr uint16_t kDialogFont = 1;
constexpr uint32_t kDialogBackColour = 0xe8000000;
constexpr uint32_t kDialogEdgeColour = 0xff6e5a32;
constexpr uint32_t kDialogFieldColour = 0xff201810;
constexpr uint32_t kDialogTitleColour = 0xfff0c060; // as the options page's headings
constexpr uint32_t kDialogTextColour = 0xffffffff;
constexpr int kSavegameActionLoad = 4; // the load button
constexpr int kSavegameActionRescan = 10; // lists the save folder again
constexpr int kSavegameActionDefault = 0xb; // double click: save or load by the mode
constexpr uint32_t kSavegameModeSave = 1;
constexpr size_t kSavegameDeleteButton = 0x8dc; // cUI_Button2 inside the window (action 7)
// The window's buttons stand 64 apart (measured: load 288, save 352, delete
// 416, back 672); "Umbenennen" and "Neuer Charakter" take the places below
// delete.
constexpr int kSavegameButtonPitch = 64;
// cUI_Savegame actions (0x71c520, thiscall (action), ret 4): defined below.
void __fastcall Savegame_Action(uint8_t* self, void* edx, int action);
RE_HOOK(0x0071c520, Savegame_Action);
// Difficulty: the row's choices; Rename: "Umbenennen" under the name field;
// NewCharacter: the campaigns; Reset: the row's worlds; ConfirmReset:
// "Zurücksetzen" for one of them.
enum class DialogKind { Difficulty, Rename, NewCharacter, Reset, ConfirmReset };
struct Dialog {
uint8_t* window = nullptr; // open while set
DialogKind kind = DialogKind::Difficulty;
size_t row = 0; // index into g_rows
std::wstring title;
std::vector<std::wstring> entries; // then "Abbrechen"
std::vector<Choice> worlds; // Reset: the entries' worlds
Choice target; // ConfirmReset: the world
std::vector<uint8_t*> buttons; // made once, reused
std::vector<uint32_t> captions; // their text ids, overridden
} g_dialog;
// Rename: the name typed so far and a pending Enter / Esc, from the window
// thread.
std::mutex g_nameLock;
std::wstring g_name;
std::atomic<int> g_nameKey = 0;
// The entries and "Abbrechen".
size_t DialogEntries() {
return g_dialog.entries.size() + 1;
}
int DialogFieldSpace() {
return g_dialog.kind == DialogKind::Rename ? kDialogFieldHeight + kDialogFieldGap : 0;
}
game::UiRect DialogPanel() {
const int height =
kDialogTitleHeight + DialogFieldSpace() + static_cast<int>(DialogEntries()) * kDialogPitch + kDialogBottom;
return {(kFrameWidth - kDialogWidth) / 2, (kFrameHeight - height) / 2, static_cast<int16_t>(kDialogWidth),
static_cast<int16_t>(height)};
}
game::UiRect DialogField() {
const game::UiRect panel = DialogPanel();
return {panel.x + (kDialogWidth - kDialogButtonWidth) / 2, panel.y + kDialogTitleHeight,
static_cast<int16_t>(kDialogButtonWidth), static_cast<int16_t>(kDialogFieldHeight)};
}
game::UiRect DialogButtonRect(size_t index) {
const game::UiRect panel = DialogPanel();
return {panel.x + (kDialogWidth - kDialogButtonWidth) / 2,
panel.y + kDialogTitleHeight + DialogFieldSpace() + static_cast<int>(index) * kDialogPitch,
static_cast<int16_t>(kDialogButtonWidth), static_cast<int16_t>(kDialogButtonHeight)};
}
std::wstring DialogText(size_t index) {
return index < g_dialog.entries.size() ? g_dialog.entries[index] : L"Abbrechen";
}
// The worlds a reset can delete: every saved world of the row (not new ones,
// not autosaves). Only with the shared hero: before it exists, a world holds it.
std::vector<Choice> ResettableWorlds(const Row& row) {
std::vector<Choice> worlds;
if (!row.hasHero) {
return worlds;
}
for (const Choice& choice : row.choices) {
if (!choice.newWorld && !choice.autosave) {
worlds.push_back(choice);
}
}
return worlds;
}
// Title and entries of a dialog `kind` for row `row` (and g_dialog.target).
void FillDialog(DialogKind kind, size_t row) {
g_dialog.entries.clear();
switch (kind) {
case DialogKind::Difficulty:
g_dialog.title = L"Welt wählen";
for (const Choice& choice : g_rows[row].choices) {
g_dialog.entries.push_back(ChoiceName(choice));
}
break;
case DialogKind::Rename:
g_dialog.title = L"Neuer Name";
g_dialog.entries = {L"Umbenennen"};
break;
case DialogKind::NewCharacter:
g_dialog.title = L"Kampagne wählen";
for (int c = 0; c < kCampaignCount; ++c) {
g_dialog.entries.push_back(CampaignName(c));
}
break;
case DialogKind::Reset:
g_dialog.title = L"Welt zurücksetzen";
g_dialog.worlds = ResettableWorlds(g_rows[row]);
for (const Choice& choice : g_dialog.worlds) {
g_dialog.entries.push_back(ChoiceName(choice));
}
break;
case DialogKind::ConfirmReset:
g_dialog.title = ChoiceName(g_dialog.target) + L" zurücksetzen?";
g_dialog.entries = {L"Zurücksetzen"};
break;
}
}
// The character typed with key `vk` under the current modifiers, 0 if none.
wchar_t TypedChar(int vk) {
BYTE keys[256];
if (!GetKeyboardState(keys)) {
return 0;
}
wchar_t buffer[4] = {};
const int count = ToUnicode(vk, MapVirtualKeyW(vk, MAPVK_VK_TO_VSC), keys, buffer, 4, 0);
return count == 1 && std::iswprint(buffer[0]) ? buffer[0] : 0;
}
void OnNameKey(int vk) {
if (vk == VK_RETURN || vk == VK_ESCAPE) {
g_nameKey = vk;
return;
}
std::lock_guard guard(g_nameLock);
if (vk == VK_BACK) {
if (!g_name.empty()) {
g_name.pop_back();
}
} else if (const wchar_t c = TypedChar(vk); c && g_name.size() < kHeroNameMaxChars) {
g_name.push_back(c);
}
}
void OpenDialog(uint8_t* window, DialogKind kind, size_t row) {
g_dialog.window = window;
g_dialog.kind = kind;
g_dialog.row = row;
FillDialog(kind, row);
for (size_t i = 0; i < DialogEntries(); ++i) {
if (i == g_dialog.buttons.size()) {
const uint32_t caption = game::RegisterText(L"");
uint8_t* button = game::NewButtonLike(window + kSavegameLoadButton, DialogButtonRect(i), caption,
"UI_BTN_CHAR_DIALOG");
if (!button) {
g_dialog.window = nullptr;
return;
}
g_dialog.buttons.push_back(button);
g_dialog.captions.push_back(caption);
}
*reinterpret_cast<game::UiRect*>(g_dialog.buttons[i] + game::kControlRect) = DialogButtonRect(i);
game::SetTextOverride(g_dialog.captions[i], DialogText(i));
}
if (kind == DialogKind::Rename) {
{
std::lock_guard guard(g_nameLock);
g_name = g_rows[row].name;
}
g_nameKey = 0;
input::SetKeyCapture(&OnNameKey);
}
}
void CloseDialog() {
if (g_dialog.window && g_dialog.kind == DialogKind::Rename) {
input::SetKeyCapture(nullptr);
}
g_dialog.window = nullptr;
}
// The hero's name in all of the character's files (the shared hero, every
// world, the autosave) and its meta.
bool RenameCharacter(const Row& row, const std::wstring& name) {
bool done = true;
for (const std::string& path : {row.file, AutosavePath(row.file)}) {
CharacterFile character(path);
if (!character.Open()) {
continue; // no autosave
}
std::vector<std::string> sections = {"hero"};
for (int c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) {
sections.push_back(WorldSection(c, d));
}
}
for (const std::string& section : sections) {
if (!character.Has(section)) {
continue;
}
std::optional<Bytes> bytes = character.Read(section);
if (!bytes || !SetHeroName(*bytes, name)) {
re::Log("characters: rename: no hero name in %s of %s", section.c_str(), path.c_str());
done = false;
continue;
}
character.Set(section, std::move(*bytes));
}
character.meta["name"] = Convert(name, CP_UTF8);
done = character.Save() && done;
}
re::Log("characters: renamed %s to %s%s", row.file.c_str(), Convert(name, CP_UTF8).c_str(),
done ? "" : " - FAILED in part");
return done;
}
// The row "Umbenennen" and "Zurücksetzen" apply to: the selected character,
// unless it is the one being played (its name and world are in the running
// game too).
const Row* EditableRow(uint8_t* window) {
const uint32_t mode = *reinterpret_cast<uint32_t*>(window + kSavegameMode);
if (mode != kSavegameModeLoad && mode != kSavegameModeSave) {
return nullptr;
}
const Row* row = SelectedRow(window);
if (!row || (row->file == g_current.file && game::GetPlayerHero())) {
return nullptr;
}
return row;
}
// "Neuer Charakter": in the main menu's load window (not in a game, not in
// multiplayer).
bool NewCharacterAllowed(uint8_t* window) {
return *reinterpret_cast<uint32_t*>(window + kSavegameMode) == kSavegameModeLoad && !g_inGame &&
!*reinterpret_cast<const uint8_t*>(kMultiplayer);
}
bool CanReset(uint8_t* window) {
const Row* row = EditableRow(window);
return row && !ResettableWorlds(*row).empty();
}
// Buttons of ours below the window's delete button, made on first use; each
// opens a dialog (for the selected character, if it needs one).
struct SideButton {
int slot; // places below delete
const wchar_t* caption;
const char* name;
bool (*shown)(uint8_t* window);
DialogKind opens;
uint8_t* control = nullptr;
};
SideButton g_sideButtons[] = {
{1, L"Umbenennen", "UI_BTN_CHAR_RENAME", [](uint8_t* window) { return EditableRow(window) != nullptr; },
DialogKind::Rename},
{2, L"Zurücksetzen", "UI_BTN_CHAR_RESET", &CanReset, DialogKind::Reset},
{3, L"Neuer Charakter", "UI_BTN_CHAR_NEW", &NewCharacterAllowed, DialogKind::NewCharacter},
};
// "Speichern" is not drawn (kNoImport); Löschen moves up into its place.
constexpr size_t kSavegameSaveButton = 0x820; // cUI_Button2 inside the window (action 1)
void MoveDeleteButton(uint8_t* window) {
std::memcpy(window + kSavegameDeleteButton + game::kControlRect, window + kSavegameSaveButton + game::kControlRect,
sizeof(game::UiRect));
}
game::UiRect SideButtonRect(uint8_t* window, const SideButton& side) {
game::UiRect rect = game::ControlScreenRect(window + kSavegameDeleteButton);
rect.y += side.slot * kSavegameButtonPitch;
return rect;
}
uint8_t* SideButtonControl(uint8_t* window, SideButton& side) {
if (!side.control) {
side.control = game::NewButtonLike(window + kSavegameDeleteButton, SideButtonRect(window, side),
game::RegisterText(side.caption), side.name);
}
if (side.control) {
*reinterpret_cast<game::UiRect*>(side.control + game::kControlRect) = SideButtonRect(window, side);
}
return side.control;
}
void ConfirmRename(uint8_t* window) {
std::wstring name;
{
std::lock_guard guard(g_nameLock);
name = g_name;
}
while (!name.empty() && name.back() == L' ') {
name.pop_back();
}
while (!name.empty() && name.front() == L' ') {
name.erase(name.begin());
}
if (name.empty()) {
return;
}
const Row row = g_rows[g_dialog.row];
CloseDialog();
if (name != row.name) {
RenameCharacter(row, name);
Savegame_Action_original(window, nullptr, kSavegameActionRescan);
}
}
void FillBox(void* device, const game::UiRect& r, uint32_t colour) {
game::FillRect(device, static_cast<float>(r.x), static_cast<float>(r.y), static_cast<float>(r.x + r.w),
static_cast<float>(r.y + r.h), colour);
}
game::UiRect Grown(const game::UiRect& r, int by) {
return {r.x - by, r.y - by, static_cast<int16_t>(r.w + 2 * by), static_cast<int16_t>(r.h + 2 * by)};
}
void RenderDialog(void* device) {
const game::UiRect panel = DialogPanel();
FillBox(device, Grown(panel, 2), kDialogEdgeColour);
FillBox(device, panel, kDialogBackColour);
const game::UiRect title = {panel.x, panel.y, panel.w, static_cast<int16_t>(kDialogTitleHeight)};
game::DrawText(device, kDialogFont, game::CenterLineVertically(kDialogFont, title), g_dialog.title.c_str(),
kDialogTitleColour, game::kTextCenter);
if (g_dialog.kind == DialogKind::Rename) {
const game::UiRect field = DialogField();
FillBox(device, Grown(field, 1), kDialogEdgeColour);
FillBox(device, field, kDialogFieldColour);
std::wstring text;
{
std::lock_guard guard(g_nameLock);
text = g_name + L"_";
}
game::UiRect line = {field.x + kDialogFieldPadding, field.y,
static_cast<int16_t>(field.w - 2 * kDialogFieldPadding), field.h};
game::DrawText(device, kDialogFont, game::CenterLineVertically(kDialogFont, line), text.c_str(),
kDialogTextColour, 0);
}
for (size_t i = 0; i < DialogEntries(); ++i) {
game::ControlRender(g_dialog.buttons[i], device);
}
}
// cUI_Savegame's actions (0x71c520, thiscall (action), ret 4). Loading a
// character with a choice opens the dialog first. 8 deletes the selected
// row's file after the confirmation; here the character goes, kept in
// characters\deleted.
void __fastcall Savegame_Action(uint8_t* self, void* edx, int action) {
const bool loading = *reinterpret_cast<uint32_t*>(self + kSavegameMode) == kSavegameModeLoad;
if (loading && (action == kSavegameActionLoad || action == kSavegameActionDefault)) {
if (const Row* row = SelectedRow(self); row && row->choices.size() > 1) {
OpenDialog(self, DialogKind::Difficulty, static_cast<size_t>(row - g_rows.data()));
return;
} else if (row) {
// As the main menu's entries do before a game starts; cEngine::Load
// sets it from the save again.
GameCampaign() = static_cast<uint16_t>(row->choices[row->chosen].campaign);
}
}
const Row* toDelete = action == kSavegameActionDelete ? SelectedRow(self) : nullptr;
if (toDelete && g_inGame && toDelete->file == g_current.file) {
re::Log("characters: %s is being played, not deleted", toDelete->file.c_str());
return;
}
if (const Row* row = toDelete) {
const std::string deleted = CharactersDir() + "deleted\\";
CreateDirectoryA(deleted.c_str(), nullptr);
for (const std::string& file : {row->file, AutosavePath(row->file)}) {
const std::string name = file.substr(file.find_last_of('\\') + 1);
MoveFileExA(file.c_str(), (deleted + name).c_str(), MOVEFILE_REPLACE_EXISTING);
}
re::Log("characters: deleted %s (moved to %s)", row->file.c_str(), deleted.c_str());
}
Savegame_Action_original(self, edx, action);
}
// A new game on a difficulty without a world, with the character's shared
// hero; it becomes the current character in Game_LoadHeroFile.
struct NewWorld {
std::string file; // set while the game starts
int campaign = kCampaignAncaria;
int difficulty = 0;
} g_newWorld;
void* Session() {
using InstanceFn = void*(__cdecl*)();
return reinterpret_cast<InstanceFn>(0x007d7c40)();
}
// The game setup singleton (0x417e50, 0x12e6c bytes); both character-screen
// starts set its first byte.
uint8_t* GameSetup() {
using InstanceFn = uint8_t*(__cdecl*)();
return reinterpret_cast<InstanceFn>(0x00417e50)();
}
void StartNewWorld(uint8_t* window, const Row& row, int campaign, int difficulty) {
CharacterFile character(row.file);
std::optional<Bytes> hero = character.Open() ? character.Read("hero") : std::nullopt;
if (!hero || !WriteFileReplacing(WorkDir() + kWorkHero, *hero)) {
re::Log("characters: %s has no shared hero, no new game", row.file.c_str());
return;
}
g_newWorld = {row.file, campaign, difficulty};
GameCampaign() = static_cast<uint16_t>(campaign);
auto* session = static_cast<uint8_t*>(Session());
*reinterpret_cast<uint32_t*>(session + kSessionSinglePlayer) = 1;
*reinterpret_cast<uint32_t*>(session + kSessionHeroClass) = character.meta.value("class", 1u);
*GameSetup() = 1;
*reinterpret_cast<int*>(kGameLevel) = difficulty;
using HideFn = void(__fastcall*)(void*, void*, uint32_t);
reinterpret_cast<HideFn>(0x00731ee0)(window, nullptr, 1); // as the load action before it starts
struct PlayCommand {
uint32_t vtable;
uint16_t mode;
uint32_t argument;
} command{0x00894ff4, kPlayModeStart, kPlayNewGame | (1u << kPlayHeroFileShift)};
re::Log("characters: new game for %s, campaign %d, difficulty %d", row.file.c_str(), campaign, difficulty);
using ExecuteFn = void(__fastcall*)(void*, void*);
reinterpret_cast<ExecuteFn>(0x00755ac0)(&command, nullptr);
}
// A new world from a running game: the game stops as the Esc menu's "Beenden"
// stops it (cCommand_armaStop, execute 0x755b30, thiscall, plain ret: posts
// 0x8001 to the main window, whose handler 0x811fa6 deletes the engine), and
// the new game starts once the engine is gone (OnGameEnd): its start command
// (0x8002) is handled right after the stop, without the main menu.
struct PendingStart {
uint8_t* window = nullptr;
Row row;
Choice world;
} g_pendingStart;
void StopGameThenStart(uint8_t* window, const Row& row, const Choice& world) {
g_pendingStart = {window, row, world};
re::Log("characters: stopping the game for a new world of %s", row.file.c_str());
struct StopCommand {
uint32_t vtable;
} command{0x0088ee2c};
using ExecuteFn = void(__fastcall*)(void*, void*);
reinterpret_cast<ExecuteFn>(0x00755b30)(&command, nullptr);
}
void StartPendingWorld() {
if (const PendingStart pending = std::exchange(g_pendingStart, {}); pending.window) {
StartNewWorld(pending.window, pending.row, pending.world.campaign, pending.world.difficulty);
}
}
// A new character in `campaign`: the game's character creation, as the main
// menu's campaign entries opened it (window command show UI_CHARACTER, the
// campaign set first); its first save makes the character file.
void OpenCharacterCreation(int campaign) {
GameCampaign() = static_cast<uint16_t>(campaign);
uint32_t* ui = game::UiManager();
game::SendWindowCommand(ui, kSaveWindowName, game::WindowCommand::Hide);
game::SendWindowCommand(ui, "UI_CHARACTER", game::WindowCommand::Show);
re::Log("characters: new character, campaign %d", campaign);
}
// Reset (request of 2026-10-20): the world goes (kept in characters\deleted),
// with an autosave of it, and the shared hero's story completion for its
// difficulty is cleared (decided: cleared too; the game counts it per
// difficulty, so for both campaigns). The hero keeps everything else.
bool ResetWorld(const Row& row, const Choice& world) {
CharacterFile character(row.file);
const std::string section = WorldSection(world.campaign, world.difficulty);
std::optional<Bytes> removed = character.Open() ? character.Read(section) : std::nullopt;
std::optional<Bytes> hero = removed ? character.Read("hero") : std::nullopt;
if (!hero || !SetStoryCompleted(*hero, world.difficulty, 0)) {
re::Log("characters: reset of %s in %s failed (no world or no shared hero)", section.c_str(), row.file.c_str());
return false;
}
const std::string deleted = CharactersDir() + "deleted\\";
CreateDirectoryA(deleted.c_str(), nullptr);
const std::string name = row.file.substr(row.file.find_last_of('\\') + 1);
WriteFileReplacing(deleted + name + "." + section + ".PAK", *removed);
character.Remove(section);
character.Set("hero", std::move(*hero));
if (character.meta.value("lastCampaign", kCampaignAncaria) == world.campaign &&
character.meta.value("lastDifficulty", 0) == world.difficulty) {
character.meta.erase("lastCampaign");
character.meta.erase("lastDifficulty");
}
const bool saved = character.Save();
CharacterFile autosave(AutosavePath(row.file));
if (autosave.Open() && autosave.meta.value("campaign", kCampaignAncaria) == world.campaign &&
autosave.meta.value("difficulty", -1) == world.difficulty) {
DeleteFileA(autosave.Path().c_str());
}
re::Log("characters: reset %s of %s (backup in %s)%s", section.c_str(), row.file.c_str(), deleted.c_str(),
saved ? "" : " - WRITING FAILED");
return saved;
}
// Button `index` of the dialog chosen.
void DialogChoose(uint8_t* window, size_t index) {
if (index + 1 == DialogEntries()) {
CloseDialog();
} else if (g_dialog.kind == DialogKind::Rename) {
ConfirmRename(window);
} else if (g_dialog.kind == DialogKind::NewCharacter) {
CloseDialog();
OpenCharacterCreation(static_cast<int>(index));
} else if (g_dialog.kind == DialogKind::Reset) {
g_dialog.target = g_dialog.worlds[index];
OpenDialog(window, DialogKind::ConfirmReset, g_dialog.row);
} else if (g_dialog.kind == DialogKind::ConfirmReset) {
const Row row = g_rows[g_dialog.row];
CloseDialog();
ResetWorld(row, g_dialog.target);
Savegame_Action_original(window, nullptr, kSavegameActionRescan);
} else if (const Row row = g_rows[g_dialog.row]; row.choices[index].newWorld) {
CloseDialog();
if (g_inGame) {
StopGameThenStart(window, row, row.choices[index]);
} else {
StartNewWorld(window, row, row.choices[index].campaign, row.choices[index].difficulty);
}
} else {
g_rows[g_dialog.row].chosen = index;
GameCampaign() = static_cast<uint16_t>(row.choices[index].campaign);
CloseDialog();
Savegame_Action_original(window, nullptr, kSavegameActionLoad);
}
}
// The dialog takes every mouse event while it is open.
bool HandleDialogEvent(uint8_t* window, void* event) {
const game::MouseEvent kind = game::MouseEventKind(event);
if (kind == game::MouseEvent::None) {
return false;
}
for (size_t i = 0; i < DialogEntries(); ++i) {
game::ControlHandleEvent(g_dialog.buttons[i], event);
}
if (kind != game::MouseEvent::ButtonDown || game::EventField(event, game::kMouseButton) != game::kLeftButton) {
return true;
}
const int x = game::EventField(event, game::kMouseButtonX);
const int y = game::EventField(event, game::kMouseButtonY);
for (size_t i = 0; i < DialogEntries(); ++i) {
if (game::Inside(DialogButtonRect(i), x, y)) {
DialogChoose(window, i);
break;
}
}
return true;
}
// Our buttons next to the window's; true if one took the event.
bool HandleSideButtons(uint8_t* window, void* event) {
const game::MouseEvent kind = game::MouseEventKind(event);
if (kind == game::MouseEvent::None) {
return false;
}
for (SideButton& side : g_sideButtons) {
uint8_t* button = side.shown(window) ? SideButtonControl(window, side) : nullptr;
if (!button) {
continue;
}
game::ControlHandleEvent(button, event);
if (kind != game::MouseEvent::ButtonDown ||
game::EventField(event, game::kMouseButton) != game::kLeftButton ||
!game::Inside(SideButtonRect(window, side), game::EventField(event, game::kMouseButtonX),
game::EventField(event, game::kMouseButtonY))) {
continue;
}
const Row* row = EditableRow(window);
OpenDialog(window, side.opens, row ? static_cast<size_t>(row - g_rows.data()) : 0);
return true;
}
return false;
}
using WindowFn = int(__fastcall*)(void*, void*, void*);
struct WindowCall {
uint8_t* window;
void* argument; // device or event
WindowFn original;
};
int RenderBody(void* context) {
auto* call = static_cast<WindowCall*>(context);
MoveDeleteButton(call->window);
const int result = call->original(call->window, nullptr, call->argument);
if (!game::ControlShown(call->window)) {
if (g_dialog.window == call->window) {
CloseDialog();
}
return result;
}
if (g_dialog.window != call->window) {
for (SideButton& side : g_sideButtons) {
if (side.shown(call->window)) {
if (uint8_t* button = SideButtonControl(call->window, side)) {
game::ControlRender(button, call->argument);
}
}
}
return result;
}
if (g_dialog.kind != DialogKind::NewCharacter && g_dialog.row >= g_rows.size()) {
CloseDialog();
return result;
}
if (g_dialog.kind == DialogKind::Rename) {
// Enter and Esc arrive on the window thread; acted on here.
const int key = g_nameKey.exchange(0);
if (key == VK_RETURN) {
ConfirmRename(call->window);
} else if (key == VK_ESCAPE) {
CloseDialog();
}
if (g_dialog.window != call->window) {
return result;
}
}
RenderDialog(call->argument);
return result;
}
int EventBody(void* context) {
auto* call = static_cast<WindowCall*>(context);
MoveDeleteButton(call->window);
if (g_dialog.window == call->window) {
if (HandleDialogEvent(call->window, call->argument)) {
return 1;
}
} else if (game::ControlShown(call->window) && HandleSideButtons(call->window, call->argument)) {
return 1;
}
return call->original(call->window, nullptr, call->argument);
}
// cUI_Savegame::vf14 (0x71dd80, render, ret 4): framed like the export screen,
// the dialog on top.
int __fastcall Savegame_Render(void* self, void* edx, void* device);
RE_HOOK(0x0071dd80, Savegame_Render);
int __fastcall Savegame_Render(void* self, void*, void* device) {
WindowCall call{static_cast<uint8_t*>(self), device, Savegame_Render_original};
return mods::hud::RenderFramed(self, device, &RenderBody, &call);
}
// cUI_Savegame::vf10 (0x71d130, events, ret 4); centred, the dialog first.
int __fastcall Savegame_HandleEvent(void* self, void* edx, void* event);
RE_HOOK(0x0071d130, Savegame_HandleEvent);
int __fastcall Savegame_HandleEvent(void* self, void*, void* event) {
WindowCall call{static_cast<uint8_t*>(self), event, Savegame_HandleEvent_original};
return mods::hud::HandleCentred(event, &EventBody, &call);
}
// cEngine::Load (0x61b790, thiscall (path, quiet), ret 8): a listed row loads
// its character's world, then the shared hero.
uint32_t __fastcall Engine_Load(uint8_t* self, void* edx, const char* path, char quiet);
RE_HOOK(0x0061b790, Engine_Load);
uint32_t __fastcall Engine_Load(uint8_t* self, void* edx, const char* path, char quiet) {
const int row = RowFromPath(path);
if (row < 1) {
return Engine_Load_original(self, edx, path, quiet);
}
if (g_rows.empty()) {
RebuildList(); // a load without the load window
}
if (row > static_cast<int>(g_rows.size())) {
// GAMEnn.PAK in the work folder is only a preview.
re::Log("characters: row %d is not listed, not loading %s", row, path);
return 0;
}
const Row& target = g_rows[row - 1];
const Choice choice = target.choices[target.chosen];
CharacterFile character(target.file);
CharacterFile source(choice.autosave ? AutosavePath(target.file) : target.file);
std::optional<Bytes> world = character.Open() && source.Open()
? source.Read(WorldSection(choice.campaign, choice.difficulty))
: std::nullopt;
if (!world) {
re::Log("characters: %s has no world %d/%d", source.Path().c_str(), choice.campaign, choice.difficulty);
return 0;
}
std::optional<Bytes> hero = source.Read("hero");
const std::string work = WorkDir();
WriteFileReplacing(work + kWorkWorld, *world);
DeleteFileA((work + kWorkHero).c_str());
if (hero) {
WriteFileReplacing(work + kWorkHero, *hero);
}
re::Log("characters: loading %s, campaign %d, difficulty %d%s", target.file.c_str(), choice.campaign,
choice.difficulty, choice.autosave ? " (autosave)" : "");
const uint32_t result = Engine_Load_original(self, edx, kGameWorld, quiet);
if (!result) {
return result;
}
g_current.file = target.file;
if (hero) {
ApplySharedHero();
} else if (SaveHero(kGameHero)) {
// A migrated character whose newest hero was in this world: it becomes
// the shared hero now, and the other difficulties can be loaded.
if (std::optional<Bytes> saved = ReadFile(work + kWorkHero)) {
character.Set("hero", std::move(*saved));
character.meta.erase("heroFrom");
character.meta.erase("heroFromCampaign");
character.Save();
re::Log("characters: shared hero of %s taken from difficulty %d", target.file.c_str(),
choice.difficulty);
}
}
return result;
}
// cEngine::InitGame (0x60dc00, thiscall (int), ret 4): a new game, a new character.
void __fastcall Engine_InitGame(uint8_t* self, void* edx, int fromSave02);
RE_HOOK(0x0060dc00, Engine_InitGame);
void __fastcall Engine_InitGame(uint8_t* self, void* edx, int fromSave02) {
g_current = {};
Engine_InitGame_original(self, edx, fromSave02);
}
// LoadHeroFile (0x61d190, cdecl (path, 1, 0)), called by cEngine's
// constructor after InitGame for a new game with a hero file: for our new
// world it loads the character's shared hero (unpacked by StartNewWorld) with
// no portals found yet, and the game becomes that character's.
uint32_t __cdecl Game_LoadHeroFile(const char* path, uint32_t a, uint32_t b);
RE_HOOK(0x0061d190, Game_LoadHeroFile);
uint32_t __cdecl Game_LoadHeroFile(const char* path, uint32_t a, uint32_t b) {
if (g_newWorld.file.empty()) {
return Game_LoadHeroFile_original(path, a, b);
}
const NewWorld started = std::exchange(g_newWorld, {});
const uint32_t result = KeepingHeroPosition([&] { return Game_LoadHeroFile_original(kGameHero, a, b); });
if (void* hero = game::GetPlayerHero()) {
HeroField(hero, kHeroKnownPortals) = 0;
}
g_current.file = started.file;
re::Log("characters: new game of %s, campaign %d, difficulty %d, shared hero loaded (%u)",
started.file.c_str(), started.campaign, started.difficulty, result);
return result;
}
// cEngine::ReceiveEvent (0x618130, thiscall (event), ret 4): notes whether the
// save it starts is a story autosave (engine event 0xf, subtype 2, flag +0x18);
// quicksaves set the save flag here too.
uint32_t __fastcall Engine_ReceiveEvent(uint8_t* self, void* edx, uint32_t* event);
RE_HOOK(0x00618130, Engine_ReceiveEvent);
uint32_t __fastcall Engine_ReceiveEvent(uint8_t* self, void* edx, uint32_t* event) {
auto& flags = *reinterpret_cast<uint32_t*>(self + kEngineFlags);
const bool wasSaving = (flags & kEngineSaving) != 0;
const uint32_t result = Engine_ReceiveEvent_original(self, edx, event);
// Only the save event itself decides: another event may run in between
// (measured: an event with code 0 right after the save window's).
if (!wasSaving && (flags & kEngineSaving) && event[kEventCode] == kEngineEventSaveLoad &&
event[kEventSubtype] == kSaveLoadSave) {
g_autosavePending = event[kEventAutosave] != 0;
}
return result;
}
// cEngine::Save (0x619c90, thiscall (path), ret 4; render thread): every save
// goes into the current character, story autosaves into its autosave file.
uint32_t __fastcall Engine_Save(uint8_t* self, void* edx, const char* path);
RE_HOOK(0x00619c90, Engine_Save);
uint32_t __fastcall Engine_Save(uint8_t* self, void* edx, const char* path) {
EnsureFolders();
const bool autosave = g_autosavePending.exchange(false);
g_lastSaveAt = GetTickCount64();
const std::string work = WorkDir();
DeleteFileA((work + kWorkWorld).c_str());
const uint32_t result = Engine_Save_original(self, edx, kGameWorld);
std::optional<Bytes> world = ReadFile(work + kWorkWorld);
std::optional<SaveInfo> info = world ? ReadSaveInfo(*world) : std::nullopt;
if (!info || !SaveHero(kGameHero)) {
re::Log("characters: save failed (%s)", path ? path : "");
return result;
}
std::optional<Bytes> hero = ReadFile(work + kWorkHero);
if (!hero) {
re::Log("characters: saving the hero failed");
return result;
}
if (g_current.file.empty()) {
g_current.file = NewCharacterPath();
}
const bool exists = GetFileAttributesA(g_current.file.c_str()) != INVALID_FILE_ATTRIBUTES;
const bool toAutosave = autosave && exists;
CharacterFile character(toAutosave ? AutosavePath(g_current.file) : g_current.file);
if (!toAutosave) {
character.Open();
}
SetHeroMeta(character.meta, *info);
character.meta["lastDifficulty"] = info->difficulty;
character.meta["lastCampaign"] = info->campaign;
character.meta.erase("heroFrom");
character.meta.erase("heroFromCampaign");
if (toAutosave) {
character.meta["difficulty"] = info->difficulty;
character.meta["campaign"] = info->campaign;
}
character.Set("hero", std::move(*hero));
character.Set(WorldSection(info->campaign, info->difficulty), std::move(*world));
const bool saved = character.Save();
re::Log("characters: %s %s, campaign %d, difficulty %d%s", toAutosave ? "autosaved" : "saved",
character.Path().c_str(), info->campaign, info->difficulty, saved ? "" : " - WRITING FAILED");
return result;
}
// Direct save (request of 2026-10-20): the save key and the Esc menu's
// "Speichern" (was "Spielstände", which opened the save window) save at once,
// as the engine's quicksave does (cEngine::ReceiveEvent 0x618ebb): only while
// the UI manager lets a window open (0x75a690), thumbnail (0x613710 (1)),
// description at +0x6a, row +0x68 = 0, flag 0x40; the render thread then runs
// cEngine::Save, which writes into the current character (Engine_Save).
constexpr size_t kEngineSaveRow = 0x68; // u16
constexpr size_t kEngineSaveDescription = 0x6a; // char[], "QUICKSAVE" for quicksaves
constexpr char kSaveDescription[] = "SAVE"; // not shown: the list shows the characters
std::atomic<bool> g_saveRequested{false};
// Silent autosave (request of 2026-10-19: every 10 minutes, a setting later):
// the same save, into the autosave file (Engine_Save with g_autosavePending),
// sacredK.ini [characters] AutosaveMinutes (default 10, 0: off) after the
// last save of any kind. While a window is open or a
// save runs it waits for the next tick. A character never saved yet gets its
// file from it (the list shows character files only).
constexpr int kAutosaveMinutesDefault = 10;
uint64_t AutosaveIntervalMs() {
static const int minutes = re::ConfigInt("characters", "AutosaveMinutes", kAutosaveMinutesDefault);
return minutes > 0 ? uint64_t(minutes) * 60 * 1000 : UINT64_MAX;
}
void SaveOnTick(uint8_t* engine) {
const bool requested = g_saveRequested.exchange(false);
const bool autosaveDue = GetTickCount64() - g_lastSaveAt >= AutosaveIntervalMs();
if (!requested && !autosaveDue) {
return;
}
auto& flags = *reinterpret_cast<uint32_t*>(engine + kEngineFlags);
uint32_t* ui = game::UiManager();
if (!ui || !game::UiCanOpenWindow(ui) || (flags & kEngineSaving)) {
if (requested) {
re::Log("characters: no save now (a window is open or a save is running)");
}
return;
}
g_autosavePending = !requested;
g_lastSaveAt = GetTickCount64();
using CaptureFn = void(__fastcall*)(void*, void*, int);
reinterpret_cast<CaptureFn>(0x00613710)(engine, nullptr, 1);
std::memcpy(engine + kEngineSaveDescription, kSaveDescription, sizeof(kSaveDescription));
*reinterpret_cast<uint16_t*>(engine + kEngineSaveRow) = 0;
flags |= kEngineSaving;
}
// cUI_Manager::ReceiveEvent (vf00 0x757120, thiscall (event), ret 4) routes
// window commands by name (game/ui/windows.h). In a game:
// - showing the save window (the Esc menu's "Speichern") saves instead;
// - showing UI_CHARACTER (the Esc menu's "Laden", the former "Exportieren"
// line, whose command shows the export screen) opens the load window.
// The main menu's load window opens as before.
constexpr size_t kUiEventCode = 4;
constexpr size_t kUiEventSubtype = 8;
constexpr uint32_t kUiEventWindowSubtype = 5;
constexpr size_t kUiEventWindowName = 0x48; // char[0x20]
constexpr char kCharacterWindowName[] = "UI_CHARACTER";
thread_local bool g_openingLoadWindow = false; // our own Show of the save window passes
bool IsShowCommand(const uint8_t* event, const char* window, size_t size) {
return *reinterpret_cast<const uint32_t*>(event + kUiEventCode) ==
static_cast<uint32_t>(game::WindowCommand::Show) &&
*reinterpret_cast<const uint32_t*>(event + kUiEventSubtype) == kUiEventWindowSubtype &&
std::strncmp(reinterpret_cast<const char*>(event + kUiEventWindowName), window, size) == 0;
}
int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event);
RE_HOOK(0x00757120, UiManager_ReceiveEvent);
int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event) {
if (g_inGame && !g_openingLoadWindow) {
if (IsShowCommand(event, kSaveWindowName, sizeof(kSaveWindowName))) {
RequestSave();
return 1;
}
if (IsShowCommand(event, kCharacterWindowName, sizeof(kCharacterWindowName))) {
g_openingLoadWindow = true;
game::SendWindowCommand(static_cast<uint32_t*>(self), kSaveWindowName, game::WindowCommand::Show);
g_openingLoadWindow = false;
return 1;
}
}
return UiManager_ReceiveEvent_original(self, edx, event);
}
// cUI_Savegame::Show (vf24 0x71cfb0, thiscall (shown), ret 4) picks load mode
// (2) in the main menu and save mode (1) in a game (0x71cfd2). Saving is
// direct now: the window always opens for loading (SetMode 0x71c4f0,
// thiscall (mode), ret 4; it also disables the hidden "Speichern" button).
void __fastcall Savegame_Show(uint8_t* self, void* edx, char shown);
RE_HOOK(0x0071cfb0, Savegame_Show);
void __fastcall Savegame_Show(uint8_t* self, void* edx, char shown) {
Savegame_Show_original(self, edx, shown);
if (shown && *reinterpret_cast<uint32_t*>(self + kSavegameMode) == kSavegameModeSave) {
using SetModeFn = void(__fastcall*)(void*, void*, uint32_t);
reinterpret_cast<SetModeFn>(0x0071c4f0)(self, nullptr, kSavegameModeLoad);
}
}
// The Esc menu (cUI_EscMenu::BuildLines 0x6bb870): "Spielstände" (text id
// 0x41b pushed at 0x6bc1f7; the id is used by other windows too) reads
// "Speichern"; "Exportieren" (text id 0x419 pushed at 0x6bc272, its command
// shows UI_CHARACTER and hides the menu) reads "Laden" (request of
// 2026-10-20).
constexpr uint32_t kEscMenuSaveLineText = 0x006bc1f7;
constexpr uint32_t kSavegamesText = 0x41b;
constexpr uint32_t kEscMenuExportLineText = 0x006bc272;
constexpr uint32_t kExportText = 0x419;
// The load window's second line of a row showed the (preview) file name; it
// shows the level and the chosen difficulty instead. Row texts are set every
// frame (0x71d830): line 1 the save's name (+0x204), line 2 the file name (+4,
// call 0x71db2a to cUI_StaticText SetText 0x736f00, thiscall (text), ret 4),
// line 3 the date (+0x104).
constexpr uint32_t kRowFileNameCall = 0x0071db2a;
constexpr uint32_t kStaticSetText = 0x00736f00;
void __fastcall SetRowSecondLine(void* text, void* edx, const char* fileName) {
using SetTextFn = void(__fastcall*)(void*, void*, const char*);
const int row = RowFromPath(fileName);
if (row < 1 || row > static_cast<int>(g_rows.size())) {
reinterpret_cast<SetTextFn>(kStaticSetText)(text, edx, fileName);
return;
}
const Row& r = g_rows[row - 1];
const Choice& choice = r.choices[r.chosen];
const std::wstring line = L"Stufe " + std::to_wstring(r.level) + L" - " + ChoiceName(choice);
reinterpret_cast<SetTextFn>(kStaticSetText)(text, edx, Convert(line, CP_ACP).c_str());
}
// The character window (cUI_Character) without import (request of
// 2026-10-20; exported heroes are gone):
// - its Underworld checks read a campaign that is always Ancaria. With
// g_campaign 1 in single player it opened in mode 7, the import of an
// exported hero of level 25 or more (Show 0x70e3e8, SelectSlot 0x70c473,
// confirm text 0x70eeb2), its slots unlit below level 25 (render 0x70f4df,
// 0x70f57c); vanilla made no new Underworld heroes. Now it creates them
// with all classes, the game starting with the campaign set (ExecuteAction
// 0x70d0f0 has no campaign check there). No level requirement (decided
// 2026-10-20).
// - "Importieren" (button +0x3a0, caption UI_IMPORT in modes 0/1, the class
// selection) is not drawn there (0x70f67e, the modes 0/1 draw) and its
// click no longer switches to mode 7 (0x70eab4 mode 0, 0x70eac7 mode 1:
// jump to the handler's end 0x70ec0e as after SetMode).
const uint16_t kCharacterWindowCampaign = kCampaignAncaria;
constexpr uint32_t kCharacterWindowCampaignChecks[] = {0x0070c473, 0x0070d199, 0x0070e3e8, 0x0070eade,
0x0070eeb2, 0x0070f4df, 0x0070f57c, 0x0070f6aa};
constexpr re::CodeReplacement kNoImport[] = {
{0x0070f67e, {0x55, 0x8b, 0x11, 0xff, 0x52, 0x14}, {0x90, 0x90, 0x90, 0x90, 0x90, 0x90}, 6},
{0x0070eab4, {0x6a, 0x07, 0x8b, 0xce, 0xe8}, {0xe9, 0x55, 0x01, 0x00, 0x00}, 5},
{0x0070eac7, {0x6a, 0x07, 0x8b, 0xce, 0xe8}, {0xe9, 0x42, 0x01, 0x00, 0x00}, 5},
// The load window's "Speichern" (+0x820, disabled in load mode; saving is
// direct): its draw (0x71ddbc, 16 bytes) is jumped over. Löschen takes its
// place (MoveDeleteButton).
{0x0071ddbc, {0x8b, 0x86}, {0xeb, 0x0e}, 2},
};
void Startup() {
int failed = re::PatchAll(kCharacterWindowCampaignChecks, static_cast<uint32_t>(kCampaign),
reinterpret_cast<uint32_t>(&kCharacterWindowCampaign));
failed += re::ReplaceAll(kNoImport);
if (failed) {
re::Log("characters: %d character / load window patches failed", failed);
}
re::RedirectCall(kRowFileNameCall, kStaticSetText, reinterpret_cast<const void*>(&SetRowSecondLine));
static const uint32_t kSaveText = game::RegisterText(L"Speichern");
re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText);
static const uint32_t kLoadText = game::RegisterText(L"Laden");
re::PatchPushImm32(kEscMenuExportLineText, kExportText, kLoadText);
}
struct Init {
Init() {
userfolders::SetGameSaveSubfolder(kGameSaveSubfolder);
game::OnEngineTick(&SaveOnTick);
game::OnGameStart([] {
g_inGame = true;
g_lastSaveAt = GetTickCount64(); // the autosave counts from the start
});
game::OnGameEnd([] {
g_inGame = false;
StartPendingWorld();
});
}
} init;
} // namespace
void RequestSave() {
g_saveRequested = true;
}
RE_STARTUP(CharactersStartup, Startup);
} // namespace mods::characters