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