From feebe72ab7148c60ceffd41b215d4adab07c9f29 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Tue, 20 Oct 2026 22:04:41 +0200 Subject: [PATCH] Characters: reset a difficulty "Zuruecksetzen" in the load window lists the selected character's worlds and asks for confirmation. The world goes (a copy in characters\deleted), with an autosave of it, and the shared hero's story completion of that difficulty is cleared. The dialog is one generic list (title + entries). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/characters.md | 12 ++- docs/todo.md | 2 - src/mods/characters.cpp | 165 +++++++++++++++++++++++++++++++--------- src/mods/charfile.cpp | 10 +++ src/mods/charfile.h | 5 ++ 5 files changed, 153 insertions(+), 41 deletions(-) diff --git a/docs/characters.md b/docs/characters.md index 735fe88..c46c4d6 100644 --- a/docs/characters.md +++ b/docs/characters.md @@ -222,6 +222,17 @@ keeping the gap before "Beenden". (`game::NewButtonLike`); their captions are our own text ids with overrides (texts.cpp checks overrides for our ids too). +- "Zurücksetzen" (below Umbenennen; a selected character with the shared + hero and a world, not the one being played) lists the character's worlds, + then asks " zurücksetzen?". A reset (decided 2026-10-20) removes the + world section (a copy goes to `charactersdeletedCharacterNN.sacredK.
.PAK`) + and an autosave of that world, and clears the shared hero's story + completion of that difficulty (hero header +0xed + d); the game counts it + per difficulty, so the next difficulty closes in both campaigns unless a + world keeps it open. The hero keeps everything else. +- The dialog is one generic list: each kind fills a title and entries + (FillDialog), "Abbrechen" last. + ### Load `cEngine::Load` (0x61b790) of a listed row: the chosen world and the hero are @@ -275,7 +286,6 @@ kept, the newest file overall is the shared hero. ## Open -- Reset a difficulty. - Silent autosave every 10 minutes. - Quickload (row 0) does not know characters. - Multiplayer (see `todo.md`). diff --git a/docs/todo.md b/docs/todo.md index abc4799..f9d0787 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -184,8 +184,6 @@ From the characters rework (`docs/characters.md`, request of 2026-10-19): are one byte per difficulty in cStatsManager +0x5704 (read 0x424be0) and travel in the save header (+0x88..+0x98) and the hero file. Done 2026-10-20 (docs/characters.md). -- **Reset a difficulty.** Remove one world from a character (the shared hero - stays); a button / dialog entry in the load window. - **Silent autosave every 10 minutes** (a setting later) into the autosave file, without the busy dialog, like the direct save (`SaveOnTick`) but marked as an autosave. diff --git a/src/mods/characters.cpp b/src/mods/characters.cpp index 9fd6f38..aa9ec56 100644 --- a/src/mods/characters.cpp +++ b/src/mods/characters.cpp @@ -358,6 +358,7 @@ struct Row { std::vector choices; size_t chosen = 0; uint32_t level = 0; // the shared hero's + bool hasHero = false; // the shared hero is in the file (migrated ones get it at their first load) }; std::vector g_rows; // row nn of the game = g_rows[nn - 1] @@ -415,6 +416,7 @@ void RebuildList() { const int heroFrom = character.meta.value("heroFrom", -1); const int heroFromCampaign = character.meta.value("heroFromCampaign", kCampaignAncaria); Row row{character.Path()}; + row.hasHero = hasHero; row.name = FromUtf8(character.meta.value("name", std::string())); row.level = character.meta.value("level", 0u); int highest = kAlwaysOpenDifficulty; @@ -618,14 +620,21 @@ constexpr int kSavegameButtonPitch = 64; void __fastcall Savegame_Action(uint8_t* self, void* edx, int action); RE_HOOK(0x0071c520, Savegame_Action); -enum class DialogKind { Difficulty, Rename, NewCharacter }; +// Difficulty: the row's choices; Rename: "Umbenennen" under the name field; +// NewCharacter: the campaigns; Reset: the row's worlds; ConfirmReset: +// "Zurücksetzen" for one of them. +enum class DialogKind { Difficulty, Rename, NewCharacter, Reset, ConfirmReset }; struct Dialog { - uint8_t* window = nullptr; // open while set + uint8_t* window = nullptr; // open while set DialogKind kind = DialogKind::Difficulty; - size_t row = 0; // index into g_rows - std::vector buttons; // made once, reused - std::vector captions; // their text ids, overridden + size_t row = 0; // index into g_rows + std::wstring title; + std::vector entries; // then "Abbrechen" + std::vector worlds; // Reset: the entries' worlds + Choice target; // ConfirmReset: the world + std::vector buttons; // made once, reused + std::vector captions; // their text ids, overridden } g_dialog; // Rename: the name typed so far and a pending Enter / Esc, from the window @@ -634,15 +643,9 @@ std::mutex g_nameLock; std::wstring g_name; std::atomic g_nameKey = 0; -// Entries: the row's choices, "Umbenennen" or the campaigns, then "Abbrechen". +// The entries and "Abbrechen". size_t DialogEntries() { - if (g_dialog.kind == DialogKind::NewCharacter) { - return kCampaignCount + 1; - } - if (g_dialog.row >= g_rows.size()) { - return 0; - } - return g_dialog.kind == DialogKind::Rename ? 2 : g_rows[g_dialog.row].choices.size() + 1; + return g_dialog.entries.size() + 1; } int DialogFieldSpace() { @@ -670,16 +673,56 @@ game::UiRect DialogButtonRect(size_t index) { } std::wstring DialogText(size_t index) { - if (index + 1 == DialogEntries()) { - return L"Abbrechen"; + return index < g_dialog.entries.size() ? g_dialog.entries[index] : L"Abbrechen"; +} + +// The worlds a reset can delete: every saved world of the row (not new ones, +// not autosaves). Only with the shared hero: before it exists, a world holds it. +std::vector ResettableWorlds(const Row& row) { + std::vector worlds; + if (!row.hasHero) { + return worlds; } - if (g_dialog.kind == DialogKind::Rename) { - return L"Umbenennen"; + for (const Choice& choice : row.choices) { + if (!choice.newWorld && !choice.autosave) { + worlds.push_back(choice); + } } - if (g_dialog.kind == DialogKind::NewCharacter) { - return CampaignName(static_cast(index)); + return worlds; +} + +// Title and entries of a dialog `kind` for row `row` (and g_dialog.target). +void FillDialog(DialogKind kind, size_t row) { + g_dialog.entries.clear(); + switch (kind) { + case DialogKind::Difficulty: + g_dialog.title = L"Welt wählen"; + for (const Choice& choice : g_rows[row].choices) { + g_dialog.entries.push_back(ChoiceName(choice)); + } + break; + case DialogKind::Rename: + g_dialog.title = L"Neuer Name"; + g_dialog.entries = {L"Umbenennen"}; + break; + case DialogKind::NewCharacter: + g_dialog.title = L"Kampagne wählen"; + for (int c = 0; c < kCampaignCount; ++c) { + g_dialog.entries.push_back(CampaignName(c)); + } + break; + case DialogKind::Reset: + g_dialog.title = L"Welt zurücksetzen"; + g_dialog.worlds = ResettableWorlds(g_rows[row]); + for (const Choice& choice : g_dialog.worlds) { + g_dialog.entries.push_back(ChoiceName(choice)); + } + break; + case DialogKind::ConfirmReset: + g_dialog.title = ChoiceName(g_dialog.target) + L" zurücksetzen?"; + g_dialog.entries = {L"Zurücksetzen"}; + break; } - return ChoiceName(g_rows[g_dialog.row].choices[index]); } // The character typed with key `vk` under the current modifiers, 0 if none. @@ -712,6 +755,7 @@ void OpenDialog(uint8_t* window, DialogKind kind, size_t row) { g_dialog.window = window; g_dialog.kind = kind; g_dialog.row = row; + FillDialog(kind, row); for (size_t i = 0; i < DialogEntries(); ++i) { if (i == g_dialog.buttons.size()) { const uint32_t caption = game::RegisterText(L""); @@ -779,9 +823,10 @@ bool RenameCharacter(const Row& row, const std::wstring& name) { return done; } -// The row "Umbenennen" applies to: the selected character, unless it is the -// one being played (its name is in the running game too). -const Row* RenameTarget(uint8_t* window) { +// The row "Umbenennen" and "Zurücksetzen" apply to: the selected character, +// unless it is the one being played (its name and world are in the running +// game too). +const Row* EditableRow(uint8_t* window) { const uint32_t mode = *reinterpret_cast(window + kSavegameMode); if (mode != kSavegameModeLoad && mode != kSavegameModeSave) { return nullptr; @@ -802,20 +847,27 @@ bool NewCharacterAllowed(uint8_t* window) { !*reinterpret_cast(kMultiplayer); } -// Buttons of ours below the window's delete button, made on first use. +bool CanReset(uint8_t* window) { + const Row* row = EditableRow(window); + return row && !ResettableWorlds(*row).empty(); +} + +// Buttons of ours below the window's delete button, made on first use; each +// opens a dialog (for the selected character, if it needs one). struct SideButton { int slot; // places below delete const wchar_t* caption; const char* name; bool (*shown)(uint8_t* window); + DialogKind opens; 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}, + {1, L"Umbenennen", "UI_BTN_CHAR_RENAME", [](uint8_t* window) { return EditableRow(window) != nullptr; }, + DialogKind::Rename}, + {2, L"Zurücksetzen", "UI_BTN_CHAR_RESET", &CanReset, DialogKind::Reset}, + {3, L"Neuer Charakter", "UI_BTN_CHAR_NEW", &NewCharacterAllowed, DialogKind::NewCharacter}, }; -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) @@ -879,11 +931,8 @@ void RenderDialog(void* device) { FillBox(device, Grown(panel, 2), kDialogEdgeColour); FillBox(device, panel, kDialogBackColour); const game::UiRect title = {panel.x, panel.y, panel.w, static_cast(kDialogTitleHeight)}; - const wchar_t* heading = g_dialog.kind == DialogKind::Rename ? L"Neuer Name" - : 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); + game::DrawText(device, kDialogFont, game::CenterLineVertically(kDialogFont, title), g_dialog.title.c_str(), + kDialogTitleColour, game::kTextCenter); if (g_dialog.kind == DialogKind::Rename) { const game::UiRect field = DialogField(); FillBox(device, Grown(field, 1), kDialogEdgeColour); @@ -988,6 +1037,41 @@ void OpenCharacterCreation(int campaign) { re::Log("characters: new character, campaign %d", campaign); } +// Reset (request of 2026-10-20): the world goes (kept in characters\deleted), +// with an autosave of it, and the shared hero's story completion for its +// difficulty is cleared (decided: cleared too; the game counts it per +// difficulty, so for both campaigns). The hero keeps everything else. +bool ResetWorld(const Row& row, const Choice& world) { + CharacterFile character(row.file); + const std::string section = WorldSection(world.campaign, world.difficulty); + std::optional removed = character.Open() ? character.Read(section) : std::nullopt; + std::optional hero = removed ? character.Read("hero") : std::nullopt; + if (!hero || !SetStoryCompleted(*hero, world.difficulty, 0)) { + re::Log("characters: reset of %s in %s failed (no world or no shared hero)", section.c_str(), row.file.c_str()); + return false; + } + const std::string deleted = CharactersDir() + "deleted\\"; + CreateDirectoryA(deleted.c_str(), nullptr); + const std::string name = row.file.substr(row.file.find_last_of('\\') + 1); + WriteFileReplacing(deleted + name + "." + section + ".PAK", *removed); + character.Remove(section); + character.Set("hero", std::move(*hero)); + if (character.meta.value("lastCampaign", kCampaignAncaria) == world.campaign && + character.meta.value("lastDifficulty", 0) == world.difficulty) { + character.meta.erase("lastCampaign"); + character.meta.erase("lastDifficulty"); + } + const bool saved = character.Save(); + CharacterFile autosave(AutosavePath(row.file)); + if (autosave.Open() && autosave.meta.value("campaign", kCampaignAncaria) == world.campaign && + autosave.meta.value("difficulty", -1) == world.difficulty) { + DeleteFileA(autosave.Path().c_str()); + } + re::Log("characters: reset %s of %s (backup in %s)%s", section.c_str(), row.file.c_str(), deleted.c_str(), + saved ? "" : " - WRITING FAILED"); + return saved; +} + // Button `index` of the dialog chosen. void DialogChoose(uint8_t* window, size_t index) { if (index + 1 == DialogEntries()) { @@ -997,6 +1081,14 @@ void DialogChoose(uint8_t* window, size_t index) { } else if (g_dialog.kind == DialogKind::NewCharacter) { CloseDialog(); OpenCharacterCreation(static_cast(index)); + } else if (g_dialog.kind == DialogKind::Reset) { + g_dialog.target = g_dialog.worlds[index]; + OpenDialog(window, DialogKind::ConfirmReset, g_dialog.row); + } else if (g_dialog.kind == DialogKind::ConfirmReset) { + const Row row = g_rows[g_dialog.row]; + CloseDialog(); + ResetWorld(row, g_dialog.target); + Savegame_Action_original(window, nullptr, kSavegameActionRescan); } else if (const Row row = g_rows[g_dialog.row]; row.choices[index].newWorld) { CloseDialog(); StartNewWorld(window, row, row.choices[index].campaign, row.choices[index].difficulty); @@ -1049,11 +1141,8 @@ bool HandleSideButtons(uint8_t* window, void* event) { game::EventField(event, game::kMouseButtonY))) { continue; } - if (&side == &g_renameButton) { - OpenDialog(window, DialogKind::Rename, static_cast(RenameTarget(window) - g_rows.data())); - } else { - OpenDialog(window, DialogKind::NewCharacter, 0); - } + const Row* row = EditableRow(window); + OpenDialog(window, side.opens, row ? static_cast(row - g_rows.data()) : 0); return true; } return false; diff --git a/src/mods/charfile.cpp b/src/mods/charfile.cpp index 2d3bd61..663a8d1 100644 --- a/src/mods/charfile.cpp +++ b/src/mods/charfile.cpp @@ -123,6 +123,16 @@ bool SetHeroName(Bytes& file, const std::wstring& name) { return true; } +bool SetStoryCompleted(Bytes& file, int difficulty, uint8_t count) { + const std::optional hero = FindChunk(file, kChunkHero); + if (!hero || difficulty < 0 || difficulty >= kDifficultyCount || + hero->size < kHeroStoryCompleted + kDifficultyCount) { + return false; + } + file[hero->offset + kHeroStoryCompleted + difficulty] = count; + return true; +} + uint32_t CampaignSaveMode(int campaign) { return campaign == kCampaignUnderworld ? kSaveModeUnderworld : kSaveModeAncaria; } diff --git a/src/mods/charfile.h b/src/mods/charfile.h index b8ab718..9bea78c 100644 --- a/src/mods/charfile.h +++ b/src/mods/charfile.h @@ -46,6 +46,11 @@ std::optional ReadSaveInfo(const Bytes& file); constexpr size_t kHeroNameMaxChars = 63; bool SetHeroName(Bytes& file, const std::wstring& name); +// Sets the hero header's story completions of `difficulty` (+0xed..; in a hero +// file they are the hero's, loadHero puts them into cStatsManager); false if +// the file has no hero header. +bool SetStoryCompleted(Bytes& file, int difficulty, uint8_t count); + // 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);