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) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-20 22:04:41 +02:00
1 parent e0d8d0ce7e
commit feebe72ab7
5 files changed
+149 -37

No files matched your search

+11 -1
View File
@@ -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 "<Welt> zurücksetzen?". A reset (decided 2026-10-20) removes the
world section (a copy goes to `charactersdeletedCharacterNN.sacredK.<section>.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`).
-2
View File
@@ -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.
+123 -34
View File
@@ -358,6 +358,7 @@ struct Row {
std::vector<Choice> 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<Row> 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,12 +620,19 @@ 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
DialogKind kind = DialogKind::Difficulty;
size_t row = 0; // index into g_rows
std::wstring title;
std::vector<std::wstring> entries; // then "Abbrechen"
std::vector<Choice> worlds; // Reset: the entries' worlds
Choice target; // ConfirmReset: the world
std::vector<uint8_t*> buttons; // made once, reused
std::vector<uint32_t> captions; // their text ids, overridden
} g_dialog;
@@ -634,15 +643,9 @@ std::mutex g_nameLock;
std::wstring g_name;
std::atomic<int> 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";
}
if (g_dialog.kind == DialogKind::Rename) {
return L"Umbenennen";
// 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<Choice> ResettableWorlds(const Row& row) {
std::vector<Choice> worlds;
if (!row.hasHero) {
return worlds;
}
if (g_dialog.kind == DialogKind::NewCharacter) {
return CampaignName(static_cast<int>(index));
for (const Choice& choice : row.choices) {
if (!choice.newWorld && !choice.autosave) {
worlds.push_back(choice);
}
}
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<uint32_t*>(window + kSavegameMode);
if (mode != kSavegameModeLoad && mode != kSavegameModeSave) {
return nullptr;
@@ -802,20 +847,27 @@ bool NewCharacterAllowed(uint8_t* window) {
!*reinterpret_cast<const uint8_t*>(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<int16_t>(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<Bytes> removed = character.Open() ? character.Read(section) : std::nullopt;
std::optional<Bytes> 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<int>(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<size_t>(RenameTarget(window) - g_rows.data()));
} else {
OpenDialog(window, DialogKind::NewCharacter, 0);
}
const Row* row = EditableRow(window);
OpenDialog(window, side.opens, row ? static_cast<size_t>(row - g_rows.data()) : 0);
return true;
}
return false;
+10
View File
@@ -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<Chunk> 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;
}
+5
View File
@@ -46,6 +46,11 @@ std::optional<SaveInfo> 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);