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) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-20 22:41:20 +02:00
1 parent feebe72ab7
commit 732d9c2abb
4 files changed
+161 -71

No files matched your search

+126 -59
View File
@@ -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<bool> g_autosavePending{false};
bool g_inGame = false; // a game runs (the load window opens from the Esc menu too)
std::atomic<uint64_t> g_lastSaveAt{0}; // GetTickCount64 of the last save of any kind
std::string Description(const json& meta) {
return Convert(FromUtf8(meta.value("name", std::string())) + L" - " + ClassName(meta.value("class", 0u)),
@@ -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<const uint8_t*>(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<uint32_t*>(window + kSavegameMode) == kSavegameModeLoad && !g_inGame &&
@@ -968,7 +973,12 @@ void __fastcall Savegame_Action(uint8_t* self, void* edx, int action) {
GameCampaign() = static_cast<uint16_t>(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<ExecuteFn>(0x00755ac0)(&command, nullptr);
}
// A new world from a running game: the game stops as the Esc menu's "Beenden"
// stops it (cCommand_armaStop, execute 0x755b30, thiscall, plain ret: posts
// 0x8001 to the main window, whose handler 0x811fa6 deletes the engine), and
// the new game starts once the engine is gone (OnGameEnd): its start command
// (0x8002) is handled right after the stop, without the main menu.
struct PendingStart {
uint8_t* window = nullptr;
Row row;
Choice world;
} g_pendingStart;
void StopGameThenStart(uint8_t* window, const Row& row, const Choice& world) {
g_pendingStart = {window, row, world};
re::Log("characters: stopping the game for a new world of %s", row.file.c_str());
struct StopCommand {
uint32_t vtable;
} command{0x0088ee2c};
using ExecuteFn = void(__fastcall*)(void*, void*);
reinterpret_cast<ExecuteFn>(0x00755b30)(&command, nullptr);
}
void StartPendingWorld() {
if (const PendingStart pending = std::exchange(g_pendingStart, {}); pending.window) {
StartNewWorld(pending.window, pending.row, pending.world.campaign, pending.world.difficulty);
}
}
// A new character in `campaign`: the game's character creation, as the main
// menu's campaign entries opened it (window command show UI_CHARACTER, the
// campaign set first); its first save makes the character file.
@@ -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<uint16_t>(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<bool> g_saveRequested{false};
// Silent autosave (request of 2026-10-19: every 10 minutes, a setting later):
// the same save, into the autosave file (Engine_Save with g_autosavePending),
// sacredK.ini [characters] AutosaveMinutes (default 10, 0: off) after the
// last save of any kind. While a window is open or a
// save runs it waits for the next tick. A character never saved yet gets its
// file from it (the list shows character files only).
constexpr int kAutosaveMinutesDefault = 10;
uint64_t AutosaveIntervalMs() {
static const int minutes = re::ConfigInt("characters", "AutosaveMinutes", kAutosaveMinutesDefault);
return minutes > 0 ? uint64_t(minutes) * 60 * 1000 : UINT64_MAX;
}
void SaveOnTick(uint8_t* engine) {
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<uint32_t*>(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<CaptureFn>(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<const uint32_t*>(event + kUiEventCode) ==
static_cast<uint32_t>(game::WindowCommand::Show) &&
*reinterpret_cast<const uint32_t*>(event + kUiEventSubtype) == kUiEventWindowSubtype &&
std::strncmp(reinterpret_cast<const char*>(event + kUiEventWindowName), window, size) == 0;
}
int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event);
RE_HOOK(0x00757120, UiManager_ReceiveEvent);
int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event) {
if (g_inGame && *reinterpret_cast<uint32_t*>(event + kUiEventCode) == static_cast<uint32_t>(game::WindowCommand::Show) &&
*reinterpret_cast<uint32_t*>(event + kUiEventSubtype) == kUiEventWindowSubtype &&
std::strncmp(reinterpret_cast<const char*>(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<uint32_t*>(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<game::UiRect*>(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<game::UiRect*>(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<uint32_t*>(self + kSavegameMode) == kSavegameModeSave) {
using SetModeFn = void(__fastcall*)(void*, void*, uint32_t);
reinterpret_cast<SetModeFn>(0x0071c4f0)(self, nullptr, kSavegameModeLoad);
}
}
// The Esc menu (cUI_EscMenu::BuildLines 0x6bb870): "Spielstände" (text id
// 0x41b pushed at 0x6bc1f7; the id is used by other windows too) reads
// "Speichern"; "Exportieren" (text id 0x419 pushed at 0x6bc272, its command
// shows UI_CHARACTER and hides the menu) reads "Laden" (request of
// 2026-10-20).
constexpr uint32_t kEscMenuSaveLineText = 0x006bc1f7;
constexpr uint32_t kSavegamesText = 0x41b;
constexpr uint32_t kEscMenuExportLineText = 0x006bc272;
constexpr uint32_t kExportText = 0x419;
// The load window's second line of a row showed the (preview) file name; it
// shows the level and the chosen difficulty instead. Row texts are set every
// frame (0x71d830): line 1 the save's name (+0x204), line 2 the file name (+4,
@@ -1529,14 +1588,22 @@ void Startup() {
re::RedirectCall(kRowFileNameCall, kStaticSetText, reinterpret_cast<const void*>(&SetRowSecondLine));
static const uint32_t kSaveText = game::RegisterText(L"Speichern");
re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText);
static const uint32_t kLoadText = game::RegisterText(L"Laden");
re::PatchPushImm32(kEscMenuExportLineText, kExportText, kLoadText);
}
struct Init {
Init() {
userfolders::SetGameSaveSubfolder(kGameSaveSubfolder);
game::OnEngineTick(&SaveOnTick);
game::OnGameStart([] { g_inGame = true; });
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;