In the Underworld the potion opens the Underworld portals (busy dialog mode 6, as the teleporters there do); g_campaign moved to game/addresses.h. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2048 lines
86 KiB
C++
2048 lines
86 KiB
C++
// Characters and difficulties (requests of 2026-10-19/20, docs/characters.md):
|
||
// one file per character instead of save rows and exported heroes. The file
|
||
// (charfile.h) holds the shared hero (a .pax as cEngine::SaveHero writes it)
|
||
// and one world per campaign and difficulty (a GAME.PAK as cEngine::Save
|
||
// writes it). The main menu's "Spielen" (mainmenu.cpp) opens the load window,
|
||
// which lists every character of both campaigns; "Neuer Charakter" there asks
|
||
// for the campaign and opens the game's character creation.
|
||
//
|
||
// The game keeps doing the work on its own files; only which files changes.
|
||
// Its save folder is a work folder (save\characters\_work, via userfolders):
|
||
// - the load window (cUI_Savegame) 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/addresses.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/granny.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 = game::addr::g_campaign;
|
||
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
|
||
|
||
void ForgetHeroModel(); // the files may have changed: read the hero anew
|
||
|
||
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();
|
||
ForgetHeroModel();
|
||
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.
|
||
// Top: room for the heading and the frames' ornament; the buttons end
|
||
// above the screen's bottom.
|
||
constexpr int kPanelTop = 164 + screen::kFrameOrnament;
|
||
constexpr int kPanelHeight = 7 * 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);
|
||
}
|
||
}
|
||
|
||
// The selected character's hero in 3D (game/ui/granny.h): its hero file is
|
||
// unpacked to the game's save folder (_work) and read as a hero info. A
|
||
// migrated character without the shared hero has none until it is played.
|
||
constexpr float kHeroModelScale = 3.8f; // the game's 2.5 fills a third of the panel
|
||
constexpr int kHeroModelFeetAboveBottom = 24;
|
||
constexpr char kWorkPreviewHero[] = "preview.pax";
|
||
constexpr char kGamePreviewHero[] = "Save\\preview.pax";
|
||
|
||
struct HeroModel {
|
||
uint8_t* control = nullptr;
|
||
std::string file; // whose hero it shows
|
||
bool hasHero = false;
|
||
} g_heroModel;
|
||
|
||
void ForgetHeroModel() {
|
||
g_heroModel.file.clear();
|
||
}
|
||
|
||
void ShowHeroOf(const Row& row) {
|
||
if (row.file == g_heroModel.file) {
|
||
return;
|
||
}
|
||
g_heroModel.file = row.file;
|
||
g_heroModel.hasHero = false;
|
||
CharacterFile character(row.file);
|
||
std::optional<Bytes> hero = character.Open() ? character.Read("hero") : std::nullopt;
|
||
std::vector<uint8_t> info(game::kHeroInfoSize);
|
||
if (hero && WriteFileReplacing(WorkDir() + kWorkPreviewHero, *hero) &&
|
||
game::ReadHeroInfo(kGamePreviewHero, info.data())) {
|
||
game::SetNetGrannyHero(g_heroModel.control, info.data());
|
||
g_heroModel.hasHero = true;
|
||
} else {
|
||
game::ClearNetGrannyHero(g_heroModel.control);
|
||
}
|
||
DeleteFileA((WorkDir() + kWorkPreviewHero).c_str());
|
||
}
|
||
|
||
// The model stands centred in `rect` (canvas coordinates). The control centres
|
||
// it as in a 1024 wide layout (x - 512 around the screen centre); the canvas
|
||
// is centred too, so its x is moved by the difference.
|
||
void DrawHeroModel(void* device, const Row& row, game::UiRect rect) {
|
||
rect.x -= (screen::kWidth - 1024) / 2;
|
||
if (!g_heroModel.control) {
|
||
g_heroModel.control = game::NewNetGranny(rect);
|
||
if (!g_heroModel.control) {
|
||
return;
|
||
}
|
||
g_heroModel.control[game::kNetGrannyWhiteLight] = 1;
|
||
*reinterpret_cast<float*>(g_heroModel.control + game::kNetGrannyScale) = kHeroModelScale;
|
||
game::SetControlShown(g_heroModel.control, true);
|
||
}
|
||
*reinterpret_cast<game::UiRect*>(g_heroModel.control + game::kControlRect) = rect;
|
||
ShowHeroOf(row);
|
||
if (g_heroModel.hasHero) {
|
||
game::RenderNetGranny(g_heroModel.control, device);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
// The control puts the feet at 5/6 of its rect's height (0x88f04c).
|
||
const int feet = content.y + content.h - kHeroModelFeetAboveBottom;
|
||
DrawHeroModel(device, *row, {content.x, y, content.w, static_cast<int16_t>((feet - y) * 6 / 5)});
|
||
}
|
||
|
||
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"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
|