From 732d9c2abba2b8b25fe6bd85dca6a1dec8a815eb Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Tue, 20 Oct 2026 22:41:20 +0200 Subject: [PATCH] Characters: Laden in the Esc menu, timed autosave - Esc menu: "Exportieren" became "Laden"; it opens the load window, always in load mode. In a game every world loads (the current one reloads); a "(neu)" world stops the game (cCommand_armaStop) and starts the new one without the main menu. The character being played cannot be deleted. - Silent autosave every sacredK.ini [characters] AutosaveMinutes (default 10, 0 = off) into the character's autosave file. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/characters.md | 30 +++++-- docs/todo.md | 14 ++- src/mods/characters.cpp | 185 +++++++++++++++++++++++++++------------- tools/names.txt | 3 + 4 files changed, 161 insertions(+), 71 deletions(-) diff --git a/docs/characters.md b/docs/characters.md index c46c4d6..ee1f4f6 100644 --- a/docs/characters.md +++ b/docs/characters.md @@ -261,21 +261,34 @@ game the character's. number). Story autosaves (engine event 0xf subtype 2 with the flag at +0x18, noted in `cEngine::ReceiveEvent`) go into the autosave file. +Silent autosave: 10 minutes after the last save of any kind (or the game's +start), `SaveOnTick` does the direct save marked as an autosave, so it goes +into the autosave file. While a window is open or a save runs it waits for +the next tick. A character never saved yet gets its character file from it. +Interval: `sacredK.ini` `[characters] AutosaveMinutes` (default 10, 0 off). + 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. +used by other windows too). -The Esc menu has no "Exportieren" (request 2026-10-20): `cUI_EscMenu::BuildLines` -(0x6bb870, called by the constructor and on an event) makes five -cUI_StaticText64 lines in the vector +0x158, 0x24 apart; each handles its own -events. After it the UI_ESC_EXPORT line is removed and deleted, the lines -below move up, and the window (0x108 x 0xc8) loses 0x24 of height, staying -centred. With it the export screen (cUI_Character mode 5) is unreachable; -its mod (charexport.cpp, an erase button for export slots) was deleted. +The Esc menu: "Exportieren" became "Laden" (requests 2026-10-20). The line +(text id 0x419 pushed at 0x6bc272, now "Laden") keeps its command, which shows +UI_CHARACTER (the export screen) and hides the menu; in a game +`UiManager_ReceiveEvent` turns that into showing the load window. The load +window always opens in load mode (`cUI_Savegame::Show` vf24 0x71cfb0 picks +save mode in a game; `SetMode` 0x71c4f0 (2) after it). In a game every +world can be loaded, the current one too (a reload from its last save; the +game's in-game load, engine flag 0x200). A "(neu)" world needs a new engine: +the running game stops as "Beenden" stops it (`cCommand_armaStop`, execute +0x755b30, vtable 0x88ee2c: posts 0x8001 to the main window, whose handler +deletes the engine), and when the engine is gone (`OnGameEnd`) the new game's +start command follows (0x8002), without the main menu. The character being +played cannot be deleted. The export screen (cUI_Character +mode 5) is unreachable; its mod (charexport.cpp, an erase button for export slots) was deleted. ### Migration @@ -286,6 +299,5 @@ kept, the newest file overall is the shared hero. ## Open -- 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 f9d0787..8e15ae6 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -184,14 +184,22 @@ From the characters rework (`docs/characters.md`, request of 2026-10-19): are one byte per difficulty in cStatsManager +0x5704 (read 0x424be0) and travel in the save header (+0x88..+0x98) and the hero file. Done 2026-10-20 (docs/characters.md). -- **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. +- **Autosave interval in the options window** (now only + `sacredK.ini` `[characters] AutosaveMinutes`). - **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). +## Two saves in a row (2026-10-20, not reproduced) + +Once two real saves ("saved", not "autosaved") went into the same character +1.8 s apart, about 77 s after loading, with the timed autosave off. Save +flag 0x40 is set in cEngine::ReceiveEvent at 0x618876 (quicksave), +0x618f08 and 0x6190c5 (save event 0xf: +0xc row, +0x18 autosave flag). A +log of the event that sets the flag (dwords 0..9) did not catch it again; +add it back if it shows. + ## Savegames screen redesign ("Spielen") Asked 2026-10-20: redesign the load window (cUI_Savegame, the "Spielen" diff --git a/src/mods/characters.cpp b/src/mods/characters.cpp index aa9ec56..7377c44 100644 --- a/src/mods/characters.cpp +++ b/src/mods/characters.cpp @@ -56,6 +56,7 @@ #include "mods/hud.h" #include "mods/input.h" #include "mods/userfolders.h" +#include "re/config.h" #include "re/hook.h" #include "re/patch.h" #include "re/log.h" @@ -368,7 +369,11 @@ struct Current { std::string file; } g_current; -bool g_autosavePending = false; +// The next cEngine::Save (render thread) is an autosave: a story autosave +// (Engine_ReceiveEvent) or the timed one (SaveOnTick). +std::atomic g_autosavePending{false}; +bool g_inGame = false; // a game runs (the load window opens from the Esc menu too) +std::atomic g_lastSaveAt{0}; // GetTickCount64 of the last save of any kind std::string Description(const json& meta) { return Convert(FromUtf8(meta.value("name", std::string())) + L" - " + ClassName(meta.value("class", 0u)), @@ -436,7 +441,8 @@ void RebuildList() { highest = std::max(highest, std::min(d + 1, kDifficultyCount - 1)); } } - // New games need the shared hero; multiplayer games are not ours. + // New games need the shared hero (in a game the running one stops + // first, StartNewWorld); multiplayer games are not ours. const bool canStart = hasHero && !*reinterpret_cast(kMultiplayer); for (int c = 0; c < kCampaignCount; ++c) { for (int d = 0; d < kDifficultyCount; ++d) { @@ -840,7 +846,6 @@ const Row* EditableRow(uint8_t* window) { // "Neuer Charakter": in the main menu's load window (not in a game, not in // multiplayer). -bool g_inGame = false; bool NewCharacterAllowed(uint8_t* window) { return *reinterpret_cast(window + kSavegameMode) == kSavegameModeLoad && !g_inGame && @@ -968,7 +973,12 @@ void __fastcall Savegame_Action(uint8_t* self, void* edx, int action) { GameCampaign() = static_cast(row->choices[row->chosen].campaign); } } - if (const Row* row = action == kSavegameActionDelete ? SelectedRow(self) : nullptr) { + const Row* toDelete = action == kSavegameActionDelete ? SelectedRow(self) : nullptr; + if (toDelete && g_inGame && toDelete->file == g_current.file) { + re::Log("characters: %s is being played, not deleted", toDelete->file.c_str()); + return; + } + if (const Row* row = toDelete) { const std::string deleted = CharactersDir() + "deleted\\"; CreateDirectoryA(deleted.c_str(), nullptr); for (const std::string& file : {row->file, AutosavePath(row->file)}) { @@ -1026,6 +1036,33 @@ void StartNewWorld(uint8_t* window, const Row& row, int campaign, int difficulty reinterpret_cast(0x00755ac0)(&command, nullptr); } +// A new world from a running game: the game stops as the Esc menu's "Beenden" +// stops it (cCommand_armaStop, execute 0x755b30, thiscall, plain ret: posts +// 0x8001 to the main window, whose handler 0x811fa6 deletes the engine), and +// the new game starts once the engine is gone (OnGameEnd): its start command +// (0x8002) is handled right after the stop, without the main menu. +struct PendingStart { + uint8_t* window = nullptr; + Row row; + Choice world; +} g_pendingStart; + +void StopGameThenStart(uint8_t* window, const Row& row, const Choice& world) { + g_pendingStart = {window, row, world}; + re::Log("characters: stopping the game for a new world of %s", row.file.c_str()); + struct StopCommand { + uint32_t vtable; + } command{0x0088ee2c}; + using ExecuteFn = void(__fastcall*)(void*, void*); + reinterpret_cast(0x00755b30)(&command, nullptr); +} + +void StartPendingWorld() { + if (const PendingStart pending = std::exchange(g_pendingStart, {}); pending.window) { + StartNewWorld(pending.window, pending.row, pending.world.campaign, pending.world.difficulty); + } +} + // A new character in `campaign`: the game's character creation, as the main // menu's campaign entries opened it (window command show UI_CHARACTER, the // campaign set first); its first save makes the character file. @@ -1091,7 +1128,11 @@ void DialogChoose(uint8_t* window, size_t index) { Savegame_Action_original(window, nullptr, kSavegameActionRescan); } else if (const Row row = g_rows[g_dialog.row]; row.choices[index].newWorld) { CloseDialog(); - StartNewWorld(window, row, row.choices[index].campaign, row.choices[index].difficulty); + if (g_inGame) { + StopGameThenStart(window, row, row.choices[index]); + } else { + StartNewWorld(window, row, row.choices[index].campaign, row.choices[index].difficulty); + } } else { g_rows[g_dialog.row].chosen = index; GameCampaign() = static_cast(row.choices[index].campaign); @@ -1337,8 +1378,8 @@ 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 bool autosave = g_autosavePending.exchange(false); + g_lastSaveAt = GetTickCount64(); const std::string work = WorkDir(); DeleteFileA((work + kWorkWorld).c_str()); const uint32_t result = Engine_Save_original(self, edx, kGameWorld); @@ -1390,16 +1431,35 @@ constexpr size_t kEngineSaveDescription = 0x6a; // char[], "QUICKSAVE" for quic constexpr char kSaveDescription[] = "SAVE"; // not shown: the list shows the characters std::atomic g_saveRequested{false}; +// Silent autosave (request of 2026-10-19: every 10 minutes, a setting later): +// the same save, into the autosave file (Engine_Save with g_autosavePending), +// sacredK.ini [characters] AutosaveMinutes (default 10, 0: off) after the +// last save of any kind. While a window is open or a +// save runs it waits for the next tick. A character never saved yet gets its +// file from it (the list shows character files only). +constexpr int kAutosaveMinutesDefault = 10; + +uint64_t AutosaveIntervalMs() { + static const int minutes = re::ConfigInt("characters", "AutosaveMinutes", kAutosaveMinutesDefault); + return minutes > 0 ? uint64_t(minutes) * 60 * 1000 : UINT64_MAX; +} + void SaveOnTick(uint8_t* engine) { - if (!g_saveRequested.exchange(false)) { + const bool requested = g_saveRequested.exchange(false); + const bool autosaveDue = GetTickCount64() - g_lastSaveAt >= AutosaveIntervalMs(); + if (!requested && !autosaveDue) { return; } auto& flags = *reinterpret_cast(engine + kEngineFlags); uint32_t* ui = game::UiManager(); if (!ui || !game::UiCanOpenWindow(ui) || (flags & kEngineSaving)) { - re::Log("characters: no save now (a window is open or a save is running)"); + if (requested) { + re::Log("characters: no save now (a window is open or a save is running)"); + } return; } + g_autosavePending = !requested; + g_lastSaveAt = GetTickCount64(); using CaptureFn = void(__fastcall*)(void*, void*, int); reinterpret_cast(0x00613710)(engine, nullptr, 1); std::memcpy(engine + kEngineSaveDescription, kSaveDescription, sizeof(kSaveDescription)); @@ -1408,69 +1468,68 @@ void SaveOnTick(uint8_t* engine) { } // 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. +// window commands by name (game/ui/windows.h). In a game: +// - showing the save window (the Esc menu's "Speichern") saves instead; +// - showing UI_CHARACTER (the Esc menu's "Laden", the former "Exportieren" +// line, whose command shows the export screen) opens the load window. +// The main menu's load window opens as before. constexpr size_t kUiEventCode = 4; constexpr size_t kUiEventSubtype = 8; constexpr uint32_t kUiEventWindowSubtype = 5; constexpr size_t kUiEventWindowName = 0x48; // char[0x20] +constexpr char kCharacterWindowName[] = "UI_CHARACTER"; + +thread_local bool g_openingLoadWindow = false; // our own Show of the save window passes + +bool IsShowCommand(const uint8_t* event, const char* window, size_t size) { + return *reinterpret_cast(event + kUiEventCode) == + static_cast(game::WindowCommand::Show) && + *reinterpret_cast(event + kUiEventSubtype) == kUiEventWindowSubtype && + std::strncmp(reinterpret_cast(event + kUiEventWindowName), window, size) == 0; +} int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event); RE_HOOK(0x00757120, UiManager_ReceiveEvent); int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event) { - if (g_inGame && *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; + if (g_inGame && !g_openingLoadWindow) { + if (IsShowCommand(event, kSaveWindowName, sizeof(kSaveWindowName))) { + RequestSave(); + return 1; + } + if (IsShowCommand(event, kCharacterWindowName, sizeof(kCharacterWindowName))) { + g_openingLoadWindow = true; + game::SendWindowCommand(static_cast(self), kSaveWindowName, game::WindowCommand::Show); + g_openingLoadWindow = false; + return 1; + } } return UiManager_ReceiveEvent_original(self, edx, event); } -// 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 Esc menu without "Exportieren" (request of 2026-10-20; exported heroes -// are gone). cUI_EscMenu::BuildLines (0x6bb870, thiscall, plain ret; called -// by the constructor 0x6bae60 and on an event, 0x6bc497) makes five -// cUI_StaticText64 lines (constructor 0x753470) in the vector at +0x158, 0x24 -// apart (y 8 .. 0x98); each line handles its own events. The window is -// 0x108 x 0xc8, centred (0x6bae9b). The export line goes, the lines below it -// move up and the window loses one line's height, staying centred. -constexpr size_t kEscMenuLines = 0x158; -constexpr char kEscExportLine[] = "UI_ESC_EXPORT"; -constexpr int kEscLinePitch = 0x24; -constexpr int16_t kEscMenuHeight = 0xc8; - -void __fastcall EscMenu_BuildLines(uint8_t* self, void* edx); -RE_HOOK(0x006bb870, EscMenu_BuildLines); -void __fastcall EscMenu_BuildLines(uint8_t* self, void* edx) { - EscMenu_BuildLines_original(self, edx); - bool removed = false; - for (uint8_t* line : game::ControlsIn(self, kEscMenuLines)) { - auto& rect = *reinterpret_cast(line + game::kControlRect); - if (removed) { - rect.y -= kEscLinePitch; - } else if (game::ControlNamed(line, kEscExportLine)) { - game::RemoveControl(self, kEscMenuLines, line); - game::DeleteControl(line); - removed = true; - } - } - auto& window = *reinterpret_cast(self + game::kControlRect); - if (removed && window.h == kEscMenuHeight) { - window.h -= kEscLinePitch; - window.y += kEscLinePitch / 2; - } - if (!removed) { - re::Log("characters: Esc menu has no %s line", kEscExportLine); +// cUI_Savegame::Show (vf24 0x71cfb0, thiscall (shown), ret 4) picks load mode +// (2) in the main menu and save mode (1) in a game (0x71cfd2). Saving is +// direct now: the window always opens for loading (SetMode 0x71c4f0, +// thiscall (mode), ret 4; it also disables the hidden "Speichern" button). +void __fastcall Savegame_Show(uint8_t* self, void* edx, char shown); +RE_HOOK(0x0071cfb0, Savegame_Show); +void __fastcall Savegame_Show(uint8_t* self, void* edx, char shown) { + Savegame_Show_original(self, edx, shown); + if (shown && *reinterpret_cast(self + kSavegameMode) == kSavegameModeSave) { + using SetModeFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(0x0071c4f0)(self, nullptr, kSavegameModeLoad); } } +// The Esc menu (cUI_EscMenu::BuildLines 0x6bb870): "Spielstände" (text id +// 0x41b pushed at 0x6bc1f7; the id is used by other windows too) reads +// "Speichern"; "Exportieren" (text id 0x419 pushed at 0x6bc272, its command +// shows UI_CHARACTER and hides the menu) reads "Laden" (request of +// 2026-10-20). +constexpr uint32_t kEscMenuSaveLineText = 0x006bc1f7; +constexpr uint32_t kSavegamesText = 0x41b; +constexpr uint32_t kEscMenuExportLineText = 0x006bc272; +constexpr uint32_t kExportText = 0x419; + // The 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, @@ -1529,14 +1588,22 @@ void Startup() { re::RedirectCall(kRowFileNameCall, kStaticSetText, reinterpret_cast(&SetRowSecondLine)); static const uint32_t kSaveText = game::RegisterText(L"Speichern"); re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText); + static const uint32_t kLoadText = game::RegisterText(L"Laden"); + re::PatchPushImm32(kEscMenuExportLineText, kExportText, kLoadText); } struct Init { Init() { userfolders::SetGameSaveSubfolder(kGameSaveSubfolder); game::OnEngineTick(&SaveOnTick); - game::OnGameStart([] { g_inGame = true; }); - game::OnGameEnd([] { g_inGame = false; }); + game::OnGameStart([] { + g_inGame = true; + g_lastSaveAt = GetTickCount64(); // the autosave counts from the start + }); + game::OnGameEnd([] { + g_inGame = false; + StartPendingWorld(); + }); } } init; diff --git a/tools/names.txt b/tools/names.txt index a2f713b..831513b 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -334,10 +334,13 @@ 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 +0x0071c4f0 cUI_Savegame::SetMode # (mode), ret 4: 1 save, 2 load (+0x75c) +0x0071cfb0 cUI_Savegame::Show # vf24 (bool), ret 4: picks save mode in a game 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 +0x00755b30 cCommand_armaStop::execute # vf04, plain ret: ends the game, posts 0x8001 to the main window (engine deleted, main menu) 0x007d7c40 Session_Instance 0x007d7cf0 Session::SetHeroClass # (class) -> +0x44, ret 4 0x00417e50 GameSetup_Instance # 0x12e6c bytes; +0 set by both character-screen starts