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:
1 parent
5bccb0734e
commit
618288e310
7 files changed
+498
-109
No files matched your search
@@ -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
@@ -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.
|
||||||
|
|||||||
+241
-86
@@ -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) {
|
||||||
highest = std::max(highest, d);
|
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.
|
// 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 d = 0; d < kDifficultyCount; ++d) {
|
for (int c = 0; c < kCampaignCount; ++c) {
|
||||||
const uint64_t savedAt = SavedAt(character, d);
|
for (int d = 0; d < kDifficultyCount; ++d) {
|
||||||
// Without the shared hero yet only the world holding it can be loaded.
|
const uint64_t savedAt = SavedAt(character, c, d);
|
||||||
if (savedAt && (hasHero || d == heroFrom)) {
|
// Without the shared hero yet only the world holding it can be loaded.
|
||||||
row.choices.push_back({d, false});
|
if (savedAt && (hasHero || (c == heroFromCampaign && d == heroFrom))) {
|
||||||
} else if (!savedAt && d <= highest && canStart) {
|
row.choices.push_back({c, d});
|
||||||
row.choices.push_back({d, false, true});
|
} else if (!savedAt && d <= highest && canStart) {
|
||||||
}
|
row.choices.push_back({c, d, false, true});
|
||||||
if (d == autosaved && SavedAt(autosave, d) > savedAt) {
|
}
|
||||||
row.choices.push_back({d, true});
|
if (c == autosavedCampaign && d == autosaved && SavedAt(autosave, c, d) > savedAt) {
|
||||||
|
row.choices.push_back({c, d, true});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The preview shows the last played world (or any world).
|
// 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 d = 0; d < kDifficultyCount; ++d) {
|
for (int c = 0; c < kCampaignCount; ++c) {
|
||||||
sections.push_back(WorldSection(d));
|
for (int d = 0; d < kDifficultyCount; ++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;
|
||||||
}
|
}
|
||||||
game::ControlHandleEvent(button, event);
|
for (SideButton& side : g_sideButtons) {
|
||||||
if (kind != game::MouseEvent::ButtonDown || game::EventField(event, game::kMouseButton) != game::kLeftButton ||
|
uint8_t* button = side.shown(window) ? SideButtonControl(window, side) : nullptr;
|
||||||
!game::Inside(RenameButtonRect(window), game::EventField(event, game::kMouseButtonX),
|
if (!button) {
|
||||||
game::EventField(event, game::kMouseButtonY))) {
|
continue;
|
||||||
return false;
|
}
|
||||||
|
game::ControlHandleEvent(button, event);
|
||||||
|
if (kind != game::MouseEvent::ButtonDown ||
|
||||||
|
game::EventField(event, game::kMouseButton) != game::kLeftButton ||
|
||||||
|
!game::Inside(SideButtonRect(window, side), game::EventField(event, game::kMouseButtonX),
|
||||||
|
game::EventField(event, game::kMouseButtonY))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (&side == &g_renameButton) {
|
||||||
|
OpenDialog(window, DialogKind::Rename, static_cast<size_t>(RenameTarget(window) - g_rows.data()));
|
||||||
|
} else {
|
||||||
|
OpenDialog(window, DialogKind::NewCharacter, 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
OpenDialog(window, DialogKind::Rename, static_cast<size_t>(row - g_rows.data()));
|
return false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
game::ControlRender(button, call->argument);
|
if (uint8_t* button = SideButtonControl(call->window, side)) {
|
||||||
|
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()
|
||||||
: std::nullopt;
|
? source.Read(WorldSection(choice.campaign, choice.difficulty))
|
||||||
|
: 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
@@ -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(§ion.offset, entry + kSectionNameSize, 4);
|
std::memcpy(§ion.offset, entry + kSectionNameSize, 4);
|
||||||
std::memcpy(§ion.size, entry + kSectionNameSize + 4, 4);
|
std::memcpy(§ion.size, entry + kSectionNameSize + 4, 4);
|
||||||
sections_.push_back(std::move(section));
|
sections_.push_back(std::move(section));
|
||||||
|
|||||||
+20
-4
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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+)
|
||||||
Reference in new issue
Block a user