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/userfolders.cpp
src/mods/charfile.cpp src/mods/charfile.cpp
src/mods/characters.cpp src/mods/characters.cpp
src/mods/mainmenu.cpp
) )
target_include_directories(sacredK PRIVATE src) target_include_directories(sacredK PRIVATE src)
target_compile_options(sacredK PRIVATE ${SACREDK_CXX_FLAGS}) 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). 0x425160) +0x5704, one byte counter per difficulty (6 entries).
`0x424bc0` (k) increments, `0x424be0` (k) reads, `0x424c00` (k, value) sets. `0x424bc0` (k) increments, `0x424be0` (k) reads, `0x424c00` (k, value) sets.
The script opcode at 0x4aed26 increments counter[`g_gameLevel`]: the story The script opcode at 0x4aed26 increments counter[`g_gameLevel`]: the story
end. The counters travel in the save header (loader 0x61bb19..) and in the end. The counters travel in the save header (dwords at +0x88.., loader
hero file (loadHero 0x606632.., saveHero 0x60444c..). The console cheats 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. `bronze` / `silber` / ... (0x615d00..) increment them too.
- Vanilla offers a difficulty by **hero level**, not by completion: - Vanilla offers a difficulty by **hero level**, not by completion:
thresholds `0xad2dc4..0xad2dd4` (5 dwords, parsed from the balance data key 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. without a world starts a new game with the shared hero.
- The save key and the Esc menu ("Spielstände" becomes "Speichern") save - The save key and the Esc menu ("Spielstände" becomes "Speichern") save
directly, without the save window. 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}`) ## 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, - `Documents\Sacred\save\characters\CharacterNN.sacredK`: "SKCH", version 1,
section count, then `{name[16], offset, size}` entries. Sections: `meta` section count, then `{name[16], offset, size}` entries. Sections: `meta`
(JSON: format, name UTF-8, class, level, lastDifficulty, migratedFrom, (JSON: format, name UTF-8, class, level, lastCampaign, lastDifficulty,
heroFrom), `hero` (AMH as `cEngine::SaveHero` writes it), `world<d>` (AMS as migratedFrom, heroFrom, heroFromCampaign), `hero` (AMH as
`cEngine::Save` writes it). Written to a temp file, then moved over. `cEngine::SaveHero` writes it), one world per campaign and difficulty,
- `CharacterNN.autosave.sacredK`: same layout, one world (`meta.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's save folder is redirected to `characters\_work` (userfolders);
the game reads and writes `world.PAK` / `hero.pax` there. the game reads and writes `world.PAK` / `hero.pax` there.
- Deleted characters move to `characters\deleted\`. - 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) ### Load window (cUI_Savegame)
- Before each scan (0x71e330) one preview `GAMEnn.PAK` per character is - Before each scan (0x71e330) one preview `GAMEnn.PAK` per character is
written into `_work` (header, thumbnail, world time, description written into `_work` (header, thumbnail, world time, description
"Name - Klasse"); row nn = character nn. The row's second line (call "Name - Klasse"); row nn = character nn. The preview's campaign field is
0x71db2a to SetText 0x736f00) shows "Stufe N - difficulty". the running campaign, so the window lists every character. The row's
- Laden (action 4 / 0xb) opens the difficulty dialog when there is more than second line (call 0x71db2a to SetText 0x736f00) shows
one choice: worlds, newer autosaves "(Autosave)", and "(neu)" for every "Stufe N - Campaign - difficulty".
difficulty up to max(highest world, Silber) without a world (needs the - Laden (action 4 / 0xb) opens the world dialog when there is more than one
shared hero; not in multiplayer, byte 0x182cb6c). 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 - "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 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 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 ## Open
- Unlocking by story completion (counters at save header +0x88..).
- Reset a difficulty. - Reset a difficulty.
- Remove the export / import UI. - Remove the export / import UI.
- Silent autosave every 10 minutes. - 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): // 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 // 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) // (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. // 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): // Its save folder is a work folder (save\characters\_work, via userfolders):
// - the load window (cUI_Savegame) lists GAMEnn.PAK there: before it scans, // - the load window (cUI_Savegame) lists GAMEnn.PAK there: before it scans,
// one small preview per character is written (header, thumbnail, // one small preview per character is written (header, thumbnail,
// "Name - Klasse"); row nn of the game is g_rows[nn - 1]. Laden asks for // "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; // renames a character, deleting moves it to characters\deleted;
// - loading row nn (cEngine::Load) unpacks the chosen world and the hero into // - 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 // 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) 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, // Bronze and Silber are always open; completing the story on a difficulty
// can be played; one without a world starts a new game. // 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; constexpr int kAlwaysOpenDifficulty = 1;
// Starting a game (as cUI_Character::executeAction IMPORT_DO, 0x70d637, and // Starting a game (as cUI_Character::executeAction IMPORT_DO, 0x70d637, and
// the load window's load, 0x71cc73, do it). // 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 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 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 kSessionSinglePlayer = 0x14; // set to 1 by both
constexpr size_t kSessionHeroClass = 0x44; // the start hero's class (Session::SetHeroClass 0x7d7cf0) constexpr size_t kSessionHeroClass = 0x44; // the start hero's class (Session::SetHeroClass 0x7d7cf0)
// cCommand_armaPlay (vtable 0x894ff4): execute (0x755ac0) posts 0x8002 (mode, // 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 size_t kSavegameMode = 0x75c; // 1 save, 2 load, 3..5 a confirmation
constexpr uint32_t kSavegameModeLoad = 2; constexpr uint32_t kSavegameModeLoad = 2;
constexpr size_t kSavegameLoadButton = 0x764; // cUI_Button2 inside the window (action 4) constexpr size_t kSavegameLoadButton = 0x764; // cUI_Button2 inside the window (action 4)
constexpr char kSaveWindowName[] = "UI_WND_SAVEGAME";
// --- paths and texts ------------------------------------------------------- // --- paths and texts -------------------------------------------------------
@@ -174,6 +183,14 @@ std::wstring DifficultyName(int difficulty) {
: std::wstring(L"?"); : 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 // A file for a new character: CharacterNN.sacredK, the first free number
// (the hero's name is inside, in the meta). // (the hero's name is inside, in the meta).
std::string NewCharacterPath() { std::string NewCharacterPath() {
@@ -248,7 +265,7 @@ void Migrate() {
uint64_t time = 0; uint64_t time = 0;
}; };
struct Group { 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::optional<Source> hero; // newest of all
std::vector<std::string> files; std::vector<std::string> files;
}; };
@@ -265,9 +282,10 @@ void Migrate() {
Group& group = groups[{info->name, info->heroClass}]; Group& group = groups[{info->name, info->heroClass}];
group.files.push_back(file); group.files.push_back(file);
if (world) { 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) { 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) { if (!group.hero || group.hero->time < source.time) {
@@ -285,17 +303,21 @@ void Migrate() {
CharacterFile character(NewCharacterPath()); CharacterFile character(NewCharacterPath());
SetHeroMeta(character.meta, group.hero->info); SetHeroMeta(character.meta, group.hero->info);
character.meta["migratedFrom"] = group.files; character.meta["migratedFrom"] = group.files;
for (const auto& [difficulty, source] : group.worlds) { for (const auto& [world, source] : group.worlds) {
character.Set(WorldSection(difficulty), *ReadFile(saveDir + source.file)); character.Set(WorldSection(world.first, world.second), *ReadFile(saveDir + source.file));
} }
if (group.hero->info.world) { if (group.hero->info.world) {
// The newest hero is in a world: it becomes the shared hero the // The newest hero is in a world: it becomes the shared hero the
// first time that world is loaded (Engine_Load). // first time that world is loaded (Engine_Load).
character.meta["heroFrom"] = group.hero->info.difficulty; character.meta["heroFrom"] = group.hero->info.difficulty;
character.meta["heroFromCampaign"] = group.hero->info.campaign;
character.meta["lastDifficulty"] = group.hero->info.difficulty; character.meta["lastDifficulty"] = group.hero->info.difficulty;
character.meta["lastCampaign"] = group.hero->info.campaign;
} else { } else {
character.Set("hero", *ReadFile(saveDir + group.hero->file)); 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(); const bool saved = character.Save();
re::Log("characters: migrated %s (%zu files, %zu worlds, hero from %s)%s", character.Path().c_str(), 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. // A world a row can load: a difficulty's save or its newer autosave.
struct Choice { struct Choice {
int campaign = kCampaignAncaria;
int difficulty = 0; int difficulty = 0;
bool autosave = false; bool autosave = false;
bool newWorld = false; // no world yet: a new game with the shared hero 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) { // "Ancaria - Silber", with "(neu)" / "(Autosave)".
std::optional<Bytes> world = file.Read(WorldSection(difficulty)); 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; std::optional<SaveInfo> info = world ? ReadSaveInfo(*world) : std::nullopt;
return info ? info->savedAt : 0; return info ? info->savedAt : 0;
} }
@@ -376,37 +410,54 @@ void RebuildList() {
} }
CharacterFile autosave(AutosavePath(character.Path())); CharacterFile autosave(AutosavePath(character.Path()));
const int autosaved = autosave.Open() ? autosave.meta.value("difficulty", -1) : -1; 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 bool hasHero = character.Has("hero");
const int heroFrom = character.meta.value("heroFrom", -1); const int heroFrom = character.meta.value("heroFrom", -1);
const int heroFromCampaign = character.meta.value("heroFromCampaign", kCampaignAncaria);
Row row{character.Path()}; Row row{character.Path()};
row.name = FromUtf8(character.meta.value("name", std::string())); row.name = FromUtf8(character.meta.value("name", std::string()));
row.level = character.meta.value("level", 0u); row.level = character.meta.value("level", 0u);
int highest = kAlwaysOpenDifficulty; 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) { 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); 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. // New games need the shared hero; multiplayer games are not ours.
const bool canStart = hasHero && !*reinterpret_cast<const uint8_t*>(kMultiplayer); const bool canStart = hasHero && !*reinterpret_cast<const uint8_t*>(kMultiplayer);
for (int c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) { 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. // Without the shared hero yet only the world holding it can be loaded.
if (savedAt && (hasHero || d == heroFrom)) { if (savedAt && (hasHero || (c == heroFromCampaign && d == heroFrom))) {
row.choices.push_back({d, false}); row.choices.push_back({c, d});
} else if (!savedAt && d <= highest && canStart) { } 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). // The preview shows the last played world (or any world).
std::optional<size_t> shownChoice; std::optional<size_t> shownChoice;
const int last = character.meta.value("lastDifficulty", 0); 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) { for (size_t i = 0; i < row.choices.size(); ++i) {
const Choice& choice = row.choices[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; shownChoice = i;
} }
} }
@@ -416,14 +467,14 @@ void RebuildList() {
row.chosen = *shownChoice; row.chosen = *shownChoice;
const Choice& shown = row.choices[row.chosen]; const Choice& shown = row.choices[row.chosen];
const CharacterFile& source = shown.autosave ? autosave : character; 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) { if (!world) {
continue; continue;
} }
g_rows.push_back(std::move(row)); g_rows.push_back(std::move(row));
char preview[32]; char preview[32];
wsprintfA(preview, "GAME%02u.PAK", static_cast<unsigned>(g_rows.size())); 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()); 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 uint32_t kSavegameModeSave = 1;
constexpr size_t kSavegameDeleteButton = 0x8dc; // cUI_Button2 inside the window (action 7) 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 // 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; constexpr int kSavegameButtonPitch = 64;
// cUI_Savegame actions (0x71c520, thiscall (action), ret 4): defined below. // cUI_Savegame actions (0x71c520, thiscall (action), ret 4): defined below.
void __fastcall Savegame_Action(uint8_t* self, void* edx, int action); void __fastcall Savegame_Action(uint8_t* self, void* edx, int action);
RE_HOOK(0x0071c520, Savegame_Action); RE_HOOK(0x0071c520, Savegame_Action);
enum class DialogKind { Difficulty, Rename }; enum class DialogKind { Difficulty, Rename, NewCharacter };
struct Dialog { struct Dialog {
uint8_t* window = nullptr; // open while set uint8_t* window = nullptr; // open while set
@@ -582,10 +634,11 @@ std::mutex g_nameLock;
std::wstring g_name; std::wstring g_name;
std::atomic<int> g_nameKey = 0; std::atomic<int> g_nameKey = 0;
uint8_t* g_renameButton = nullptr; // Entries: the row's choices, "Umbenennen" or the campaigns, then "Abbrechen".
// Entries: the row's choices or "Umbenennen", then "Abbrechen".
size_t DialogEntries() { size_t DialogEntries() {
if (g_dialog.kind == DialogKind::NewCharacter) {
return kCampaignCount + 1;
}
if (g_dialog.row >= g_rows.size()) { if (g_dialog.row >= g_rows.size()) {
return 0; return 0;
} }
@@ -623,11 +676,10 @@ std::wstring DialogText(size_t index) {
if (g_dialog.kind == DialogKind::Rename) { if (g_dialog.kind == DialogKind::Rename) {
return L"Umbenennen"; return L"Umbenennen";
} }
const Choice& choice = g_rows[g_dialog.row].choices[index]; if (g_dialog.kind == DialogKind::NewCharacter) {
if (choice.newWorld) { return CampaignName(static_cast<int>(index));
return DifficultyName(choice.difficulty) + L" (neu)";
} }
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. // 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 continue; // no autosave
} }
std::vector<std::string> sections = {"hero"}; std::vector<std::string> sections = {"hero"};
for (int c = 0; c < kCampaignCount; ++c) {
for (int d = 0; d < kDifficultyCount; ++d) { for (int d = 0; d < kDifficultyCount; ++d) {
sections.push_back(WorldSection(d)); sections.push_back(WorldSection(c, d));
}
} }
for (const std::string& section : sections) { for (const std::string& section : sections) {
if (!character.Has(section)) { if (!character.Has(section)) {
@@ -739,22 +793,53 @@ const Row* RenameTarget(uint8_t* window) {
return row; 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); game::UiRect rect = game::ControlScreenRect(window + kSavegameDeleteButton);
rect.y += kSavegameButtonPitch; rect.y += side.slot * kSavegameButtonPitch;
return rect; return rect;
} }
uint8_t* RenameButton(uint8_t* window) { uint8_t* SideButtonControl(uint8_t* window, SideButton& side) {
if (!g_renameButton) { if (!side.control) {
static const uint32_t kCaption = game::RegisterText(L"Umbenennen"); side.control = game::NewButtonLike(window + kSavegameDeleteButton, SideButtonRect(window, side),
g_renameButton = game::NewButtonLike(window + kSavegameDeleteButton, RenameButtonRect(window), kCaption, game::RegisterText(side.caption), side.name);
"UI_BTN_CHAR_RENAME");
} }
if (g_renameButton) { if (side.control) {
*reinterpret_cast<game::UiRect*>(g_renameButton + game::kControlRect) = RenameButtonRect(window); *reinterpret_cast<game::UiRect*>(side.control + game::kControlRect) = SideButtonRect(window, side);
} }
return g_renameButton; return side.control;
} }
void ConfirmRename(uint8_t* window) { void ConfirmRename(uint8_t* window) {
@@ -794,7 +879,9 @@ void RenderDialog(void* device) {
FillBox(device, Grown(panel, 2), kDialogEdgeColour); FillBox(device, Grown(panel, 2), kDialogEdgeColour);
FillBox(device, panel, kDialogBackColour); FillBox(device, panel, kDialogBackColour);
const game::UiRect title = {panel.x, panel.y, panel.w, static_cast<int16_t>(kDialogTitleHeight)}; 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::DrawText(device, kDialogFont, game::CenterLineVertically(kDialogFont, title), heading, kDialogTitleColour,
game::kTextCenter); game::kTextCenter);
if (g_dialog.kind == DialogKind::Rename) { 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) { if (const Row* row = SelectedRow(self); row && row->choices.size() > 1) {
OpenDialog(self, DialogKind::Difficulty, static_cast<size_t>(row - g_rows.data())); OpenDialog(self, DialogKind::Difficulty, static_cast<size_t>(row - g_rows.data()));
return; 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) { 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. // hero; it becomes the current character in Game_LoadHeroFile.
struct NewWorld { struct NewWorld {
std::string file; // set while the game starts std::string file; // set while the game starts
int campaign = kCampaignAncaria;
int difficulty = 0; int difficulty = 0;
} g_newWorld; } g_newWorld;
@@ -859,14 +951,15 @@ uint8_t* GameSetup() {
return reinterpret_cast<InstanceFn>(0x00417e50)(); 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); CharacterFile character(row.file);
std::optional<Bytes> hero = character.Open() ? character.Read("hero") : std::nullopt; std::optional<Bytes> hero = character.Open() ? character.Read("hero") : std::nullopt;
if (!hero || !WriteFileReplacing(WorkDir() + kWorkHero, *hero)) { if (!hero || !WriteFileReplacing(WorkDir() + kWorkHero, *hero)) {
re::Log("characters: %s has no shared hero, no new game", row.file.c_str()); re::Log("characters: %s has no shared hero, no new game", row.file.c_str());
return; 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()); auto* session = static_cast<uint8_t*>(Session());
*reinterpret_cast<uint32_t*>(session + kSessionSinglePlayer) = 1; *reinterpret_cast<uint32_t*>(session + kSessionSinglePlayer) = 1;
*reinterpret_cast<uint32_t*>(session + kSessionHeroClass) = character.meta.value("class", 1u); *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; uint16_t mode;
uint32_t argument; uint32_t argument;
} command{0x00894ff4, kPlayModeStart, kPlayNewGame | (1u << kPlayHeroFileShift)}; } 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*); using ExecuteFn = void(__fastcall*)(void*, void*);
reinterpret_cast<ExecuteFn>(0x00755ac0)(&command, nullptr); 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. // Button `index` of the dialog chosen.
void DialogChoose(uint8_t* window, size_t index) { void DialogChoose(uint8_t* window, size_t index) {
if (index + 1 == DialogEntries()) { if (index + 1 == DialogEntries()) {
CloseDialog(); CloseDialog();
} else if (g_dialog.kind == DialogKind::Rename) { } else if (g_dialog.kind == DialogKind::Rename) {
ConfirmRename(window); 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) { } else if (const Row row = g_rows[g_dialog.row]; row.choices[index].newWorld) {
CloseDialog(); CloseDialog();
StartNewWorld(window, row, row.choices[index].difficulty); StartNewWorld(window, row, row.choices[index].campaign, row.choices[index].difficulty);
} else { } else {
g_rows[g_dialog.row].chosen = index; g_rows[g_dialog.row].chosen = index;
GameCampaign() = static_cast<uint16_t>(row.choices[index].campaign);
CloseDialog(); CloseDialog();
Savegame_Action_original(window, nullptr, kSavegameActionLoad); Savegame_Action_original(window, nullptr, kSavegameActionLoad);
} }
@@ -923,22 +1031,32 @@ bool HandleDialogEvent(uint8_t* window, void* event) {
return true; return true;
} }
// "Umbenennen" next to the window's buttons; true if it took the event. // Our buttons next to the window's; true if one took the event.
bool HandleRenameButton(uint8_t* window, void* event) { bool HandleSideButtons(uint8_t* window, void* event) {
const Row* row = RenameTarget(window);
uint8_t* button = row ? RenameButton(window) : nullptr;
const game::MouseEvent kind = game::MouseEventKind(event); const game::MouseEvent kind = game::MouseEventKind(event);
if (!button || kind == game::MouseEvent::None) { if (kind == game::MouseEvent::None) {
return false; return false;
} }
for (SideButton& side : g_sideButtons) {
uint8_t* button = side.shown(window) ? SideButtonControl(window, side) : nullptr;
if (!button) {
continue;
}
game::ControlHandleEvent(button, event); game::ControlHandleEvent(button, event);
if (kind != game::MouseEvent::ButtonDown || game::EventField(event, game::kMouseButton) != game::kLeftButton || if (kind != game::MouseEvent::ButtonDown ||
!game::Inside(RenameButtonRect(window), game::EventField(event, game::kMouseButtonX), game::EventField(event, game::kMouseButton) != game::kLeftButton ||
!game::Inside(SideButtonRect(window, side), game::EventField(event, game::kMouseButtonX),
game::EventField(event, game::kMouseButtonY))) { 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 true;
}
return false;
} }
using WindowFn = int(__fastcall*)(void*, void*, void*); using WindowFn = int(__fastcall*)(void*, void*, void*);
@@ -950,6 +1068,7 @@ struct WindowCall {
int RenderBody(void* context) { int RenderBody(void* context) {
auto* call = static_cast<WindowCall*>(context); auto* call = static_cast<WindowCall*>(context);
MoveDeleteButton(call->window);
const int result = call->original(call->window, nullptr, call->argument); const int result = call->original(call->window, nullptr, call->argument);
if (!game::ControlShown(call->window)) { if (!game::ControlShown(call->window)) {
if (g_dialog.window == call->window) { if (g_dialog.window == call->window) {
@@ -958,14 +1077,16 @@ int RenderBody(void* context) {
return result; return result;
} }
if (g_dialog.window != call->window) { if (g_dialog.window != call->window) {
if (RenameTarget(call->window)) { for (SideButton& side : g_sideButtons) {
if (uint8_t* button = RenameButton(call->window)) { if (side.shown(call->window)) {
if (uint8_t* button = SideButtonControl(call->window, side)) {
game::ControlRender(button, call->argument); game::ControlRender(button, call->argument);
} }
} }
}
return result; return result;
} }
if (g_dialog.row >= g_rows.size()) { if (g_dialog.kind != DialogKind::NewCharacter && g_dialog.row >= g_rows.size()) {
CloseDialog(); CloseDialog();
return result; return result;
} }
@@ -987,11 +1108,12 @@ int RenderBody(void* context) {
int EventBody(void* context) { int EventBody(void* context) {
auto* call = static_cast<WindowCall*>(context); auto* call = static_cast<WindowCall*>(context);
MoveDeleteButton(call->window);
if (g_dialog.window == call->window) { if (g_dialog.window == call->window) {
if (HandleDialogEvent(call->window, call->argument)) { if (HandleDialogEvent(call->window, call->argument)) {
return 1; 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 1;
} }
return call->original(call->window, nullptr, call->argument); 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]; const Choice choice = target.choices[target.chosen];
CharacterFile character(target.file); CharacterFile character(target.file);
CharacterFile source(choice.autosave ? AutosavePath(target.file) : 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; : std::nullopt;
if (!world) { 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; return 0;
} }
std::optional<Bytes> hero = source.Read("hero"); 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) { if (hero) {
WriteFileReplacing(work + kWorkHero, *hero); WriteFileReplacing(work + kWorkHero, *hero);
} }
re::Log("characters: loading %s, difficulty %d%s", target.file.c_str(), choice.difficulty, re::Log("characters: loading %s, campaign %d, difficulty %d%s", target.file.c_str(), choice.campaign,
choice.autosave ? " (autosave)" : ""); choice.difficulty, choice.autosave ? " (autosave)" : "");
const uint32_t result = Engine_Load_original(self, edx, kGameWorld, quiet); const uint32_t result = Engine_Load_original(self, edx, kGameWorld, quiet);
if (!result) { if (!result) {
return 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)) { if (std::optional<Bytes> saved = ReadFile(work + kWorkHero)) {
character.Set("hero", std::move(*saved)); character.Set("hero", std::move(*saved));
character.meta.erase("heroFrom"); character.meta.erase("heroFrom");
character.meta.erase("heroFromCampaign");
character.Save(); character.Save();
re::Log("characters: shared hero of %s taken from difficulty %d", target.file.c_str(), re::Log("characters: shared hero of %s taken from difficulty %d", target.file.c_str(),
choice.difficulty); choice.difficulty);
@@ -1095,8 +1219,8 @@ uint32_t __cdecl Game_LoadHeroFile(const char* path, uint32_t a, uint32_t b) {
HeroField(hero, kHeroKnownPortals) = 0; HeroField(hero, kHeroKnownPortals) = 0;
} }
g_current.file = started.file; g_current.file = started.file;
re::Log("characters: new game of %s, difficulty %d, shared hero loaded (%u)", started.file.c_str(), re::Log("characters: new game of %s, campaign %d, difficulty %d, shared hero loaded (%u)",
started.difficulty, result); started.file.c_str(), started.campaign, started.difficulty, result);
return result; return result;
} }
@@ -1151,15 +1275,18 @@ uint32_t __fastcall Engine_Save(uint8_t* self, void* edx, const char* path) {
} }
SetHeroMeta(character.meta, *info); SetHeroMeta(character.meta, *info);
character.meta["lastDifficulty"] = info->difficulty; character.meta["lastDifficulty"] = info->difficulty;
character.meta["lastCampaign"] = info->campaign;
character.meta.erase("heroFrom"); character.meta.erase("heroFrom");
character.meta.erase("heroFromCampaign");
if (toAutosave) { if (toAutosave) {
character.meta["difficulty"] = info->difficulty; character.meta["difficulty"] = info->difficulty;
character.meta["campaign"] = info->campaign;
} }
character.Set("hero", std::move(*hero)); 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(); const bool saved = character.Save();
re::Log("characters: %s %s, difficulty %d%s", toAutosave ? "autosaved" : "saved", character.Path().c_str(), re::Log("characters: %s %s, campaign %d, difficulty %d%s", toAutosave ? "autosaved" : "saved",
info->difficulty, saved ? "" : " - WRITING FAILED"); character.Path().c_str(), info->campaign, info->difficulty, saved ? "" : " - WRITING FAILED");
return result; return result;
} }
@@ -1173,7 +1300,6 @@ constexpr size_t kEngineSaveRow = 0x68; // u16
constexpr size_t kEngineSaveDescription = 0x6a; // char[], "QUICKSAVE" for quicksaves constexpr size_t kEngineSaveDescription = 0x6a; // char[], "QUICKSAVE" for quicksaves
constexpr char kSaveDescription[] = "SAVE"; // not shown: the list shows the characters constexpr char kSaveDescription[] = "SAVE"; // not shown: the list shows the characters
std::atomic<bool> g_saveRequested{false}; std::atomic<bool> g_saveRequested{false};
bool g_inGame = false;
void SaveOnTick(uint8_t* engine) { void SaveOnTick(uint8_t* engine) {
if (!g_saveRequested.exchange(false)) { if (!g_saveRequested.exchange(false)) {
@@ -1200,7 +1326,6 @@ constexpr size_t kUiEventCode = 4;
constexpr size_t kUiEventSubtype = 8; constexpr size_t kUiEventSubtype = 8;
constexpr uint32_t kUiEventWindowSubtype = 5; constexpr uint32_t kUiEventWindowSubtype = 5;
constexpr size_t kUiEventWindowName = 0x48; // char[0x20] constexpr size_t kUiEventWindowName = 0x48; // char[0x20]
constexpr char kSaveWindowName[] = "UI_WND_SAVEGAME";
int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event); int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event);
RE_HOOK(0x00757120, UiManager_ReceiveEvent); 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 Row& r = g_rows[row - 1];
const Choice& choice = r.choices[r.chosen]; const Choice& choice = r.choices[r.chosen];
std::wstring line = L"Stufe " + std::to_wstring(r.level) + L" - " + DifficultyName(choice.difficulty); const std::wstring line = L"Stufe " + std::to_wstring(r.level) + L" - " + ChoiceName(choice);
if (choice.autosave) {
line += L" (Autosave)";
}
reinterpret_cast<SetTextFn>(kStaticSetText)(text, edx, Convert(line, CP_ACP).c_str()); 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() { 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)); re::RedirectCall(kRowFileNameCall, kStaticSetText, reinterpret_cast<const void*>(&SetRowSecondLine));
static const uint32_t kSaveText = game::RegisterText(L"Speichern"); static const uint32_t kSaveText = game::RegisterText(L"Speichern");
re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText); re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText);
+35 -6
View File
@@ -1,5 +1,6 @@
#include "mods/charfile.h" #include "mods/charfile.h"
#include <algorithm>
#include <cstring> #include <cstring>
#include <fstream> #include <fstream>
@@ -25,8 +26,11 @@ constexpr size_t kHeroLevel = 0x0;
constexpr size_t kHeroClass = 0x4; constexpr size_t kHeroClass = 0x4;
constexpr size_t kHeroName = 0x8; constexpr size_t kHeroName = 0x8;
constexpr size_t kHeroNameChars = 0x40; 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 kWorldMultiplayer = 0x2c;
constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords) constexpr size_t kWorldSavedAt = 0x5c; // year, month, day, weekday, hour, minute, second, ms (dwords)
constexpr size_t kWorldDifficulty = 0x84; constexpr size_t kWorldDifficulty = 0x84;
@@ -86,10 +90,13 @@ std::optional<SaveInfo> ReadSaveInfo(const Bytes& file) {
std::memcpy(&info.heroClass, header + kHeroClass, 4); std::memcpy(&info.heroClass, header + kHeroClass, 4);
const auto* name = reinterpret_cast<const wchar_t*>(header + kHeroName); const auto* name = reinterpret_cast<const wchar_t*>(header + kHeroName);
info.name.assign(name, wcsnlen(name, kHeroNameChars)); 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'; info.world = file[2] == 'S';
if (info.world) { if (info.world) {
info.difficulty = static_cast<int>(U32(file, kWorldDifficulty)); 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; info.multiplayer = U32(file, kWorldMultiplayer) != 0;
SYSTEMTIME time{}; SYSTEMTIME time{};
WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek, WORD* fields[] = {&time.wYear, &time.wMonth, &time.wDay, &time.wDayOfWeek,
@@ -116,9 +123,14 @@ bool SetHeroName(Bytes& file, const std::wstring& name) {
return true; 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)); Bytes preview(world.begin(), world.begin() + std::min(world.size(), kChunkData));
preview.resize(kChunkData); preview.resize(kChunkData);
PutU32(preview, kWorldCampaign, CampaignSaveMode(campaign));
std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize); std::memset(preview.data() + kChunkTable, 0, kChunkCount * kChunkEntrySize);
size_t entry = kChunkTable; size_t entry = kChunkTable;
auto append = [&](uint32_t type, const uint8_t* data, uint32_t size) { 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; return MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
} }
std::string WorldSection(int difficulty) { std::string WorldSection(int campaign, int difficulty) {
return "world" + std::to_string(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() { bool CharacterFile::Open() {
sections_.clear(); sections_.clear();
std::ifstream file(path_, std::ios::binary); std::ifstream file(path_, std::ios::binary);
@@ -181,7 +210,7 @@ bool CharacterFile::Open() {
} }
Section section; Section section;
const auto* name = reinterpret_cast<const char*>(entry); 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.offset, entry + kSectionNameSize, 4);
std::memcpy(&section.size, entry + kSectionNameSize + 4, 4); std::memcpy(&section.size, entry + kSectionNameSize + 4, 4);
sections_.push_back(std::move(section)); 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 // 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. // hero and one world per difficulty, plus reading the game's save files.
#include <array>
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -16,6 +17,11 @@ using Bytes = std::vector<uint8_t>;
// Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4). // Bronze, Silber, Gold, Platin, Niob (g_gameLevel 0..4).
constexpr int kDifficultyCount = 5; 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 // 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, // their hero: header 0x100 bytes, then a table of 64 chunks {type, offset,
// size}; chunk 0xc3 is the hero header. // size}; chunk 0xc3 is the hero header.
@@ -23,9 +29,12 @@ struct SaveInfo {
std::wstring name; // hero header +8, UTF-16 std::wstring name; // hero header +8, UTF-16
uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin uint32_t heroClass = 0; // hero header +4: 1 Seraphim .. 9 Daemonin
uint32_t level = 0; // hero header +0 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 bool world = false; // a save game (AMS), not a hero file
int difficulty = 0; // AMS header +0x84 (g_gameLevel) 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 bool multiplayer = false; // AMS header +0x2c
uint64_t savedAt = 0; // AMS header +0x5c SYSTEMTIME, as FILETIME 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; constexpr size_t kHeroNameMaxChars = 63;
bool SetHeroName(Bytes& file, const std::wstring& name); 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 // 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 // date, its thumbnail and world time, and `description` (ANSI, up to 63
// characters) as the save's name. // characters) as the save's name. The load window lists only saves of the
Bytes BuildPreview(const Bytes& world, const std::string& description); // 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). // Reading and writing whole files (paths in the game's code page).
std::optional<Bytes> ReadFile(const std::string& path); std::optional<Bytes> ReadFile(const std::string& path);
@@ -77,6 +91,8 @@ private:
std::vector<Section> sections_; 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 } // 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 0x006bb870 cUI_EscMenu::BuildLines # line actions; "Spielstaende" text 0x41b at 0x6bc1f7
0x00810af0 BringWindowToFront # (hwnd, timeout), ret 8; loops forever with timeout -1 0x00810af0 BringWindowToFront # (hwnd, timeout), ret 8; loops forever with timeout -1
0x0072ad60 cUI_Button2::SetCaption # (text id, font), ret 8 -> +0xa4 / +0xa0 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+)