diff --git a/CMakeLists.txt b/CMakeLists.txt index 7328f60..bc84d96 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,8 @@ add_library(sacredK SHARED src/mods/options.cpp src/mods/lootfilter.cpp src/mods/userfolders.cpp + src/mods/charfile.cpp + src/mods/characters.cpp ) target_include_directories(sacredK PRIVATE src) target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS}) diff --git a/docs/characters.md b/docs/characters.md new file mode 100644 index 0000000..3811204 --- /dev/null +++ b/docs/characters.md @@ -0,0 +1,210 @@ +# Characters and difficulties (one file per character) + +## Request (2026-10-19) + +Get rid of exporting characters. One character = one save file; inside it one +world state per difficulty. The hero (gear, level, attributes, skills, chest, +everything) is shared between the difficulties; only the unlocked portals are +per difficulty (they have to be found again on each level). + +- Unlocking: Bronze and Silver are open from the start; each following + difficulty opens by completing the story on the one before it. More + difficulties shall be addable later. +- The export / import screen goes away. The load window lists characters; + loading asks for the difficulty. +- "Spiel speichern" saves the current character into its current difficulty. + No "switch difficulty" button: save, end the game, load on another + difficulty. No save on quit. +- A silent autosave every few minutes into a separate slot, so the player + still decides when the real save is updated. +- Existing saves are migrated. +- Delete a character (everything of it); reset a single difficulty. +- Multiplayer: later (see `todo.md`). + +Decided (2026-10-19, follow-up): +- Unlocking by story completion only; Bronze and Silver always open (maybe + only Bronze after a later rebalance). No level ranges: enemies always scale + with the player and the difficulty. +- Autosave every 10 minutes for now (a game setting later); the load window + offers it per character when it is newer than the real save. +- The game's own autosaves on story steps also go into the autosave slot, + silently. +- Migration as proposed (group by hero name + class), a backup of all saves + first. Done by hand once: + `Documents\Sacred\backup\2026-10-19_before_characters` (Documents save + folder and game save folder); the migration code makes its own backup too. + +## Measured / read so far + +### Files + +- Save games `GAMEnn.PAK` ("AMS", version 0x1b): a full world dump; the hero + is one object in it (`* Objects`, ref 1, family 3) and its items are in + `* Inventory`. Chunks are listed by the loader's log (`* Engine`, `* world`, + `* calendar`, `* Blood`, `* RegionInfo`, `* Team/AllianceInfo`, + `* Objects`, `* Inventory`, `* Particles`, `* Views`, `* Weather`, + `* Scripts`). +- Hero files `Heronn.pax` ("AMH"): written by `ItemDataMgr::saveHero` + `0x6046f0`; header 0x22c bytes + chunks (items, inventories, chest, stats + block). The hero's **portals** and world position are part of it (log + `saveHero ... wpos[..] portals[%x]`; portal bits on the hero at +0x578). + +### Functions + +| Address | What | +|---|---| +| 0x60ac70 | `cEngine::cEngine`: new game or save-game load | +| 0x61b790 | `cEngine::Load(path, quiet)`: reads a `GAME.PAK` (version check 0x15..0x1b), chunk loop, logs `---LOADING SAVEGAME.PAK` | +| 0x619c90 | `cEngine::Save(path)`: called from the render thread when engine +0x54 has 0x40/0x80; row = engine +0x68 | +| 0x60dc00 | `cEngine::InitGame`: new game (campaign start, start equipment) | +| 0x61cda0 | `cEngine::SaveHero(path)`: hero export (`saveHero` into a RAM file, then to disk) | +| 0x7d7c40 | session singleton (0x3ad0 bytes, `g_session` 0x182cb68) | +| 0x7efd90 | `Session::SelectHeroSlot(slot 1..8)`, ret 4: loads `Save\Hero%02d.pax` into the session's RAM file +0x3a3c, sets +0x49 = 1 (hero from file), +0x37b8 = slot, reads the hero header into +0x16a (0x22c bytes) | +| 0x755ac0 | `cCommand_armaPlay::execute`: posts 0x8002 (mode 1 load row, 4 load multi, else init) to the main window | +| 0x706b90 | difficulty list for a hero level (Bronze .. Niob) | + +- With session +0x49 set, the engine constructor loads the hero from the + session's file into the **loaded save game** (`loadHero` at 0x60ba65) and + destroys the old hero's items afterwards. That is the vanilla mechanism for + "this world, that hero" and the base of the shared hero. +- In-game save / load: engine +0x54 flags 0xc0 (save row +0x68 via + `cEngine::Save`), 0x200 (load row via `cEngine::Load`). + +### Difficulty + +- `g_gameLevel` 0x182cdc4: current difficulty, from the save header (log + `GameLevel[n]`). Index mapping still to confirm (0 = Bronze ..). +- Story completion: `cStatsManager` (`cStatsManager::Instance(player)` + 0x425160) +0x5704, one byte counter per difficulty (6 entries). + `0x424bc0` (k) increments, `0x424be0` (k) reads, `0x424c00` (k, value) sets. + The script opcode at 0x4aed26 increments counter[`g_gameLevel`]: the story + end. The counters travel in the save header (loader 0x61bb19..) and in the + hero file (loadHero 0x606632.., saveHero 0x60444c..). The console cheats + `bronze` / `silber` / ... (0x615d00..) increment them too. +- Vanilla offers a difficulty by **hero level**, not by completion: + thresholds `0xad2dc4..0xad2dd4` (5 dwords, parsed from the balance data key + `StartLevMulti` at 0x5ef12c). Silver for levels [dc4, dcc], Gold + [dc8, dd0], Platinum [dcc, dd4], Niob >= dd0, Bronze <= dc8. The same + thresholds reduce experience when the hero is far above the difficulty + (0x57dd60). Values: to be logged at runtime. + +### Runtime log (2026-10-19, vanilla flows) + +| Flow | What happened | +|---|---| +| load row 2 | `armaPlay load [2]` -> `cEngine(0x00000002)` -> `cEngine::Load("./SAVE/GAME02.PAK", 1)`; engine +0x68 = row | +| in-game save | `cEngine::Save("SAVE/GAME02.PAK")`, engine +0x54 = 0x444 | +| new game, exported hero, Silver | `cUI_Character::executeAction(IMPORT_DO) difficulty[1]`, `cEngine(0x80080000)`: bit 31 new game, bits 16..23 = hero slot + 1; `InitGame` creates a start hero of class session +0x44 (9) with start equipment, then `LoadHeroFile("Save\Hero07.pax", 1, 0)` (`0x61d190`, cdecl) loads the exported hero onto it | +| new game, new hero, Bronze | `armaPlay init`, `cEngine(0x80000000)`, class +0x44 = 6, no hero file | + +- `g_gameLevel`: 0 Bronze, 1 Silver (2 Gold, 3 Platinum, 4 Niob). The + battlemage row is Silver. +- Level thresholds at runtime: 20, 60, 100, 140, 180. +- `Session::SelectHeroSlot` and session +0x49 are not used in single player + (multiplayer). The hero swap block after it (0x60ba1c..) is not on the + single-player load path. +- `LoadHeroFile(path, 1, 0)` puts a hero file onto the existing player hero, + start items and all: the same operation the shared hero needs after a + world load. +- Confirmed in-game (2026-10-19/20): `LoadHeroFile(hero, 1, 0)` called right + after `cEngine::Load` returns puts the hero onto the loaded world's hero. + An exported battlemage loaded onto another character's world came out + exact: level, attributes, skills, gear, backpack, chest; nothing + duplicated or left behind. It even replaced the class (dwarf hero onto a + battlemage world). The result survives an in-game save and reload, with no + cheat-check message. + +## Follow-up requests (2026-10-20) + +- Numbered files (`CharacterNN.sacredK`), one list row per character; the + difficulty is chosen when loading, in a dialog (or a newer autosave, marked). +- Rename a character from the load window. +- Every difficulty below the highest played one is always available; one + without a world starts a new game with the shared hero. +- The save key and the Esc menu ("Spielstände" becomes "Speichern") save + directly, without the save window. + +## Implementation (`src/mods/characters.cpp`, `charfile.{h,cpp}`) + +### Files + +- `Documents\Sacred\save\characters\CharacterNN.sacredK`: "SKCH", version 1, + section count, then `{name[16], offset, size}` entries. Sections: `meta` + (JSON: format, name UTF-8, class, level, lastDifficulty, migratedFrom, + heroFrom), `hero` (AMH as `cEngine::SaveHero` writes it), `world` (AMS as + `cEngine::Save` writes it). Written to a temp file, then moved over. +- `CharacterNN.autosave.sacredK`: same layout, one world (`meta.difficulty`). +- The game's save folder is redirected to `characters\_work` (userfolders); + the game reads and writes `world.PAK` / `hero.pax` there. +- Deleted characters move to `characters\deleted\`. + +### Load window (cUI_Savegame) + +- Before each scan (0x71e330) one preview `GAMEnn.PAK` per character is + written into `_work` (header, thumbnail, world time, description + "Name - Klasse"); row nn = character nn. The row's second line (call + 0x71db2a to SetText 0x736f00) shows "Stufe N - difficulty". +- Laden (action 4 / 0xb) opens the difficulty dialog when there is more than + one choice: worlds, newer autosaves "(Autosave)", and "(neu)" for every + difficulty up to max(highest world, Silber) without a world (needs the + shared hero; not in multiplayer, byte 0x182cb6c). +- "Umbenennen" (below Löschen; not for the character being played) asks for + a name; keys go to the field through the input key capture. The name is + only in the hero header chunk 0xc3 (+8, UTF-16, 0x40 chars): rewritten in + the hero, every world and the autosave, plus `meta.name`. Confirmed: the + game loads renamed characters without complaint. +- Dialog buttons are cUI_Button2 copies of the load button + (`game::NewButtonLike`); their captions are our own text ids with + overrides (texts.cpp checks overrides for our ids too). + +### Load + +`cEngine::Load` (0x61b790) of a listed row: the chosen world and the hero are +unpacked to `_work`, the original loads `world.PAK`, then `LoadHeroFile` +(0x61d190) puts the shared hero on. The world's portals (hero +0x578) and +position (+0x18..+0x28) are put back afterwards: the hero file holds the +whole hero object and `loadHero` deserialises it over the hero, position +included (measured: the hero stood where it was saved on another +difficulty; the hero stays the same object). A migrated character without a +shared hero takes it from the first loaded world (`heroFrom`). + +### New world on a difficulty + +As `cUI_Character::executeAction(IMPORT_DO)` (0x70d637): session (0x7d7c40) ++0x14 = 1, +0x44 = class, GameSetup (0x417e50) +0 = 1, `g_gameLevel` = d, +then cCommand_armaPlay (vtable 0x894ff4, execute 0x755ac0) mode 1, argument +`0x80000000 | 1 << 16`. cEngine's constructor runs InitGame and then +`LoadHeroFile("Save\Hero00.pax")` (0x60bf2c); our hook loads the character's +hero instead, keeps the start position, clears the portals and makes the +game the character's. + +### Save + +`cEngine::Save` (0x619c90) writes `world.PAK`, then `SaveHero` writes +`hero.pax`; both go into the current character (a new one gets the next free +number). Story autosaves (engine event 0xf subtype 2 with the flag at +0x18, +noted in `cEngine::ReceiveEvent`) go into the autosave file. + +Direct save (K key, Esc menu "Speichern"): on the next engine tick, as the +quicksave does (0x618ebb): `cUI_Manager::CanOpenWindow` (0x75a690), +`cEngine::CaptureScreen` (0x613710, 1), description +0x6a, row +0x68 = 0, +flag +0x54 |= 0x40. The Esc menu line keeps its action (Show +`UI_WND_SAVEGAME`); `cUI_Manager::ReceiveEvent` (0x757120) turns that into the +direct save while a game runs. Its text is patched at 0x6bc1f7 (id 0x41b is +used by other windows too). Loading is from the main menu only. + +### Migration + +On first start (no `characters\` folder): after a backup in +`Documents\Sacred\backup\_characters_migration`, `GAME*.PAK` and +`Hero*.pax` are grouped by (name, class); the newest world per difficulty is +kept, the newest file overall is the shared hero. + +## Open + +- Unlocking by story completion (counters at save header +0x88..). +- Reset a difficulty. +- Remove the export / import UI. +- Silent autosave every 10 minutes. +- Quickload (row 0) does not know characters. +- Multiplayer (see `todo.md`). diff --git a/docs/todo.md b/docs/todo.md index c761eed..b31634b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -166,3 +166,33 @@ The hero sometimes plays a walking animation after a save game load while standing still. Not reproduced yet: a load log (hero mode, action, animation fields, orders) showed nothing unusual; right after a load the game itself sends one walk order (0x4) while the hero is idle. Measure again when it shows. + +## Multiplayer with characters (2026-10-19) + +Characters became one file each with a world per difficulty +(`docs/characters.md`); vanilla multiplayer picks exported `Heronn.pax` +heroes, which no longer exist. Later: give multiplayer the shared hero of a +character file (the session's hero file +0x3a3c, as `SelectHeroSlot` does), +and decide what a multiplayer game does to the character's progress. + +## Characters: open parts of the rework (2026-10-20) + +From the characters rework (`docs/characters.md`, request of 2026-10-19): + +- **Unlock difficulties by story completion.** Bronze and Silber stay open; + difficulty d >= 2 opens when the story on d - 1 is completed. The counters + are one byte per difficulty in cStatsManager +0x5704 (read 0x424be0) and + travel in the save header (+0x88..+0x98) and the hero file. Today every + difficulty up to the highest played one is offered (and Silber). +- **Reset a difficulty.** Remove one world from a character (the shared hero + stays); a button / dialog entry in the load window. +- **Remove the export / import UI.** The Esc menu's "Exportieren" and the + character screen's import (cUI_Character mode 5, `docs/char-export.md`): + characters replace exported heroes. +- **Silent autosave every 10 minutes** (a setting later) into the + autosave file, without the busy dialog, like the direct save + (`SaveOnTick`) but marked as an autosave. +- **Quickload (row 0) does not know characters.** Ctrl+Z saves into the + current character, but the quickload event (0x116, engine +0x68 = row, + flag 0x200) loads `GAME00.PAK`, which is not listed; make it reload the + current character's world (or its newer autosave). diff --git a/src/game/ui/button.h b/src/game/ui/button.h new file mode 100644 index 0000000..1ac5801 --- /dev/null +++ b/src/game/ui/button.h @@ -0,0 +1,66 @@ +#pragma once +// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain ret). +// Windows set their buttons up as cUI_Options 0x7184e0 does: images +// (+0x54..+0x87), sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text +// id, font 1) -> +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name +// (0x732090). + +#include +#include +#include + +#include "game/ui/tabs.h" +#include "game/ui/text.h" + +namespace game { + +constexpr size_t kButtonSize = 0xbc; +constexpr size_t kButtonLook = 0x54; +constexpr size_t kButtonLookEnd = 0x88; +constexpr size_t kButtonSounds = 0x90; +constexpr size_t kButtonSoundsEnd = 0xa0; +constexpr size_t kButtonCaptionOffset = 0xb0; // x, y (int16) +constexpr uint32_t kButtonFlags = 5; // 0x731e70, as 0x7184e0 passes + +// Control vtable entries: vf10 handles an event (ret 4), vf14 renders (ret 4). +constexpr size_t kVfHandleEvent = 0x10 / 4; +constexpr size_t kVfRender = 0x14 / 4; + +// A new button with `model`'s look and sounds at `rect` (relative to the +// parent it is added to, or the screen frame without one), captioned with text +// id `caption`. +inline uint8_t* NewButtonLike(const uint8_t* model, const UiRect& rect, uint32_t caption, const char* name) { + void* memory = GameNew(kButtonSize); + if (!memory) { + return nullptr; + } + using ConstructFn = uint8_t*(__fastcall*)(void*, void*); + uint8_t* button = reinterpret_cast(0x0072a180)(memory, nullptr); + using FlagsFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(0x00731e70)(button, nullptr, kButtonFlags); + std::copy(model + kButtonLook, model + kButtonLookEnd, button + kButtonLook); + std::copy(model + kButtonSounds, model + kButtonSoundsEnd, button + kButtonSounds); + std::copy(model + kButtonCaptionOffset, model + kButtonCaptionOffset + 4, button + kButtonCaptionOffset); + *reinterpret_cast(button + kControlRect) = rect; + using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t); + reinterpret_cast(0x0072ad60)(button, nullptr, caption, 1); + using NameFn = void(__fastcall*)(void*, void*, const char*); + reinterpret_cast(0x00732090)(button, nullptr, name); + return button; +} + +inline int ControlHandleEvent(void* control, void* event) { + using Fn = int(__fastcall*)(void*, void*, void*); + return reinterpret_cast((*static_cast(control))[kVfHandleEvent])(control, nullptr, event); +} + +inline int ControlRender(void* control, void* device) { + using Fn = int(__fastcall*)(void*, void*, void*); + return reinterpret_cast((*static_cast(control))[kVfRender])(control, nullptr, device); +} + +inline bool Inside(const UiRect& rect, int x, int y) { + return x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h; +} + +} // namespace game diff --git a/src/game/ui/texts.cpp b/src/game/ui/texts.cpp index af8be99..a22d4c6 100644 --- a/src/game/ui/texts.cpp +++ b/src/game/ui/texts.cpp @@ -182,6 +182,9 @@ void SetWideString(void* string, const std::wstring& value) { const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) { if (id >= kFirstOwnText && id < 0x80000000) { std::lock_guard guard(g_lock); + if (const auto found = g_overrides.find(id); found != g_overrides.end()) { + return &found->second->string; + } const size_t index = id - kFirstOwnText; if (index < Texts().size()) { return &Texts()[index]; diff --git a/src/mods/borderless.cpp b/src/mods/borderless.cpp index 954cc96..9f557a5 100644 --- a/src/mods/borderless.cpp +++ b/src/mods/borderless.cpp @@ -256,3 +256,41 @@ SHORT WINAPI GetAsyncKeyStateWhenFocused(int key) { } return GetAsyncKeyStateWhenFocused_original(key); } + +// The game's "bring the window to the front" (0x810af0, thiscall (window, +// timeout ms), ret 8; WinMain calls it three times): ShowWindow, then +// SetForegroundWindow until the window is in front and SetFocus until it has +// the focus. WinMain passes 0xffffffff, which never times out. Windows refuses +// the foreground to a process when the user clicked or typed elsewhere while +// it started, so the game hung at startup (2026-10-20, four times; main +// thread in this loop, log ending at WM_ACTIVATEAPP(1, 0)). Now it asks for a +// limited time, with the input of the window in front attached (which Windows +// allows), and carries on in the background otherwise; a click brings it up. +constexpr DWORD kForegroundWaitMs = 2000; + +bool __fastcall BringWindowToFront(void* self, void* edx, HWND window, DWORD timeout); +RE_HOOK(0x00810af0, BringWindowToFront); +bool __fastcall BringWindowToFront(void*, void*, HWND window, DWORD timeout) { + ShowWindow(window, SW_SHOW); + const DWORD wait = std::min(timeout, kForegroundWaitMs); + const DWORD start = GetTickCount(); + while (GetForegroundWindow() != window && GetTickCount() - start < wait) { + const DWORD front = GetWindowThreadProcessId(GetForegroundWindow(), nullptr); + const DWORD self = GetCurrentThreadId(); + const bool attached = front && front != self && AttachThreadInput(front, self, TRUE); + BringWindowToTop(window); + SetForegroundWindow(window); + if (attached) { + AttachThreadInput(front, self, FALSE); + } + Sleep(10); + } + const bool inFront = GetForegroundWindow() == window; + if (inFront) { + SetFocus(window); + } else { + re::Log("borderless: the game window did not get to the front, starting in the background"); + } + UpdateWindow(window); + return inFront; +} diff --git a/src/mods/characters.cpp b/src/mods/characters.cpp new file mode 100644 index 0000000..2389eb1 --- /dev/null +++ b/src/mods/characters.cpp @@ -0,0 +1,1270 @@ +// 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 difficulty (a GAME.PAK as cEngine::Save writes it). +// +// 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 +// the 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "game/engine/events.h" +#include "game/engine/objects.h" +#include "game/ui/draw.h" +#include "game/ui/button.h" +#include "game/ui/control.h" +#include "game/ui/events.h" +#include "game/ui/text.h" +#include "game/ui/texts.h" +#include "game/ui/windows.h" +#include "mods/characters.h" +#include "mods/charfile.h" +#include "mods/hud.h" +#include "mods/input.h" +#include "mods/userfolders.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) + +// Every difficulty up to the highest with a world, and at least up to Silber, +// can be played; 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 +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) + +// --- paths and texts ------------------------------------------------------- + +std::string CharactersDir() { + return userfolders::SaveDir() + "characters\\"; +} + +std::string WorkDir() { + return CharactersDir() + "_work\\"; +} + +std::string AutosavePath(const std::string& path) { + return path.substr(0, path.size() - std::strlen(kExtension)) + kAutosaveExtension; +} + +bool EndsWith(const std::string& text, const char* suffix) { + const size_t length = std::strlen(suffix); + return text.size() >= length && _stricmp(text.c_str() + text.size() - length, suffix) == 0; +} + +std::string Convert(const std::wstring& text, UINT codePage) { + if (text.empty()) { + return {}; + } + const int size = WideCharToMultiByte(codePage, 0, text.c_str(), static_cast(text.size()), nullptr, 0, + nullptr, nullptr); + std::string result(size, '\0'); + WideCharToMultiByte(codePage, 0, text.c_str(), static_cast(text.size()), result.data(), size, nullptr, + nullptr); + return result; +} + +std::wstring FromUtf8(const std::string& text) { + if (text.empty()) { + return {}; + } + const int size = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(text.size()), nullptr, 0); + std::wstring result(size, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(text.size()), result.data(), size); + return result; +} + +std::wstring ClassName(uint32_t heroClass) { + const char* key = heroClass < std::size(kClassTextKeys) ? kClassTextKeys[heroClass] : nullptr; + std::wstring name = key ? game::GameTextByKey(key) : std::wstring(); + return name.empty() ? L"?" : name; +} + +std::wstring DifficultyName(int difficulty) { + return difficulty >= 0 && difficulty < kDifficultyCount ? game::GameTextByKey(kDifficultyTextKeys[difficulty]) + : std::wstring(L"?"); +} + +// A file for a new character: CharacterNN.sacredK, the first free number +// (the hero's name is inside, in the meta). +std::string NewCharacterPath() { + for (int n = 1;; ++n) { + char name[32]; + wsprintfA(name, "Character%02d%s", n, kExtension); + const std::string path = CharactersDir() + name; + if (GetFileAttributesA(path.c_str()) == INVALID_FILE_ATTRIBUTES && + GetFileAttributesA(AutosavePath(path).c_str()) == INVALID_FILE_ATTRIBUTES) { + return path; + } + } +} + +std::vector FilesIn(const std::string& dir, const char* pattern) { + std::vector files; + WIN32_FIND_DATAA found; + HANDLE find = FindFirstFileA((dir + pattern).c_str(), &found); + if (find == INVALID_HANDLE_VALUE) { + return files; + } + do { + if (!(found.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) { + files.push_back(found.cFileName); + } + } while (FindNextFileA(find, &found)); + FindClose(find); + return files; +} + +uint64_t FileTime(const std::string& path) { + WIN32_FILE_ATTRIBUTE_DATA data; + if (!GetFileAttributesExA(path.c_str(), GetFileExInfoStandard, &data)) { + return 0; + } + return (uint64_t(data.ftLastWriteTime.dwHighDateTime) << 32) | data.ftLastWriteTime.dwLowDateTime; +} + +void SetHeroMeta(json& meta, const SaveInfo& info) { + meta["format"] = 1; + meta["name"] = Convert(info.name, CP_UTF8); + meta["class"] = info.heroClass; + meta["level"] = info.level; +} + +// --- migration --------------------------------------------------------------- + +// The old save rows and exported heroes become character files, once. +void Migrate() { + const std::string saveDir = userfolders::SaveDir(); + const std::vector worlds = FilesIn(saveDir, "GAME*.PAK"); + const std::vector heroes = FilesIn(saveDir, "Hero*.pax"); + + // Backup of everything that is read. + SYSTEMTIME now; + GetLocalTime(&now); + char stamp[32]; + wsprintfA(stamp, "%04u-%02u-%02u_%02u%02u", now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute); + const std::string sacredDir = saveDir.substr(0, saveDir.find_last_of('\\', saveDir.size() - 2) + 1); + const std::string backupDir = sacredDir + "backup\\" + stamp + "_characters_migration\\"; + CreateDirectoryA((sacredDir + "backup").c_str(), nullptr); + CreateDirectoryA(backupDir.c_str(), nullptr); + for (const auto* list : {&worlds, &heroes}) { + for (const std::string& file : *list) { + CopyFileA((saveDir + file).c_str(), (backupDir + file).c_str(), TRUE); + } + } + + struct Source { + std::string file; + SaveInfo info; + uint64_t time = 0; + }; + struct Group { + std::map worlds; // newest per difficulty + std::optional hero; // newest of all + std::vector files; + }; + std::map, Group> groups; + auto add = [&](const std::string& file, bool world) { + std::optional bytes = ReadFile(saveDir + file); + std::optional info = bytes ? ReadSaveInfo(*bytes) : std::nullopt; + if (!info || info->world != world || info->multiplayer || info->difficulty < 0 || + info->difficulty >= kDifficultyCount) { + re::Log("characters: migration skips %s", file.c_str()); + return; + } + Source source{file, *info, world && info->savedAt ? info->savedAt : FileTime(saveDir + file)}; + Group& group = groups[{info->name, info->heroClass}]; + group.files.push_back(file); + if (world) { + auto it = group.worlds.find(info->difficulty); + if (it == group.worlds.end() || it->second.time < source.time) { + group.worlds[info->difficulty] = 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& [difficulty, source] : group.worlds) { + character.Set(WorldSection(difficulty), *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["lastDifficulty"] = group.hero->info.difficulty; + } else { + character.Set("hero", *ReadFile(saveDir + group.hero->file)); + character.meta["lastDifficulty"] = group.worlds.empty() ? 0 : group.worlds.rbegin()->first; + } + 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 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 choices; + size_t chosen = 0; + uint32_t level = 0; // the shared hero's +}; +std::vector g_rows; // row nn of the game = g_rows[nn - 1] + +// The character being played: saves go there (empty file: a new character, +// named at its first save). +struct Current { + std::string file; +} g_current; + +bool g_autosavePending = false; + +std::string Description(const json& meta) { + return Convert(FromUtf8(meta.value("name", std::string())) + L" - " + ClassName(meta.value("class", 0u)), + CP_ACP); +} + + +uint64_t SavedAt(const CharacterFile& file, int difficulty) { + std::optional world = file.Read(WorldSection(difficulty)); + std::optional info = world ? ReadSaveInfo(*world) : std::nullopt; + return info ? info->savedAt : 0; +} + +// Writes one preview per row into the work folder, where the game scans. +void RebuildList() { + EnsureFolders(); + const std::string work = WorkDir(); + for (const std::string& file : FilesIn(work, "GAME*.PAK")) { + DeleteFileA((work + file).c_str()); + } + g_rows.clear(); + for (const std::string& name : FilesIn(CharactersDir(), "*.sacredK")) { + if (EndsWith(name, kAutosaveExtension) || g_rows.size() >= kMaxRows) { + continue; + } + CharacterFile character(CharactersDir() + name); + if (!character.Open()) { + continue; + } + CharacterFile autosave(AutosavePath(character.Path())); + const int autosaved = autosave.Open() ? autosave.meta.value("difficulty", -1) : -1; + const bool hasHero = character.Has("hero"); + const int heroFrom = character.meta.value("heroFrom", -1); + Row row{character.Path()}; + row.name = FromUtf8(character.meta.value("name", std::string())); + row.level = character.meta.value("level", 0u); + int highest = kAlwaysOpenDifficulty; + for (int d = 0; d < kDifficultyCount; ++d) { + if (SavedAt(character, d)) { + highest = std::max(highest, d); + } + } + // New games need the shared hero; multiplayer games are not ours. + const bool canStart = hasHero && !*reinterpret_cast(kMultiplayer); + for (int d = 0; d < kDifficultyCount; ++d) { + const uint64_t savedAt = SavedAt(character, d); + // Without the shared hero yet only the world holding it can be loaded. + if (savedAt && (hasHero || d == heroFrom)) { + row.choices.push_back({d, false}); + } else if (!savedAt && d <= highest && canStart) { + row.choices.push_back({d, false, true}); + } + if (d == autosaved && SavedAt(autosave, d) > savedAt) { + row.choices.push_back({d, true}); + } + } + // The preview shows the last played world (or any world). + std::optional shownChoice; + const int last = character.meta.value("lastDifficulty", 0); + for (size_t i = 0; i < row.choices.size(); ++i) { + const Choice& choice = row.choices[i]; + if (!choice.newWorld && (!shownChoice || (choice.difficulty == last && !choice.autosave))) { + shownChoice = i; + } + } + if (!shownChoice) { + continue; + } + row.chosen = *shownChoice; + const Choice& shown = row.choices[row.chosen]; + const CharacterFile& source = shown.autosave ? autosave : character; + std::optional world = source.Read(WorldSection(shown.difficulty)); + if (!world) { + continue; + } + g_rows.push_back(std::move(row)); + char preview[32]; + wsprintfA(preview, "GAME%02u.PAK", static_cast(g_rows.size())); + WriteFileReplacing(work + preview, BuildPreview(*world, Description(character.meta))); + } + re::Log("characters: %zu characters listed", g_rows.size()); +} + +// Row number nn from the game's "./SAVE/GAMEnn.PAK"; 0 if it isn't one. +int RowFromPath(const char* path) { + if (!path) { + return 0; + } + const std::string_view full(path); + const size_t slash = full.find_last_of("\\/"); + const char* name = path + (slash == std::string_view::npos ? 0 : slash + 1); + unsigned row = 0; + char rest[8] = {}; + if (_strnicmp(name, "GAME", 4) != 0 || sscanf_s(name + 4, "%2u%7s", &row, rest, unsigned(sizeof(rest))) != 2 || + _stricmp(rest, ".PAK") != 0) { + return 0; + } + return static_cast(row); +} + +// --- game functions ------------------------------------------------------------ + +// LoadHeroFile (0x61d190, cdecl (path, 1, 0)): a hero file onto the player hero. +uint32_t LoadHeroFile(const char* path) { + using Fn = uint32_t(__cdecl*)(const char*, uint32_t, uint32_t); + return reinterpret_cast(0x0061d190)(path, 1, 0); +} + +// cEngine::SaveHero (0x61cda0, cdecl (path)): the hero as a hero file. +bool SaveHero(const char* path) { + using Fn = char(__cdecl*)(const char*); + return reinterpret_cast(0x0061cda0)(path) != 0; +} + +uint32_t& HeroField(void* hero, size_t offset) { + return *reinterpret_cast(static_cast(hero) + offset); +} + +// The hero file holds the whole hero object, its world position too: +// loadHero (0x6059f0..) deserialises it over the hero (vf1c), so the hero +// stood where it was saved, on another difficulty (measured). The position +// belongs to the world: it is put back right after. (cObjectManager::Place +// 0x5fcd10 in loadHero only runs for carried objects, flag 0x2.) +constexpr size_t kObjectPosition = 0x18; // area (u16), x, y, level (byte) +constexpr size_t kObjectPositionSize = 0x10; // as saveHero logs it: +0x18 .. +0x24 + +// Runs `load` (a LoadHeroFile) keeping the hero where the world has it. +template +uint32_t KeepingHeroPosition(Load load) { + // Measured: the hero stays the same object. + auto* hero = static_cast(game::GetPlayerHero()); + uint8_t position[kObjectPositionSize] = {}; + if (hero) { + std::memcpy(position, hero + kObjectPosition, kObjectPositionSize); + } + const uint32_t result = load(); + if (hero && hero == game::GetPlayerHero()) { + std::memcpy(hero + kObjectPosition, position, kObjectPositionSize); + } + return result; +} + +// The shared hero onto the loaded world's hero; the world's portals and +// position stay. +void ApplySharedHero() { + void* hero = game::GetPlayerHero(); + const uint32_t portals = hero ? HeroField(hero, kHeroKnownPortals) : 0; + const uint32_t loaded = KeepingHeroPosition([] { return LoadHeroFile(kGameHero); }); + hero = game::GetPlayerHero(); + if (hero) { + HeroField(hero, kHeroKnownPortals) = portals; + } + re::Log("characters: shared hero loaded (%u), portals %08x kept", loaded, portals); +} + +// --- hooks ----------------------------------------------------------------------- + +// cUI_Savegame's scan of the save folder (0x71e330, thiscall (dir, multiplayer), +// ret 8): previews first. +int __fastcall Savegame_Scan(void* self, void* edx, const char* dir, uint32_t multiplayer); +RE_HOOK(0x0071e330, Savegame_Scan); +int __fastcall Savegame_Scan(void* self, void* edx, const char* dir, uint32_t multiplayer) { + RebuildList(); + return Savegame_Scan_original(self, edx, dir, multiplayer); +} + +// The row of the window's selected entry (0: none, or not a character). +Row* SelectedRow(uint8_t* window) { + const int selected = *reinterpret_cast(window + kSavegameSelected); + if (selected <= 0) { + return nullptr; + } + using FirstVisibleFn = int16_t(__fastcall*)(void*, void*); + const int index = reinterpret_cast(0x0071bb40)(window, nullptr) + selected - 1; + const uint8_t* begin = *reinterpret_cast(window + kSavegameEntries); + const uint8_t* end = *reinterpret_cast(window + kSavegameEntriesEnd); + if (index < 0 || index >= static_cast((end - begin) / kSavegameEntrySize)) { + return nullptr; + } + const int row = RowFromPath(reinterpret_cast(begin + index * kSavegameEntrySize + kSavegameEntryName)); + return row >= 1 && row <= static_cast(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" takes the place 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); + +enum class DialogKind { Difficulty, Rename }; + +struct Dialog { + uint8_t* window = nullptr; // open while set + DialogKind kind = DialogKind::Difficulty; + size_t row = 0; // index into g_rows + std::vector buttons; // made once, reused + std::vector captions; // their text ids, overridden +} g_dialog; + +// Rename: the name typed so far and a pending Enter / Esc, from the window +// thread. +std::mutex g_nameLock; +std::wstring g_name; +std::atomic g_nameKey = 0; + +uint8_t* g_renameButton = nullptr; + +// Entries: the row's choices or "Umbenennen", then "Abbrechen". +size_t DialogEntries() { + if (g_dialog.row >= g_rows.size()) { + return 0; + } + return g_dialog.kind == DialogKind::Rename ? 2 : g_rows[g_dialog.row].choices.size() + 1; +} + +int DialogFieldSpace() { + return g_dialog.kind == DialogKind::Rename ? kDialogFieldHeight + kDialogFieldGap : 0; +} + +game::UiRect DialogPanel() { + const int height = + kDialogTitleHeight + DialogFieldSpace() + static_cast(DialogEntries()) * kDialogPitch + kDialogBottom; + return {(kFrameWidth - kDialogWidth) / 2, (kFrameHeight - height) / 2, static_cast(kDialogWidth), + static_cast(height)}; +} + +game::UiRect DialogField() { + const game::UiRect panel = DialogPanel(); + return {panel.x + (kDialogWidth - kDialogButtonWidth) / 2, panel.y + kDialogTitleHeight, + static_cast(kDialogButtonWidth), static_cast(kDialogFieldHeight)}; +} + +game::UiRect DialogButtonRect(size_t index) { + const game::UiRect panel = DialogPanel(); + return {panel.x + (kDialogWidth - kDialogButtonWidth) / 2, + panel.y + kDialogTitleHeight + DialogFieldSpace() + static_cast(index) * kDialogPitch, + static_cast(kDialogButtonWidth), static_cast(kDialogButtonHeight)}; +} + +std::wstring DialogText(size_t index) { + if (index + 1 == DialogEntries()) { + return L"Abbrechen"; + } + if (g_dialog.kind == DialogKind::Rename) { + return L"Umbenennen"; + } + const Choice& choice = g_rows[g_dialog.row].choices[index]; + if (choice.newWorld) { + return DifficultyName(choice.difficulty) + L" (neu)"; + } + return choice.autosave ? DifficultyName(choice.difficulty) + L" (Autosave)" : DifficultyName(choice.difficulty); +} + +// 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; + 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(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 sections = {"hero"}; + for (int d = 0; d < kDifficultyCount; ++d) { + sections.push_back(WorldSection(d)); + } + for (const std::string& section : sections) { + if (!character.Has(section)) { + continue; + } + std::optional bytes = character.Read(section); + if (!bytes || !SetHeroName(*bytes, name)) { + re::Log("characters: rename: no hero name in %s of %s", section.c_str(), path.c_str()); + done = false; + continue; + } + character.Set(section, std::move(*bytes)); + } + character.meta["name"] = Convert(name, CP_UTF8); + done = character.Save() && done; + } + re::Log("characters: renamed %s to %s%s", row.file.c_str(), Convert(name, CP_UTF8).c_str(), + done ? "" : " - FAILED in part"); + return done; +} + +// The row "Umbenennen" applies to: the selected character, unless it is the +// one being played (its name is in the running game too). +const Row* RenameTarget(uint8_t* window) { + const uint32_t mode = *reinterpret_cast(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; +} + +game::UiRect RenameButtonRect(uint8_t* window) { + game::UiRect rect = game::ControlScreenRect(window + kSavegameDeleteButton); + rect.y += kSavegameButtonPitch; + return rect; +} + +uint8_t* RenameButton(uint8_t* window) { + if (!g_renameButton) { + static const uint32_t kCaption = game::RegisterText(L"Umbenennen"); + g_renameButton = game::NewButtonLike(window + kSavegameDeleteButton, RenameButtonRect(window), kCaption, + "UI_BTN_CHAR_RENAME"); + } + if (g_renameButton) { + *reinterpret_cast(g_renameButton + game::kControlRect) = RenameButtonRect(window); + } + return g_renameButton; +} + +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(r.x), static_cast(r.y), static_cast(r.x + r.w), + static_cast(r.y + r.h), colour); +} + +game::UiRect Grown(const game::UiRect& r, int by) { + return {r.x - by, r.y - by, static_cast(r.w + 2 * by), static_cast(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(kDialogTitleHeight)}; + const wchar_t* heading = g_dialog.kind == DialogKind::Rename ? L"Neuer Name" : L"Schwierigkeit wählen"; + game::DrawText(device, kDialogFont, game::CenterLineVertically(kDialogFont, title), heading, 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(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(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(row - g_rows.data())); + return; + } + } + if (const Row* row = action == kSavegameActionDelete ? SelectedRow(self) : nullptr) { + 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 difficulty = 0; +} g_newWorld; + +void* Session() { + using InstanceFn = void*(__cdecl*)(); + return reinterpret_cast(0x007d7c40)(); +} + +// The game setup singleton (0x417e50, 0x12e6c bytes); both character-screen +// starts set its first byte. +uint8_t* GameSetup() { + using InstanceFn = uint8_t*(__cdecl*)(); + return reinterpret_cast(0x00417e50)(); +} + +void StartNewWorld(uint8_t* window, const Row& row, int difficulty) { + CharacterFile character(row.file); + std::optional hero = character.Open() ? character.Read("hero") : std::nullopt; + if (!hero || !WriteFileReplacing(WorkDir() + kWorkHero, *hero)) { + re::Log("characters: %s has no shared hero, no new game", row.file.c_str()); + return; + } + g_newWorld = {row.file, difficulty}; + auto* session = static_cast(Session()); + *reinterpret_cast(session + kSessionSinglePlayer) = 1; + *reinterpret_cast(session + kSessionHeroClass) = character.meta.value("class", 1u); + *GameSetup() = 1; + *reinterpret_cast(kGameLevel) = difficulty; + using HideFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(0x00731ee0)(window, nullptr, 1); // as the load action before it starts + struct PlayCommand { + uint32_t vtable; + uint16_t mode; + uint32_t argument; + } command{0x00894ff4, kPlayModeStart, kPlayNewGame | (1u << kPlayHeroFileShift)}; + re::Log("characters: new game for %s, difficulty %d", row.file.c_str(), difficulty); + using ExecuteFn = void(__fastcall*)(void*, void*); + reinterpret_cast(0x00755ac0)(&command, nullptr); +} + +// 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 (const Row row = g_rows[g_dialog.row]; row.choices[index].newWorld) { + CloseDialog(); + StartNewWorld(window, row, row.choices[index].difficulty); + } else { + g_rows[g_dialog.row].chosen = index; + 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; +} + +// "Umbenennen" next to the window's buttons; true if it took the event. +bool HandleRenameButton(uint8_t* window, void* event) { + const Row* row = RenameTarget(window); + uint8_t* button = row ? RenameButton(window) : nullptr; + const game::MouseEvent kind = game::MouseEventKind(event); + if (!button || kind == game::MouseEvent::None) { + return false; + } + game::ControlHandleEvent(button, event); + if (kind != game::MouseEvent::ButtonDown || game::EventField(event, game::kMouseButton) != game::kLeftButton || + !game::Inside(RenameButtonRect(window), game::EventField(event, game::kMouseButtonX), + game::EventField(event, game::kMouseButtonY))) { + return false; + } + OpenDialog(window, DialogKind::Rename, static_cast(row - g_rows.data())); + return true; +} + +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(context); + 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) { + if (RenameTarget(call->window)) { + if (uint8_t* button = RenameButton(call->window)) { + game::ControlRender(button, call->argument); + } + } + return result; + } + if (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(context); + if (g_dialog.window == call->window) { + if (HandleDialogEvent(call->window, call->argument)) { + return 1; + } + } else if (game::ControlShown(call->window) && HandleRenameButton(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(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(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(g_rows.size())) { + // GAMEnn.PAK in the work folder is only a preview. + re::Log("characters: row %d is not listed, not loading %s", row, path); + return 0; + } + const Row& target = g_rows[row - 1]; + const Choice choice = target.choices[target.chosen]; + CharacterFile character(target.file); + CharacterFile source(choice.autosave ? AutosavePath(target.file) : target.file); + std::optional world = character.Open() && source.Open() ? source.Read(WorldSection(choice.difficulty)) + : std::nullopt; + if (!world) { + re::Log("characters: %s has no world %d", source.Path().c_str(), choice.difficulty); + return 0; + } + std::optional hero = source.Read("hero"); + const std::string work = WorkDir(); + WriteFileReplacing(work + kWorkWorld, *world); + DeleteFileA((work + kWorkHero).c_str()); + if (hero) { + WriteFileReplacing(work + kWorkHero, *hero); + } + re::Log("characters: loading %s, difficulty %d%s", target.file.c_str(), choice.difficulty, + choice.autosave ? " (autosave)" : ""); + const uint32_t result = Engine_Load_original(self, edx, kGameWorld, quiet); + if (!result) { + return result; + } + g_current.file = target.file; + if (hero) { + ApplySharedHero(); + } else if (SaveHero(kGameHero)) { + // A migrated character whose newest hero was in this world: it becomes + // the shared hero now, and the other difficulties can be loaded. + if (std::optional saved = ReadFile(work + kWorkHero)) { + character.Set("hero", std::move(*saved)); + character.meta.erase("heroFrom"); + character.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, difficulty %d, shared hero loaded (%u)", started.file.c_str(), + started.difficulty, result); + return result; +} + +// cEngine::ReceiveEvent (0x618130, thiscall (event), ret 4): notes whether the +// save it starts is a story autosave (engine event 0xf, subtype 2, flag +0x18); +// quicksaves set the save flag here too. +uint32_t __fastcall Engine_ReceiveEvent(uint8_t* self, void* edx, uint32_t* event); +RE_HOOK(0x00618130, Engine_ReceiveEvent); +uint32_t __fastcall Engine_ReceiveEvent(uint8_t* self, void* edx, uint32_t* event) { + auto& flags = *reinterpret_cast(self + kEngineFlags); + const bool wasSaving = (flags & kEngineSaving) != 0; + const uint32_t result = Engine_ReceiveEvent_original(self, edx, event); + // Only the save event itself decides: another event may run in between + // (measured: an event with code 0 right after the save window's). + if (!wasSaving && (flags & kEngineSaving) && event[kEventCode] == kEngineEventSaveLoad && + event[kEventSubtype] == kSaveLoadSave) { + g_autosavePending = event[kEventAutosave] != 0; + } + return result; +} + +// cEngine::Save (0x619c90, thiscall (path), ret 4; render thread): every save +// goes into the current character, story autosaves into its autosave file. +uint32_t __fastcall Engine_Save(uint8_t* self, void* edx, const char* path); +RE_HOOK(0x00619c90, Engine_Save); +uint32_t __fastcall Engine_Save(uint8_t* self, void* edx, const char* path) { + EnsureFolders(); + const bool autosave = g_autosavePending; + g_autosavePending = false; + const std::string work = WorkDir(); + DeleteFileA((work + kWorkWorld).c_str()); + const uint32_t result = Engine_Save_original(self, edx, kGameWorld); + std::optional world = ReadFile(work + kWorkWorld); + std::optional info = world ? ReadSaveInfo(*world) : std::nullopt; + if (!info || !SaveHero(kGameHero)) { + re::Log("characters: save failed (%s)", path ? path : ""); + return result; + } + std::optional hero = ReadFile(work + kWorkHero); + if (!hero) { + re::Log("characters: saving the hero failed"); + return result; + } + if (g_current.file.empty()) { + g_current.file = NewCharacterPath(); + } + const bool exists = GetFileAttributesA(g_current.file.c_str()) != INVALID_FILE_ATTRIBUTES; + const bool toAutosave = autosave && exists; + CharacterFile character(toAutosave ? AutosavePath(g_current.file) : g_current.file); + if (!toAutosave) { + character.Open(); + } + SetHeroMeta(character.meta, *info); + character.meta["lastDifficulty"] = info->difficulty; + character.meta.erase("heroFrom"); + if (toAutosave) { + character.meta["difficulty"] = info->difficulty; + } + character.Set("hero", std::move(*hero)); + character.Set(WorldSection(info->difficulty), std::move(*world)); + const bool saved = character.Save(); + re::Log("characters: %s %s, difficulty %d%s", toAutosave ? "autosaved" : "saved", character.Path().c_str(), + info->difficulty, saved ? "" : " - WRITING FAILED"); + return result; +} + +// Direct save (request of 2026-10-20): the save key and the Esc menu's +// "Speichern" (was "Spielstände", which opened the save window) save at once, +// as the engine's quicksave does (cEngine::ReceiveEvent 0x618ebb): only while +// the UI manager lets a window open (0x75a690), thumbnail (0x613710 (1)), +// description at +0x6a, row +0x68 = 0, flag 0x40; the render thread then runs +// cEngine::Save, which writes into the current character (Engine_Save). +constexpr size_t kEngineSaveRow = 0x68; // u16 +constexpr size_t kEngineSaveDescription = 0x6a; // char[], "QUICKSAVE" for quicksaves +constexpr char kSaveDescription[] = "SAVE"; // not shown: the list shows the characters +std::atomic g_saveRequested{false}; +bool g_inGame = false; + +void SaveOnTick(uint8_t* engine) { + if (!g_saveRequested.exchange(false)) { + return; + } + auto& flags = *reinterpret_cast(engine + kEngineFlags); + uint32_t* ui = game::UiManager(); + if (!ui || !game::UiCanOpenWindow(ui) || (flags & kEngineSaving)) { + re::Log("characters: no save now (a window is open or a save is running)"); + return; + } + using CaptureFn = void(__fastcall*)(void*, void*, int); + reinterpret_cast(0x00613710)(engine, nullptr, 1); + std::memcpy(engine + kEngineSaveDescription, kSaveDescription, sizeof(kSaveDescription)); + *reinterpret_cast(engine + kEngineSaveRow) = 0; + flags |= kEngineSaving; +} + +// cUI_Manager::ReceiveEvent (vf00 0x757120, thiscall (event), ret 4) routes +// window commands by name (game/ui/windows.h); in a game, showing the save +// window (the Esc menu line) saves instead. The main menu's load window +// still opens. +constexpr size_t kUiEventCode = 4; +constexpr size_t kUiEventSubtype = 8; +constexpr uint32_t kUiEventWindowSubtype = 5; +constexpr size_t kUiEventWindowName = 0x48; // char[0x20] +constexpr char kSaveWindowName[] = "UI_WND_SAVEGAME"; + +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 && *reinterpret_cast(event + kUiEventCode) == static_cast(game::WindowCommand::Show) && + *reinterpret_cast(event + kUiEventSubtype) == kUiEventWindowSubtype && + std::strncmp(reinterpret_cast(event + kUiEventWindowName), kSaveWindowName, + sizeof(kSaveWindowName)) == 0) { + RequestSave(); + return 1; + } + return UiManager_ReceiveEvent_original(self, edx, event); +} + +// The Esc menu's line "Spielstände" (built by 0x6bb870, text id 0x41b pushed +// at 0x6bc1f7; the id is used by other windows too) reads "Speichern". +constexpr uint32_t kEscMenuSaveLineText = 0x006bc1f7; +constexpr uint32_t kSavegamesText = 0x41b; + +// 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(g_rows.size())) { + reinterpret_cast(kStaticSetText)(text, edx, fileName); + return; + } + const Row& r = g_rows[row - 1]; + const Choice& choice = r.choices[r.chosen]; + std::wstring line = L"Stufe " + std::to_wstring(r.level) + L" - " + DifficultyName(choice.difficulty); + if (choice.autosave) { + line += L" (Autosave)"; + } + reinterpret_cast(kStaticSetText)(text, edx, Convert(line, CP_ACP).c_str()); +} + +void Startup() { + re::RedirectCall(kRowFileNameCall, kStaticSetText, reinterpret_cast(&SetRowSecondLine)); + static const uint32_t kSaveText = game::RegisterText(L"Speichern"); + re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText); +} + +struct Init { + Init() { + userfolders::SetGameSaveSubfolder(kGameSaveSubfolder); + game::OnEngineTick(&SaveOnTick); + game::OnGameStart([] { g_inGame = true; }); + game::OnGameEnd([] { g_inGame = false; }); + } +} init; + +} // namespace + +void RequestSave() { + g_saveRequested = true; +} + +RE_STARTUP(CharactersStartup, Startup); + +} // namespace mods::characters diff --git a/src/mods/characters.h b/src/mods/characters.h new file mode 100644 index 0000000..34d556f --- /dev/null +++ b/src/mods/characters.h @@ -0,0 +1,10 @@ +#pragma once +// Characters and difficulties (characters.cpp, docs/characters.md). + +namespace mods::characters { + +// Saves the running game into the current character at once, without the +// save window (on the next engine tick). +void RequestSave(); + +} // namespace mods::characters diff --git a/src/mods/charfile.cpp b/src/mods/charfile.cpp new file mode 100644 index 0000000..f4743cb --- /dev/null +++ b/src/mods/charfile.cpp @@ -0,0 +1,282 @@ +#include "mods/charfile.h" + +#include +#include + +#include + +namespace mods::characters { +namespace { + +// Save files (AMS / AMH): header, chunk table at 0x100 with 64 entries. +constexpr size_t kChunkTable = 0x100; +constexpr size_t kChunkEntrySize = 12; +constexpr size_t kChunkCount = 64; +constexpr size_t kChunkData = kChunkTable + kChunkCount * kChunkEntrySize; // 0x400 +constexpr uint32_t kChunkHero = 0xc3; +constexpr uint32_t kChunkWorldTime = 0x95; // "Date[..] Time[..]", UTF-16 +constexpr uint32_t kChunkDescription = 0x83; // the save's name, 0x40 bytes ANSI +constexpr uint32_t kChunkThumbnail = 0x93; +constexpr size_t kDescriptionSize = 0x40; +// The load window reads the header and 0xc00 table bytes (0x71de90). +constexpr size_t kPreviewMinimumSize = 0xd00; + +constexpr size_t kHeroLevel = 0x0; +constexpr size_t kHeroClass = 0x4; +constexpr size_t kHeroName = 0x8; +constexpr size_t kHeroNameChars = 0x40; + +constexpr size_t kWorldMode = 0x24; +constexpr size_t kWorldMultiplayer = 0x2c; +constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords) +constexpr size_t kWorldDifficulty = 0x84; + +// Character file: "SKCH", version, section count, then {name[16], offset, size}. +constexpr char kMagic[4] = {'S', 'K', 'C', 'H'}; +constexpr uint32_t kVersion = 1; +constexpr size_t kSectionNameSize = 16; +constexpr size_t kSectionEntrySize = kSectionNameSize + 8; +constexpr size_t kFileHeaderSize = 12; + +uint32_t U32(const Bytes& bytes, size_t offset) { + uint32_t value = 0; + if (offset + 4 <= bytes.size()) { + std::memcpy(&value, bytes.data() + offset, 4); + } + return value; +} + +void PutU32(Bytes& bytes, size_t offset, uint32_t value) { + std::memcpy(bytes.data() + offset, &value, 4); +} + +struct Chunk { + uint32_t type = 0; + uint32_t offset = 0; + uint32_t size = 0; +}; + +std::optional FindChunk(const Bytes& file, uint32_t type) { + if (file.size() < kChunkData) { + return std::nullopt; + } + for (size_t i = 0; i < kChunkCount; ++i) { + const size_t entry = kChunkTable + i * kChunkEntrySize; + Chunk chunk{U32(file, entry), U32(file, entry + 4), U32(file, entry + 8)}; + if (chunk.type == type && chunk.offset + uint64_t(chunk.size) <= file.size()) { + return chunk; + } + } + return std::nullopt; +} + +} // namespace + +std::optional ReadSaveInfo(const Bytes& file) { + if (file.size() < kChunkData || (std::memcmp(file.data(), "AMS", 3) != 0 && std::memcmp(file.data(), "AMH", 3) != 0)) { + return std::nullopt; + } + const std::optional hero = FindChunk(file, kChunkHero); + if (!hero || hero->size < kHeroName + kHeroNameChars * 2) { + return std::nullopt; + } + SaveInfo info; + const uint8_t* header = file.data() + hero->offset; + std::memcpy(&info.level, header + kHeroLevel, 4); + std::memcpy(&info.heroClass, header + kHeroClass, 4); + const auto* name = reinterpret_cast(header + kHeroName); + info.name.assign(name, wcsnlen(name, kHeroNameChars)); + info.world = file[2] == 'S'; + if (info.world) { + info.difficulty = static_cast(U32(file, kWorldDifficulty)); + info.mode = U32(file, kWorldMode); + info.multiplayer = U32(file, kWorldMultiplayer) != 0; + SYSTEMTIME time{}; + WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek, + &time.wHour, &time.wMinute, &time.wSecond, &time.wMilliseconds}; + for (size_t i = 0; i < std::size(fields); ++i) { + *fields[i] = static_cast(U32(file, kWorldSavedAt + i * 4)); + } + FILETIME stamp{}; + if (SystemTimeToFileTime(&time, &stamp)) { + info.savedAt = (uint64_t(stamp.dwHighDateTime) << 32) | stamp.dwLowDateTime; + } + } + return info; +} + +bool SetHeroName(Bytes& file, const std::wstring& name) { + const std::optional hero = FindChunk(file, kChunkHero); + if (!hero || hero->size < kHeroName + kHeroNameChars * 2 || name.empty() || name.size() > kHeroNameMaxChars) { + return false; + } + uint8_t* field = file.data() + hero->offset + kHeroName; + std::memset(field, 0, kHeroNameChars * 2); + std::memcpy(field, name.data(), name.size() * 2); + return true; +} + +Bytes BuildPreview(const Bytes& world, const std::string& description) { + Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData)); + preview.resize(kChunkData); + std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize); + size_t entry = kChunkTable; + auto append = [&](uint32_t type, const uint8_t* data, uint32_t size) { + PutU32(preview, entry, type); + PutU32(preview, entry + 4, static_cast(preview.size())); + PutU32(preview, entry + 8, size); + entry += kChunkEntrySize; + preview.insert(preview.end(), data, data + size); + }; + for (uint32_t type : {kChunkWorldTime, kChunkThumbnail}) { + if (const std::optional chunk = FindChunk(world, type)) { + append(type, world.data() + chunk->offset, chunk->size); + } + } + uint8_t text[kDescriptionSize] = {}; + std::memcpy(text, description.data(), std::min(description.size(), kDescriptionSize - 1)); + append(kChunkDescription, text, kDescriptionSize); + if (preview.size() < kPreviewMinimumSize) { + preview.resize(kPreviewMinimumSize); + } + return preview; +} + +std::optional ReadFile(const std::string& path) { + std::ifstream file(path, std::ios::binary); + if (!file) { + return std::nullopt; + } + return Bytes(std::istreambuf_iterator(file), std::istreambuf_iterator()); +} + +bool WriteFileReplacing(const std::string& path, const Bytes& bytes) { + const std::string temp = path + ".tmp"; + { + std::ofstream file(temp, std::ios::binary | std::ios::trunc); + if (!file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) { + return false; + } + } + return MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; +} + +std::string WorldSection(int difficulty) { + return "world" + std::to_string(difficulty); +} + +bool CharacterFile::Open() { + sections_.clear(); + std::ifstream file(path_, std::ios::binary); + uint8_t header[kFileHeaderSize]; + if (!file.read(reinterpret_cast(header), sizeof(header)) || std::memcmp(header, kMagic, 4) != 0) { + return false; + } + uint32_t count = 0; + std::memcpy(&count, header + 8, 4); + for (uint32_t i = 0; i < count; ++i) { + uint8_t entry[kSectionEntrySize]; + if (!file.read(reinterpret_cast(entry), sizeof(entry))) { + return false; + } + Section section; + const auto* name = reinterpret_cast(entry); + section.name.assign(name, strnlen(name, kSectionNameSize)); + std::memcpy(§ion.offset, entry + kSectionNameSize, 4); + std::memcpy(§ion.size, entry + kSectionNameSize + 4, 4); + sections_.push_back(std::move(section)); + } + meta = nlohmann::json::object(); + if (std::optional text = Read("meta")) { + meta = nlohmann::json::parse(text->begin(), text->end(), nullptr, false); + if (meta.is_discarded() || !meta.is_object()) { + meta = nlohmann::json::object(); + } + } + return true; +} + +bool CharacterFile::Has(const std::string& name) const { + for (const Section& section : sections_) { + if (section.name == name) { + return true; + } + } + return false; +} + +std::optional CharacterFile::Read(const std::string& name) const { + for (const Section& section : sections_) { + if (section.name != name) { + continue; + } + if (section.data) { + return section.data; + } + std::ifstream file(path_, std::ios::binary); + Bytes bytes(section.size); + if (!file.seekg(section.offset) || + !file.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) { + return std::nullopt; + } + return bytes; + } + return std::nullopt; +} + +void CharacterFile::Set(const std::string& name, Bytes bytes) { + for (Section& section : sections_) { + if (section.name == name) { + section.size = static_cast(bytes.size()); + section.data = std::move(bytes); + return; + } + } + Section section; + section.name = name; + section.size = static_cast(bytes.size()); + section.data = std::move(bytes); + sections_.push_back(std::move(section)); +} + +void CharacterFile::Remove(const std::string& name) { + std::erase_if(sections_, [&](const Section& section) { return section.name == name; }); +} + +bool CharacterFile::Save() { + const std::string text = meta.dump(2); + Set("meta", Bytes(text.begin(), text.end())); + + std::vector contents; + for (const Section& section : sections_) { + std::optional bytes = Read(section.name); + if (!bytes) { + return false; + } + contents.push_back(std::move(*bytes)); + } + Bytes out(kFileHeaderSize + sections_.size() * kSectionEntrySize); + std::memcpy(out.data(), kMagic, 4); + PutU32(out, 4, kVersion); + PutU32(out, 8, static_cast(sections_.size())); + for (size_t i = 0; i < sections_.size(); ++i) { + const size_t entry = kFileHeaderSize + i * kSectionEntrySize; + std::memcpy(out.data() + entry, sections_[i].name.data(), std::min(sections_[i].name.size(), kSectionNameSize - 1)); + PutU32(out, entry + kSectionNameSize, static_cast(out.size())); + PutU32(out, entry + kSectionNameSize + 4, static_cast(contents[i].size())); + out.insert(out.end(), contents[i].begin(), contents[i].end()); + } + if (!WriteFileReplacing(path_, out)) { + return false; + } + // The file now holds every section; offsets as just written. + for (size_t i = 0; i < sections_.size(); ++i) { + const size_t entry = kFileHeaderSize + i * kSectionEntrySize; + sections_[i].offset = U32(out, entry + kSectionNameSize); + sections_[i].size = U32(out, entry + kSectionNameSize + 4); + sections_[i].data.reset(); + } + return true; +} + +} // namespace mods::characters diff --git a/src/mods/charfile.h b/src/mods/charfile.h new file mode 100644 index 0000000..fa0433c --- /dev/null +++ b/src/mods/charfile.h @@ -0,0 +1,82 @@ +#pragma once +// Character files (docs/characters.md): one file per character with its shared +// hero and one world per difficulty, plus reading the game's save files. + +#include +#include +#include +#include + +#include + +namespace mods::characters { + +using Bytes = std::vector; + +// Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4). +constexpr int kDifficultyCount = 5; + +// What the game's save files (GAME.PAK "AMS", hero files .pax "AMH") say about +// their hero: header 0x100 bytes, then a table of 64 chunks {type, offset, +// size}; chunk 0xc3 is the hero header. +struct SaveInfo { + std::wstring name; // hero header +8, UTF-16 + uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin + uint32_t level = 0; // hero header +0 + bool world = false; // a save game (AMS), not a hero file + int difficulty = 0; // AMS header +0x84 (g_gameLevel) + uint32_t mode = 0; // AMS header +0x24: 2 normal, 3 hardcore + bool multiplayer = false; // AMS header +0x2c + uint64_t savedAt = 0; // AMS header +0x5c SYSTEMTIME, as FILETIME +}; +std::optional ReadSaveInfo(const Bytes& file); + +// Writes `name` as the hero's name (hero header +8, 0x40 UTF-16 characters +// with the terminator), the only place the name is stored; false if the file +// has no hero header or the name is empty or too long. +constexpr size_t kHeroNameMaxChars = 63; +bool SetHeroName(Bytes& file, const std::wstring& name); + +// A small save game the game's load window can list: `world`'s header with its +// date, its thumbnail and world time, and `description` (ANSI, up to 63 +// characters) as the save's name. +Bytes BuildPreview(const Bytes& world, const std::string& description); + +// Reading and writing whole files (paths in the game's code page). +std::optional ReadFile(const std::string& path); +bool WriteFileReplacing(const std::string& path, const Bytes& bytes); + +// A character file: named sections ("meta" JSON, "hero" .pax, "world" +// GAME.PAK). Sections are read from disk on demand; Save writes all of them +// to a temporary file and replaces the old one. +class CharacterFile { +public: + explicit CharacterFile(std::string path) : path_(std::move(path)) {} + + // Reads the section table and the meta; false if the file is missing or + // not a character file. + bool Open(); + + const std::string& Path() const { return path_; } + bool Has(const std::string& section) const; + std::optional Read(const std::string& section) const; + void Set(const std::string& section, Bytes bytes); + void Remove(const std::string& section); + bool Save(); + + nlohmann::json meta = nlohmann::json::object(); + +private: + struct Section { + std::string name; + uint32_t offset = 0; // in the file; unused once `data` is set + uint32_t size = 0; + std::optional data; // set or changed since Open + }; + std::string path_; + std::vector
sections_; +}; + +std::string WorldSection(int difficulty); + +} // namespace mods::characters diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp index 5b0a1e7..09612f1 100644 --- a/src/mods/hud.cpp +++ b/src/mods/hud.cpp @@ -695,8 +695,7 @@ HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center) HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center) HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game // cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp. -HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center) -HUD_RENDER_FRAMED(0x0071dd80, UiSavegame_Render) +// cUI_Savegame (0x71d130 / 0x71dd80): hooked by mods/characters.cpp. HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center) HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center) diff --git a/src/mods/input.cpp b/src/mods/input.cpp index 7fad430..8371bf1 100644 --- a/src/mods/input.cpp +++ b/src/mods/input.cpp @@ -26,7 +26,7 @@ // AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys // ; Space/B/Q/W/E/R are switched off) // PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up) -// SaveGame=K ; what S did (save game window) +// SaveGame=K ; save the game at once (S opened the save window) // Map=M ; open / close the map (the game's M is switched off) // OverviewMap=Tab ; the overview map while held (the game's Tab is switched off) // Labels=Alt ; names of items and creatures while held (the game's Alt is switched off) @@ -45,6 +45,7 @@ #include "game/engine/items.h" #include "game/engine/orders.h" #include "game/ui/windows.h" +#include "mods/characters.h" #include "mods/skillbar.h" #include "mods/skillkeys.h" #include "re/config.h" @@ -252,24 +253,6 @@ void OnTick(uint8_t* engine) { } } -// The save game window, as the UI manager's S hotkey opens it (0x757775): -// only with the window built (manager slot 0x2b) and no blocking window open; -// it posts an engine event (code 0x13, 5, 1; with 0 for the kernel's second -// argument) and shows UI_WND_SAVEGAME. -constexpr int kSaveGameSlot = 0x2b; -constexpr uint32_t kEngineEventVtable = 0x0088eb38; -constexpr uint32_t kEngineEventSaveGame = 0x13; - -void OpenSaveGame() { - uint32_t* ui = game::UiManager(); - if (!ui || !ui[kSaveGameSlot] || !game::UiCanOpenWindow(ui)) { - return; - } - uint32_t event[9] = {kEngineEventVtable, kEngineEventSaveGame, 5, 1}; - game::PostEvent(event, 0); - game::SendWindowCommand(ui, "UI_WND_SAVEGAME", game::WindowCommand::Show); -} - // The map (cUI_Megamap, manager slot 0x25), as the UI manager's M hotkey // toggles it (cUI_Manager::ReceiveEvent 0x757120): open, M closes it; else // nothing while one of the windows it checks first is open (slots 0x2b save @@ -376,7 +359,7 @@ void Press(const Binding& binding, int vk) { mods::skillbar::SelectWeaponSet(slot); break; case Kind::SaveGame: - OpenSaveGame(); + characters::RequestSave(); // at once, no save window break; case Kind::PickUp: g_pickUp = true; diff --git a/src/mods/options.cpp b/src/mods/options.cpp index b13e743..3574223 100644 --- a/src/mods/options.cpp +++ b/src/mods/options.cpp @@ -28,6 +28,7 @@ #include +#include "game/ui/button.h" #include "game/ui/control.h" #include "game/ui/draw.h" #include "game/ui/events.h" @@ -326,10 +327,6 @@ game::UiRect OnWindow(void* window, const game::UiRect& r) { return {origin.x + r.x, origin.y + r.y, r.w, r.h}; } -bool Inside(const game::UiRect& r, int x, int y) { - return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h; -} - void FillBox(void* device, const game::UiRect& r, uint32_t colour) { game::FillRect(device, static_cast(r.x), static_cast(r.y), static_cast(r.x + r.w), static_cast(r.y + r.h), colour); @@ -412,25 +409,13 @@ void ResetPending() { // The binding whose box is at (x, y), -1 if none. int BoxAt(void* window, int x, int y) { for (const Line& line : Layout()) { - if (line.kind == LineKind::Binding && Inside(BoxRect(window, line), x, y)) { + if (line.kind == LineKind::Binding && game::Inside(BoxRect(window, line), x, y)) { return line.binding; } } return -1; } -// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain -// ret). 0x7184e0 sets up Cancel and OK the same way: images (+0x54..+0x87), -// sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text id, font 1) -> -// +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name (0x732090). -constexpr size_t kButtonSize = 0xbc; -constexpr size_t kButtonLook = 0x54; -constexpr size_t kButtonLookEnd = 0x88; -constexpr size_t kButtonSounds = 0x90; -constexpr size_t kButtonSoundsEnd = 0xa0; -constexpr size_t kButtonCaptionOffset = 0xb0; // x, y (int16) -constexpr uint32_t kButtonFlags = 5; // 0x731e70, as 0x7184e0 passes - void PushControl(uint8_t* window, void* control) { using PushFn = void(__fastcall*)(void*, void*, void**); reinterpret_cast(0x006bcf50)(window + kWindowControls, nullptr, &control); @@ -439,27 +424,17 @@ void PushControl(uint8_t* window, void* control) { // "Alle zurücksetzen": a button with Cancel's look. uint8_t* CreateResetButton(uint8_t* window) { static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen"); - uint8_t* cancel = Field(window, kCancelButton); - void* memory = cancel ? game::GameNew(kButtonSize) : nullptr; - if (!memory) { + const uint8_t* cancel = Field(window, kCancelButton); + if (!cancel) { return nullptr; } - using ConstructFn = uint8_t*(__fastcall*)(void*, void*); - uint8_t* button = reinterpret_cast(0x0072a180)(memory, nullptr); - using FlagsFn = void(__fastcall*)(void*, void*, uint32_t); - reinterpret_cast(0x00731e70)(button, nullptr, kButtonFlags); - std::copy(cancel + kButtonLook, cancel + kButtonLookEnd, button + kButtonLook); - std::copy(cancel + kButtonSounds, cancel + kButtonSoundsEnd, button + kButtonSounds); - std::copy(cancel + kButtonCaptionOffset, cancel + kButtonCaptionOffset + 4, button + kButtonCaptionOffset); - auto* rect = reinterpret_cast(button + game::kControlRect); - *rect = *reinterpret_cast(cancel + game::kControlRect); - rect->x = kResetX; - rect->w = kResetWidth; - using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t); - reinterpret_cast(0x0072ad60)(button, nullptr, kCaption, 1); - using NameFn = void(__fastcall*)(void*, void*, const char*); - reinterpret_cast(0x00732090)(button, nullptr, "UI_BTN_KEYS_RESET"); - PushControl(window, button); + game::UiRect rect = *reinterpret_cast(cancel + game::kControlRect); + rect.x = kResetX; + rect.w = kResetWidth; + uint8_t* button = game::NewButtonLike(cancel, rect, kCaption, "UI_BTN_KEYS_RESET"); + if (button) { + PushControl(window, button); + } return button; } @@ -551,7 +526,7 @@ game::UiRect LootBoxRect(void* window, int row, int mode) { int LootBoxAt(void* window, int x, int y) { for (int category = 0; category < mods::lootfilter::kCategoryCount; ++category) { for (int mode = 0; mode < mods::lootfilter::kModeCount; ++mode) { - if (Inside(LootBoxRect(window, category + 1, mode), x, y)) { + if (game::Inside(LootBoxRect(window, category + 1, mode), x, y)) { return category * mods::lootfilter::kModeCount + mode; } } @@ -690,7 +665,7 @@ int EventBody(void* context) { if (g_capturing >= 0) { StopCapture(); } - if (left && w->resetButton && Inside(game::ControlScreenRect(w->resetButton), x, y)) { + if (left && w->resetButton && game::Inside(game::ControlScreenRect(w->resetButton), x, y)) { ResetPending(); // the button still gets the click (pressed look, sound) break; } diff --git a/src/mods/userfolders.cpp b/src/mods/userfolders.cpp index 858ad8b..6c3fdab 100644 --- a/src/mods/userfolders.cpp +++ b/src/mods/userfolders.cpp @@ -17,6 +17,10 @@ // folder's files are copied into it (the originals stay where they were). // Config.exe and gameserver.exe are separate programs and still use the game // folder. +// +// The game's own view of the save folder can be moved into a subfolder +// (SetGameSaveSubfolder): the characters mod (characters.cpp) keeps the game's +// files in a work folder and its own files beside it. #include "mods/userfolders.h" @@ -136,6 +140,13 @@ Folders FindFolders() { return folders; } +// Below the save folder, with leading separator ("\characters\_work"); empty: +// the save folder itself. +std::string& GameSaveSubfolder() { + static std::string subfolder; + return subfolder; +} + // Looked up on first use, not at startup: the hooks are installed under the // loader lock, where the shell's folder lookup must not run. const Folders& UserFolders() { @@ -166,15 +177,18 @@ const char* Redirect(const char* path, std::string& buffer) { return nullptr; } const Folders& folders = UserFolders(); - if (folders.save.empty()) { - return nullptr; - } if (save) { - buffer = folders.save; + if (folders.save.empty() && GameSaveSubfolder().empty()) { + return nullptr; + } + buffer = (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + GameSaveSubfolder(); for (char c : rest.substr(first.size())) { buffer += c == '/' ? '\\' : c; } } else { + if (folders.config.empty()) { + return nullptr; + } buffer = folders.config + "\\" + kSettingsFile; } return buffer.c_str(); @@ -188,6 +202,10 @@ const char* Mapped(const char* path, std::string& buffer) { } // namespace +void SetGameSaveSubfolder(const std::string& subfolder) { + GameSaveSubfolder() = subfolder; +} + std::string SaveDir() { const Folders& folders = UserFolders(); return (folders.save.empty() ? GameDir() + kSaveFolder : folders.save) + "\\"; diff --git a/src/mods/userfolders.h b/src/mods/userfolders.h index ae98c62..14153b1 100644 --- a/src/mods/userfolders.h +++ b/src/mods/userfolders.h @@ -8,4 +8,9 @@ namespace mods::userfolders { // or the game's own save\ when the Documents folder is unusable. std::string SaveDir(); +// The game's save folder becomes SaveDir() + `subfolder` (leading separator, +// e.g. "\characters\_work"); SaveDir() itself stays for sacredK's files. +// Set during static initialisation, before the game touches a file. +void SetGameSaveSubfolder(const std::string& subfolder); + } // namespace mods::userfolders diff --git a/tools/names.txt b/tools/names.txt index 7a16160..c32034e 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -329,3 +329,18 @@ 0x006116c0 cEngine::dropItem # the player's drop into the world 0x005fc910 cObjectManager::RemoveFromMap # (id), ret 4 0x00819580 Options_PickupAuto # PICKUPAUTO from the config (0 all, 1 valuable, 2 gold) +0x0061d190 LoadHeroFile # cdecl (path, 1, 0): a hero file onto the player hero (whole object, position too) +0x0061cda0 cEngine::SaveHero # cdecl (path): the player hero as a hero file +0x0061b790 cEngine::Load # (path, quiet), ret 8: reads a GAME.PAK +0x00619c90 cEngine::Save # (path), ret 4; render thread, engine +0x54 & 0x40 +0x0060dc00 cEngine::InitGame # (int), ret 4: new game, start hero +0x0071c520 cUI_Savegame::Action # (action), ret 4: 2 save, 4 load, 8 delete, 10 rescan, 0xb default +0x0071e330 cUI_Savegame::Scan # (dir, multiplayer), ret 8: "./SAVE/*.PAK" +0x0071be90 cUI_Savegame::SendSaveLoad # (kind, row): engine event 0xf +0x00755ac0 cCommand_armaPlay::execute # posts 0x8002 (mode, argument) to the main window +0x007d7c40 Session_Instance +0x007d7cf0 Session::SetHeroClass # (class) -> +0x44, ret 4 +0x00417e50 GameSetup_Instance # 0x12e6c bytes; +0 set by both character-screen starts +0x006bb870 cUI_EscMenu::BuildLines # line actions; "Spielstaende" text 0x41b at 0x6bc1f7 +0x00810af0 BringWindowToFront # (hwnd, timeout), ret 8; loops forever with timeout -1 +0x0072ad60 cUI_Button2::SetCaption # (text id, font), ret 8 -> +0xa4 / +0xa0