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
+487 -98

No files matched your search

+1
View File
@@ -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})
+77 -13
View File
@@ -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<d>` (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<d>` / `underworld<d>` 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<d>`; 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.
+230 -75
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)) {
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 c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) {
const uint64_t savedAt = SavedAt(character, 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 || d == heroFrom)) {
row.choices.push_back({d, false});
if (savedAt && (hasHero || (c == heroFromCampaign && d == heroFrom))) {
row.choices.push_back({c, d});
} else if (!savedAt && d <= highest && canStart) {
row.choices.push_back({d, false, true});
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});
}
if (d == autosaved && SavedAt(autosave, d) > savedAt) {
row.choices.push_back({d, true});
}
}
// The preview shows the last played world (or any world).
std::optional<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 c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) {
sections.push_back(WorldSection(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,23 +1031,33 @@ 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;
}
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(RenameButtonRect(window), game::EventField(event, game::kMouseButtonX),
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))) {
return false;
continue;
}
if (&side == &g_renameButton) {
OpenDialog(window, DialogKind::Rename, static_cast<size_t>(RenameTarget(window) - g_rows.data()));
} else {
OpenDialog(window, DialogKind::NewCharacter, 0);
}
OpenDialog(window, DialogKind::Rename, static_cast<size_t>(row - g_rows.data()));
return true;
}
return false;
}
using WindowFn = int(__fastcall*)(void*, void*, void*);
struct WindowCall {
@@ -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)) {
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::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);
+35 -6
View File
@@ -1,5 +1,6 @@
#include "mods/charfile.h"
#include <algorithm>
#include <cstring>
#include <fstream>
@@ -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<SaveInfo> ReadSaveInfo(const Bytes& file) {
std::memcpy(&info.heroClass, header + kHeroClass, 4);
const auto* name = reinterpret_cast<const wchar_t*>(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<int>(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<d>" 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<const char*>(entry);
section.name.assign(name, strnlen(name, kSectionNameSize));
section.name = CurrentSectionName(std::string(name, strnlen(name, kSectionNameSize)));
std::memcpy(&section.offset, entry + kSectionNameSize, 4);
std::memcpy(&section.size, entry + kSectionNameSize + 4, 4);
sections_.push_back(std::move(section));
+20 -4
View File
@@ -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 <array>
#include <cstdint>
#include <optional>
#include <string>
@@ -16,6 +17,11 @@ using Bytes = std::vector<uint8_t>;
// 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<uint8_t, kDifficultyCount> 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<SaveInfo> 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<Bytes> ReadFile(const std::string& path);
@@ -77,6 +91,8 @@ private:
std::vector<Section> sections_;
};
std::string WorldSection(int difficulty);
// A world's section: "ancaria<d>" / "underworld<d>", 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
+120
View File
@@ -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 <algorithm>
#include <cstdint>
#include <cstring>
#include <vector>
#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<uint8_t*> Entries(uint8_t* owner, size_t offset) {
auto** begin = *reinterpret_cast<uint8_t***>(owner + offset);
auto** end = *reinterpret_cast<uint8_t***>(owner + offset + 4);
return std::vector<uint8_t*>(begin, end);
}
void Erase(uint8_t* owner, size_t offset, const uint8_t* item) {
auto** begin = *reinterpret_cast<uint8_t***>(owner + offset);
auto**& end = *reinterpret_cast<uint8_t***>(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<uint8_t***>(owner + offset))[index] = item;
}
const char* Name(const uint8_t* entry) {
return reinterpret_cast<const char*>(entry + kEntryName);
}
int32_t& EntryY(uint8_t* entry) {
return reinterpret_cast<game::UiRect*>(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<uint8_t*> 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<int32_t> 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<uint8_t*> kept = {*play};
std::vector<int32_t> 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<DeleteFn>((*reinterpret_cast<void***>(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<uint32_t*>(*play + kEntryTextId) = kPlayText;
re::Log("mainmenu: %zu entries, \"Spielen\" first", kept.size());
}
} // namespace
} // namespace mods::mainmenu
+4
View File
@@ -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+)