Files
sacred_k/src/mods/characters.cpp
T
khallaandClaude Opus 5.5 34a119433a Characters: 16:9 "Spielen" screen, autosave interval option
- Shared screen kit (mods/screen, docs/screens.md): a 1365x768 canvas
  centred in the render width on our own dark background, the game's
  frames and buttons; the main menu renders full width while it is shown.
- The load window (cUI_Savegame) is replaced by the Spielen screen:
  character list, hero panel, campaign x difficulty grid, save/autosave
  choice, rename/delete/reset/new character dialogs.
- Migrated characters without a shared hero load their hero's world
  first, then start the new world.
- Options, Allgemein: autosave interval (Aus/5/10/15/30 minutes).

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

1980 lines
83 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) is our 16:9 "Spielen" screen
// (mods/screen.h): the characters, the selected one, its worlds as a grid
// of campaigns and difficulties. The window still lists GAMEnn.PAK there:
// before it scans, one small preview per character is written; row nn of
// the game is g_rows[nn - 1], and the window's entries carry the load;
// - 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 <array>
#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/screen.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 kSavegameModeSave = 1;
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
uint64_t savedAt = 0; // FILETIME of the save (local time, as the game writes it)
};
// 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
uint32_t heroClass = 0;
bool hasHero = false; // the shared hero is in the file (migrated ones get it at their first load)
std::array<bool, kDifficultyCount> storyCompleted{};
int lastCampaign = kCampaignAncaria; // the world played last
int lastDifficulty = 0;
// Migrated, not loaded since: the world holding the hero (-1: none).
int heroFromCampaign = kCampaignAncaria;
int heroFrom = -1;
};
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.heroFrom = hasHero ? -1 : character.meta.value("heroFrom", -1);
row.heroFromCampaign = character.meta.value("heroFromCampaign", kCampaignAncaria);
row.name = FromUtf8(character.meta.value("name", std::string()));
row.level = character.meta.value("level", 0u);
row.heroClass = character.meta.value("class", 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);
}
}
row.storyCompleted[d] = hero && hero->storyCompleted[d];
if (hero && hero->storyCompleted[d]) {
highest = std::max(highest, std::min(d + 1, kDifficultyCount - 1));
}
}
// New games need the shared hero: a migrated character gets it by
// loading the world holding it first (Play). In a game the running
// one stops first (StopGameThenStart); multiplayer games are not ours.
const bool canStart = (hasHero || row.heroFrom >= 0) && !*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, false, false, savedAt});
} else if (!savedAt && d <= highest && canStart) {
row.choices.push_back({c, d, false, true});
}
if (const uint64_t autosavedAt = SavedAt(autosave, c, d);
c == autosavedCampaign && d == autosaved && autosavedAt > savedAt) {
row.choices.push_back({c, d, true, false, autosavedAt});
}
}
}
// 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);
row.lastCampaign = lastCampaign;
row.lastDifficulty = last;
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 Spielen screen ------------------------------------------------------------
//
// The load window (cUI_Savegame) is drawn as a 16:9 screen of ours (request of
// 2026-10-20, mods/screen.h, docs/characters.md "Spielen screen"): characters
// on the left, the selected one in the middle, its worlds as a grid of
// campaigns and difficulties on the right, the buttons below. The window's
// own drawing and mouse handling are not used; it still lists the previews
// (its entries name the rows' files) and does the loading: "Spielen" points
// the load at the chosen row (g_loadRow, read by Engine_Load) and runs the
// window's load action (4), with its confirmation in a game. Keys still go to
// the window (Esc closes it).
constexpr int kSavegameActionLoad = 4; // the load button
constexpr int kSavegameActionConfirmDelete = 7;
constexpr int kSavegameActionRescan = 10; // lists the save folder again
constexpr int kSavegameActionDefault = 0xb; // double click / Enter: save or load by the mode
constexpr int kSavegameActionBack = 0xc; // "Zurück": hides the window, the main menu outside a game
// 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);
// The row the next load is for (index into g_rows), set by Play.
std::atomic<int> g_loadRow{-1};
// Layout on the canvas (1365x768): three panels side by side (sizes in the
// frames' 64 steps, screen::kFrameStep), centred, a row of buttons below.
constexpr int kPanelTop = 96; // the title above the frames' ornament
constexpr int kPanelHeight = 8 * screen::kFrameStep;
constexpr int kListWidth = 5 * screen::kFrameStep;
constexpr int kHeroWidth = 6 * screen::kFrameStep;
constexpr int kWorldsWidth = 6 * screen::kFrameStep;
constexpr int kListX = (screen::kWidth - kListWidth - kHeroWidth - kWorldsWidth - 2 * screen::kPanelGap) / 2;
constexpr game::UiRect kListRect = {kListX, kPanelTop, kListWidth, kPanelHeight};
constexpr game::UiRect kHeroRect = {kListRect.x + kListWidth + screen::kPanelGap, kPanelTop, kHeroWidth,
kPanelHeight};
constexpr game::UiRect kWorldsRect = {kHeroRect.x + kHeroWidth + screen::kPanelGap, kPanelTop, kWorldsWidth,
kPanelHeight};
constexpr game::UiRect kTitleRect = {0, 24, screen::kWidth, 32};
constexpr int kListRowHeight = 52;
constexpr int kListRowGap = 4;
constexpr int kLineHeight = 24;
constexpr int kGridLabelWidth = 100;
constexpr int kGridGap = 6;
constexpr int kGridHeaderHeight = 28;
constexpr int kGridRowHeight = 54;
constexpr int kSourceHeight = 28;
constexpr int kButtonWidth = 150;
constexpr int kButtonY = kPanelTop + kPanelHeight + screen::kFrameOrnament + 28;
constexpr int kButtonPitch = kButtonWidth + 2 * screen::kButtonOrnament + 10;
constexpr uint32_t kCellColour = 0x50302418;
constexpr uint32_t kCellLockedColour = 0x30000000;
// What the screen shows and where the mouse is (window thread).
struct ScreenState {
std::string file; // the selected character (kept across rescans)
int campaign = kCampaignAncaria; // the selected world
int difficulty = -1; // -1: none
bool fromAutosave = false; // load the world's newer autosave
int hoverRow = -1; // list position
int hoverCell = -1; // campaign * kDifficultyCount + difficulty
int hoverSource = -1; // 0 the save, 1 the autosave
screen::DoubleClick rowClick;
screen::DoubleClick cellClick;
} g_screen;
// The list shows the characters by their last save, newest first.
std::vector<size_t> ListOrder() {
std::vector<size_t> order(g_rows.size());
for (size_t i = 0; i < order.size(); ++i) {
order[i] = i;
}
auto newest = [](const Row& row) {
uint64_t at = 0;
for (const Choice& choice : row.choices) {
at = std::max(at, choice.savedAt);
}
return at;
};
std::stable_sort(order.begin(), order.end(),
[&](size_t a, size_t b) { return newest(g_rows[a]) > newest(g_rows[b]); });
return order;
}
Row* SelectedCharacter() {
for (Row& row : g_rows) {
if (row.file == g_screen.file) {
return &row;
}
}
return nullptr;
}
const Choice* FindChoice(const Row& row, int campaign, int difficulty, bool autosave) {
for (const Choice& choice : row.choices) {
if (choice.campaign == campaign && choice.difficulty == difficulty && choice.autosave == autosave) {
return &choice;
}
}
return nullptr;
}
// The world's save (or "(neu)") and its newer autosave, if any.
struct Cell {
const Choice* world = nullptr; // a save or a new world
const Choice* autosave = nullptr; // newer than the save
};
Cell CellOf(const Row& row, int campaign, int difficulty) {
return {FindChoice(row, campaign, difficulty, false), FindChoice(row, campaign, difficulty, true)};
}
bool Selectable(const Cell& cell) {
return cell.world || cell.autosave;
}
void SelectCharacter(const Row& row) {
g_screen.file = row.file;
g_screen.campaign = row.lastCampaign;
g_screen.difficulty = row.lastDifficulty;
if (!Selectable(CellOf(row, g_screen.campaign, g_screen.difficulty))) {
g_screen.difficulty = -1;
for (const Choice& choice : row.choices) {
if (!choice.newWorld) {
g_screen.campaign = choice.campaign;
g_screen.difficulty = choice.difficulty;
break;
}
}
}
const Cell cell = g_screen.difficulty >= 0 ? CellOf(row, g_screen.campaign, g_screen.difficulty) : Cell{};
g_screen.fromAutosave = cell.autosave != nullptr;
}
// After a rescan: the same character if it is still there, else the first.
void KeepSelection() {
if (Row* row = SelectedCharacter()) {
const Cell cell = g_screen.difficulty >= 0 ? CellOf(*row, g_screen.campaign, g_screen.difficulty) : Cell{};
if (!Selectable(cell)) {
SelectCharacter(*row);
}
return;
}
const std::vector<size_t> order = ListOrder();
if (order.empty()) {
g_screen.file.clear();
g_screen.difficulty = -1;
} else {
SelectCharacter(g_rows[order.front()]);
}
}
// The chosen world of the selected character: what "Spielen" starts.
const Choice* ChosenWorld(const Row& row) {
if (g_screen.difficulty < 0) {
return nullptr;
}
const Cell cell = CellOf(row, g_screen.campaign, g_screen.difficulty);
return g_screen.fromAutosave && cell.autosave ? cell.autosave : cell.world ? cell.world : cell.autosave;
}
bool IsPlayed(const Row& row) {
return g_inGame && row.file == g_current.file;
}
std::wstring FormatTime(uint64_t fileTime) {
FILETIME ft = {static_cast<DWORD>(fileTime), static_cast<DWORD>(fileTime >> 32)};
SYSTEMTIME t;
if (!fileTime || !FileTimeToSystemTime(&ft, &t)) {
return {};
}
wchar_t text[32];
wsprintfW(text, L"%02u.%02u.%02u %02u:%02u", t.wDay, t.wMonth, t.wYear % 100, t.wHour, t.wMinute);
return text;
}
// --- dialogs -------------------------------------------------------------------------
// Over the screen, centred on the canvas: rename (a name field: every key
// goes to it while open, input key capture, window thread; Enter and Esc act
// on the next frame), the campaign of a new character, and the confirmations
// of a reset and a delete. The last button is "Abbrechen".
constexpr int kDialogWidth = 360;
constexpr int kDialogTitleHeight = 48;
constexpr int kDialogFieldHeight = 32; // rename: the name field
constexpr int kDialogFieldGap = 16;
constexpr int kDialogFieldPadding = 8;
constexpr int kDialogButtonWidth = 240;
constexpr int kDialogPitch = 44;
constexpr int kDialogBottom = 20;
constexpr uint32_t kDialogShade = 0x90000000;
constexpr uint32_t kDialogBackColour = 0xe8000000;
constexpr uint32_t kDialogEdgeColour = 0xff6e5a32;
constexpr uint32_t kDialogFieldColour = 0xff201810;
constexpr uint32_t kDialogTextColour = 0xffffffff;
enum class DialogKind { Rename, NewCharacter, ConfirmReset, ConfirmDelete };
struct Dialog {
uint8_t* window = nullptr; // open while set
DialogKind kind = DialogKind::Rename;
std::string file; // the character
std::wstring name; // its name
Choice target; // ConfirmReset: the world
std::wstring title;
std::vector<std::wstring> entries; // then "Abbrechen"
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;
}
int16_t DialogButtonHeight(const uint8_t* window) {
return screen::ButtonHeight(window + kSavegameLoadButton);
}
game::UiRect DialogPanel() {
const int height =
kDialogTitleHeight + DialogFieldSpace() + static_cast<int>(DialogEntries()) * kDialogPitch + kDialogBottom;
return {(screen::kWidth - kDialogWidth) / 2, (screen::kHeight - 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(const uint8_t* window, 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), DialogButtonHeight(window)};
}
std::wstring DialogText(size_t index) {
return index < g_dialog.entries.size() ? g_dialog.entries[index] : L"Abbrechen";
}
void FillDialog() {
switch (g_dialog.kind) {
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";
g_dialog.entries.clear();
for (int c = 0; c < kCampaignCount; ++c) {
g_dialog.entries.push_back(CampaignName(c));
}
break;
case DialogKind::ConfirmReset:
g_dialog.title = ChoiceName(g_dialog.target) + L" zurücksetzen?";
g_dialog.entries = {L"Zurücksetzen"};
break;
case DialogKind::ConfirmDelete:
g_dialog.title = g_dialog.name + L" löschen?";
g_dialog.entries = {L"Löschen"};
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, const Row* row) {
g_dialog.window = window;
g_dialog.kind = kind;
g_dialog.file = row ? row->file : std::string();
g_dialog.name = row ? row->name : std::wstring();
FillDialog();
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(window, 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(window, i);
game::SetTextOverride(g_dialog.captions[i], DialogText(i));
}
if (kind == DialogKind::Rename) {
{
std::lock_guard guard(g_nameLock);
g_name = g_dialog.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 std::string& file, const std::wstring& name) {
bool done = true;
for (const std::string& path : {file, AutosavePath(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", file.c_str(), Convert(name, CP_UTF8).c_str(),
done ? "" : " - FAILED in part");
return done;
}
void Rescan(uint8_t* window) {
Savegame_Action_original(window, nullptr, kSavegameActionRescan);
KeepSelection();
}
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 Dialog dialog = g_dialog;
CloseDialog();
if (name != dialog.name) {
RenameCharacter(dialog.file, name);
Rescan(window);
}
}
// The character goes, with its autosave; kept in characters\deleted.
void DeleteCharacter(const std::string& file) {
const std::string deleted = CharactersDir() + "deleted\\";
CreateDirectoryA(deleted.c_str(), nullptr);
for (const std::string& path : {file, AutosavePath(file)}) {
const std::string name = path.substr(path.find_last_of('\\') + 1);
MoveFileExA(path.c_str(), (deleted + name).c_str(), MOVEFILE_REPLACE_EXISTING);
}
re::Log("characters: deleted %s (moved to %s)", file.c_str(), deleted.c_str());
}
void RenderDialog(uint8_t* window, void* device) {
screen::Fill(device, {0, 0, screen::kWidth, screen::kHeight}, kDialogShade);
const game::UiRect panel = DialogPanel();
screen::Fill(device, screen::Inset(panel, -2), kDialogEdgeColour);
screen::Fill(device, panel, kDialogBackColour);
screen::Text(device, {panel.x, panel.y, panel.w, static_cast<int16_t>(kDialogTitleHeight)}, g_dialog.title,
screen::kHeadingColour, game::kTextCenter);
if (g_dialog.kind == DialogKind::Rename) {
const game::UiRect field = DialogField();
screen::Fill(device, screen::Inset(field, -1), kDialogEdgeColour);
screen::Fill(device, field, kDialogFieldColour);
std::wstring text;
{
std::lock_guard guard(g_nameLock);
text = g_name + L"_";
}
screen::Text(device, screen::Inset(field, kDialogFieldPadding), text, kDialogTextColour);
}
for (size_t i = 0; i < DialogEntries(); ++i) {
*reinterpret_cast<game::UiRect*>(g_dialog.buttons[i] + game::kControlRect) = DialogButtonRect(window, i);
game::ControlRender(g_dialog.buttons[i], device);
}
}
// --- starting and loading -------------------------------------------------------
// 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 std::string& file, int campaign, int difficulty) {
CharacterFile character(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", file.c_str());
return;
}
g_newWorld = {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", 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;
std::string file;
Choice world;
} g_pendingStart;
void StopGameThenStart(uint8_t* window, const std::string& file, const Choice& world) {
g_pendingStart = {window, file, world};
re::Log("characters: stopping the game for a new world of %s", 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.file, pending.world.campaign, pending.world.difficulty);
}
}
// A new world for a migrated character without the shared hero yet: the
// world holding the hero loads first (Engine_Load makes the shared hero),
// then, on the next engine tick, the game stops and the new world starts.
struct StartAfterLoad {
uint8_t* window = nullptr;
std::string file;
Choice world;
bool loaded = false; // the hero's world is loaded
} g_startAfterLoad;
void StartAfterLoadOnTick() {
if (g_startAfterLoad.loaded) {
const StartAfterLoad start = std::exchange(g_startAfterLoad, {});
StopGameThenStart(start.window, start.file, start.world);
}
}
// 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 std::string& file, const Choice& world) {
CharacterFile character(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(), file.c_str());
return false;
}
const std::string deleted = CharactersDir() + "deleted\\";
CreateDirectoryA(deleted.c_str(), nullptr);
const std::string name = file.substr(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(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(), file.c_str(), deleted.c_str(),
saved ? "" : " - WRITING FAILED");
return saved;
}
// "Spielen": the chosen world of the selected character. A saved world loads
// through the window's load action (its entry selection only has to exist:
// Engine_Load takes the row from g_loadRow); a new one starts a game.
void Play(uint8_t* window) {
Row* row = SelectedCharacter();
const Choice* world = row ? ChosenWorld(*row) : nullptr;
if (!world) {
return;
}
if (world->newWorld && !row->hasHero) {
const Choice* heroWorld = FindChoice(*row, row->heroFromCampaign, row->heroFrom, false);
if (!heroWorld || heroWorld->newWorld) {
re::Log("characters: %s has no world with its hero, no new game", row->file.c_str());
return;
}
g_startAfterLoad = {window, row->file, *world};
re::Log("characters: loading the hero's world of %s first, then the new world", row->file.c_str());
world = heroWorld;
} else if (world->newWorld) {
if (g_inGame) {
StopGameThenStart(window, row->file, *world);
} else {
StartNewWorld(window, row->file, world->campaign, world->difficulty);
}
return;
}
const uint8_t* begin = *reinterpret_cast<uint8_t**>(window + kSavegameEntries);
const uint8_t* end = *reinterpret_cast<uint8_t**>(window + kSavegameEntriesEnd);
if (begin == end) {
re::Log("characters: the load window lists nothing, cannot load");
return;
}
row->chosen = static_cast<size_t>(world - row->choices.data());
g_loadRow = static_cast<int>(row - g_rows.data());
// As the main menu's entries do before a game starts; cEngine::Load sets
// it from the save again.
GameCampaign() = static_cast<uint16_t>(world->campaign);
*reinterpret_cast<int16_t*>(window + kSavegameSelected) = 1;
Savegame_Action_original(window, nullptr, kSavegameActionLoad);
}
// cUI_Savegame's actions (0x71c520, thiscall (action), ret 4): loading (the
// load button, Enter) is "Spielen"; deleting is the screen's own.
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)) {
Play(self);
return;
}
if (action == kSavegameActionConfirmDelete || action == kSavegameActionDelete) {
return;
}
Savegame_Action_original(self, edx, action);
}
// --- drawing and clicks ------------------------------------------------------------
screen::Panel g_listPanel("UI_FRAME_CHAR_LIST");
screen::Panel g_heroPanel("UI_FRAME_CHAR_HERO");
screen::Panel g_worldsPanel("UI_FRAME_CHAR_WORLDS");
screen::Button g_playButton("UI_BTN_CHAR_PLAY", L"Spielen");
screen::Button g_backButton("UI_BTN_CHAR_BACK", L"Zurück");
screen::Button g_newButton("UI_BTN_CHAR_NEW", L"Neuer Charakter");
screen::Button g_renameButton("UI_BTN_CHAR_RENAME", L"Umbenennen");
screen::Button g_deleteButton("UI_BTN_CHAR_DELETE", L"Löschen");
screen::Button g_resetButton("UI_BTN_CHAR_RESET", L"Welt zurücksetzen");
game::UiRect ListContent() {
return screen::Inset(kListRect, screen::kPanelPadding);
}
// List position `index` (0 the first shown); the heading takes the top line.
game::UiRect ListRowRect(int index) {
const game::UiRect content = ListContent();
return {content.x, content.y + kLineHeight + 8 + index * (kListRowHeight + kListRowGap), content.w,
kListRowHeight};
}
int ListRowsShown() {
return (ListContent().h - kLineHeight - 8) / (kListRowHeight + kListRowGap);
}
game::UiRect WorldsContent() {
return screen::Inset(kWorldsRect, screen::kPanelPadding);
}
int CellWidth() {
return (WorldsContent().w - kGridLabelWidth - (kCampaignCount - 1) * kGridGap) / kCampaignCount;
}
int GridTop() {
return WorldsContent().y + kLineHeight + 8;
}
game::UiRect CellRect(int campaign, int difficulty) {
const game::UiRect content = WorldsContent();
return {content.x + kGridLabelWidth + campaign * (CellWidth() + kGridGap),
GridTop() + kGridHeaderHeight + difficulty * (kGridRowHeight + kGridGap), static_cast<int16_t>(CellWidth()),
kGridRowHeight};
}
// Below the grid, for a world with a newer autosave: which one to load.
game::UiRect SourceRect(int source) {
const game::UiRect content = WorldsContent();
const int top = GridTop() + kGridHeaderHeight + kDifficultyCount * (kGridRowHeight + kGridGap) + 12;
const int width = (content.w - kGridGap) / 2;
return {content.x + source * (width + kGridGap), top + kLineHeight, static_cast<int16_t>(width), kSourceHeight};
}
game::UiRect ButtonRect(int x, const uint8_t* window) {
return {x, kButtonY, kButtonWidth, screen::ButtonHeight(window + kSavegameLoadButton)};
}
// Bottom row: Neuer Charakter, Umbenennen, Löschen from the left; Zurück,
// Spielen from the right.
int LeftButtonX(int slot) {
return kListRect.x + screen::kButtonOrnament + slot * kButtonPitch;
}
int RightButtonX(int slot) {
return kWorldsRect.x + kWorldsRect.w - screen::kButtonOrnament - kButtonWidth - slot * kButtonPitch;
}
game::UiRect ResetButtonRect(const uint8_t* window) {
const game::UiRect content = WorldsContent();
const int16_t height = screen::ButtonHeight(window + kSavegameLoadButton);
return {content.x + (content.w - 200) / 2, content.y + content.h - height - 4, 200, height};
}
bool CanReset(const Row& row) {
const Cell cell = g_screen.difficulty >= 0 ? CellOf(row, g_screen.campaign, g_screen.difficulty) : Cell{};
return row.hasHero && !IsPlayed(row) && cell.world && !cell.world->newWorld;
}
bool CanCreate() {
return !g_inGame && !*reinterpret_cast<const uint8_t*>(kMultiplayer);
}
void DrawList(void* device) {
const game::UiRect content = g_listPanel.Draw(device, kListRect);
screen::Text(device, {content.x, content.y, content.w, kLineHeight}, L"Charaktere", screen::kHeadingColour);
const std::vector<size_t> order = ListOrder();
for (int i = 0; i < static_cast<int>(order.size()) && i < ListRowsShown(); ++i) {
const Row& row = g_rows[order[i]];
const game::UiRect rect = ListRowRect(i);
const bool selected = row.file == g_screen.file;
screen::Fill(device, rect,
selected ? screen::kRowSelectedColour : g_screen.hoverRow == i ? screen::kRowHoverColour
: screen::kRowColour);
const game::UiRect text = screen::Inset(rect, 6);
screen::Text(device, {text.x, text.y, text.w, kLineHeight - 4}, row.name, screen::kTextColour);
screen::Text(device, {text.x, text.y + kLineHeight - 4, text.w, kLineHeight - 4},
ClassName(row.heroClass) + L" · Stufe " + std::to_wstring(row.level), screen::kDimColour);
if (IsPlayed(row)) {
screen::Text(device, {text.x, text.y, text.w, kLineHeight - 4}, L"im Spiel", screen::kDimColour,
game::kTextRight);
}
}
if (order.empty()) {
screen::Text(device, ListRowRect(0), L"Noch keine Charaktere", screen::kDimColour, game::kTextCenter);
}
}
void DrawHero(void* device) {
const game::UiRect content = g_heroPanel.Draw(device, kHeroRect);
const Row* row = SelectedCharacter();
if (!row) {
return;
}
int y = content.y;
auto line = [&](const std::wstring& text, uint32_t colour) {
screen::Text(device, {content.x, y, content.w, kLineHeight}, text, colour, game::kTextCenter);
y += kLineHeight;
};
line(row->name, screen::kHeadingColour);
line(ClassName(row->heroClass) + L" · Stufe " + std::to_wstring(row->level), screen::kTextColour);
y += kLineHeight;
if (const Choice* last = FindChoice(*row, row->lastCampaign, row->lastDifficulty, false); last && !last->newWorld) {
line(L"Zuletzt gespielt", screen::kDimColour);
line(CampaignName(last->campaign) + L" · " + DifficultyName(last->difficulty), screen::kTextColour);
line(FormatTime(last->savedAt), screen::kDimColour);
}
}
void DrawCell(void* device, const Row& row, int campaign, int difficulty) {
const game::UiRect rect = CellRect(campaign, difficulty);
const Cell cell = CellOf(row, campaign, difficulty);
const int index = campaign * kDifficultyCount + difficulty;
const bool selected = campaign == g_screen.campaign && difficulty == g_screen.difficulty;
if (!Selectable(cell)) {
screen::Fill(device, rect, kCellLockedColour);
screen::Text(device, rect, L"–", screen::kDimColour, game::kTextCenter);
return;
}
screen::Fill(device, rect,
selected ? screen::kRowSelectedColour : g_screen.hoverCell == index ? screen::kRowHoverColour
: kCellColour);
const game::UiRect top = {rect.x, rect.y + 4, rect.w, kLineHeight - 2};
const game::UiRect bottom = {rect.x, rect.y + kLineHeight, rect.w, kLineHeight - 2};
if (cell.world && cell.world->newWorld) {
screen::Text(device, rect, L"Neu", screen::kTextColour, game::kTextCenter);
return;
}
const uint64_t at = cell.world ? cell.world->savedAt : cell.autosave->savedAt;
const bool secondLine = (cell.autosave && cell.world) || row.storyCompleted[difficulty];
screen::Text(device, secondLine ? top : rect, FormatTime(at), screen::kTextColour, game::kTextCenter);
if (cell.autosave && cell.world) {
screen::Text(device, bottom, L"+ Autosave", screen::kHeadingColour, game::kTextCenter);
} else if (row.storyCompleted[difficulty]) {
screen::Text(device, bottom, L"Story abgeschlossen", screen::kDimColour, game::kTextCenter);
}
}
void DrawWorlds(void* device, const uint8_t* window) {
const game::UiRect content = g_worldsPanel.Draw(device, kWorldsRect);
screen::Text(device, {content.x, content.y, content.w, kLineHeight}, L"Welten", screen::kHeadingColour);
const Row* row = SelectedCharacter();
if (!row) {
return;
}
for (int c = 0; c < kCampaignCount; ++c) {
const game::UiRect cell = CellRect(c, 0);
screen::Text(device, {cell.x, GridTop(), cell.w, kGridHeaderHeight}, CampaignName(c), screen::kTextColour,
game::kTextCenter);
}
for (int d = 0; d < kDifficultyCount; ++d) {
const game::UiRect cell = CellRect(0, d);
screen::Text(device, {content.x, cell.y, kGridLabelWidth, cell.h}, DifficultyName(d), screen::kTextColour);
for (int c = 0; c < kCampaignCount; ++c) {
DrawCell(device, *row, c, d);
}
}
const Cell chosen = g_screen.difficulty >= 0 ? CellOf(*row, g_screen.campaign, g_screen.difficulty) : Cell{};
if (chosen.world && chosen.autosave) {
const game::UiRect first = SourceRect(0);
screen::Text(device, {first.x, first.y - kLineHeight, content.w, kLineHeight}, L"Laden von:",
screen::kDimColour);
const Choice* sources[2] = {chosen.world, chosen.autosave};
const wchar_t* names[2] = {L"Spielstand ", L"Autosave "};
for (int s = 0; s < 2; ++s) {
const bool on = (s == 1) == g_screen.fromAutosave;
screen::Fill(device, SourceRect(s),
on ? screen::kRowSelectedColour : g_screen.hoverSource == s ? screen::kRowHoverColour
: kCellColour);
screen::Text(device, SourceRect(s), names[s] + FormatTime(sources[s]->savedAt), screen::kTextColour,
game::kTextCenter);
}
}
if (CanReset(*row)) {
g_resetButton.Draw(device, window + kSavegameLoadButton, ResetButtonRect(window));
}
}
void DrawButtons(void* device, const uint8_t* window) {
const uint8_t* model = window + kSavegameLoadButton;
const Row* row = SelectedCharacter();
int left = 0;
if (CanCreate()) {
g_newButton.Draw(device, model, ButtonRect(LeftButtonX(left++), window));
}
if (row && !IsPlayed(*row)) {
g_renameButton.Draw(device, model, ButtonRect(LeftButtonX(left++), window));
g_deleteButton.Draw(device, model, ButtonRect(LeftButtonX(left++), window));
}
g_backButton.Draw(device, model, ButtonRect(RightButtonX(1), window));
if (row && ChosenWorld(*row)) {
g_playButton.Draw(device, model, ButtonRect(RightButtonX(0), window));
}
}
struct WindowCall {
uint8_t* window;
void* argument; // device or event
};
int RenderBody(void* context) {
auto* call = static_cast<WindowCall*>(context);
void* device = call->argument;
if (g_dialog.window == call->window && 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 (!SelectedCharacter()) {
KeepSelection();
}
screen::Text(device, kTitleRect, L"Spielen", screen::kHeadingColour, game::kTextCenter);
DrawList(device);
DrawHero(device);
DrawWorlds(device, call->window);
DrawButtons(device, call->window);
if (g_dialog.window == call->window) {
RenderDialog(call->window, device);
}
return 1;
}
// The dialog takes every mouse event while it is open.
void HandleDialogEvent(uint8_t* window, void* event) {
for (size_t i = 0; i < DialogEntries(); ++i) {
game::ControlHandleEvent(g_dialog.buttons[i], event);
}
if (game::MouseEventKind(event) != game::MouseEvent::ButtonDown ||
game::EventField(event, game::kMouseButton) != game::kLeftButton) {
return;
}
const int x = game::EventField(event, game::kMouseButtonX);
const int y = game::EventField(event, game::kMouseButtonY);
size_t index = 0;
while (index < DialogEntries() && !game::Inside(DialogButtonRect(window, index), x, y)) {
++index;
}
if (index == DialogEntries()) {
return;
}
const Dialog dialog = g_dialog;
if (index + 1 == DialogEntries()) {
CloseDialog();
} else if (dialog.kind == DialogKind::Rename) {
ConfirmRename(window);
} else if (dialog.kind == DialogKind::NewCharacter) {
CloseDialog();
OpenCharacterCreation(static_cast<int>(index));
} else if (dialog.kind == DialogKind::ConfirmReset) {
CloseDialog();
ResetWorld(dialog.file, dialog.target);
Rescan(window);
} else if (dialog.kind == DialogKind::ConfirmDelete) {
CloseDialog();
DeleteCharacter(dialog.file);
g_screen.file.clear();
Rescan(window);
}
}
void HandleMove(int x, int y) {
g_screen.hoverRow = -1;
g_screen.hoverCell = -1;
g_screen.hoverSource = -1;
for (int i = 0; i < ListRowsShown(); ++i) {
if (game::Inside(ListRowRect(i), x, y)) {
g_screen.hoverRow = i;
}
}
for (int c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) {
if (game::Inside(CellRect(c, d), x, y)) {
g_screen.hoverCell = c * kDifficultyCount + d;
}
}
}
for (int s = 0; s < 2; ++s) {
if (game::Inside(SourceRect(s), x, y)) {
g_screen.hoverSource = s;
}
}
}
void HandleClick(uint8_t* window, int x, int y) {
const std::vector<size_t> order = ListOrder();
for (int i = 0; i < static_cast<int>(order.size()) && i < ListRowsShown(); ++i) {
if (game::Inside(ListRowRect(i), x, y)) {
const Row& row = g_rows[order[i]];
if (row.file != g_screen.file) {
SelectCharacter(row);
}
if (g_screen.rowClick.Click(i)) {
Play(window);
}
return;
}
}
Row* row = SelectedCharacter();
if (!row) {
return;
}
for (int c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) {
const Cell cell = CellOf(*row, c, d);
if (!game::Inside(CellRect(c, d), x, y) || !Selectable(cell)) {
continue;
}
if (c != g_screen.campaign || d != g_screen.difficulty) {
g_screen.campaign = c;
g_screen.difficulty = d;
g_screen.fromAutosave = cell.autosave != nullptr;
}
if (g_screen.cellClick.Click(c * kDifficultyCount + d)) {
Play(window);
}
return;
}
}
const Cell chosen = g_screen.difficulty >= 0 ? CellOf(*row, g_screen.campaign, g_screen.difficulty) : Cell{};
if (chosen.world && chosen.autosave) {
for (int s = 0; s < 2; ++s) {
if (game::Inside(SourceRect(s), x, y)) {
g_screen.fromAutosave = s == 1;
}
}
}
}
int EventBody(void* context) {
auto* call = static_cast<WindowCall*>(context);
uint8_t* window = call->window;
void* event = call->argument;
if (g_dialog.window == window) {
HandleDialogEvent(window, event);
return 1;
}
Row* row = SelectedCharacter();
if (g_playButton.Clicked(event)) {
Play(window);
} else if (g_backButton.Clicked(event)) {
Savegame_Action_original(window, nullptr, kSavegameActionBack);
} else if (g_newButton.Clicked(event)) {
OpenDialog(window, DialogKind::NewCharacter, nullptr);
} else if (g_renameButton.Clicked(event) && row) {
OpenDialog(window, DialogKind::Rename, row);
} else if (g_deleteButton.Clicked(event) && row) {
OpenDialog(window, DialogKind::ConfirmDelete, row);
} else if (g_resetButton.Clicked(event) && row) {
if (const Choice* world = ChosenWorld(*row)) {
g_dialog.target = *world;
OpenDialog(window, DialogKind::ConfirmReset, row);
}
} else if (game::MouseEventKind(event) == game::MouseEvent::Move) {
HandleMove(game::EventField(event, 0xc), game::EventField(event, 0x10));
} else if (game::MouseEventKind(event) == game::MouseEvent::ButtonDown &&
game::EventField(event, game::kMouseButton) == game::kLeftButton) {
HandleClick(window, game::EventField(event, game::kMouseButtonX),
game::EventField(event, game::kMouseButtonY));
}
return 1;
}
// cUI_Savegame::vf14 (0x71dd80, render, ret 4): the screen instead. The
// window's rect covers the canvas, so the UI manager's hit tests send it the
// mouse everywhere on the screen.
int __fastcall Savegame_Render(void* self, void* edx, void* device);
RE_HOOK(0x0071dd80, Savegame_Render);
int __fastcall Savegame_Render(void* self, void*, void* device) {
auto* window = static_cast<uint8_t*>(self);
*reinterpret_cast<game::UiRect*>(window + game::kControlRect) = {0, 0, screen::kWidth, screen::kHeight};
if (!game::ControlShown(window) && g_dialog.window == window) {
CloseDialog();
}
WindowCall call{window, device};
return screen::Render(window, device, &RenderBody, &call);
}
// cUI_Savegame::vf10 (0x71d130, events, ret 4): mouse events are the
// screen's, the rest (keys) the window's.
int __fastcall Savegame_HandleEvent(void* self, void* edx, void* event);
RE_HOOK(0x0071d130, Savegame_HandleEvent);
int __fastcall Savegame_HandleEvent(void* self, void* edx, void* event) {
auto* window = static_cast<uint8_t*>(self);
if (game::MouseEventKind(event) == game::MouseEvent::None || !game::ControlShown(window)) {
return Savegame_HandleEvent_original(self, edx, event);
}
WindowCall call{window, event};
return screen::Handle(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) {
int row = RowFromPath(path);
if (row < 1) {
return Engine_Load_original(self, edx, path, quiet);
}
if (const int chosen = g_loadRow.exchange(-1); chosen >= 0) {
row = chosen + 1; // "Spielen" (Play): the window's entry only carried the load
}
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);
g_startAfterLoad.loaded = g_startAfterLoad.file == target.file;
}
}
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, settable): the
// same save, into the autosave file (Engine_Save with g_autosavePending),
// 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;
std::atomic<int>& AutosaveMinutesSetting() {
static std::atomic<int> minutes{re::ConfigInt("characters", "AutosaveMinutes", kAutosaveMinutesDefault)};
return minutes;
}
uint64_t AutosaveIntervalMs() {
const int minutes = AutosaveMinutesSetting();
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 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},
};
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 window patches failed", failed);
}
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::OnEngineTick([](uint8_t*) { StartAfterLoadOnTick(); });
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;
}
int AutosaveMinutes() {
return AutosaveMinutesSetting();
}
void SetAutosaveMinutes(int minutes) {
AutosaveMinutesSetting() = minutes;
re::SetConfigString("characters", "AutosaveMinutes", std::to_string(minutes).c_str());
}
RE_STARTUP(CharactersStartup, Startup);
} // namespace mods::characters