From 34a119433a69bd23d238beedaaa0e3d5b5ae0d36 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Tue, 20 Oct 2026 23:20:45 +0200 Subject: [PATCH] Characters: 16:9 "Spielen" screen, autosave interval option - Shared screen kit (mods/screen, docs/screens.md): a 1365x768 canvas centred in the render width on our own dark background, the game's frames and buttons; the main menu renders full width while it is shown. - The load window (cUI_Savegame) is replaced by the Spielen screen: character list, hero panel, campaign x difficulty grid, save/autosave choice, rename/delete/reset/new character dialogs. - Migrated characters without a shared hero load their hero's world first, then start the new world. - Options, Allgemein: autosave interval (Aus/5/10/15/30 minutes). Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + docs/characters.md | 97 ++-- docs/options.md | 15 + docs/screens.md | 39 ++ docs/todo.md | 26 +- src/game/ui/draw.h | 29 +- src/game/ui/frame.h | 36 ++ src/mods/characters.cpp | 1143 ++++++++++++++++++++++++++------------- src/mods/characters.h | 5 + src/mods/hud.cpp | 30 +- src/mods/hud.h | 10 + src/mods/options.cpp | 157 +++++- src/mods/screen.cpp | 154 ++++++ src/mods/screen.h | 105 ++++ src/mods/widescreen.cpp | 7 +- src/mods/widescreen.h | 5 + 16 files changed, 1391 insertions(+), 468 deletions(-) create mode 100644 docs/screens.md create mode 100644 src/game/ui/frame.h create mode 100644 src/mods/screen.cpp create mode 100644 src/mods/screen.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6b77165..a6261d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,7 @@ add_library(sacredK SHARED src/mods/lootfilter.cpp src/mods/userfolders.cpp src/mods/charfile.cpp + src/mods/screen.cpp src/mods/characters.cpp src/mods/mainmenu.cpp ) diff --git a/docs/characters.md b/docs/characters.md index ee1f4f6..ccab834 100644 --- a/docs/characters.md +++ b/docs/characters.md @@ -177,26 +177,55 @@ 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) +### Spielen screen (cUI_Savegame) + +Request 2026-10-20: redesign the load window, 16:9, using the space (layout +agreed: characters left, the character in the middle, worlds right, buttons +below; our own background). Built on the shared screen kit +(docs/screens.md); the window's own drawing and mouse handling are not used. + +- The window still scans: before each scan (0x71e330) one preview + `GAMEnn.PAK` per character is written into `_work`; row nn = character nn. + Its entries are only needed for the load. +- Left: the characters, newest save first (name, class, level; "im Spiel" for + the one being played). Click selects, double click plays. +- Middle: name, class and level, the world played last with its date. (A + render of the hero is planned.) +- Right: a grid campaigns x difficulties. A cell shows the save's date, + "+ Autosave" when the autosave is newer, "Story abgeschlossen", "Neu" for a + difficulty that is open but has no world, "–" when closed. 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..); new worlds need the shared hero, not in multiplayer + (byte 0x182cb6c). Click selects, double click plays. With a newer autosave + "Laden von:" picks the save or the autosave (default: the autosave). + "Welt zurücksetzen" for a saved world (not the character being played). +- Buttons: Neuer Charakter (main menu only), Umbenennen, Löschen (not for the + character being played), Zurück (action 0xc), Spielen. +- Spielen: a saved world loads through the window's load action (4, with its + "really load?" confirmation in a game): the window's selection is set to + its first entry and `g_loadRow` names the row, which `Engine_Load` takes + instead of the entry's. A new world starts a game (below; in a game after + stopping the running one). The window's own load / default actions (Enter) + run Spielen too; its delete actions (7, 8) are ignored. +- Dialogs over the screen: rename (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 the hero, every world and the autosave, plus + `meta.name`), campaign of a new character, confirmations of reset and + delete. Buttons are cUI_Button2 copies of the load button + (`game::NewButtonLike`) with our own text ids and overrides. +- Delete moves the character file and its autosave to `charactersdeleted`. +- Neuer Charakter: 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. +- 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. + +### Character window (cUI_Character) -- Before each scan (0x71e330) one preview `GAMEnn.PAK` per character is - written into `_work` (header, thumbnail, world time, description - "Name - Klasse"); row nn = character nn. The preview's campaign field is - the running campaign, so the window lists every character. The row's - second line (call 0x71db2a to SetText 0x736f00) shows - "Stufe N - Campaign - difficulty". -- Laden (action 4 / 0xb) opens the world dialog when there is more than one - 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 @@ -209,30 +238,6 @@ keeping the gap before "Beenden". - 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 - 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 - the hero, every world and the autosave, plus `meta.name`. Confirmed: the - game loads renamed characters without complaint. -- Dialog buttons are cUI_Button2 copies of the load button - (`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 @@ -265,7 +270,8 @@ Silent autosave: 10 minutes after the last save of any kind (or the game's start), `SaveOnTick` does the direct save marked as an autosave, so it goes into the autosave file. While a window is open or a save runs it waits for the next tick. A character never saved yet gets its character file from it. -Interval: `sacredK.ini` `[characters] AutosaveMinutes` (default 10, 0 off). +Interval: options window, *Allgemein*, *Autosave (Min.)* (docs/options.md); kept in +`sacredK.ini` `[characters] AutosaveMinutes` (default 10, 0 off). Direct save (K key, Esc menu "Speichern"): on the next engine tick, as the quicksave does (0x618ebb): `cUI_Manager::CanOpenWindow` (0x75a690), @@ -299,5 +305,6 @@ kept, the newest file overall is the shared hero. ## Open -- Quickload (row 0) does not know characters. +- Quickload (row 0) does not know characters (not needed, user 2026-10-20). +- Spielen screen: a render of the hero; scrolling the list beyond 9 characters. - Multiplayer (see `todo.md`). diff --git a/docs/options.md b/docs/options.md index 6f46a78..754841c 100644 --- a/docs/options.md +++ b/docs/options.md @@ -117,3 +117,18 @@ choice (PICKUPAUTO radio buttons, their labels, the separator; matched by pointer and control name at +0x30) is never shown, a framed table below the auto-pickup frame sets a mode per category. Its frame is made like the key page's (`AddFrame`). + +## Allgemein: autosave interval (2026-10-20) + +Request: the silent autosave's interval (docs/characters.md, Save) settable +in the options, not only in `sacredK.ini`. The general frame moved up into +the auto-pickup frame's place; it is taller than the frames beside it, so the +new frame goes below the lowest frame of the left column. It copies the +right column's lowest frame (Internet-Optionen): its height and the gap the +right column leaves between its frames, measured from the window's frames +when it is made (a lower frame broke its side ornaments, a smaller gap joined +the borders: screenshots 2026-10-20). One row *Autosave (Min.)*: Aus / 5 / +10 / 15 / 30 (the key page's box colour is not seen on the dark backdrop: a +lighter one). A click selects, OK writes +`[characters] AutosaveMinutes` (`characters::SetAutosaveMinutes`, the timer +uses it at once), Cancel drops it. diff --git a/docs/screens.md b/docs/screens.md new file mode 100644 index 0000000..01b38a3 --- /dev/null +++ b/docs/screens.md @@ -0,0 +1,39 @@ +# 16:9 menu screens + +Request (2026-10-20): the game's menu screens are laid out for 1024x768; we +design our own UIs for them. There is no 16:9 artwork for the game, so the +background is our own (option 3 of the ones discussed: dark, no picture), and +the approach is shared by all menu screens. Target: 16:9, 1920x1080, which is +1365x768 in UI units; wider screens only get more background at the sides +(not designed for ultrawide). + +## How a screen works + +- `mods/screen.{h,cpp}`: a screen replaces a full-screen window's drawing and + mouse handling. Its render hook calls `screen::Render(window, device, body, + context)`: the background across the whole render target, then the body in + canvas coordinates (1365x768, centred: `hud::RenderCanvas`, the HUD + layer's offset machinery with `Anchor::Canvas`; drawing, the cursor and the + window's hit tests shifted alike). Mouse events go through `screen::Handle` + (`hud::HandleCanvas`). Keys stay the window's. +- In the main menu the game renders a classic 1024x768 frame (presented 4:3). + While a screen's window is shown, `widescreen::SetWideMenuScreen` makes the + menu thread render and present the full width. +- Parts: `screen::Panel` (dark backdrop and the game's frame, `cUI_Frame`, + game/ui/frame.h, made anew when its rect changes), `screen::Button` (a + button of the game's with the look of one of the window's, + `game::NewButtonLike`), `screen::Text`, `screen::Fill`, + `screen::DoubleClick`. Spacing constants from measurements: frames reach + 0x20 beyond their rects and need a height of about 0x40, buttons reach 0x21. + Frames are built of 64-unit pieces: widths and heights in steps of 64 + (`screen::kFrameStep`), other sizes leave gaps (screenshot 2026-10-20). +- Fonts (logged 2026-10-20): font 0 is another class (no line height field); + the TrueType fonts 1..7 have line heights 20, 19, 25, 31, 25, 28, 13. + Screens use font 1; 4 or 6 would suit headings. +- Inside the canvas scope the HUD layer does not stretch "full 1024 layout" + quads (`g_canvasScope`). + +## Screens + +- Spielen (the load window, cUI_Savegame): docs/characters.md, "Spielen + screen". diff --git a/docs/todo.md b/docs/todo.md index 8e15ae6..63e132c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -184,12 +184,10 @@ 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). -- **Autosave interval in the options window** (now only - `sacredK.ini` `[characters] AutosaveMinutes`). -- **Quickload (row 0) does not know characters.** Ctrl+Z saves into the - current character, but the quickload event (0x116, engine +0x68 = row, - flag 0x200) loads `GAME00.PAK`, which is not listed; make it reload the - current character's world (or its newer autosave). +- **Autosave interval in the options window.** Done 2026-10-20: Allgemein + page, "Spielstände" frame (docs/options.md). +- Quickload (row 0, `GAME00.PAK`) does not know characters; not needed + (user, 2026-10-20). ## Two saves in a row (2026-10-20, not reproduced) @@ -200,10 +198,20 @@ flag 0x40 is set in cEngine::ReceiveEvent at 0x618876 (quicksave), log of the event that sets the flag (dwords 0..9) did not catch it again; add it back if it shows. +Once (2026-10-20, not reproduced) an autosave seemed to fire right after a +new world started through the hero chain (Character08: hero world loaded, +then the new world). The log of that run had no save at all; the timed +autosave counts from the game start (OnGameStart). + ## Savegames screen redesign ("Spielen") Asked 2026-10-20: redesign the load window (cUI_Savegame, the "Spielen" screen, characters.cpp): show a render of the selected character, use the -space far better. The user looks for 16:9 artwork so the screens can be -widened beyond the 1024 frame (now drawn in a centred 1024x768 viewport with -black sides, hud.cpp FrameScope). +space far better. No 16:9 artwork exists for the game (user, 2026-10-20): +we design our own UIs, using the width beyond the 1024 frame (now drawn in +a centred 1024x768 viewport with black sides, hud.cpp FrameScope). Design +for 16:9 (1920x1080, 1365x768 UI units), not ultrawide (user). Screen done +2026-10-20 (docs/characters.md, "Spielen screen"; docs/screens.md). Open: +the render of the selected hero (the hero panel shows text only; reuse +cUI_Character's 3D hero?), list scrolling beyond 9 characters, larger +heading font. diff --git a/src/game/ui/draw.h b/src/game/ui/draw.h index 158dd33..0da4112 100644 --- a/src/game/ui/draw.h +++ b/src/game/ui/draw.h @@ -19,18 +19,35 @@ struct ScreenVertex { float u, v; }; -// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`. -inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) { +// Fills (x0, y0)-(x1, y1), ARGB colours per corner: top left, top right, +// bottom left, bottom right (blended between them). +inline void FillCorners(void* device, float x0, float y0, float x1, float y1, uint32_t topLeft, uint32_t topRight, + uint32_t bottomLeft, uint32_t bottomRight) { void** vtable = *static_cast(device); using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*); using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD); reinterpret_cast(vtable[kDeviceSetTexture])(device, 0, nullptr); - ScreenVertex quad[4] = {{x0, y0, 0, 1, color, 0, 0, 0}, - {x1, y0, 0, 1, color, 0, 1, 0}, - {x0, y1, 0, 1, color, 0, 0, 1}, - {x1, y1, 0, 1, color, 0, 1, 1}}; + ScreenVertex quad[4] = {{x0, y0, 0, 1, topLeft, 0, 0, 0}, + {x1, y0, 0, 1, topRight, 0, 1, 0}, + {x0, y1, 0, 1, bottomLeft, 0, 0, 1}, + {x1, y1, 0, 1, bottomRight, 0, 1, 1}}; reinterpret_cast(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture, quad, 4, 0); } +// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`. +inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) { + FillCorners(device, x0, y0, x1, y1, color, color, color, color); +} + +// Top to bottom from `top` to `bottom`. +inline void FillVertical(void* device, float x0, float y0, float x1, float y1, uint32_t top, uint32_t bottom) { + FillCorners(device, x0, y0, x1, y1, top, top, bottom, bottom); +} + +// Left to right from `left` to `right`. +inline void FillHorizontal(void* device, float x0, float y0, float x1, float y1, uint32_t left, uint32_t right) { + FillCorners(device, x0, y0, x1, y1, left, right, left, right); +} + } // namespace game diff --git a/src/game/ui/frame.h b/src/game/ui/frame.h new file mode 100644 index 0000000..d2beb69 --- /dev/null +++ b/src/game/ui/frame.h @@ -0,0 +1,36 @@ +#pragma once +// cUI_Frame (vtable 0x8955ac, 0xe0 bytes): the game's ornate frame. Its +// constructor (0x739f90, thiscall (name, type, rect*, 1), ret 0x10) places the +// border pieces once, around `rect`: a frame is not moved afterwards, a new one +// is made instead. Its render (0x73a540) draws while +0xdc is set (1 from the +// constructor), not by the visible flag. Type 1 is the windows' main frame +// (cUI_Options 0x7184e0). Its ornaments reach about 0x20 beyond the rect, and +// it needs a height of about 0x40 for its side pieces to join (measured on the +// options window, 2026-10-20). + +#include +#include + +#include "game/ui/tabs.h" +#include "game/ui/text.h" + +namespace game { + +constexpr uint32_t kFrameVtable = 0x008955ac; +constexpr size_t kFrameSize = 0xe0; +constexpr size_t kFrameDrawn = 0xdc; +constexpr uint32_t kFrameMain = 1; +constexpr int kFrameMinHeight = 0x40; + +// A new frame around `rect` (relative to the parent it is added to, or the +// screen without one); null if out of memory. +inline uint8_t* NewFrame(const char* name, UiRect rect, uint32_t type = kFrameMain) { + void* memory = GameNew(kFrameSize); + if (!memory) { + return nullptr; + } + using FrameFn = uint8_t*(__fastcall*)(void*, void*, const char*, uint32_t, UiRect*, uint32_t); + return reinterpret_cast(0x00739f90)(memory, nullptr, name, type, &rect, 1); +} + +} // namespace game diff --git a/src/mods/characters.cpp b/src/mods/characters.cpp index 7377c44..9e76b60 100644 --- a/src/mods/characters.cpp +++ b/src/mods/characters.cpp @@ -8,11 +8,11 @@ // // 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 -// campaign and difficulty (worlds, newer autosaves, new worlds), "Umbenennen" -// renames a character, deleting moves it to characters\deleted; +// - the load window (cUI_Savegame) is our 16:9 "Spielen" screen +// (mods/screen.h): the characters, the selected one, its worlds as a grid +// of campaigns and difficulties. The window still lists GAMEnn.PAK there: +// before it scans, one small preview per character is written; row nn of +// the game is g_rows[nn - 1], and the window's entries carry the load; // - 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 // with LoadHeroFile (0x61d190), the operation vanilla uses for an exported @@ -28,6 +28,7 @@ // difficulty; the newest of all is the shared hero), after a backup. #include +#include #include #include #include @@ -55,6 +56,7 @@ #include "mods/charfile.h" #include "mods/hud.h" #include "mods/input.h" +#include "mods/screen.h" #include "mods/userfolders.h" #include "re/config.h" #include "re/hook.h" @@ -128,6 +130,7 @@ constexpr size_t kSavegameEntrySize = 0x310; constexpr size_t kSavegameEntryName = 4; constexpr int kSavegameActionDelete = 8; constexpr size_t kSavegameMode = 0x75c; // 1 save, 2 load, 3..5 a confirmation +constexpr uint32_t kSavegameModeSave = 1; constexpr uint32_t kSavegameModeLoad = 2; constexpr size_t kSavegameLoadButton = 0x764; // cUI_Button2 inside the window (action 4) constexpr char kSaveWindowName[] = "UI_WND_SAVEGAME"; @@ -350,6 +353,7 @@ struct Choice { int difficulty = 0; bool autosave = false; bool newWorld = false; // no world yet: a new game with the shared hero + uint64_t savedAt = 0; // FILETIME of the save (local time, as the game writes it) }; // A character in the load window; the difficulty dialog picks `chosen`. @@ -359,7 +363,14 @@ struct Row { std::vector choices; size_t chosen = 0; uint32_t level = 0; // the shared hero's + uint32_t heroClass = 0; bool hasHero = false; // the shared hero is in the file (migrated ones get it at their first load) + std::array storyCompleted{}; + int lastCampaign = kCampaignAncaria; // the world played last + int lastDifficulty = 0; + // Migrated, not loaded since: the world holding the hero (-1: none). + int heroFromCampaign = kCampaignAncaria; + int heroFrom = -1; }; std::vector g_rows; // row nn of the game = g_rows[nn - 1] @@ -422,8 +433,11 @@ void RebuildList() { const int heroFromCampaign = character.meta.value("heroFromCampaign", kCampaignAncaria); Row row{character.Path()}; row.hasHero = hasHero; + row.heroFrom = hasHero ? -1 : character.meta.value("heroFrom", -1); + row.heroFromCampaign = character.meta.value("heroFromCampaign", kCampaignAncaria); row.name = FromUtf8(character.meta.value("name", std::string())); row.level = character.meta.value("level", 0u); + row.heroClass = character.meta.value("class", 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 @@ -437,24 +451,27 @@ void RebuildList() { highest = std::max(highest, d); } } + row.storyCompleted[d] = hero && hero->storyCompleted[d]; if (hero && hero->storyCompleted[d]) { highest = std::max(highest, std::min(d + 1, kDifficultyCount - 1)); } } - // New games need the shared hero (in a game the running one stops - // first, StartNewWorld); multiplayer games are not ours. - const bool canStart = hasHero && !*reinterpret_cast(kMultiplayer); + // New games need the shared hero: a migrated character gets it by + // loading the world holding it first (Play). In a game the running + // one stops first (StopGameThenStart); multiplayer games are not ours. + const bool canStart = (hasHero || row.heroFrom >= 0) && !*reinterpret_cast(kMultiplayer); 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}); + row.choices.push_back({c, d, false, false, savedAt}); } 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}); + if (const uint64_t autosavedAt = SavedAt(autosave, c, d); + c == autosavedCampaign && d == autosaved && autosavedAt > savedAt) { + row.choices.push_back({c, d, true, false, autosavedAt}); } } } @@ -462,6 +479,8 @@ void RebuildList() { std::optional shownChoice; const int last = character.meta.value("lastDifficulty", 0); const int lastCampaign = character.meta.value("lastCampaign", kCampaignAncaria); + row.lastCampaign = lastCampaign; + row.lastDifficulty = last; for (size_t i = 0; i < row.choices.size(); ++i) { const Choice& choice = row.choices[i]; if (!choice.newWorld && @@ -570,75 +589,212 @@ int __fastcall Savegame_Scan(void* self, void* edx, const char* dir, uint32_t mu return Savegame_Scan_original(self, edx, dir, multiplayer); } -// The row of the window's selected entry (0: none, or not a character). -Row* SelectedRow(uint8_t* window) { - const int selected = *reinterpret_cast(window + kSavegameSelected); - if (selected <= 0) { - return nullptr; - } - using FirstVisibleFn = int16_t(__fastcall*)(void*, void*); - const int index = reinterpret_cast(0x0071bb40)(window, nullptr) + selected - 1; - const uint8_t* begin = *reinterpret_cast(window + kSavegameEntries); - const uint8_t* end = *reinterpret_cast(window + kSavegameEntriesEnd); - if (index < 0 || index >= static_cast((end - begin) / kSavegameEntrySize)) { - return nullptr; - } - const int row = RowFromPath(reinterpret_cast(begin + index * kSavegameEntrySize + kSavegameEntryName)); - return row >= 1 && row <= static_cast(g_rows.size()) ? &g_rows[row - 1] : nullptr; -} +// --- the Spielen screen ------------------------------------------------------------ +// +// The load window (cUI_Savegame) is drawn as a 16:9 screen of ours (request of +// 2026-10-20, mods/screen.h, docs/characters.md "Spielen screen"): characters +// on the left, the selected one in the middle, its worlds as a grid of +// campaigns and difficulties on the right, the buttons below. The window's +// own drawing and mouse handling are not used; it still lists the previews +// (its entries name the rows' files) and does the loading: "Spielen" points +// the load at the chosen row (g_loadRow, read by Engine_Load) and runs the +// window's load action (4), with its confirmation in a game. Keys still go to +// the window (Esc closes it). -// Dialogs over the window, in its coordinates (1024x768): -// - difficulty: "Laden" on a character with more than one world to choose -// (difficulties, newer autosaves); a choice runs the window's own load -// action with that world; -// - rename: the "Umbenennen" button below "Löschen" asks for the selected -// character's new name; while it is open every key goes to the name field -// (input key capture, window thread), Enter and Esc act on the next frame. -// Buttons look like the window's load button; the last one is "Abbrechen". -constexpr int kFrameWidth = 1024; -constexpr int kFrameHeight = 768; -constexpr int kDialogWidth = 320; -constexpr int kDialogTitleHeight = 48; -constexpr int kDialogFieldHeight = 32; // rename: the name field -constexpr int kDialogFieldGap = 16; -constexpr int kDialogFieldPadding = 8; -constexpr int kDialogButtonWidth = 220; -constexpr int kDialogButtonHeight = 32; -constexpr int kDialogPitch = 44; -constexpr int kDialogBottom = 20; -constexpr uint16_t kDialogFont = 1; -constexpr uint32_t kDialogBackColour = 0xe8000000; -constexpr uint32_t kDialogEdgeColour = 0xff6e5a32; -constexpr uint32_t kDialogFieldColour = 0xff201810; -constexpr uint32_t kDialogTitleColour = 0xfff0c060; // as the options page's headings -constexpr uint32_t kDialogTextColour = 0xffffffff; constexpr int kSavegameActionLoad = 4; // the load button +constexpr int kSavegameActionConfirmDelete = 7; constexpr int kSavegameActionRescan = 10; // lists the save folder again -constexpr int kSavegameActionDefault = 0xb; // double click: save or load by the mode -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" and "Neuer Charakter" take the places below -// delete. -constexpr int kSavegameButtonPitch = 64; +constexpr int kSavegameActionDefault = 0xb; // double click / Enter: save or load by the mode +constexpr int kSavegameActionBack = 0xc; // "Zurück": hides the window, the main menu outside a game // 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); -// 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 }; +// The row the next load is for (index into g_rows), set by Play. +std::atomic g_loadRow{-1}; + +// Layout on the canvas (1365x768): three panels side by side (sizes in the +// frames' 64 steps, screen::kFrameStep), centred, a row of buttons below. +constexpr int kPanelTop = 96; // the title above the frames' ornament +constexpr int kPanelHeight = 8 * screen::kFrameStep; +constexpr int kListWidth = 5 * screen::kFrameStep; +constexpr int kHeroWidth = 6 * screen::kFrameStep; +constexpr int kWorldsWidth = 6 * screen::kFrameStep; +constexpr int kListX = (screen::kWidth - kListWidth - kHeroWidth - kWorldsWidth - 2 * screen::kPanelGap) / 2; +constexpr game::UiRect kListRect = {kListX, kPanelTop, kListWidth, kPanelHeight}; +constexpr game::UiRect kHeroRect = {kListRect.x + kListWidth + screen::kPanelGap, kPanelTop, kHeroWidth, + kPanelHeight}; +constexpr game::UiRect kWorldsRect = {kHeroRect.x + kHeroWidth + screen::kPanelGap, kPanelTop, kWorldsWidth, + kPanelHeight}; +constexpr game::UiRect kTitleRect = {0, 24, screen::kWidth, 32}; +constexpr int kListRowHeight = 52; +constexpr int kListRowGap = 4; +constexpr int kLineHeight = 24; +constexpr int kGridLabelWidth = 100; +constexpr int kGridGap = 6; +constexpr int kGridHeaderHeight = 28; +constexpr int kGridRowHeight = 54; +constexpr int kSourceHeight = 28; +constexpr int kButtonWidth = 150; +constexpr int kButtonY = kPanelTop + kPanelHeight + screen::kFrameOrnament + 28; +constexpr int kButtonPitch = kButtonWidth + 2 * screen::kButtonOrnament + 10; +constexpr uint32_t kCellColour = 0x50302418; +constexpr uint32_t kCellLockedColour = 0x30000000; + +// What the screen shows and where the mouse is (window thread). +struct ScreenState { + std::string file; // the selected character (kept across rescans) + int campaign = kCampaignAncaria; // the selected world + int difficulty = -1; // -1: none + bool fromAutosave = false; // load the world's newer autosave + int hoverRow = -1; // list position + int hoverCell = -1; // campaign * kDifficultyCount + difficulty + int hoverSource = -1; // 0 the save, 1 the autosave + screen::DoubleClick rowClick; + screen::DoubleClick cellClick; +} g_screen; + +// The list shows the characters by their last save, newest first. +std::vector ListOrder() { + std::vector order(g_rows.size()); + for (size_t i = 0; i < order.size(); ++i) { + order[i] = i; + } + auto newest = [](const Row& row) { + uint64_t at = 0; + for (const Choice& choice : row.choices) { + at = std::max(at, choice.savedAt); + } + return at; + }; + std::stable_sort(order.begin(), order.end(), + [&](size_t a, size_t b) { return newest(g_rows[a]) > newest(g_rows[b]); }); + return order; +} + +Row* SelectedCharacter() { + for (Row& row : g_rows) { + if (row.file == g_screen.file) { + return &row; + } + } + return nullptr; +} + +const Choice* FindChoice(const Row& row, int campaign, int difficulty, bool autosave) { + for (const Choice& choice : row.choices) { + if (choice.campaign == campaign && choice.difficulty == difficulty && choice.autosave == autosave) { + return &choice; + } + } + return nullptr; +} + +// The world's save (or "(neu)") and its newer autosave, if any. +struct Cell { + const Choice* world = nullptr; // a save or a new world + const Choice* autosave = nullptr; // newer than the save +}; + +Cell CellOf(const Row& row, int campaign, int difficulty) { + return {FindChoice(row, campaign, difficulty, false), FindChoice(row, campaign, difficulty, true)}; +} + +bool Selectable(const Cell& cell) { + return cell.world || cell.autosave; +} + +void SelectCharacter(const Row& row) { + g_screen.file = row.file; + g_screen.campaign = row.lastCampaign; + g_screen.difficulty = row.lastDifficulty; + if (!Selectable(CellOf(row, g_screen.campaign, g_screen.difficulty))) { + g_screen.difficulty = -1; + for (const Choice& choice : row.choices) { + if (!choice.newWorld) { + g_screen.campaign = choice.campaign; + g_screen.difficulty = choice.difficulty; + break; + } + } + } + const Cell cell = g_screen.difficulty >= 0 ? CellOf(row, g_screen.campaign, g_screen.difficulty) : Cell{}; + g_screen.fromAutosave = cell.autosave != nullptr; +} + +// After a rescan: the same character if it is still there, else the first. +void KeepSelection() { + if (Row* row = SelectedCharacter()) { + const Cell cell = g_screen.difficulty >= 0 ? CellOf(*row, g_screen.campaign, g_screen.difficulty) : Cell{}; + if (!Selectable(cell)) { + SelectCharacter(*row); + } + return; + } + const std::vector order = ListOrder(); + if (order.empty()) { + g_screen.file.clear(); + g_screen.difficulty = -1; + } else { + SelectCharacter(g_rows[order.front()]); + } +} + +// The chosen world of the selected character: what "Spielen" starts. +const Choice* ChosenWorld(const Row& row) { + if (g_screen.difficulty < 0) { + return nullptr; + } + const Cell cell = CellOf(row, g_screen.campaign, g_screen.difficulty); + return g_screen.fromAutosave && cell.autosave ? cell.autosave : cell.world ? cell.world : cell.autosave; +} + +bool IsPlayed(const Row& row) { + return g_inGame && row.file == g_current.file; +} + +std::wstring FormatTime(uint64_t fileTime) { + FILETIME ft = {static_cast(fileTime), static_cast(fileTime >> 32)}; + SYSTEMTIME t; + if (!fileTime || !FileTimeToSystemTime(&ft, &t)) { + return {}; + } + wchar_t text[32]; + wsprintfW(text, L"%02u.%02u.%02u %02u:%02u", t.wDay, t.wMonth, t.wYear % 100, t.wHour, t.wMinute); + return text; +} + +// --- dialogs ------------------------------------------------------------------------- + +// Over the screen, centred on the canvas: rename (a name field: every key +// goes to it while open, input key capture, window thread; Enter and Esc act +// on the next frame), the campaign of a new character, and the confirmations +// of a reset and a delete. The last button is "Abbrechen". +constexpr int kDialogWidth = 360; +constexpr int kDialogTitleHeight = 48; +constexpr int kDialogFieldHeight = 32; // rename: the name field +constexpr int kDialogFieldGap = 16; +constexpr int kDialogFieldPadding = 8; +constexpr int kDialogButtonWidth = 240; +constexpr int kDialogPitch = 44; +constexpr int kDialogBottom = 20; +constexpr uint32_t kDialogShade = 0x90000000; +constexpr uint32_t kDialogBackColour = 0xe8000000; +constexpr uint32_t kDialogEdgeColour = 0xff6e5a32; +constexpr uint32_t kDialogFieldColour = 0xff201810; +constexpr uint32_t kDialogTextColour = 0xffffffff; + +enum class DialogKind { Rename, NewCharacter, ConfirmReset, ConfirmDelete }; struct Dialog { uint8_t* window = nullptr; // open while set - DialogKind kind = DialogKind::Difficulty; - size_t row = 0; // index into g_rows + DialogKind kind = DialogKind::Rename; + std::string file; // the character + std::wstring name; // its name + Choice target; // ConfirmReset: the world 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; @@ -658,10 +814,14 @@ int DialogFieldSpace() { return g_dialog.kind == DialogKind::Rename ? kDialogFieldHeight + kDialogFieldGap : 0; } +int16_t DialogButtonHeight(const uint8_t* window) { + return screen::ButtonHeight(window + kSavegameLoadButton); +} + game::UiRect DialogPanel() { const int height = kDialogTitleHeight + DialogFieldSpace() + static_cast(DialogEntries()) * kDialogPitch + kDialogBottom; - return {(kFrameWidth - kDialogWidth) / 2, (kFrameHeight - height) / 2, static_cast(kDialogWidth), + return {(screen::kWidth - kDialogWidth) / 2, (screen::kHeight - height) / 2, static_cast(kDialogWidth), static_cast(height)}; } @@ -671,63 +831,38 @@ game::UiRect DialogField() { static_cast(kDialogButtonWidth), static_cast(kDialogFieldHeight)}; } -game::UiRect DialogButtonRect(size_t index) { +game::UiRect DialogButtonRect(const uint8_t* window, size_t index) { const game::UiRect panel = DialogPanel(); return {panel.x + (kDialogWidth - kDialogButtonWidth) / 2, panel.y + kDialogTitleHeight + DialogFieldSpace() + static_cast(index) * kDialogPitch, - static_cast(kDialogButtonWidth), static_cast(kDialogButtonHeight)}; + static_cast(kDialogButtonWidth), DialogButtonHeight(window)}; } std::wstring DialogText(size_t index) { 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; - } - 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; +void FillDialog() { + switch (g_dialog.kind) { 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"; + g_dialog.entries.clear(); 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; + case DialogKind::ConfirmDelete: + g_dialog.title = g_dialog.name + L" löschen?"; + g_dialog.entries = {L"Löschen"}; + break; } } @@ -757,15 +892,16 @@ void OnNameKey(int vk) { } } -void OpenDialog(uint8_t* window, DialogKind kind, size_t row) { +void OpenDialog(uint8_t* window, DialogKind kind, const Row* row) { g_dialog.window = window; g_dialog.kind = kind; - g_dialog.row = row; - FillDialog(kind, row); + g_dialog.file = row ? row->file : std::string(); + g_dialog.name = row ? row->name : std::wstring(); + FillDialog(); for (size_t i = 0; i < DialogEntries(); ++i) { if (i == g_dialog.buttons.size()) { const uint32_t caption = game::RegisterText(L""); - uint8_t* button = game::NewButtonLike(window + kSavegameLoadButton, DialogButtonRect(i), caption, + uint8_t* button = game::NewButtonLike(window + kSavegameLoadButton, DialogButtonRect(window, i), caption, "UI_BTN_CHAR_DIALOG"); if (!button) { g_dialog.window = nullptr; @@ -774,13 +910,13 @@ void OpenDialog(uint8_t* window, DialogKind kind, size_t row) { g_dialog.buttons.push_back(button); g_dialog.captions.push_back(caption); } - *reinterpret_cast(g_dialog.buttons[i] + game::kControlRect) = DialogButtonRect(i); + *reinterpret_cast(g_dialog.buttons[i] + game::kControlRect) = DialogButtonRect(window, i); game::SetTextOverride(g_dialog.captions[i], DialogText(i)); } if (kind == DialogKind::Rename) { { std::lock_guard guard(g_nameLock); - g_name = g_rows[row].name; + g_name = g_dialog.name; } g_nameKey = 0; input::SetKeyCapture(&OnNameKey); @@ -796,9 +932,9 @@ void CloseDialog() { // The hero's name in all of the character's files (the shared hero, every // world, the autosave) and its meta. -bool RenameCharacter(const Row& row, const std::wstring& name) { +bool RenameCharacter(const std::string& file, const std::wstring& name) { bool done = true; - for (const std::string& path : {row.file, AutosavePath(row.file)}) { + for (const std::string& path : {file, AutosavePath(file)}) { CharacterFile character(path); if (!character.Open()) { continue; // no autosave @@ -824,79 +960,14 @@ bool RenameCharacter(const Row& row, const std::wstring& name) { character.meta["name"] = Convert(name, CP_UTF8); done = character.Save() && done; } - re::Log("characters: renamed %s to %s%s", row.file.c_str(), Convert(name, CP_UTF8).c_str(), + re::Log("characters: renamed %s to %s%s", file.c_str(), Convert(name, CP_UTF8).c_str(), done ? "" : " - FAILED in part"); return done; } -// 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; - } - const Row* row = SelectedRow(window); - if (!row || (row->file == g_current.file && game::GetPlayerHero())) { - return nullptr; - } - return row; -} - -// "Neuer Charakter": in the main menu's load window (not in a game, not in -// multiplayer). - -bool NewCharacterAllowed(uint8_t* window) { - return *reinterpret_cast(window + kSavegameMode) == kSavegameModeLoad && !g_inGame && - !*reinterpret_cast(kMultiplayer); -} - -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 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}, -}; - -// "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 += side.slot * kSavegameButtonPitch; - return rect; -} - -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 (side.control) { - *reinterpret_cast(side.control + game::kControlRect) = SideButtonRect(window, side); - } - return side.control; +void Rescan(uint8_t* window) { + Savegame_Action_original(window, nullptr, kSavegameActionRescan); + KeepSelection(); } void ConfirmRename(uint8_t* window) { @@ -914,81 +985,50 @@ void ConfirmRename(uint8_t* window) { if (name.empty()) { return; } - const Row row = g_rows[g_dialog.row]; + const Dialog dialog = g_dialog; CloseDialog(); - if (name != row.name) { - RenameCharacter(row, name); - Savegame_Action_original(window, nullptr, kSavegameActionRescan); + if (name != dialog.name) { + RenameCharacter(dialog.file, name); + Rescan(window); } } -void FillBox(void* device, const game::UiRect& r, uint32_t colour) { - game::FillRect(device, static_cast(r.x), static_cast(r.y), static_cast(r.x + r.w), - static_cast(r.y + r.h), colour); +// The character goes, with its autosave; kept in characters\deleted. +void DeleteCharacter(const std::string& file) { + const std::string deleted = CharactersDir() + "deleted\\"; + CreateDirectoryA(deleted.c_str(), nullptr); + for (const std::string& path : {file, AutosavePath(file)}) { + const std::string name = path.substr(path.find_last_of('\\') + 1); + MoveFileExA(path.c_str(), (deleted + name).c_str(), MOVEFILE_REPLACE_EXISTING); + } + re::Log("characters: deleted %s (moved to %s)", file.c_str(), deleted.c_str()); } -game::UiRect Grown(const game::UiRect& r, int by) { - return {r.x - by, r.y - by, static_cast(r.w + 2 * by), static_cast(r.h + 2 * by)}; -} - -void RenderDialog(void* device) { +void RenderDialog(uint8_t* window, void* device) { + screen::Fill(device, {0, 0, screen::kWidth, screen::kHeight}, kDialogShade); const game::UiRect panel = DialogPanel(); - FillBox(device, Grown(panel, 2), kDialogEdgeColour); - FillBox(device, panel, kDialogBackColour); - const game::UiRect title = {panel.x, panel.y, panel.w, static_cast(kDialogTitleHeight)}; - game::DrawText(device, kDialogFont, game::CenterLineVertically(kDialogFont, title), g_dialog.title.c_str(), - kDialogTitleColour, game::kTextCenter); + screen::Fill(device, screen::Inset(panel, -2), kDialogEdgeColour); + screen::Fill(device, panel, kDialogBackColour); + screen::Text(device, {panel.x, panel.y, panel.w, static_cast(kDialogTitleHeight)}, g_dialog.title, + screen::kHeadingColour, game::kTextCenter); if (g_dialog.kind == DialogKind::Rename) { const game::UiRect field = DialogField(); - FillBox(device, Grown(field, 1), kDialogEdgeColour); - FillBox(device, field, kDialogFieldColour); + screen::Fill(device, screen::Inset(field, -1), kDialogEdgeColour); + screen::Fill(device, field, kDialogFieldColour); std::wstring text; { std::lock_guard guard(g_nameLock); text = g_name + L"_"; } - game::UiRect line = {field.x + kDialogFieldPadding, field.y, - static_cast(field.w - 2 * kDialogFieldPadding), field.h}; - game::DrawText(device, kDialogFont, game::CenterLineVertically(kDialogFont, line), text.c_str(), - kDialogTextColour, 0); + screen::Text(device, screen::Inset(field, kDialogFieldPadding), text, kDialogTextColour); } for (size_t i = 0; i < DialogEntries(); ++i) { + *reinterpret_cast(g_dialog.buttons[i] + game::kControlRect) = DialogButtonRect(window, i); game::ControlRender(g_dialog.buttons[i], device); } } -// cUI_Savegame's actions (0x71c520, thiscall (action), ret 4). Loading a -// character with a choice opens the dialog first. 8 deletes the selected -// row's file after the confirmation; here the character goes, kept in -// characters\deleted. -void __fastcall Savegame_Action(uint8_t* self, void* edx, int action) { - const bool loading = *reinterpret_cast(self + kSavegameMode) == kSavegameModeLoad; - if (loading && (action == kSavegameActionLoad || action == kSavegameActionDefault)) { - if (const Row* row = SelectedRow(self); row && row->choices.size() > 1) { - OpenDialog(self, DialogKind::Difficulty, static_cast(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(row->choices[row->chosen].campaign); - } - } - const Row* toDelete = action == kSavegameActionDelete ? SelectedRow(self) : nullptr; - if (toDelete && g_inGame && toDelete->file == g_current.file) { - re::Log("characters: %s is being played, not deleted", toDelete->file.c_str()); - return; - } - if (const Row* row = toDelete) { - const std::string deleted = CharactersDir() + "deleted\\"; - CreateDirectoryA(deleted.c_str(), nullptr); - for (const std::string& file : {row->file, AutosavePath(row->file)}) { - const std::string name = file.substr(file.find_last_of('\\') + 1); - MoveFileExA(file.c_str(), (deleted + name).c_str(), MOVEFILE_REPLACE_EXISTING); - } - re::Log("characters: deleted %s (moved to %s)", row->file.c_str(), deleted.c_str()); - } - Savegame_Action_original(self, edx, action); -} +// --- starting and loading ------------------------------------------------------- // A new game on a difficulty without a world, with the character's shared // hero; it becomes the current character in Game_LoadHeroFile. @@ -1010,14 +1050,14 @@ uint8_t* GameSetup() { return reinterpret_cast(0x00417e50)(); } -void StartNewWorld(uint8_t* window, const Row& row, int campaign, int difficulty) { - CharacterFile character(row.file); +void StartNewWorld(uint8_t* window, const std::string& file, int campaign, int difficulty) { + CharacterFile character(file); std::optional 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()); + re::Log("characters: %s has no shared hero, no new game", file.c_str()); return; } - g_newWorld = {row.file, campaign, difficulty}; + g_newWorld = {file, campaign, difficulty}; GameCampaign() = static_cast(campaign); auto* session = static_cast(Session()); *reinterpret_cast(session + kSessionSinglePlayer) = 1; @@ -1031,7 +1071,7 @@ void StartNewWorld(uint8_t* window, const Row& row, int campaign, int difficulty uint16_t mode; uint32_t argument; } command{0x00894ff4, kPlayModeStart, kPlayNewGame | (1u << kPlayHeroFileShift)}; - re::Log("characters: new game for %s, campaign %d, difficulty %d", row.file.c_str(), campaign, difficulty); + re::Log("characters: new game for %s, campaign %d, difficulty %d", file.c_str(), campaign, difficulty); using ExecuteFn = void(__fastcall*)(void*, void*); reinterpret_cast(0x00755ac0)(&command, nullptr); } @@ -1043,13 +1083,13 @@ void StartNewWorld(uint8_t* window, const Row& row, int campaign, int difficulty // (0x8002) is handled right after the stop, without the main menu. struct PendingStart { uint8_t* window = nullptr; - Row row; + std::string file; Choice world; } g_pendingStart; -void StopGameThenStart(uint8_t* window, const Row& row, const Choice& world) { - g_pendingStart = {window, row, world}; - re::Log("characters: stopping the game for a new world of %s", row.file.c_str()); +void StopGameThenStart(uint8_t* window, const std::string& file, const Choice& world) { + g_pendingStart = {window, file, world}; + re::Log("characters: stopping the game for a new world of %s", file.c_str()); struct StopCommand { uint32_t vtable; } command{0x0088ee2c}; @@ -1059,7 +1099,24 @@ void StopGameThenStart(uint8_t* window, const Row& row, const Choice& world) { void StartPendingWorld() { if (const PendingStart pending = std::exchange(g_pendingStart, {}); pending.window) { - StartNewWorld(pending.window, pending.row, pending.world.campaign, pending.world.difficulty); + StartNewWorld(pending.window, pending.file, pending.world.campaign, pending.world.difficulty); + } +} + +// A new world for a migrated character without the shared hero yet: the +// world holding the hero loads first (Engine_Load makes the shared hero), +// then, on the next engine tick, the game stops and the new world starts. +struct StartAfterLoad { + uint8_t* window = nullptr; + std::string file; + Choice world; + bool loaded = false; // the hero's world is loaded +} g_startAfterLoad; + +void StartAfterLoadOnTick() { + if (g_startAfterLoad.loaded) { + const StartAfterLoad start = std::exchange(g_startAfterLoad, {}); + StopGameThenStart(start.window, start.file, start.world); } } @@ -1078,18 +1135,18 @@ void OpenCharacterCreation(int campaign) { // 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); +bool ResetWorld(const std::string& file, const Choice& world) { + CharacterFile character(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()); + re::Log("characters: reset of %s in %s failed (no world or no shared hero)", section.c_str(), 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); + const std::string name = file.substr(file.find_last_of('\\') + 1); WriteFileReplacing(deleted + name + "." + section + ".PAK", *removed); character.Remove(section); character.Set("hero", std::move(*hero)); @@ -1099,128 +1156,295 @@ bool ResetWorld(const Row& row, const Choice& world) { character.meta.erase("lastDifficulty"); } const bool saved = character.Save(); - CharacterFile autosave(AutosavePath(row.file)); + CharacterFile autosave(AutosavePath(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(), + re::Log("characters: reset %s of %s (backup in %s)%s", section.c_str(), 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()) { - CloseDialog(); - } else if (g_dialog.kind == DialogKind::Rename) { - ConfirmRename(window); - } 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(); +// "Spielen": the chosen world of the selected character. A saved world loads +// through the window's load action (its entry selection only has to exist: +// Engine_Load takes the row from g_loadRow); a new one starts a game. +void Play(uint8_t* window) { + Row* row = SelectedCharacter(); + const Choice* world = row ? ChosenWorld(*row) : nullptr; + if (!world) { + return; + } + if (world->newWorld && !row->hasHero) { + const Choice* heroWorld = FindChoice(*row, row->heroFromCampaign, row->heroFrom, false); + if (!heroWorld || heroWorld->newWorld) { + re::Log("characters: %s has no world with its hero, no new game", row->file.c_str()); + return; + } + g_startAfterLoad = {window, row->file, *world}; + re::Log("characters: loading the hero's world of %s first, then the new world", row->file.c_str()); + world = heroWorld; + } else if (world->newWorld) { if (g_inGame) { - StopGameThenStart(window, row, row.choices[index]); + StopGameThenStart(window, row->file, *world); } else { - StartNewWorld(window, row, row.choices[index].campaign, row.choices[index].difficulty); + StartNewWorld(window, row->file, world->campaign, world->difficulty); } - } else { - g_rows[g_dialog.row].chosen = index; - GameCampaign() = static_cast(row.choices[index].campaign); - CloseDialog(); - Savegame_Action_original(window, nullptr, kSavegameActionLoad); + return; + } + const uint8_t* begin = *reinterpret_cast(window + kSavegameEntries); + const uint8_t* end = *reinterpret_cast(window + kSavegameEntriesEnd); + if (begin == end) { + re::Log("characters: the load window lists nothing, cannot load"); + return; + } + row->chosen = static_cast(world - row->choices.data()); + g_loadRow = static_cast(row - g_rows.data()); + // As the main menu's entries do before a game starts; cEngine::Load sets + // it from the save again. + GameCampaign() = static_cast(world->campaign); + *reinterpret_cast(window + kSavegameSelected) = 1; + Savegame_Action_original(window, nullptr, kSavegameActionLoad); +} + +// cUI_Savegame's actions (0x71c520, thiscall (action), ret 4): loading (the +// load button, Enter) is "Spielen"; deleting is the screen's own. +void __fastcall Savegame_Action(uint8_t* self, void* edx, int action) { + const bool loading = *reinterpret_cast(self + kSavegameMode) == kSavegameModeLoad; + if (loading && (action == kSavegameActionLoad || action == kSavegameActionDefault)) { + Play(self); + return; + } + if (action == kSavegameActionConfirmDelete || action == kSavegameActionDelete) { + return; + } + Savegame_Action_original(self, edx, action); +} + +// --- drawing and clicks ------------------------------------------------------------ + +screen::Panel g_listPanel("UI_FRAME_CHAR_LIST"); +screen::Panel g_heroPanel("UI_FRAME_CHAR_HERO"); +screen::Panel g_worldsPanel("UI_FRAME_CHAR_WORLDS"); +screen::Button g_playButton("UI_BTN_CHAR_PLAY", L"Spielen"); +screen::Button g_backButton("UI_BTN_CHAR_BACK", L"Zurück"); +screen::Button g_newButton("UI_BTN_CHAR_NEW", L"Neuer Charakter"); +screen::Button g_renameButton("UI_BTN_CHAR_RENAME", L"Umbenennen"); +screen::Button g_deleteButton("UI_BTN_CHAR_DELETE", L"Löschen"); +screen::Button g_resetButton("UI_BTN_CHAR_RESET", L"Welt zurücksetzen"); + +game::UiRect ListContent() { + return screen::Inset(kListRect, screen::kPanelPadding); +} + +// List position `index` (0 the first shown); the heading takes the top line. +game::UiRect ListRowRect(int index) { + const game::UiRect content = ListContent(); + return {content.x, content.y + kLineHeight + 8 + index * (kListRowHeight + kListRowGap), content.w, + kListRowHeight}; +} + +int ListRowsShown() { + return (ListContent().h - kLineHeight - 8) / (kListRowHeight + kListRowGap); +} + +game::UiRect WorldsContent() { + return screen::Inset(kWorldsRect, screen::kPanelPadding); +} + +int CellWidth() { + return (WorldsContent().w - kGridLabelWidth - (kCampaignCount - 1) * kGridGap) / kCampaignCount; +} + +int GridTop() { + return WorldsContent().y + kLineHeight + 8; +} + +game::UiRect CellRect(int campaign, int difficulty) { + const game::UiRect content = WorldsContent(); + return {content.x + kGridLabelWidth + campaign * (CellWidth() + kGridGap), + GridTop() + kGridHeaderHeight + difficulty * (kGridRowHeight + kGridGap), static_cast(CellWidth()), + kGridRowHeight}; +} + +// Below the grid, for a world with a newer autosave: which one to load. +game::UiRect SourceRect(int source) { + const game::UiRect content = WorldsContent(); + const int top = GridTop() + kGridHeaderHeight + kDifficultyCount * (kGridRowHeight + kGridGap) + 12; + const int width = (content.w - kGridGap) / 2; + return {content.x + source * (width + kGridGap), top + kLineHeight, static_cast(width), kSourceHeight}; +} + +game::UiRect ButtonRect(int x, const uint8_t* window) { + return {x, kButtonY, kButtonWidth, screen::ButtonHeight(window + kSavegameLoadButton)}; +} + +// Bottom row: Neuer Charakter, Umbenennen, Löschen from the left; Zurück, +// Spielen from the right. +int LeftButtonX(int slot) { + return kListRect.x + screen::kButtonOrnament + slot * kButtonPitch; +} + +int RightButtonX(int slot) { + return kWorldsRect.x + kWorldsRect.w - screen::kButtonOrnament - kButtonWidth - slot * kButtonPitch; +} + +game::UiRect ResetButtonRect(const uint8_t* window) { + const game::UiRect content = WorldsContent(); + const int16_t height = screen::ButtonHeight(window + kSavegameLoadButton); + return {content.x + (content.w - 200) / 2, content.y + content.h - height - 4, 200, height}; +} + +bool CanReset(const Row& row) { + const Cell cell = g_screen.difficulty >= 0 ? CellOf(row, g_screen.campaign, g_screen.difficulty) : Cell{}; + return row.hasHero && !IsPlayed(row) && cell.world && !cell.world->newWorld; +} + +bool CanCreate() { + return !g_inGame && !*reinterpret_cast(kMultiplayer); +} + +void DrawList(void* device) { + const game::UiRect content = g_listPanel.Draw(device, kListRect); + screen::Text(device, {content.x, content.y, content.w, kLineHeight}, L"Charaktere", screen::kHeadingColour); + const std::vector order = ListOrder(); + for (int i = 0; i < static_cast(order.size()) && i < ListRowsShown(); ++i) { + const Row& row = g_rows[order[i]]; + const game::UiRect rect = ListRowRect(i); + const bool selected = row.file == g_screen.file; + screen::Fill(device, rect, + selected ? screen::kRowSelectedColour : g_screen.hoverRow == i ? screen::kRowHoverColour + : screen::kRowColour); + const game::UiRect text = screen::Inset(rect, 6); + screen::Text(device, {text.x, text.y, text.w, kLineHeight - 4}, row.name, screen::kTextColour); + screen::Text(device, {text.x, text.y + kLineHeight - 4, text.w, kLineHeight - 4}, + ClassName(row.heroClass) + L" · Stufe " + std::to_wstring(row.level), screen::kDimColour); + if (IsPlayed(row)) { + screen::Text(device, {text.x, text.y, text.w, kLineHeight - 4}, L"im Spiel", screen::kDimColour, + game::kTextRight); + } + } + if (order.empty()) { + screen::Text(device, ListRowRect(0), L"Noch keine Charaktere", screen::kDimColour, game::kTextCenter); } } -// The dialog takes every mouse event while it is open. -bool HandleDialogEvent(uint8_t* window, void* event) { - const game::MouseEvent kind = game::MouseEventKind(event); - if (kind == game::MouseEvent::None) { - return false; +void DrawHero(void* device) { + const game::UiRect content = g_heroPanel.Draw(device, kHeroRect); + const Row* row = SelectedCharacter(); + if (!row) { + return; } - for (size_t i = 0; i < DialogEntries(); ++i) { - game::ControlHandleEvent(g_dialog.buttons[i], event); + int y = content.y; + auto line = [&](const std::wstring& text, uint32_t colour) { + screen::Text(device, {content.x, y, content.w, kLineHeight}, text, colour, game::kTextCenter); + y += kLineHeight; + }; + line(row->name, screen::kHeadingColour); + line(ClassName(row->heroClass) + L" · Stufe " + std::to_wstring(row->level), screen::kTextColour); + y += kLineHeight; + if (const Choice* last = FindChoice(*row, row->lastCampaign, row->lastDifficulty, false); last && !last->newWorld) { + line(L"Zuletzt gespielt", screen::kDimColour); + line(CampaignName(last->campaign) + L" · " + DifficultyName(last->difficulty), screen::kTextColour); + line(FormatTime(last->savedAt), screen::kDimColour); } - if (kind != game::MouseEvent::ButtonDown || game::EventField(event, game::kMouseButton) != game::kLeftButton) { - return true; - } - const int x = game::EventField(event, game::kMouseButtonX); - const int y = game::EventField(event, game::kMouseButtonY); - for (size_t i = 0; i < DialogEntries(); ++i) { - if (game::Inside(DialogButtonRect(i), x, y)) { - DialogChoose(window, i); - break; - } - } - return true; } -// 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 (kind == game::MouseEvent::None) { - return false; +void DrawCell(void* device, const Row& row, int campaign, int difficulty) { + const game::UiRect rect = CellRect(campaign, difficulty); + const Cell cell = CellOf(row, campaign, difficulty); + const int index = campaign * kDifficultyCount + difficulty; + const bool selected = campaign == g_screen.campaign && difficulty == g_screen.difficulty; + if (!Selectable(cell)) { + screen::Fill(device, rect, kCellLockedColour); + screen::Text(device, rect, L"–", screen::kDimColour, game::kTextCenter); + return; } - for (SideButton& side : g_sideButtons) { - uint8_t* button = side.shown(window) ? SideButtonControl(window, side) : nullptr; - if (!button) { - continue; + screen::Fill(device, rect, + selected ? screen::kRowSelectedColour : g_screen.hoverCell == index ? screen::kRowHoverColour + : kCellColour); + const game::UiRect top = {rect.x, rect.y + 4, rect.w, kLineHeight - 2}; + const game::UiRect bottom = {rect.x, rect.y + kLineHeight, rect.w, kLineHeight - 2}; + if (cell.world && cell.world->newWorld) { + screen::Text(device, rect, L"Neu", screen::kTextColour, game::kTextCenter); + return; + } + const uint64_t at = cell.world ? cell.world->savedAt : cell.autosave->savedAt; + const bool secondLine = (cell.autosave && cell.world) || row.storyCompleted[difficulty]; + screen::Text(device, secondLine ? top : rect, FormatTime(at), screen::kTextColour, game::kTextCenter); + if (cell.autosave && cell.world) { + screen::Text(device, bottom, L"+ Autosave", screen::kHeadingColour, game::kTextCenter); + } else if (row.storyCompleted[difficulty]) { + screen::Text(device, bottom, L"Story abgeschlossen", screen::kDimColour, game::kTextCenter); + } +} + +void DrawWorlds(void* device, const uint8_t* window) { + const game::UiRect content = g_worldsPanel.Draw(device, kWorldsRect); + screen::Text(device, {content.x, content.y, content.w, kLineHeight}, L"Welten", screen::kHeadingColour); + const Row* row = SelectedCharacter(); + if (!row) { + return; + } + for (int c = 0; c < kCampaignCount; ++c) { + const game::UiRect cell = CellRect(c, 0); + screen::Text(device, {cell.x, GridTop(), cell.w, kGridHeaderHeight}, CampaignName(c), screen::kTextColour, + game::kTextCenter); + } + for (int d = 0; d < kDifficultyCount; ++d) { + const game::UiRect cell = CellRect(0, d); + screen::Text(device, {content.x, cell.y, kGridLabelWidth, cell.h}, DifficultyName(d), screen::kTextColour); + for (int c = 0; c < kCampaignCount; ++c) { + DrawCell(device, *row, c, d); } - 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; + } + const Cell chosen = g_screen.difficulty >= 0 ? CellOf(*row, g_screen.campaign, g_screen.difficulty) : Cell{}; + if (chosen.world && chosen.autosave) { + const game::UiRect first = SourceRect(0); + screen::Text(device, {first.x, first.y - kLineHeight, content.w, kLineHeight}, L"Laden von:", + screen::kDimColour); + const Choice* sources[2] = {chosen.world, chosen.autosave}; + const wchar_t* names[2] = {L"Spielstand ", L"Autosave "}; + for (int s = 0; s < 2; ++s) { + const bool on = (s == 1) == g_screen.fromAutosave; + screen::Fill(device, SourceRect(s), + on ? screen::kRowSelectedColour : g_screen.hoverSource == s ? screen::kRowHoverColour + : kCellColour); + screen::Text(device, SourceRect(s), names[s] + FormatTime(sources[s]->savedAt), screen::kTextColour, + game::kTextCenter); } - const Row* row = EditableRow(window); - OpenDialog(window, side.opens, row ? static_cast(row - g_rows.data()) : 0); - return true; + } + if (CanReset(*row)) { + g_resetButton.Draw(device, window + kSavegameLoadButton, ResetButtonRect(window)); + } +} + +void DrawButtons(void* device, const uint8_t* window) { + const uint8_t* model = window + kSavegameLoadButton; + const Row* row = SelectedCharacter(); + int left = 0; + if (CanCreate()) { + g_newButton.Draw(device, model, ButtonRect(LeftButtonX(left++), window)); + } + if (row && !IsPlayed(*row)) { + g_renameButton.Draw(device, model, ButtonRect(LeftButtonX(left++), window)); + g_deleteButton.Draw(device, model, ButtonRect(LeftButtonX(left++), window)); + } + g_backButton.Draw(device, model, ButtonRect(RightButtonX(1), window)); + if (row && ChosenWorld(*row)) { + g_playButton.Draw(device, model, ButtonRect(RightButtonX(0), window)); } - return false; } -using WindowFn = int(__fastcall*)(void*, void*, void*); struct WindowCall { uint8_t* window; void* argument; // device or event - WindowFn original; }; int RenderBody(void* context) { auto* call = static_cast(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) { - CloseDialog(); - } - return result; - } - if (g_dialog.window != call->window) { - 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.kind != DialogKind::NewCharacter && g_dialog.row >= g_rows.size()) { - CloseDialog(); - return result; - } - if (g_dialog.kind == DialogKind::Rename) { + void* device = call->argument; + if (g_dialog.window == call->window && g_dialog.kind == DialogKind::Rename) { // Enter and Esc arrive on the window thread; acted on here. const int key = g_nameKey.exchange(0); if (key == VK_RETURN) { @@ -1228,42 +1452,187 @@ int RenderBody(void* context) { } else if (key == VK_ESCAPE) { CloseDialog(); } - if (g_dialog.window != call->window) { - return result; + } + if (!SelectedCharacter()) { + KeepSelection(); + } + screen::Text(device, kTitleRect, L"Spielen", screen::kHeadingColour, game::kTextCenter); + DrawList(device); + DrawHero(device); + DrawWorlds(device, call->window); + DrawButtons(device, call->window); + if (g_dialog.window == call->window) { + RenderDialog(call->window, device); + } + return 1; +} + +// The dialog takes every mouse event while it is open. +void HandleDialogEvent(uint8_t* window, void* event) { + for (size_t i = 0; i < DialogEntries(); ++i) { + game::ControlHandleEvent(g_dialog.buttons[i], event); + } + if (game::MouseEventKind(event) != game::MouseEvent::ButtonDown || + game::EventField(event, game::kMouseButton) != game::kLeftButton) { + return; + } + const int x = game::EventField(event, game::kMouseButtonX); + const int y = game::EventField(event, game::kMouseButtonY); + size_t index = 0; + while (index < DialogEntries() && !game::Inside(DialogButtonRect(window, index), x, y)) { + ++index; + } + if (index == DialogEntries()) { + return; + } + const Dialog dialog = g_dialog; + if (index + 1 == DialogEntries()) { + CloseDialog(); + } else if (dialog.kind == DialogKind::Rename) { + ConfirmRename(window); + } else if (dialog.kind == DialogKind::NewCharacter) { + CloseDialog(); + OpenCharacterCreation(static_cast(index)); + } else if (dialog.kind == DialogKind::ConfirmReset) { + CloseDialog(); + ResetWorld(dialog.file, dialog.target); + Rescan(window); + } else if (dialog.kind == DialogKind::ConfirmDelete) { + CloseDialog(); + DeleteCharacter(dialog.file); + g_screen.file.clear(); + Rescan(window); + } +} + +void HandleMove(int x, int y) { + g_screen.hoverRow = -1; + g_screen.hoverCell = -1; + g_screen.hoverSource = -1; + for (int i = 0; i < ListRowsShown(); ++i) { + if (game::Inside(ListRowRect(i), x, y)) { + g_screen.hoverRow = i; + } + } + for (int c = 0; c < kCampaignCount; ++c) { + for (int d = 0; d < kDifficultyCount; ++d) { + if (game::Inside(CellRect(c, d), x, y)) { + g_screen.hoverCell = c * kDifficultyCount + d; + } + } + } + for (int s = 0; s < 2; ++s) { + if (game::Inside(SourceRect(s), x, y)) { + g_screen.hoverSource = s; + } + } +} + +void HandleClick(uint8_t* window, int x, int y) { + const std::vector order = ListOrder(); + for (int i = 0; i < static_cast(order.size()) && i < ListRowsShown(); ++i) { + if (game::Inside(ListRowRect(i), x, y)) { + const Row& row = g_rows[order[i]]; + if (row.file != g_screen.file) { + SelectCharacter(row); + } + if (g_screen.rowClick.Click(i)) { + Play(window); + } + return; + } + } + Row* row = SelectedCharacter(); + if (!row) { + return; + } + for (int c = 0; c < kCampaignCount; ++c) { + for (int d = 0; d < kDifficultyCount; ++d) { + const Cell cell = CellOf(*row, c, d); + if (!game::Inside(CellRect(c, d), x, y) || !Selectable(cell)) { + continue; + } + if (c != g_screen.campaign || d != g_screen.difficulty) { + g_screen.campaign = c; + g_screen.difficulty = d; + g_screen.fromAutosave = cell.autosave != nullptr; + } + if (g_screen.cellClick.Click(c * kDifficultyCount + d)) { + Play(window); + } + return; + } + } + const Cell chosen = g_screen.difficulty >= 0 ? CellOf(*row, g_screen.campaign, g_screen.difficulty) : Cell{}; + if (chosen.world && chosen.autosave) { + for (int s = 0; s < 2; ++s) { + if (game::Inside(SourceRect(s), x, y)) { + g_screen.fromAutosave = s == 1; + } } } - RenderDialog(call->argument); - return result; } int EventBody(void* context) { auto* call = static_cast(context); - MoveDeleteButton(call->window); - if (g_dialog.window == call->window) { - if (HandleDialogEvent(call->window, call->argument)) { - return 1; - } - } else if (game::ControlShown(call->window) && HandleSideButtons(call->window, call->argument)) { + uint8_t* window = call->window; + void* event = call->argument; + if (g_dialog.window == window) { + HandleDialogEvent(window, event); return 1; } - return call->original(call->window, nullptr, call->argument); + Row* row = SelectedCharacter(); + if (g_playButton.Clicked(event)) { + Play(window); + } else if (g_backButton.Clicked(event)) { + Savegame_Action_original(window, nullptr, kSavegameActionBack); + } else if (g_newButton.Clicked(event)) { + OpenDialog(window, DialogKind::NewCharacter, nullptr); + } else if (g_renameButton.Clicked(event) && row) { + OpenDialog(window, DialogKind::Rename, row); + } else if (g_deleteButton.Clicked(event) && row) { + OpenDialog(window, DialogKind::ConfirmDelete, row); + } else if (g_resetButton.Clicked(event) && row) { + if (const Choice* world = ChosenWorld(*row)) { + g_dialog.target = *world; + OpenDialog(window, DialogKind::ConfirmReset, row); + } + } else if (game::MouseEventKind(event) == game::MouseEvent::Move) { + HandleMove(game::EventField(event, 0xc), game::EventField(event, 0x10)); + } else if (game::MouseEventKind(event) == game::MouseEvent::ButtonDown && + game::EventField(event, game::kMouseButton) == game::kLeftButton) { + HandleClick(window, game::EventField(event, game::kMouseButtonX), + game::EventField(event, game::kMouseButtonY)); + } + return 1; } -// cUI_Savegame::vf14 (0x71dd80, render, ret 4): framed like the export screen, -// the dialog on top. +// cUI_Savegame::vf14 (0x71dd80, render, ret 4): the screen instead. The +// window's rect covers the canvas, so the UI manager's hit tests send it the +// mouse everywhere on the screen. int __fastcall Savegame_Render(void* self, void* edx, void* device); RE_HOOK(0x0071dd80, Savegame_Render); int __fastcall Savegame_Render(void* self, void*, void* device) { - WindowCall call{static_cast(self), device, Savegame_Render_original}; - return mods::hud::RenderFramed(self, device, &RenderBody, &call); + auto* window = static_cast(self); + *reinterpret_cast(window + game::kControlRect) = {0, 0, screen::kWidth, screen::kHeight}; + if (!game::ControlShown(window) && g_dialog.window == window) { + CloseDialog(); + } + WindowCall call{window, device}; + return screen::Render(window, device, &RenderBody, &call); } -// cUI_Savegame::vf10 (0x71d130, events, ret 4); centred, the dialog first. +// cUI_Savegame::vf10 (0x71d130, events, ret 4): mouse events are the +// screen's, the rest (keys) the window's. int __fastcall Savegame_HandleEvent(void* self, void* edx, void* event); RE_HOOK(0x0071d130, Savegame_HandleEvent); -int __fastcall Savegame_HandleEvent(void* self, void*, void* event) { - WindowCall call{static_cast(self), event, Savegame_HandleEvent_original}; - return mods::hud::HandleCentred(event, &EventBody, &call); +int __fastcall Savegame_HandleEvent(void* self, void* edx, void* event) { + auto* window = static_cast(self); + if (game::MouseEventKind(event) == game::MouseEvent::None || !game::ControlShown(window)) { + return Savegame_HandleEvent_original(self, edx, event); + } + WindowCall call{window, event}; + return screen::Handle(event, &EventBody, &call); } // cEngine::Load (0x61b790, thiscall (path, quiet), ret 8): a listed row loads @@ -1271,10 +1640,13 @@ int __fastcall Savegame_HandleEvent(void* self, void*, void* event) { uint32_t __fastcall Engine_Load(uint8_t* self, void* edx, const char* path, char quiet); RE_HOOK(0x0061b790, Engine_Load); uint32_t __fastcall Engine_Load(uint8_t* self, void* edx, const char* path, char quiet) { - const int row = RowFromPath(path); + int row = RowFromPath(path); if (row < 1) { return Engine_Load_original(self, edx, path, quiet); } + if (const int chosen = g_loadRow.exchange(-1); chosen >= 0) { + row = chosen + 1; // "Spielen" (Play): the window's entry only carried the load + } if (g_rows.empty()) { RebuildList(); // a load without the load window } @@ -1320,6 +1692,7 @@ uint32_t __fastcall Engine_Load(uint8_t* self, void* edx, const char* path, char character.Save(); re::Log("characters: shared hero of %s taken from difficulty %d", target.file.c_str(), choice.difficulty); + g_startAfterLoad.loaded = g_startAfterLoad.file == target.file; } } return result; @@ -1431,16 +1804,20 @@ constexpr size_t kEngineSaveDescription = 0x6a; // char[], "QUICKSAVE" for quic constexpr char kSaveDescription[] = "SAVE"; // not shown: the list shows the characters std::atomic g_saveRequested{false}; -// Silent autosave (request of 2026-10-19: every 10 minutes, a setting later): -// the same save, into the autosave file (Engine_Save with g_autosavePending), -// sacredK.ini [characters] AutosaveMinutes (default 10, 0: off) after the -// last save of any kind. While a window is open or a +// Silent autosave (request of 2026-10-19: every 10 minutes, settable): the +// same save, into the autosave file (Engine_Save with g_autosavePending), +// AutosaveMinutes() (default 10, 0: off) after the last save of any kind. While a window is open or a // save runs it waits for the next tick. A character never saved yet gets its // file from it (the list shows character files only). constexpr int kAutosaveMinutesDefault = 10; +std::atomic& AutosaveMinutesSetting() { + static std::atomic minutes{re::ConfigInt("characters", "AutosaveMinutes", kAutosaveMinutesDefault)}; + return minutes; +} + uint64_t AutosaveIntervalMs() { - static const int minutes = re::ConfigInt("characters", "AutosaveMinutes", kAutosaveMinutesDefault); + const int minutes = AutosaveMinutesSetting(); return minutes > 0 ? uint64_t(minutes) * 60 * 1000 : UINT64_MAX; } @@ -1530,27 +1907,6 @@ constexpr uint32_t kSavegamesText = 0x41b; constexpr uint32_t kEscMenuExportLineText = 0x006bc272; constexpr uint32_t kExportText = 0x419; -// The load window's second line of a row showed the (preview) file name; it -// shows the level and the chosen difficulty instead. Row texts are set every -// frame (0x71d830): line 1 the save's name (+0x204), line 2 the file name (+4, -// call 0x71db2a to cUI_StaticText SetText 0x736f00, thiscall (text), ret 4), -// line 3 the date (+0x104). -constexpr uint32_t kRowFileNameCall = 0x0071db2a; -constexpr uint32_t kStaticSetText = 0x00736f00; - -void __fastcall SetRowSecondLine(void* text, void* edx, const char* fileName) { - using SetTextFn = void(__fastcall*)(void*, void*, const char*); - const int row = RowFromPath(fileName); - if (row < 1 || row > static_cast(g_rows.size())) { - reinterpret_cast(kStaticSetText)(text, edx, fileName); - return; - } - const Row& r = g_rows[row - 1]; - const Choice& choice = r.choices[r.chosen]; - const std::wstring line = L"Stufe " + std::to_wstring(r.level) + L" - " + ChoiceName(choice); - reinterpret_cast(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 @@ -1572,10 +1928,6 @@ 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() { @@ -1583,9 +1935,8 @@ void Startup() { reinterpret_cast(&kCharacterWindowCampaign)); failed += re::ReplaceAll(kNoImport); if (failed) { - re::Log("characters: %d character / load window patches failed", failed); + re::Log("characters: %d character window patches failed", failed); } - re::RedirectCall(kRowFileNameCall, kStaticSetText, reinterpret_cast(&SetRowSecondLine)); static const uint32_t kSaveText = game::RegisterText(L"Speichern"); re::PatchPushImm32(kEscMenuSaveLineText, kSavegamesText, kSaveText); static const uint32_t kLoadText = game::RegisterText(L"Laden"); @@ -1596,6 +1947,7 @@ struct Init { Init() { userfolders::SetGameSaveSubfolder(kGameSaveSubfolder); game::OnEngineTick(&SaveOnTick); + game::OnEngineTick([](uint8_t*) { StartAfterLoadOnTick(); }); game::OnGameStart([] { g_inGame = true; g_lastSaveAt = GetTickCount64(); // the autosave counts from the start @@ -1613,6 +1965,15 @@ void RequestSave() { g_saveRequested = true; } +int AutosaveMinutes() { + return AutosaveMinutesSetting(); +} + +void SetAutosaveMinutes(int minutes) { + AutosaveMinutesSetting() = minutes; + re::SetConfigString("characters", "AutosaveMinutes", std::to_string(minutes).c_str()); +} + RE_STARTUP(CharactersStartup, Startup); } // namespace mods::characters diff --git a/src/mods/characters.h b/src/mods/characters.h index 34d556f..d9ddccb 100644 --- a/src/mods/characters.h +++ b/src/mods/characters.h @@ -7,4 +7,9 @@ namespace mods::characters { // save window (on the next engine tick). void RequestSave(); +// Minutes between silent autosaves (0: off); sacredK.ini [characters] +// AutosaveMinutes, set on the options window's Allgemein page. +int AutosaveMinutes(); +void SetAutosaveMinutes(int minutes); + } // namespace mods::characters diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp index 09612f1..2fee5d5 100644 --- a/src/mods/hud.cpp +++ b/src/mods/hud.cpp @@ -30,6 +30,7 @@ #include "game/addresses.h" #include "game/render/dx_driver7.h" #include "game/ui/events.h" +#include "mods/hud.h" #include "mods/widescreen.h" #include "re/config.h" #include "re/hook.h" @@ -47,11 +48,15 @@ thread_local bool g_eventShifted = false; // Screen: laid out across the whole render width by its mod (drawn unshifted). -enum class Anchor { Center, Right, Screen }; +// Canvas: a 16:9 screen (kCanvasWidth), centred. +enum class Anchor { Center, Right, Screen, Canvas }; int OffsetFor(Anchor anchor) { static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0; const int width = mods::widescreen::RenderWidth(); + if (anchor == Anchor::Canvas) { + return width > kCanvasWidth ? (width - kCanvasWidth) / 2 : 0; + } if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() || mods::widescreen::MenuIsShowing() || anchor == Anchor::Screen) { return 0; @@ -341,6 +346,9 @@ size_t VertexSize(DWORD fvf) { return size; } +// A 16:9 screen draws (RenderCanvas): no 1024 layout to stretch. +thread_local bool g_canvasScope = false; + // Returns vertices shifted by g_offsetX, or the input when nothing to do. // Primitives covering the whole 1024x768 layout (e.g. the dimming behind // modal dialogs) are stretched across the render width instead. @@ -359,7 +367,7 @@ void* ShiftVertices(DWORD fvf, void* vertices, DWORD count) { minY = std::min(minY, position[1]); maxY = std::max(maxY, position[1]); } - const bool fullLayout = minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f; + const bool fullLayout = !g_canvasScope && minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f; const float scale = static_cast(mods::widescreen::RenderWidth()) / kLayoutWidth; for (DWORD i = 0; i < count; ++i) { auto* x = reinterpret_cast(buffer.data() + stride * i); @@ -788,6 +796,24 @@ int HandleCentred(void* event, int (*body)(void* context), void* context) { return body(context); } +int CanvasOffsetX() { + return OffsetFor(Anchor::Canvas); +} + +int RenderCanvas(void* window, int (*body)(void* context), void* context) { + DrawOffset offset(window, OffsetFor(Anchor::Canvas)); + const bool outer = !g_canvasScope; + g_canvasScope = true; + const int result = body(context); + g_canvasScope = !outer; + return result; +} + +int HandleCanvas(void* event, int (*body)(void* context), void* context) { + EventOffset offset(event, OffsetFor(Anchor::Canvas)); + return body(context); +} + } // namespace mods::hud namespace game { diff --git a/src/mods/hud.h b/src/mods/hud.h index 14701d2..697adcb 100644 --- a/src/mods/hud.h +++ b/src/mods/hud.h @@ -30,4 +30,14 @@ int HandleCentred(void* event, int (*body)(void* context), void* context); // with a viewport on it (as the save game and export screens). int RenderFramed(void* window, void* device, int (*body)(void* context), void* context); +// 16:9 screens (mods/screen.h): laid out on a 1365x768 canvas, centred in +// the render width. RenderCanvas / HandleCanvas run body(context) with drawing, +// cursor and mouse events in canvas coordinates; CanvasOffsetX is the canvas' +// left edge in the render target. +constexpr int kCanvasWidth = 1365; +constexpr int kCanvasHeight = 768; +int CanvasOffsetX(); +int RenderCanvas(void* window, int (*body)(void* context), void* context); +int HandleCanvas(void* event, int (*body)(void* context), void* context); + } // namespace mods::hud diff --git a/src/mods/options.cpp b/src/mods/options.cpp index 3574223..d66ae35 100644 --- a/src/mods/options.cpp +++ b/src/mods/options.cpp @@ -1,5 +1,6 @@ // Options window in three tabs: Allgemein, Beute, Tastenbelegung (request of -// 2026-10-18). Beute holds the game's auto-pickup settings (first iteration: +// 2026-10-18). Allgemein also gets the autosave interval (characters.cpp); +// Beute holds the game's auto-pickup settings (first iteration: // moved there from the common page); Tastenbelegung sets the key bindings of // mods/input.cpp, which had only been settable in sacredK.ini. // @@ -32,9 +33,11 @@ #include "game/ui/control.h" #include "game/ui/draw.h" #include "game/ui/events.h" +#include "game/ui/frame.h" #include "game/ui/tabs.h" #include "game/ui/text.h" #include "game/ui/texts.h" +#include "mods/characters.h" #include "mods/hud.h" #include "mods/input.h" #include "mods/lootfilter.h" @@ -55,21 +58,15 @@ constexpr size_t kOkButton = 0x15c; constexpr size_t kPickupRadios[] = {0x16c, 0x170, 0x174}; constexpr const char* kPickupControlNames[] = {"UI_LABEL_COLLECT", "UI_FRAME_SEP_AUTOCOLLECT"}; constexpr size_t kControlName = 0x30; // char[32], set by 0x732090 -// cUI_Frame (vtable 0x8955ac): its render (0x73a540) doesn't test the visible -// flag but +0xdc (set to 1 by its constructors, read nowhere else). -constexpr uint32_t kFrameVtable = 0x008955ac; -constexpr size_t kFrameDrawn = 0xdc; +// cUI_Frame (game/ui/frame.h) draws by +0xdc, not by the visible flag. // The key bindings page gets a translucent backdrop and a frame per column, // made as 0x7184e0 makes the others: cUI_Window2 0x724ed0 (rect*, colour; -// ret 8) adds a backdrop (cUI_Static) to +0x90; frames are cUI_Frame -// (0xe0 bytes, constructor 0x739f90 (name, type 1, rect*, 1), ret 0x10) +// ret 8) adds a backdrop (cUI_Static) to +0x90; frames (game/ui/frame.h) // pushed onto +0x90 (vector push_back 0x6bcf50, (&pointer), ret 4). // (Drawing the existing ones at other rects did not fit: a frame's border // pieces keep the place they got when it was made.) constexpr uint32_t kBackdropColour = 0x9f000000; constexpr int kFrameBorder = 0x14; // backdrop beyond the frame on each side -constexpr size_t kFrameSize = 0xe0; -constexpr uint32_t kMainFrame = 1; // Layout of 0x7184e0 (window coordinates): the frames' borders begin 0x14 // left of / above the frame positions, their labels at y 0x20 / 0x100 / 0x1e0. @@ -120,7 +117,7 @@ int32_t& ControlY(uint8_t* control) { } bool IsFrame(const uint8_t* control) { - return *reinterpret_cast(control) == kFrameVtable; + return *reinterpret_cast(control) == game::kFrameVtable; } // cUI_Scrollbar (vtable 0x895664, the volume and map transparency sliders): @@ -224,7 +221,7 @@ void ShowPage(Window& w, uint16_t page) { c.hiddenByUs = false; } if (IsFrame(c.control)) { - c.control[kFrameDrawn] = show ? 1 : 0; + c.control[game::kFrameDrawn] = show ? 1 : 0; } } } @@ -445,9 +442,8 @@ void AddFrame(uint8_t* window, game::UiRect frame, const char* name) { static_cast(frame.h + 2 * kFrameBorder)}; using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t); reinterpret_cast(0x00724ed0)(window, nullptr, &backdrop, kBackdropColour); - if (void* memory = game::GameNew(kFrameSize)) { - using FrameFn = void*(__fastcall*)(void*, void*, const char*, uint32_t, game::UiRect*, uint32_t); - PushControl(window, reinterpret_cast(0x00739f90)(memory, nullptr, name, kMainFrame, &frame, 1)); + if (uint8_t* control = game::NewFrame(name, frame)) { + PushControl(window, control); } } @@ -567,6 +563,127 @@ void DrawLootPage(const Window& w, void* device) { } } +// --- saves (Allgemein page) ------------------------------------------------------ + +// The autosave interval (request of 2026-10-20), in the left column below +// the general frame (moved up, it is taller than the frames beside it): a +// frame of its own, as the right column's lowest frame (Internet-Optionen): +// its height, and the gap the right column has between its frames, both +// measured when the window is made. (A frame lower than that breaks its +// side ornaments; screenshots 2026-10-20.) +constexpr int kSavesFallbackGap = 0x50; +constexpr int16_t kSavesFallbackHeight = 0x40; +constexpr int kSavesLabelWidth = 0x90; +constexpr int kSavesBoxGap = 8; +constexpr int kAutosaveChoices[] = {0, 5, 10, 15, 30}; // minutes, 0: off +constexpr int kAutosaveChoiceCount = static_cast(std::size(kAutosaveChoices)); +constexpr int kSavesBoxWidth = + (kFrameWidth - 2 * kTextInset - kSavesLabelWidth - (kAutosaveChoiceCount - 1) * kSavesBoxGap) / + kAutosaveChoiceCount; +// Lighter than kBoxColour: on the frame's dark backdrop that one is not seen. +constexpr uint32_t kSavesBoxColour = 0xc0303030; +constexpr uint32_t kSavesBoxHoverColour = 0xc0484848; + +game::UiRect g_savesFrame = {kFrameX[0], 0, kFrameWidth, kSavesFallbackHeight}; +int g_autosavePending = 0; // minutes until OK +int g_savesHover = -1; // choice under the mouse + +void CreateSavesControls(Window& w) { + int bottom = 0; + std::vector right; + for (const Control& c : w.controls) { + if (c.group != Group::Common || !IsFrame(c.control)) { + continue; + } + const game::UiRect rect = *reinterpret_cast(c.control + game::kControlRect); + if (ControlX(c.control) < kColumnSplit) { + bottom = std::max(bottom, rect.y + rect.h); + } else if (rect.w == kFrameWidth) { + right.push_back(rect); // the main frames, not their label plates (200 wide) + } + } + // The lowest frame and the nearest one ending above it. (Measured: the + // right column's frames are 384 wide at y 88, 312, 536, 128 / 128 / 64 + // high: gaps of 96. Their label plates are frames too, 200 wide.) + std::sort(right.begin(), right.end(), [](const game::UiRect& a, const game::UiRect& b) { return a.y < b.y; }); + int gap = kSavesFallbackGap; + if (!right.empty()) { + const game::UiRect& lowest = right.back(); + int aboveBottom = -1; + for (const game::UiRect& r : right) { + if (r.y + r.h <= lowest.y) { + aboveBottom = std::max(aboveBottom, r.y + r.h); + } + } + if (aboveBottom >= 0) { + gap = lowest.y - aboveBottom; + } + g_savesFrame.h = lowest.h; + } + g_savesFrame.y = bottom + gap; + const size_t existing = ControlCount(w.window); + AddFrame(w.window, g_savesFrame, "UI_FRAME_SAVES"); + AssignFrom(w, existing, Group::Common); +} + +game::UiRect SavesRowRect(void* window, int dx, int width) { + const game::UiRect origin = game::ControlScreenRect(window); + return {origin.x + g_savesFrame.x + kTextInset + dx, origin.y + g_savesFrame.y + (g_savesFrame.h - kBoxHeight) / 2, + static_cast(width), static_cast(kBoxHeight)}; +} + +game::UiRect SavesBoxRect(void* window, int choice) { + return SavesRowRect(window, kSavesLabelWidth + choice * (kSavesBoxWidth + kSavesBoxGap), kSavesBoxWidth); +} + +int SavesBoxAt(void* window, int x, int y) { + for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) { + if (game::Inside(SavesBoxRect(window, choice), x, y)) { + return choice; + } + } + return -1; +} + +void DrawSaves(const Window& w, void* device) { + void* window = w.window; + game::DrawText(device, kFont, game::CenterLineVertically(kFont, SavesRowRect(window, 0, kSavesLabelWidth)), + L"Autosave (Min.)", kTextColour, 0); + for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) { + const game::UiRect box = SavesBoxRect(window, choice); + const int minutes = kAutosaveChoices[choice]; + FillBox(device, box, + g_autosavePending == minutes ? kBoxSelectedColour + : g_savesHover == choice ? kSavesBoxHoverColour + : kSavesBoxColour); + const std::wstring text = minutes ? std::to_wstring(minutes) : L"Aus"; + game::DrawText(device, kFont, game::CenterLineVertically(kFont, box), text.c_str(), kTextColour, + game::kTextCenter); + } +} + +bool HandleSavesEvent(Window& w, void* event) { + switch (game::MouseEventKind(event)) { + case game::MouseEvent::Move: + g_savesHover = SavesBoxAt(w.window, game::EventField(event, 0xc), game::EventField(event, 0x10)); + return false; + case game::MouseEvent::ButtonDown: { + if (game::EventField(event, game::kMouseButton) != game::kLeftButton) { + return false; + } + const int box = SavesBoxAt(w.window, game::EventField(event, game::kMouseButtonX), + game::EventField(event, game::kMouseButtonY)); + if (box < 0) { + return false; + } + g_autosavePending = kAutosaveChoices[box]; + return true; + } + default: + return false; + } +} + // --- hooks --------------------------------------------------------------------- struct RenderCall { @@ -588,6 +705,7 @@ int RenderBody(void* context) { if (visible && !w->wasVisible) { LoadPending(); LoadLootPending(); + g_autosavePending = mods::characters::AutosaveMinutes(); } w->wasVisible = visible; const uint16_t page = game::ActiveTab(w->sheet); @@ -600,6 +718,9 @@ int RenderBody(void* context) { if (!visible || page != kPageLoot) { g_lootHover = -1; } + if (!visible || page != kPageCommon) { + g_savesHover = -1; + } if (!visible) { return result; } @@ -608,6 +729,8 @@ int RenderBody(void* context) { DrawKeysPage(*w, call->device); } else if (page == kPageLoot && g_pendingLoaded) { DrawLootPage(*w, call->device); + } else if (page == kPageCommon && g_pendingLoaded) { + DrawSaves(*w, call->device); } return result; } @@ -649,6 +772,10 @@ int EventBody(void* context) { g_pendingLoaded && HandleLootEvent(*w, call->event)) { return 1; } + if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageCommon && + g_pendingLoaded && HandleSavesEvent(*w, call->event)) { + return 1; + } if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys && g_pendingLoaded) { switch (game::MouseEventKind(call->event)) { @@ -704,6 +831,7 @@ void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, Ui Classify(w); CreateKeyControls(w); CreateLootControls(w); + CreateSavesControls(w); CreateTabs(w); re::Log("options: tabs created (%u controls)", static_cast(w.controls.size())); } @@ -744,6 +872,7 @@ void __fastcall UiOptions_Apply(void* self, void* edx) { if (g_pendingLoaded) { mods::input::SetBindingKeys(g_pending); mods::lootfilter::SetModes(g_lootPending); + mods::characters::SetAutosaveMinutes(g_autosavePending); } } diff --git a/src/mods/screen.cpp b/src/mods/screen.cpp new file mode 100644 index 0000000..d59e653 --- /dev/null +++ b/src/mods/screen.cpp @@ -0,0 +1,154 @@ +// 16:9 menu screens (mods/screen.h, docs/screens.md). + +#include "mods/screen.h" + +#include +#include +#include +#include + +#include + +#include "game/ui/button.h" +#include "game/ui/control.h" +#include "game/ui/draw.h" +#include "game/ui/events.h" +#include "game/ui/frame.h" +#include "game/ui/texts.h" +#include "mods/widescreen.h" + +namespace mods::screen { +namespace { + +// Background (decided 2026-10-20: our own, no picture): dark, a little warm +// at the top, darker towards the bottom and the sides. +constexpr uint32_t kBackgroundTop = 0xff1c1712; +constexpr uint32_t kBackgroundBottom = 0xff070605; +constexpr uint32_t kVignette = 0xa0000000; +constexpr int kVignetteWidth = 240; +constexpr uint32_t kPanelBackdrop = 0xb4000000; +constexpr int kHeadingHeight = 30; + +// Frames drawn so far, to tell a button drawn this frame (events come between +// frames). +std::atomic g_frame{1}; + +// The windows whose screen is shown: the menu thread renders the full width +// while any is. +std::mutex g_shownLock; +std::set g_shown; + +void NoteShown(const void* window, bool shown) { + std::lock_guard guard(g_shownLock); + if (shown) { + g_shown.insert(window); + } else { + g_shown.erase(window); + } + widescreen::SetWideMenuScreen(!g_shown.empty()); +} + +void DrawBackground(void* device) { + const float width = static_cast(std::max(widescreen::RenderWidth(), kWidth)); + const float height = static_cast(kHeight); + game::FillVertical(device, 0, 0, width, height, kBackgroundTop, kBackgroundBottom); + game::FillHorizontal(device, 0, 0, kVignetteWidth, height, kVignette, 0); + game::FillHorizontal(device, width - kVignetteWidth, 0, width, height, 0, kVignette); +} + +} // namespace + +int Render(void* window, void* device, int (*body)(void* context), void* context) { + const bool shown = game::ControlShown(window); + NoteShown(window, shown); + if (!shown || !device) { + return 0; + } + ++g_frame; + DrawBackground(device); + return hud::RenderCanvas(window, body, context); +} + +int Handle(void* event, int (*body)(void* context), void* context) { + return hud::HandleCanvas(event, body, context); +} + +void Fill(void* device, const UiRect& r, uint32_t colour) { + game::FillRect(device, static_cast(r.x), static_cast(r.y), static_cast(r.x + r.w), + static_cast(r.y + r.h), colour); +} + +void Text(void* device, const UiRect& rect, const std::wstring& text, uint32_t colour, uint32_t flags) { + game::DrawText(device, kFont, game::CenterLineVertically(kFont, rect), text.c_str(), colour, flags); +} + +UiRect Inset(const UiRect& r, int by) { + return {r.x + by, r.y + by, static_cast(r.w - 2 * by), static_cast(r.h - 2 * by)}; +} + +UiRect Panel::Draw(void* device, const UiRect& rect, const std::wstring& heading) { + if (!frame_ || rect.x != rect_.x || rect.y != rect_.y || rect.w != rect_.w || rect.h != rect_.h) { + if (frame_) { + game::DeleteControl(frame_); + } + frame_ = game::NewFrame(name_, rect); + rect_ = rect; + } + Fill(device, rect, kPanelBackdrop); + if (frame_) { + game::ControlRender(frame_, device); + } + UiRect content = Inset(rect, kPanelPadding); + if (!heading.empty()) { + Text(device, {content.x, content.y, content.w, kHeadingHeight}, heading, kHeadingColour); + content.y += kHeadingHeight; + content.h = static_cast(content.h - kHeadingHeight); + } + return content; +} + +void Button::Draw(void* device, const uint8_t* model, const UiRect& rect) { + if (!control_) { + captionId_ = game::RegisterText(caption_.c_str()); + control_ = game::NewButtonLike(model, rect, captionId_, name_); + if (!control_) { + return; + } + } + rect_ = rect; + *reinterpret_cast(control_ + game::kControlRect) = rect; + game::ControlRender(control_, device); + drawnFrame_ = g_frame; +} + +bool Button::Clicked(void* event) { + if (!control_ || drawnFrame_ != g_frame) { + return false; + } + game::ControlHandleEvent(control_, event); + return game::MouseEventKind(event) == game::MouseEvent::ButtonDown && + game::EventField(event, game::kMouseButton) == game::kLeftButton && + game::Inside(rect_, game::EventField(event, game::kMouseButtonX), + game::EventField(event, game::kMouseButtonY)); +} + +void Button::SetCaption(const std::wstring& caption) { + caption_ = caption; + if (captionId_) { + game::SetTextOverride(captionId_, caption_); + } +} + +int16_t ButtonHeight(const uint8_t* model) { + return reinterpret_cast(model + game::kControlRect)->h; +} + +bool DoubleClick::Click(int target) { + const unsigned long now = GetTickCount(); + const bool twice = target == last_ && now - at_ <= GetDoubleClickTime(); + last_ = twice ? -1 : target; + at_ = now; + return twice; +} + +} // namespace mods::screen diff --git a/src/mods/screen.h b/src/mods/screen.h new file mode 100644 index 0000000..d758f3d --- /dev/null +++ b/src/mods/screen.h @@ -0,0 +1,105 @@ +#pragma once +// 16:9 menu screens (request of 2026-10-20, docs/screens.md): a screen of our +// own replaces a full-screen window of the game's (its render and mouse +// handling), laid out on a 1365x768 canvas (1920x1080 in UI units), centred in +// the render width; wider screens only get more background at the sides. The +// look is the game's: its frames and buttons on a dark background of ours. +// +// A screen hooks its window's render and event methods and calls Render / +// Handle with a body that draws / handles in canvas coordinates with the +// helpers below. + +#include +#include + +#include "game/ui/text.h" +#include "mods/hud.h" + +namespace mods::screen { + +using game::UiRect; + +constexpr int kWidth = hud::kCanvasWidth; +constexpr int kHeight = hud::kCanvasHeight; + +// Spacing: the game's frames reach about 0x20 beyond their rects, buttons +// about 0x21 (measured on the options window). +constexpr int kFrameOrnament = 0x20; +// The frames are made of pieces of 64: other sizes leave gaps (the game's +// frames are 384x128, 384x64; screenshot 2026-10-20). +constexpr int kFrameStep = 64; +constexpr int kButtonOrnament = 0x21; +constexpr int kMargin = kFrameOrnament + 16; // canvas edge to a panel's rect +constexpr int kPanelGap = 96; // between two panels' rects, as the options window's columns +constexpr int kPanelPadding = 16; // panel rect to its content + +constexpr uint16_t kFont = 1; +constexpr uint32_t kTextColour = 0xffe6d6aa; // as the options' texts +constexpr uint32_t kHeadingColour = 0xfff0c060; // as the options' headings +constexpr uint32_t kDimColour = 0xff8c8474; +constexpr uint32_t kRowColour = 0x40302418; +constexpr uint32_t kRowHoverColour = 0x80403020; +constexpr uint32_t kRowSelectedColour = 0xc0604818; + +// Draws the screen of `window`: the background across the whole render +// target, then body(context) in canvas coordinates. While `window` is shown +// the main menu thread renders the full width (widescreen::SetWideMenuScreen). +int Render(void* window, void* device, int (*body)(void* context), void* context); +// Runs body(context) with the event's mouse position in canvas coordinates. +int Handle(void* event, int (*body)(void* context), void* context); + +void Fill(void* device, const UiRect& rect, uint32_t colour); +// One line of text in `rect`, centred vertically (game::kTextCenter / +// kTextRight for horizontal alignment). +void Text(void* device, const UiRect& rect, const std::wstring& text, uint32_t colour, uint32_t flags = 0); + +UiRect Inset(const UiRect& rect, int by); + +// A panel: a dark backdrop with the game's frame around `rect` (made anew when +// the rect changes) and an optional heading at its top. +class Panel { +public: + explicit Panel(const char* name) : name_(name) {} + // The content area below the heading. + UiRect Draw(void* device, const UiRect& rect, const std::wstring& heading = {}); + +private: + const char* name_; + UiRect rect_ = {}; + uint8_t* frame_ = nullptr; +}; + +// A button of the game's (look and sounds of `model`, a button of the +// window), made on first use. +class Button { +public: + Button(const char* name, std::wstring caption) : name_(name), caption_(std::move(caption)) {} + void Draw(void* device, const uint8_t* model, const UiRect& rect); + // Passes the event to the button (pressed look, sound); true for a left + // click on it. Only for buttons drawn this frame. + bool Clicked(void* event); + void SetCaption(const std::wstring& caption); + +private: + const char* name_; + std::wstring caption_; + uint32_t captionId_ = 0; + uint8_t* control_ = nullptr; + UiRect rect_ = {}; + unsigned drawnFrame_ = 0; +}; + +// Height of the model's buttons (as the window made them). +int16_t ButtonHeight(const uint8_t* model); + +// Two clicks on the same thing within the system's double-click time. +class DoubleClick { +public: + bool Click(int target); + +private: + int last_ = -1; + unsigned long at_ = 0; +}; + +} // namespace mods::screen diff --git a/src/mods/widescreen.cpp b/src/mods/widescreen.cpp index e38a6f5..5372eac 100644 --- a/src/mods/widescreen.cpp +++ b/src/mods/widescreen.cpp @@ -70,6 +70,7 @@ std::atomic g_menuThread = 0; // menu is showing. UI input is processed outside the menu thread. std::atomic g_lastSceneClassic = false; thread_local int g_loadingScreenDepth = 0; +std::atomic g_wideMenuScreen = false; // D3DVIEWPORT7 (d3d.h is not in current Windows SDKs). struct Viewport7 { @@ -346,7 +347,11 @@ bool IsClassicFrame() { if (g_renderWidth == 0) { return false; } - return g_loadingScreenDepth > 0 || GetCurrentThreadId() == g_menuThread.load(); + return g_loadingScreenDepth > 0 || (GetCurrentThreadId() == g_menuThread.load() && !g_wideMenuScreen); +} + +void SetWideMenuScreen(bool shown) { + g_wideMenuScreen = shown; } } // namespace mods::widescreen diff --git a/src/mods/widescreen.h b/src/mods/widescreen.h index 07763fa..c82b0a2 100644 --- a/src/mods/widescreen.h +++ b/src/mods/widescreen.h @@ -22,4 +22,9 @@ int RenderWidth(); // regardless of the calling thread. bool MenuIsShowing(); +// While a 16:9 menu screen (mods/screen.h) is shown from the main menu, the +// menu thread renders and presents the full render size instead of the +// classic 1024x768. +void SetWideMenuScreen(bool shown); + } // namespace mods::widescreen