// 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #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(text.size()), nullptr, 0, nullptr, nullptr); std::string result(size, '\0'); WideCharToMultiByte(codePage, 0, text.c_str(), static_cast(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(text.size()), nullptr, 0); std::wstring result(size, L'\0'); MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(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(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 FilesIn(const std::string& dir, const char* pattern) { std::vector 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 worlds = FilesIn(saveDir, "GAME*.PAK"); const std::vector 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, Source> worlds; // newest per (campaign, difficulty) std::optional hero; // newest of all std::vector files; }; std::map, Group> groups; auto add = [&](const std::string& file, bool world) { std::optional bytes = ReadFile(saveDir + file); std::optional 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 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 last = group.worlds.empty() ? std::pair{} : 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 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 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 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 g_autosavePending{false}; bool g_inGame = false; // a game runs (the load window opens from the Esc menu too) std::atomic 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 world = file.Read(WorldSection(campaign, difficulty)); std::optional 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 heroFile = character.Read(hasHero ? std::string("hero") : WorldSection(heroFromCampaign, heroFrom)); std::optional 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(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 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 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(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(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(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(0x0061cda0)(path) != 0; } uint32_t& HeroField(void* hero, size_t offset) { return *reinterpret_cast(static_cast(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 uint32_t KeepingHeroPosition(Load load) { // Measured: the hero stays the same object. auto* hero = static_cast(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 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 ListOrder() { std::vector 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 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(fileTime), static_cast(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 entries; // then "Abbrechen" std::vector buttons; // made once, reused std::vector 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 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(DialogEntries()) * kDialogPitch + kDialogBottom; return {(screen::kWidth - kDialogWidth) / 2, (screen::kHeight - height) / 2, static_cast(kDialogWidth), static_cast(height)}; } game::UiRect DialogField() { const game::UiRect panel = DialogPanel(); return {panel.x + (kDialogWidth - kDialogButtonWidth) / 2, panel.y + kDialogTitleHeight, static_cast(kDialogButtonWidth), static_cast(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(index) * kDialogPitch, static_cast(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(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 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 = 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(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(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(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(0x00417e50)(); } void StartNewWorld(uint8_t* window, const std::string& file, int campaign, int difficulty) { CharacterFile character(file); std::optional 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(campaign); auto* session = static_cast(Session()); *reinterpret_cast(session + kSessionSinglePlayer) = 1; *reinterpret_cast(session + kSessionHeroClass) = character.meta.value("class", 1u); *GameSetup() = 1; *reinterpret_cast(kGameLevel) = difficulty; using HideFn = void(__fastcall*)(void*, void*, uint32_t); reinterpret_cast(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(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(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(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 removed = character.Open() ? character.Read(section) : std::nullopt; std::optional 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(window + kSavegameEntries); const uint8_t* end = *reinterpret_cast(window + kSavegameEntriesEnd); if (begin == end) { re::Log("characters: the load window lists nothing, cannot load"); return; } row->chosen = static_cast(world - row->choices.data()); g_loadRow = static_cast(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(world->campaign); *reinterpret_cast(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(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(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(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(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 order = ListOrder(); for (int i = 0; i < static_cast(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(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(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 order = ListOrder(); for (int i = 0; i < static_cast(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(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(self); *reinterpret_cast(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(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(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 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 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 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(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 world = ReadFile(work + kWorkWorld); std::optional info = world ? ReadSaveInfo(*world) : std::nullopt; if (!info || !SaveHero(kGameHero)) { re::Log("characters: save failed (%s)", path ? path : ""); return result; } std::optional 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 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& AutosaveMinutesSetting() { static std::atomic 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(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(0x00613710)(engine, nullptr, 1); std::memcpy(engine + kEngineSaveDescription, kSaveDescription, sizeof(kSaveDescription)); *reinterpret_cast(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(event + kUiEventCode) == static_cast(game::WindowCommand::Show) && *reinterpret_cast(event + kUiEventSubtype) == kUiEventWindowSubtype && std::strncmp(reinterpret_cast(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(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(self + kSavegameMode) == kSavegameModeSave) { using SetModeFn = void(__fastcall*)(void*, void*, uint32_t); reinterpret_cast(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(kCampaign), reinterpret_cast(&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