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);