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