From 618288e31028bb9b4c5073dcd4e5db83045d5663 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Tue, 20 Oct 2026 21:48:47 +0200 Subject: [PATCH] Characters: campaigns, "Spielen" main menu, story unlocks - One world per campaign and difficulty in a character file (sections ancaria / underworld; old world 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) --- CMakeLists.txt | 1 + docs/characters.md | 90 +++++++++-- src/mods/characters.cpp | 327 +++++++++++++++++++++++++++++----------- src/mods/charfile.cpp | 41 ++++- src/mods/charfile.h | 24 ++- src/mods/mainmenu.cpp | 120 +++++++++++++++ tools/names.txt | 4 + 7 files changed, 498 insertions(+), 109 deletions(-) create mode 100644 src/mods/mainmenu.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bc84d96..1e42996 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,6 +73,7 @@ add_library(sacredK SHARED src/mods/userfolders.cpp src/mods/charfile.cpp src/mods/characters.cpp + src/mods/mainmenu.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 index 3811204..fda1537 100644 --- a/docs/characters.md +++ b/docs/characters.md @@ -78,8 +78,11 @@ Decided (2026-10-19, follow-up): 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 + end. The counters travel in the save header (dwords at +0x88.., loader + 0x61bb19..) and in the hero header chunk 0xc3 (bytes at +0xed.., written by + 0x604360, read by loadHero 0x606632..; +0xec is `g_gameLevel`). Checked in + a character file (2026-10-20): Silber and Gold completed, played on Platin = + [0, 1, 1, 0, 0] in the hero and the Platin world. 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 @@ -123,6 +126,28 @@ Decided (2026-10-19, follow-up): 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. +- Main menu: "Kampagne Ancaria", "Kampagne Underworld" and "Spielstände" + become one "Spielen": the characters, loaded or started in either campaign + on any difficulty; new characters from there too. A character file holds a + world per campaign and difficulty; the format must take more difficulties + later. Decided: no level requirement for the Underworld (vanilla: hero level + 25, 0x70c48b), story completions open the next difficulty in both campaigns, + "Neuer Charakter" is a button in the Spielen window. + +### Campaigns (measured / read 2026-10-20) + +- `g_campaign` 0x182cdc8 (u16): 0 Ancaria, 1 Underworld. The main menu's + event handler (0x711fa0) sets it by entry name before an entry handles an + event (UI_LABEL_NEWGAME 0, UI_LABEL_NEWADDON 1); both entries show + UI_CHARACTER. `cEngine::Save` writes it to the save header +0x24 (2 + Ancaria, 3 Underworld, 0x61a5c5), `cEngine::Load` sets it from there + (0x61bafd). The load window lists only saves of the running campaign + (0x71e2b9). +- Story completions are counted per difficulty only (the same counter for + both campaigns). +- Main menu (cUI_MainMenu, entries built by 0x712f30): cUI_StaticText64FX + entries (constructor 0x753d70), name +0x30, rect +0x24 (0x20 apart), text + id +0x78, command +0x88; main page vector +0x158, children +0x90. ## Implementation (`src/mods/characters.cpp`, `charfile.{h,cpp}`) @@ -130,24 +155,64 @@ Decided (2026-10-19, follow-up): - `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`). + (JSON: format, name UTF-8, class, level, lastCampaign, lastDifficulty, + migratedFrom, heroFrom, heroFromCampaign), `hero` (AMH as + `cEngine::SaveHero` writes it), one world per campaign and difficulty, + `ancaria` / `underworld` with d = `g_gameLevel` (AMS as + `cEngine::Save` writes it). Sections are named, not slotted: a higher + difficulty (or another campaign) is one more section. Files of the first + format named Ancaria's worlds `world`; they are renamed when read + (charfile.cpp) and written under the new name at the next save. Written to + a temp file, then moved over. +- `CharacterNN.autosave.sacredK`: same layout, one world (`meta.campaign`, + `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\`. +### Main menu (`src/mods/mainmenu.cpp`) + +After the build (0x712f30) the NEWGAME and NEWADDON entries are removed (out +of both vectors, deleted) and UI_LABEL_LOADGAME ("Spielstände", shows +UI_WND_SAVEGAME) moves to the top with the text "Spielen"; the others move up, +keeping the gap before "Beenden". + ### 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). + "Name - Klasse"); row nn = character nn. The preview's campaign field is + the running campaign, so the window lists every character. The row's + second line (call 0x71db2a to SetText 0x736f00) shows + "Stufe N - Campaign - difficulty". +- Laden (action 4 / 0xb) opens the world dialog when there is more than one + choice: per campaign worlds, newer autosaves "(Autosave)", and "(neu)" for + every difficulty without a world up to the highest open one (needs the + shared hero; not in multiplayer, byte 0x182cb6c). Open: Bronze and Silber, + every difficulty up to the highest with a world in either campaign, and the + one after each difficulty whose story the shared hero completed (hero + header +0xed..). `g_campaign` is set to the chosen world's before loading + or starting. +- "Neuer Charakter" (two places below Löschen; main menu only) asks for the + campaign, sets `g_campaign`, hides the window and shows UI_CHARACTER (the + game's character creation, as the main menu's campaign entries did). The + first save makes the character file. +- Vanilla made no new heroes for the Underworld: `cUI_Character::Show` + (vf24 0x70e2a0) switches to mode 7 in single player with `g_campaign` 1 + (0x70e3e8), the import of an exported hero of level 25+ (0x70eedc); with no + exported heroes its slots were empty, and slots below level 25 never lit + up (render 0x70f4df / 0x70f57c) (reported 2026-10-20). All eight campaign + checks of the window (0x70c473, 0x70d199, 0x70e3e8, 0x70eade, 0x70eeb2, + 0x70f4df, 0x70f57c, 0x70f6aa) read a constant Ancaria instead: creation + with all classes in both campaigns; the start (ExecuteAction 0x70d0f0, + action 1) uses `g_campaign` as set. +- Class selection without "Importieren" (request 2026-10-20): button +0x3a0 + (caption UI_IMPORT in modes 0/1); its modes-0/1 draw (0x70f67e) is removed, + its click (0x70eab4 / 0x70eac7, SetMode(7)) jumps to the handler's end. +- Load window without "Speichern" (request 2026-10-20; saving is direct): + button +0x820 (action 1, disabled in load mode, so its click falls through) + is not drawn (jump over 0x71ddbc); Löschen (+0x8dc) takes its rect, our + buttons follow below it. - "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 @@ -202,7 +267,6 @@ 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. diff --git a/src/mods/characters.cpp b/src/mods/characters.cpp index 2389eb1..0bcc912 100644 --- a/src/mods/characters.cpp +++ b/src/mods/characters.cpp @@ -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(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 worlds; // newest per difficulty + std::map, Source> worlds; // newest per (campaign, difficulty) std::optional hero; // newest of all std::vector 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 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 last = group.worlds.empty() ? std::pair{} : 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 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 world = file.Read(WorldSection(campaign, difficulty)); std::optional 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 heroFile = + character.Read(hasHero ? std::string("hero") : WorldSection(heroFromCampaign, heroFrom)); + std::optional 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(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 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 world = source.Read(WorldSection(shown.difficulty)); + std::optional 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(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 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(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 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(window + kSavegameMode) == kSavegameModeLoad && !g_inGame && + !*reinterpret_cast(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(g_renameButton + game::kControlRect) = RenameButtonRect(window); + if (side.control) { + *reinterpret_cast(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(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(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(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(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 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(campaign); auto* session = static_cast(Session()); *reinterpret_cast(session + kSessionSinglePlayer) = 1; *reinterpret_cast(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(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(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(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(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(RenameTarget(window) - g_rows.data())); + } else { + OpenDialog(window, DialogKind::NewCharacter, 0); + } + return true; } - OpenDialog(window, DialogKind::Rename, static_cast(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(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(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 world = character.Open() && source.Open() ? source.Read(WorldSection(choice.difficulty)) - : std::nullopt; + std::optional 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 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 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 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(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(kCampaign), + reinterpret_cast(&kCharacterWindowCampaign)); + failed += re::ReplaceAll(kNoImport); + if (failed) { + re::Log("characters: %d character / load window patches failed", failed); + } re::RedirectCall(kRowFileNameCall, kStaticSetText, reinterpret_cast(&SetRowSecondLine)); static const uint32_t kSaveText = game::RegisterText(L"Speichern"); re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText); diff --git a/src/mods/charfile.cpp b/src/mods/charfile.cpp index f4743cb..2d3bd61 100644 --- a/src/mods/charfile.cpp +++ b/src/mods/charfile.cpp @@ -1,5 +1,6 @@ #include "mods/charfile.h" +#include #include #include @@ -25,8 +26,11 @@ constexpr size_t kHeroLevel = 0x0; constexpr size_t kHeroClass = 0x4; constexpr size_t kHeroName = 0x8; constexpr size_t kHeroNameChars = 0x40; +constexpr size_t kHeroStoryCompleted = 0xed; // one byte per difficulty (written at 0x604442..) -constexpr size_t kWorldMode = 0x24; +constexpr size_t kWorldCampaign = 0x24; +constexpr uint32_t kSaveModeAncaria = 2; +constexpr uint32_t kSaveModeUnderworld = 3; constexpr size_t kWorldMultiplayer = 0x2c; constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords) constexpr size_t kWorldDifficulty = 0x84; @@ -86,10 +90,13 @@ std::optional ReadSaveInfo(const Bytes& file) { std::memcpy(&info.heroClass, header + kHeroClass, 4); const auto* name = reinterpret_cast(header + kHeroName); info.name.assign(name, wcsnlen(name, kHeroNameChars)); + if (hero->size >= kHeroStoryCompleted + info.storyCompleted.size()) { + std::memcpy(info.storyCompleted.data(), header + kHeroStoryCompleted, info.storyCompleted.size()); + } info.world = file[2] == 'S'; if (info.world) { info.difficulty = static_cast(U32(file, kWorldDifficulty)); - info.mode = U32(file, kWorldMode); + info.campaign = U32(file, kWorldCampaign) == kSaveModeUnderworld ? kCampaignUnderworld : kCampaignAncaria; info.multiplayer = U32(file, kWorldMultiplayer) != 0; SYSTEMTIME time{}; WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek, @@ -116,9 +123,14 @@ bool SetHeroName(Bytes& file, const std::wstring& name) { return true; } -Bytes BuildPreview(const Bytes& world, const std::string& description) { +uint32_t CampaignSaveMode(int campaign) { + return campaign == kCampaignUnderworld ? kSaveModeUnderworld : kSaveModeAncaria; +} + +Bytes BuildPreview(const Bytes& world, const std::string& description, int campaign) { Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData)); preview.resize(kChunkData); + PutU32(preview, kWorldCampaign, CampaignSaveMode(campaign)); std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize); size_t entry = kChunkTable; auto append = [&](uint32_t type, const uint8_t* data, uint32_t size) { @@ -161,10 +173,27 @@ bool WriteFileReplacing(const std::string& path, const Bytes& bytes) { 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); +std::string WorldSection(int campaign, int difficulty) { + return (campaign == kCampaignUnderworld ? "underworld" : "ancaria") + std::to_string(difficulty); } +namespace { + +// Format of 2026-10-20 (before campaigns): "world" were Ancaria's worlds; +// renamed when read, written under the new name at the next save. +constexpr char kOldWorldPrefix[] = "world"; + +std::string CurrentSectionName(const std::string& name) { + constexpr size_t length = sizeof(kOldWorldPrefix) - 1; + if (name.size() > length && name.compare(0, length, kOldWorldPrefix) == 0 && + std::all_of(name.begin() + length, name.end(), [](char c) { return c >= '0' && c <= '9'; })) { + return WorldSection(kCampaignAncaria, std::stoi(name.substr(length))); + } + return name; +} + +} // namespace + bool CharacterFile::Open() { sections_.clear(); std::ifstream file(path_, std::ios::binary); @@ -181,7 +210,7 @@ bool CharacterFile::Open() { } Section section; const auto* name = reinterpret_cast(entry); - section.name.assign(name, strnlen(name, kSectionNameSize)); + section.name = CurrentSectionName(std::string(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)); diff --git a/src/mods/charfile.h b/src/mods/charfile.h index fa0433c..b8ab718 100644 --- a/src/mods/charfile.h +++ b/src/mods/charfile.h @@ -2,6 +2,7 @@ // 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 @@ -16,6 +17,11 @@ using Bytes = std::vector; // Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4). constexpr int kDifficultyCount = 5; +// The campaigns (g_campaign 0x182cdc8): Ancaria 0, Underworld 1. +constexpr int kCampaignAncaria = 0; +constexpr int kCampaignUnderworld = 1; +constexpr int kCampaignCount = 2; + // 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. @@ -23,9 +29,12 @@ 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 + // Story completions per difficulty (hero header +0xed.., cStatsManager + // +0x5704; the hero file's are the hero's, loadHero sets them). + std::array storyCompleted{}; 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 + int campaign = kCampaignAncaria; // AMS header +0x24 (CampaignSaveMode) bool multiplayer = false; // AMS header +0x2c uint64_t savedAt = 0; // AMS header +0x5c SYSTEMTIME, as FILETIME }; @@ -37,10 +46,15 @@ std::optional ReadSaveInfo(const Bytes& file); constexpr size_t kHeroNameMaxChars = 63; bool SetHeroName(Bytes& file, const std::wstring& name); +// The save header's campaign field (+0x24): 2 Ancaria, 3 Underworld (written +// at 0x61a5c5, read back by cEngine::Load 0x61bafd). +uint32_t CampaignSaveMode(int campaign); + // 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); +// characters) as the save's name. The load window lists only saves of the +// running campaign (0x71e2b9): the preview names `campaign`. +Bytes BuildPreview(const Bytes& world, const std::string& description, int campaign); // Reading and writing whole files (paths in the game's code page). std::optional ReadFile(const std::string& path); @@ -77,6 +91,8 @@ private: std::vector
sections_; }; -std::string WorldSection(int difficulty); +// A world's section: "ancaria" / "underworld", d = g_gameLevel. Sections +// are named, not slotted: more difficulties (or campaigns) are more sections. +std::string WorldSection(int campaign, int difficulty); } // namespace mods::characters diff --git a/src/mods/mainmenu.cpp b/src/mods/mainmenu.cpp new file mode 100644 index 0000000..85b2203 --- /dev/null +++ b/src/mods/mainmenu.cpp @@ -0,0 +1,120 @@ +// Main menu "Spielen" (request of 2026-10-20, docs/characters.md): the entries +// "Kampagne Ancaria", "Kampagne Underworld" and "Spielstände" become one +// "Spielen", which opens the load window: there the characters of both +// campaigns are loaded or started on any campaign and difficulty, and new +// characters are made (characters.cpp). +// +// cUI_MainMenu builds its entries in 0x712f30 (thiscall, plain ret): one +// cUI_StaticText64FX (constructor 0x753d70) per entry with its name at +0x30 +// (UI_LABEL_NEWGAME, ...), its rect at +0x24 (relative, 0x20 apart), its text +// id at +0x78 and its command at +0x88 (UI_LABEL_LOADGAME: show +// UI_WND_SAVEGAME). The main page's entries are in the vector at +0x158, all +// children in the one at +0x90. The event handler (vf10 0x711fa0) sets +// g_campaign 0x182cdc8 by entry name before an entry handles an event: +// NEWGAME 0, NEWADDON 1. After the build the campaign entries are removed and +// "Spielstände" moves to the top as "Spielen"; the others move up. + +#include +#include +#include +#include + +#include "game/ui/tabs.h" +#include "game/ui/text.h" +#include "game/ui/texts.h" +#include "re/hook.h" +#include "re/log.h" + +namespace mods::mainmenu { +namespace { + +constexpr size_t kMenuChildren = 0x90; // vector of all child controls +constexpr size_t kMenuMainPage = 0x158; // vector of the main page's entries +constexpr size_t kEntryName = 0x30; // char[] +constexpr size_t kEntryTextId = 0x78; +constexpr int kEntryPitch = 0x20; // as 0x712f30 places them + +constexpr const char* kRemoved[] = {"UI_LABEL_NEWGAME", "UI_LABEL_NEWADDON"}; +constexpr char kPlayEntry[] = "UI_LABEL_LOADGAME"; + +// A game vector of pointers {begin, end, capacity end}. +std::vector Entries(uint8_t* owner, size_t offset) { + auto** begin = *reinterpret_cast(owner + offset); + auto** end = *reinterpret_cast(owner + offset + 4); + return std::vector(begin, end); +} + +void Erase(uint8_t* owner, size_t offset, const uint8_t* item) { + auto** begin = *reinterpret_cast(owner + offset); + auto**& end = *reinterpret_cast(owner + offset + 4); + auto** found = std::find(begin, end, item); + if (found != end) { + std::memmove(found, found + 1, (end - found - 1) * sizeof(*found)); + --end; + } +} + +void SetAt(uint8_t* owner, size_t offset, size_t index, uint8_t* item) { + (*reinterpret_cast(owner + offset))[index] = item; +} + +const char* Name(const uint8_t* entry) { + return reinterpret_cast(entry + kEntryName); +} + +int32_t& EntryY(uint8_t* entry) { + return reinterpret_cast(entry + game::kControlRect)->y; +} + +bool IsRemoved(const uint8_t* entry) { + return std::any_of(std::begin(kRemoved), std::end(kRemoved), + [&](const char* name) { return std::strcmp(Name(entry), name) == 0; }); +} + +void __fastcall MainMenu_Build(uint8_t* self, void* edx); +RE_HOOK(0x00712f30, MainMenu_Build); +void __fastcall MainMenu_Build(uint8_t* self, void* edx) { + MainMenu_Build_original(self, edx); + const std::vector original = Entries(self, kMenuMainPage); + auto play = std::find_if(original.begin(), original.end(), + [](const uint8_t* entry) { return std::strcmp(Name(entry), kPlayEntry) == 0; }); + if (original.empty() || play == original.end()) { + re::Log("mainmenu: no %s entry, menu left as it is", kPlayEntry); + return; + } + // New order: "Spielen", then the rest as they were; each keeps the extra + // space it had above it (the gap before "Beenden"). + std::vector extraGap(original.size(), 0); + for (size_t i = 1; i < original.size(); ++i) { + extraGap[i] = std::max(0, EntryY(original[i]) - EntryY(original[i - 1]) - kEntryPitch); + } + int32_t y = EntryY(original.front()); + std::vector kept = {*play}; + std::vector keptGap = {0}; + for (size_t i = 0; i < original.size(); ++i) { + if (original[i] != *play && !IsRemoved(original[i])) { + kept.push_back(original[i]); + keptGap.push_back(extraGap[i]); + } + } + for (uint8_t* entry : original) { + if (IsRemoved(entry)) { + Erase(self, kMenuMainPage, entry); + Erase(self, kMenuChildren, entry); + using DeleteFn = void*(__fastcall*)(void*, void*, int); // scalar deleting destructor + reinterpret_cast((*reinterpret_cast(entry))[0])(entry, nullptr, 1); + } + } + for (size_t i = 0; i < kept.size(); ++i) { + SetAt(self, kMenuMainPage, i, kept[i]); + y += keptGap[i]; + EntryY(kept[i]) = y; + y += kEntryPitch; + } + static const uint32_t kPlayText = game::RegisterText(L"Spielen"); + *reinterpret_cast(*play + kEntryTextId) = kPlayText; + re::Log("mainmenu: %zu entries, \"Spielen\" first", kept.size()); +} + +} // namespace +} // namespace mods::mainmenu diff --git a/tools/names.txt b/tools/names.txt index c32034e..1e9da97 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -344,3 +344,7 @@ 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 +0x00712f30 cUI_MainMenu::BuildEntries # thiscall, plain ret: the menu entries (cUI_StaticText64FX); main page vector +0x158 +0x00711fa0 cUI_MainMenu::HandleEvent # vf10: sets g_campaign 0x182cdc8 by entry name (NEWGAME 0, NEWADDON 1) +0x00753d70 cUI_StaticText64FX::cUI_StaticText64FX # (name, rect, parent, ?, text id, command, flags) +0x0070e2a0 cUI_Character::Show # vf24 (shown), ret 4: single player + g_campaign 1 -> mode 7 (Underworld import, level 25+)