Characters: campaigns, "Spielen" main menu, story unlocks

- One world per campaign and difficulty in a character file (sections
  ancaria<d> / underworld<d>; old world<d> renamed when read). Difficulty
  unlocks by the shared hero's story completions (hero header +0xed..),
  shared by both campaigns.
- Main menu: "Kampagne Ancaria", "Kampagne Underworld" and "Spielstaende"
  become "Spielen" (mods/mainmenu.cpp). The load window lists all
  characters, the world dialog offers both campaigns, "Neuer Charakter"
  asks for the campaign and opens character creation.
- Character window: no Underworld import mode (new Underworld heroes, all
  classes, slots light up), no "Importieren"; load window without
  "Speichern".

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 21:48:47 +02:00
1 parent 5bccb0734e
commit 618288e310
7 files changed
+498 -109

No files matched your search

+241 -86
View File
@@ -1,14 +1,17 @@
// 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).
// and one world per campaign and difficulty (a GAME.PAK as cEngine::Save
// writes it). The main menu's "Spielen" (mainmenu.cpp) opens the load window,
// which lists every character of both campaigns; "Neuer Charakter" there asks
// for the campaign and opens the game's character creation.
//
// The game keeps doing the work on its own files; only which files changes.
// Its save folder is a work folder (save\characters\_work, via userfolders):
// - the load window (cUI_Savegame) lists GAMEnn.PAK there: before it scans,
// one small preview per character is written (header, thumbnail,
// "Name - Klasse"); row nn of the game is g_rows[nn - 1]. Laden asks for
// the difficulty (worlds, newer autosaves, new worlds), "Umbenennen"
// campaign and difficulty (worlds, newer autosaves, new worlds), "Umbenennen"
// renames a character, deleting moves it to characters\deleted;
// - loading row nn (cEngine::Load) unpacks the chosen world and the hero into
// the work folder, loads the world and puts the hero onto the world's hero
@@ -83,14 +86,19 @@ constexpr const char* kClassTextKeys[] = {
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.
// Bronze and Silber are always open; completing the story on a difficulty
// opens the next one; every difficulty below the highest with a world stays
// open. One without a world starts a new game.
constexpr int kAlwaysOpenDifficulty = 1;
// Starting a game (as cUI_Character::executeAction IMPORT_DO, 0x70d637, and
// the load window's load, 0x71cc73, do it).
constexpr uintptr_t kMultiplayer = 0x0182cb6c; // byte; the load window loads by network event then, quickload refuses
constexpr uintptr_t kGameLevel = 0x0182cdc4; // g_gameLevel: the difficulty of the game to start / running
// g_campaign (u16): the campaign of the game to start / running. The main menu
// sets it by entry (0x712180..), cEngine::Load from the save header; the load
// window lists only saves of it (0x71e2b9).
constexpr uintptr_t kCampaign = 0x0182cdc8;
constexpr size_t kSessionSinglePlayer = 0x14; // set to 1 by both
constexpr size_t kSessionHeroClass = 0x44; // the start hero's class (Session::SetHeroClass 0x7d7cf0)
// cCommand_armaPlay (vtable 0x894ff4): execute (0x755ac0) posts 0x8002 (mode,
@@ -121,6 +129,7 @@ constexpr int kSavegameActionDelete = 8;
constexpr size_t kSavegameMode = 0x75c; // 1 save, 2 load, 3..5 a confirmation
constexpr uint32_t kSavegameModeLoad = 2;
constexpr size_t kSavegameLoadButton = 0x764; // cUI_Button2 inside the window (action 4)
constexpr char kSaveWindowName[] = "UI_WND_SAVEGAME";
// --- paths and texts -------------------------------------------------------
@@ -174,6 +183,14 @@ std::wstring DifficultyName(int difficulty) {
: std::wstring(L"?");
}
uint16_t& GameCampaign() {
return *reinterpret_cast<uint16_t*>(kCampaign);
}
std::wstring CampaignName(int campaign) {
return campaign == kCampaignUnderworld ? L"Underworld" : L"Ancaria";
}
// A file for a new character: CharacterNN.sacredK, the first free number
// (the hero's name is inside, in the meta).
std::string NewCharacterPath() {
@@ -248,7 +265,7 @@ void Migrate() {
uint64_t time = 0;
};
struct Group {
std::map<int, Source> worlds; // newest per difficulty
std::map<std::pair<int, int>, Source> worlds; // newest per (campaign, difficulty)
std::optional<Source> hero; // newest of all
std::vector<std::string> files;
};
@@ -265,9 +282,10 @@ void Migrate() {
Group& group = groups[{info->name, info->heroClass}];
group.files.push_back(file);
if (world) {
auto it = group.worlds.find(info->difficulty);
const std::pair<int, int> key{info->campaign, info->difficulty};
auto it = group.worlds.find(key);
if (it == group.worlds.end() || it->second.time < source.time) {
group.worlds[info->difficulty] = source;
group.worlds[key] = source;
}
}
if (!group.hero || group.hero->time < source.time) {
@@ -285,17 +303,21 @@ void Migrate() {
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));
for (const auto& [world, source] : group.worlds) {
character.Set(WorldSection(world.first, world.second), *ReadFile(saveDir + source.file));
}
if (group.hero->info.world) {
// The newest hero is in a world: it becomes the shared hero the
// first time that world is loaded (Engine_Load).
character.meta["heroFrom"] = group.hero->info.difficulty;
character.meta["heroFromCampaign"] = group.hero->info.campaign;
character.meta["lastDifficulty"] = group.hero->info.difficulty;
character.meta["lastCampaign"] = group.hero->info.campaign;
} else {
character.Set("hero", *ReadFile(saveDir + group.hero->file));
character.meta["lastDifficulty"] = group.worlds.empty() ? 0 : group.worlds.rbegin()->first;
const std::pair<int, int> last = group.worlds.empty() ? std::pair<int, int>{} : group.worlds.rbegin()->first;
character.meta["lastCampaign"] = last.first;
character.meta["lastDifficulty"] = last.second;
}
const bool saved = character.Save();
re::Log("characters: migrated %s (%zu files, %zu worlds, hero from %s)%s", character.Path().c_str(),
@@ -323,6 +345,7 @@ void EnsureFolders() {
// A world a row can load: a difficulty's save or its newer autosave.
struct Choice {
int campaign = kCampaignAncaria;
int difficulty = 0;
bool autosave = false;
bool newWorld = false; // no world yet: a new game with the shared hero
@@ -352,8 +375,19 @@ std::string Description(const json& meta) {
}
uint64_t SavedAt(const CharacterFile& file, int difficulty) {
std::optional<Bytes> world = file.Read(WorldSection(difficulty));
// "Ancaria - Silber", with "(neu)" / "(Autosave)".
std::wstring ChoiceName(const Choice& choice) {
std::wstring name = CampaignName(choice.campaign) + L" - " + DifficultyName(choice.difficulty);
if (choice.newWorld) {
name += L" (neu)";
} else if (choice.autosave) {
name += L" (Autosave)";
}
return name;
}
uint64_t SavedAt(const CharacterFile& file, int campaign, int difficulty) {
std::optional<Bytes> world = file.Read(WorldSection(campaign, difficulty));
std::optional<SaveInfo> info = world ? ReadSaveInfo(*world) : std::nullopt;
return info ? info->savedAt : 0;
}
@@ -376,37 +410,54 @@ void RebuildList() {
}
CharacterFile autosave(AutosavePath(character.Path()));
const int autosaved = autosave.Open() ? autosave.meta.value("difficulty", -1) : -1;
const int autosavedCampaign = autosave.meta.value("campaign", kCampaignAncaria);
const bool hasHero = character.Has("hero");
const int heroFrom = character.meta.value("heroFrom", -1);
const int heroFromCampaign = character.meta.value("heroFromCampaign", kCampaignAncaria);
Row row{character.Path()};
row.name = FromUtf8(character.meta.value("name", std::string()));
row.level = character.meta.value("level", 0u);
int highest = kAlwaysOpenDifficulty;
// The story completions are the shared hero's (or, before it exists,
// the world holding it); the game counts them per difficulty only, so
// they open the next difficulty in both campaigns (decided 2026-10-20).
std::optional<Bytes> heroFile =
character.Read(hasHero ? std::string("hero") : WorldSection(heroFromCampaign, heroFrom));
std::optional<SaveInfo> hero = heroFile ? ReadSaveInfo(*heroFile) : std::nullopt;
for (int d = 0; d < kDifficultyCount; ++d) {
if (SavedAt(character, d)) {
highest = std::max(highest, d);
for (int c = 0; c < kCampaignCount; ++c) {
if (SavedAt(character, c, d)) {
highest = std::max(highest, d);
}
}
if (hero && hero->storyCompleted[d]) {
highest = std::max(highest, std::min(d + 1, kDifficultyCount - 1));
}
}
// New games need the shared hero; multiplayer games are not ours.
const bool canStart = hasHero && !*reinterpret_cast<const uint8_t*>(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});
for (int c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) {
const uint64_t savedAt = SavedAt(character, c, d);
// Without the shared hero yet only the world holding it can be loaded.
if (savedAt && (hasHero || (c == heroFromCampaign && d == heroFrom))) {
row.choices.push_back({c, d});
} else if (!savedAt && d <= highest && canStart) {
row.choices.push_back({c, d, false, true});
}
if (c == autosavedCampaign && d == autosaved && SavedAt(autosave, c, d) > savedAt) {
row.choices.push_back({c, d, true});
}
}
}
// The preview shows the last played world (or any world).
std::optional<size_t> shownChoice;
const int last = character.meta.value("lastDifficulty", 0);
const int lastCampaign = character.meta.value("lastCampaign", kCampaignAncaria);
for (size_t i = 0; i < row.choices.size(); ++i) {
const Choice& choice = row.choices[i];
if (!choice.newWorld && (!shownChoice || (choice.difficulty == last && !choice.autosave))) {
if (!choice.newWorld &&
(!shownChoice || (choice.campaign == lastCampaign && choice.difficulty == last && !choice.autosave))) {
shownChoice = i;
}
}
@@ -416,14 +467,14 @@ void RebuildList() {
row.chosen = *shownChoice;
const Choice& shown = row.choices[row.chosen];
const CharacterFile& source = shown.autosave ? autosave : character;
std::optional<Bytes> world = source.Read(WorldSection(shown.difficulty));
std::optional<Bytes> world = source.Read(WorldSection(shown.campaign, shown.difficulty));
if (!world) {
continue;
}
g_rows.push_back(std::move(row));
char preview[32];
wsprintfA(preview, "GAME%02u.PAK", static_cast<unsigned>(g_rows.size()));
WriteFileReplacing(work + preview, BuildPreview(*world, Description(character.meta)));
WriteFileReplacing(work + preview, BuildPreview(*world, Description(character.meta), GameCampaign()));
}
re::Log("characters: %zu characters listed", g_rows.size());
}
@@ -559,14 +610,15 @@ constexpr int kSavegameActionDefault = 0xb; // double click: save or load by th
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.
// 416, back 672); "Umbenennen" and "Neuer Charakter" take the places below
// delete.
constexpr int kSavegameButtonPitch = 64;
// cUI_Savegame actions (0x71c520, thiscall (action), ret 4): defined below.
void __fastcall Savegame_Action(uint8_t* self, void* edx, int action);
RE_HOOK(0x0071c520, Savegame_Action);
enum class DialogKind { Difficulty, Rename };
enum class DialogKind { Difficulty, Rename, NewCharacter };
struct Dialog {
uint8_t* window = nullptr; // open while set
@@ -582,10 +634,11 @@ std::mutex g_nameLock;
std::wstring g_name;
std::atomic<int> g_nameKey = 0;
uint8_t* g_renameButton = nullptr;
// Entries: the row's choices or "Umbenennen", then "Abbrechen".
// Entries: the row's choices, "Umbenennen" or the campaigns, then "Abbrechen".
size_t DialogEntries() {
if (g_dialog.kind == DialogKind::NewCharacter) {
return kCampaignCount + 1;
}
if (g_dialog.row >= g_rows.size()) {
return 0;
}
@@ -623,11 +676,10 @@ std::wstring DialogText(size_t index) {
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)";
if (g_dialog.kind == DialogKind::NewCharacter) {
return CampaignName(static_cast<int>(index));
}
return choice.autosave ? DifficultyName(choice.difficulty) + L" (Autosave)" : DifficultyName(choice.difficulty);
return ChoiceName(g_rows[g_dialog.row].choices[index]);
}
// The character typed with key `vk` under the current modifiers, 0 if none.
@@ -702,8 +754,10 @@ bool RenameCharacter(const Row& row, const std::wstring& name) {
continue; // no autosave
}
std::vector<std::string> sections = {"hero"};
for (int d = 0; d < kDifficultyCount; ++d) {
sections.push_back(WorldSection(d));
for (int c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) {
sections.push_back(WorldSection(c, d));
}
}
for (const std::string& section : sections) {
if (!character.Has(section)) {
@@ -739,22 +793,53 @@ const Row* RenameTarget(uint8_t* window) {
return row;
}
game::UiRect RenameButtonRect(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 &&
!*reinterpret_cast<const uint8_t*>(kMultiplayer);
}
// Buttons of ours below the window's delete button, made on first use.
struct SideButton {
int slot; // places below delete
const wchar_t* caption;
const char* name;
bool (*shown)(uint8_t* window);
uint8_t* control = nullptr;
};
SideButton g_sideButtons[] = {
{1, L"Umbenennen", "UI_BTN_CHAR_RENAME", [](uint8_t* window) { return RenameTarget(window) != nullptr; }},
{2, L"Neuer Charakter", "UI_BTN_CHAR_NEW", &NewCharacterAllowed},
};
SideButton& g_renameButton = g_sideButtons[0];
SideButton& g_newCharacterButton = g_sideButtons[1];
// "Speichern" is not drawn (kNoImport); Löschen moves up into its place.
constexpr size_t kSavegameSaveButton = 0x820; // cUI_Button2 inside the window (action 1)
void MoveDeleteButton(uint8_t* window) {
std::memcpy(window + kSavegameDeleteButton + game::kControlRect, window + kSavegameSaveButton + game::kControlRect,
sizeof(game::UiRect));
}
game::UiRect SideButtonRect(uint8_t* window, const SideButton& side) {
game::UiRect rect = game::ControlScreenRect(window + kSavegameDeleteButton);
rect.y += kSavegameButtonPitch;
rect.y += side.slot * 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");
uint8_t* SideButtonControl(uint8_t* window, SideButton& side) {
if (!side.control) {
side.control = game::NewButtonLike(window + kSavegameDeleteButton, SideButtonRect(window, side),
game::RegisterText(side.caption), side.name);
}
if (g_renameButton) {
*reinterpret_cast<game::UiRect*>(g_renameButton + game::kControlRect) = RenameButtonRect(window);
if (side.control) {
*reinterpret_cast<game::UiRect*>(side.control + game::kControlRect) = SideButtonRect(window, side);
}
return g_renameButton;
return side.control;
}
void ConfirmRename(uint8_t* window) {
@@ -794,7 +879,9 @@ void RenderDialog(void* device) {
FillBox(device, Grown(panel, 2), kDialogEdgeColour);
FillBox(device, panel, kDialogBackColour);
const game::UiRect title = {panel.x, panel.y, panel.w, static_cast<int16_t>(kDialogTitleHeight)};
const wchar_t* heading = g_dialog.kind == DialogKind::Rename ? L"Neuer Name" : L"Schwierigkeit wählen";
const wchar_t* heading = g_dialog.kind == DialogKind::Rename ? L"Neuer Name"
: g_dialog.kind == DialogKind::NewCharacter ? L"Kampagne wählen"
: L"Welt wählen";
game::DrawText(device, kDialogFont, game::CenterLineVertically(kDialogFont, title), heading, kDialogTitleColour,
game::kTextCenter);
if (g_dialog.kind == DialogKind::Rename) {
@@ -826,6 +913,10 @@ void __fastcall Savegame_Action(uint8_t* self, void* edx, int action) {
if (const Row* row = SelectedRow(self); row && row->choices.size() > 1) {
OpenDialog(self, DialogKind::Difficulty, static_cast<size_t>(row - g_rows.data()));
return;
} else if (row) {
// As the main menu's entries do before a game starts; cEngine::Load
// sets it from the save again.
GameCampaign() = static_cast<uint16_t>(row->choices[row->chosen].campaign);
}
}
if (const Row* row = action == kSavegameActionDelete ? SelectedRow(self) : nullptr) {
@@ -844,6 +935,7 @@ void __fastcall Savegame_Action(uint8_t* self, void* edx, int action) {
// hero; it becomes the current character in Game_LoadHeroFile.
struct NewWorld {
std::string file; // set while the game starts
int campaign = kCampaignAncaria;
int difficulty = 0;
} g_newWorld;
@@ -859,14 +951,15 @@ uint8_t* GameSetup() {
return reinterpret_cast<InstanceFn>(0x00417e50)();
}
void StartNewWorld(uint8_t* window, const Row& row, int difficulty) {
void StartNewWorld(uint8_t* window, const Row& row, int campaign, int difficulty) {
CharacterFile character(row.file);
std::optional<Bytes> hero = character.Open() ? character.Read("hero") : std::nullopt;
if (!hero || !WriteFileReplacing(WorkDir() + kWorkHero, *hero)) {
re::Log("characters: %s has no shared hero, no new game", row.file.c_str());
return;
}
g_newWorld = {row.file, difficulty};
g_newWorld = {row.file, campaign, difficulty};
GameCampaign() = static_cast<uint16_t>(campaign);
auto* session = static_cast<uint8_t*>(Session());
*reinterpret_cast<uint32_t*>(session + kSessionSinglePlayer) = 1;
*reinterpret_cast<uint32_t*>(session + kSessionHeroClass) = character.meta.value("class", 1u);
@@ -879,22 +972,37 @@ void StartNewWorld(uint8_t* window, const Row& row, int difficulty) {
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);
re::Log("characters: new game for %s, campaign %d, difficulty %d", row.file.c_str(), campaign, difficulty);
using ExecuteFn = void(__fastcall*)(void*, void*);
reinterpret_cast<ExecuteFn>(0x00755ac0)(&command, nullptr);
}
// A new character in `campaign`: the game's character creation, as the main
// menu's campaign entries opened it (window command show UI_CHARACTER, the
// campaign set first); its first save makes the character file.
void OpenCharacterCreation(int campaign) {
GameCampaign() = static_cast<uint16_t>(campaign);
uint32_t* ui = game::UiManager();
game::SendWindowCommand(ui, kSaveWindowName, game::WindowCommand::Hide);
game::SendWindowCommand(ui, "UI_CHARACTER", game::WindowCommand::Show);
re::Log("characters: new character, campaign %d", campaign);
}
// Button `index` of the dialog chosen.
void DialogChoose(uint8_t* window, size_t index) {
if (index + 1 == DialogEntries()) {
CloseDialog();
} else if (g_dialog.kind == DialogKind::Rename) {
ConfirmRename(window);
} else if (g_dialog.kind == DialogKind::NewCharacter) {
CloseDialog();
OpenCharacterCreation(static_cast<int>(index));
} else if (const Row row = g_rows[g_dialog.row]; row.choices[index].newWorld) {
CloseDialog();
StartNewWorld(window, row, row.choices[index].difficulty);
StartNewWorld(window, row, row.choices[index].campaign, row.choices[index].difficulty);
} else {
g_rows[g_dialog.row].chosen = index;
GameCampaign() = static_cast<uint16_t>(row.choices[index].campaign);
CloseDialog();
Savegame_Action_original(window, nullptr, kSavegameActionLoad);
}
@@ -923,22 +1031,32 @@ bool HandleDialogEvent(uint8_t* window, void* event) {
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;
// Our buttons next to the window's; true if one took the event.
bool HandleSideButtons(uint8_t* window, void* event) {
const game::MouseEvent kind = game::MouseEventKind(event);
if (!button || kind == game::MouseEvent::None) {
if (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;
for (SideButton& side : g_sideButtons) {
uint8_t* button = side.shown(window) ? SideButtonControl(window, side) : nullptr;
if (!button) {
continue;
}
game::ControlHandleEvent(button, event);
if (kind != game::MouseEvent::ButtonDown ||
game::EventField(event, game::kMouseButton) != game::kLeftButton ||
!game::Inside(SideButtonRect(window, side), game::EventField(event, game::kMouseButtonX),
game::EventField(event, game::kMouseButtonY))) {
continue;
}
if (&side == &g_renameButton) {
OpenDialog(window, DialogKind::Rename, static_cast<size_t>(RenameTarget(window) - g_rows.data()));
} else {
OpenDialog(window, DialogKind::NewCharacter, 0);
}
return true;
}
OpenDialog(window, DialogKind::Rename, static_cast<size_t>(row - g_rows.data()));
return true;
return false;
}
using WindowFn = int(__fastcall*)(void*, void*, void*);
@@ -950,6 +1068,7 @@ struct WindowCall {
int RenderBody(void* context) {
auto* call = static_cast<WindowCall*>(context);
MoveDeleteButton(call->window);
const int result = call->original(call->window, nullptr, call->argument);
if (!game::ControlShown(call->window)) {
if (g_dialog.window == call->window) {
@@ -958,14 +1077,16 @@ int RenderBody(void* context) {
return result;
}
if (g_dialog.window != call->window) {
if (RenameTarget(call->window)) {
if (uint8_t* button = RenameButton(call->window)) {
game::ControlRender(button, call->argument);
for (SideButton& side : g_sideButtons) {
if (side.shown(call->window)) {
if (uint8_t* button = SideButtonControl(call->window, side)) {
game::ControlRender(button, call->argument);
}
}
}
return result;
}
if (g_dialog.row >= g_rows.size()) {
if (g_dialog.kind != DialogKind::NewCharacter && g_dialog.row >= g_rows.size()) {
CloseDialog();
return result;
}
@@ -987,11 +1108,12 @@ int RenderBody(void* context) {
int EventBody(void* context) {
auto* call = static_cast<WindowCall*>(context);
MoveDeleteButton(call->window);
if (g_dialog.window == call->window) {
if (HandleDialogEvent(call->window, call->argument)) {
return 1;
}
} else if (game::ControlShown(call->window) && HandleRenameButton(call->window, call->argument)) {
} else if (game::ControlShown(call->window) && HandleSideButtons(call->window, call->argument)) {
return 1;
}
return call->original(call->window, nullptr, call->argument);
@@ -1035,10 +1157,11 @@ uint32_t __fastcall Engine_Load(uint8_t* self, void* edx, const char* path, char
const Choice choice = target.choices[target.chosen];
CharacterFile character(target.file);
CharacterFile source(choice.autosave ? AutosavePath(target.file) : target.file);
std::optional<Bytes> world = character.Open() && source.Open() ? source.Read(WorldSection(choice.difficulty))
: std::nullopt;
std::optional<Bytes> world = character.Open() && source.Open()
? source.Read(WorldSection(choice.campaign, choice.difficulty))
: std::nullopt;
if (!world) {
re::Log("characters: %s has no world %d", source.Path().c_str(), choice.difficulty);
re::Log("characters: %s has no world %d/%d", source.Path().c_str(), choice.campaign, choice.difficulty);
return 0;
}
std::optional<Bytes> hero = source.Read("hero");
@@ -1048,8 +1171,8 @@ uint32_t __fastcall Engine_Load(uint8_t* self, void* edx, const char* path, char
if (hero) {
WriteFileReplacing(work + kWorkHero, *hero);
}
re::Log("characters: loading %s, difficulty %d%s", target.file.c_str(), choice.difficulty,
choice.autosave ? " (autosave)" : "");
re::Log("characters: loading %s, campaign %d, difficulty %d%s", target.file.c_str(), choice.campaign,
choice.difficulty, choice.autosave ? " (autosave)" : "");
const uint32_t result = Engine_Load_original(self, edx, kGameWorld, quiet);
if (!result) {
return result;
@@ -1063,6 +1186,7 @@ uint32_t __fastcall Engine_Load(uint8_t* self, void* edx, const char* path, char
if (std::optional<Bytes> saved = ReadFile(work + kWorkHero)) {
character.Set("hero", std::move(*saved));
character.meta.erase("heroFrom");
character.meta.erase("heroFromCampaign");
character.Save();
re::Log("characters: shared hero of %s taken from difficulty %d", target.file.c_str(),
choice.difficulty);
@@ -1095,8 +1219,8 @@ uint32_t __cdecl Game_LoadHeroFile(const char* path, uint32_t a, uint32_t b) {
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);
re::Log("characters: new game of %s, campaign %d, difficulty %d, shared hero loaded (%u)",
started.file.c_str(), started.campaign, started.difficulty, result);
return result;
}
@@ -1151,15 +1275,18 @@ uint32_t __fastcall Engine_Save(uint8_t* self, void* edx, const char* path) {
}
SetHeroMeta(character.meta, *info);
character.meta["lastDifficulty"] = info->difficulty;
character.meta["lastCampaign"] = info->campaign;
character.meta.erase("heroFrom");
character.meta.erase("heroFromCampaign");
if (toAutosave) {
character.meta["difficulty"] = info->difficulty;
character.meta["campaign"] = info->campaign;
}
character.Set("hero", std::move(*hero));
character.Set(WorldSection(info->difficulty), std::move(*world));
character.Set(WorldSection(info->campaign, 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");
re::Log("characters: %s %s, campaign %d, difficulty %d%s", toAutosave ? "autosaved" : "saved",
character.Path().c_str(), info->campaign, info->difficulty, saved ? "" : " - WRITING FAILED");
return result;
}
@@ -1173,7 +1300,6 @@ constexpr size_t kEngineSaveRow = 0x68; // u16
constexpr size_t kEngineSaveDescription = 0x6a; // char[], "QUICKSAVE" for quicksaves
constexpr char kSaveDescription[] = "SAVE"; // not shown: the list shows the characters
std::atomic<bool> g_saveRequested{false};
bool g_inGame = false;
void SaveOnTick(uint8_t* engine) {
if (!g_saveRequested.exchange(false)) {
@@ -1200,7 +1326,6 @@ 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);
@@ -1237,14 +1362,44 @@ void __fastcall SetRowSecondLine(void* text, void* edx, const char* fileName) {
}
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)";
}
const std::wstring line = L"Stufe " + std::to_wstring(r.level) + L" - " + ChoiceName(choice);
reinterpret_cast<SetTextFn>(kStaticSetText)(text, edx, Convert(line, CP_ACP).c_str());
}
// The character window (cUI_Character) without import (request of
// 2026-10-20; exported heroes are gone):
// - its Underworld checks read a campaign that is always Ancaria. With
// g_campaign 1 in single player it opened in mode 7, the import of an
// exported hero of level 25 or more (Show 0x70e3e8, SelectSlot 0x70c473,
// confirm text 0x70eeb2), its slots unlit below level 25 (render 0x70f4df,
// 0x70f57c); vanilla made no new Underworld heroes. Now it creates them
// with all classes, the game starting with the campaign set (ExecuteAction
// 0x70d0f0 has no campaign check there). No level requirement (decided
// 2026-10-20).
// - "Importieren" (button +0x3a0, caption UI_IMPORT in modes 0/1, the class
// selection) is not drawn there (0x70f67e, the modes 0/1 draw) and its
// click no longer switches to mode 7 (0x70eab4 mode 0, 0x70eac7 mode 1:
// jump to the handler's end 0x70ec0e as after SetMode).
const uint16_t kCharacterWindowCampaign = kCampaignAncaria;
constexpr uint32_t kCharacterWindowCampaignChecks[] = {0x0070c473, 0x0070d199, 0x0070e3e8, 0x0070eade,
0x0070eeb2, 0x0070f4df, 0x0070f57c, 0x0070f6aa};
constexpr re::CodeReplacement kNoImport[] = {
{0x0070f67e, {0x55, 0x8b, 0x11, 0xff, 0x52, 0x14}, {0x90, 0x90, 0x90, 0x90, 0x90, 0x90}, 6},
{0x0070eab4, {0x6a, 0x07, 0x8b, 0xce, 0xe8}, {0xe9, 0x55, 0x01, 0x00, 0x00}, 5},
{0x0070eac7, {0x6a, 0x07, 0x8b, 0xce, 0xe8}, {0xe9, 0x42, 0x01, 0x00, 0x00}, 5},
// The load window's "Speichern" (+0x820, disabled in load mode; saving is
// direct): its draw (0x71ddbc, 16 bytes) is jumped over. Löschen takes its
// place (MoveDeleteButton).
{0x0071ddbc, {0x8b, 0x86}, {0xeb, 0x0e}, 2},
};
void Startup() {
int failed = re::PatchAll(kCharacterWindowCampaignChecks, static_cast<uint32_t>(kCampaign),
reinterpret_cast<uint32_t>(&kCharacterWindowCampaign));
failed += re::ReplaceAll(kNoImport);
if (failed) {
re::Log("characters: %d character / load window patches failed", failed);
}
re::RedirectCall(kRowFileNameCall, kStaticSetText, reinterpret_cast<const void*>(&SetRowSecondLine));
static const uint32_t kSaveText = game::RegisterText(L"Speichern");
re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText);