diff --git a/CMakeLists.txt b/CMakeLists.txt index 73c956b..5c3bf76 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,7 @@ add_library(sacred_re SHARED src/mods/teleportpotion.cpp src/mods/guards.cpp src/mods/charexport.cpp + src/mods/options.cpp ) target_include_directories(sacred_re PRIVATE src) target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) diff --git a/docs/options.md b/docs/options.md new file mode 100644 index 0000000..c9d0b1b --- /dev/null +++ b/docs/options.md @@ -0,0 +1,111 @@ +# Options window: tabs and key bindings + +Request (2026-10-18): the settings screen gets three tabs: *Allgemein*, +*Beute* (loot filter; first iteration: the game's loot options moved there +from the common page) and *Tastenbelegung* (key bindings, so far only +settable in `sacred_re.ini`). Code: `src/mods/options.cpp`; bindings in +`src/mods/input.cpp`. + +## The window (cUI_Options) + +- vtable `0x89505c`, constructor `0x716570` (name, rect, parent, flags; + `ret 0x18`), destructor `0x7165f0`; controls built by `0x7184e0`. +- vf44 `0x716640` (renderer, ?, ?; `ret 0xc`): window render (`0x727040`), + then drives the sliders while the mouse is held. vf10 `0x716b30`: events. +- `0x717ea0` loads the settings into the controls when the window is shown; + `0x717460` (thiscall, plain `ret`) writes them back — called for OK. +- Controls: vector at `+0x90` (begin) / `+0x94` (end); the 16 background tiles + are in the window's own list `+0x78`. Buttons: `+0x158` Cancel, `+0x15c` OK. + Pickup: radio buttons `+0x16c/+0x170/+0x174` (`PICKUPAUTO` 0/1/2), + checkbox `+0x178` (`PICKUPANIM`). +- Layout (1024x768): frames at x `0x40` / `0x220`, y `0x40` / `0x120` / + `0x200`, borders `0x14` outside, labels above (y `0x20` / `0x100` / + `0x1e0`). Left: graphics, auto-pickup (*AUTOCOLLECT*), general; right: sound + volume, 3D sound, internet. OK / Cancel at y `0x284`. +- cUI_Frame (vtable `0x8955ac`): its render `0x73a540` does not test the + visible flag, only `+0xdc` (set to 1 by its constructors). + +## How the mod works + +- After the constructor: every control in `+0x90` is put on a page by its + position — left column middle row → *Beute*, everything else → *Allgemein*, + OK / Cancel on all pages. The auto-pickup group moves up one row (to the top + of its page), the general group below it moves up into its place. +- A cUI_TabSheet (`game/ui/tabs.h`) at the top frames' border; its headers + (default row layout) sit above the frame labels. +- Each frame (vf44) the controls not on the active page are hidden (visible + flag, frames also `+0xdc`); controls hidden by the mod are shown again on + their page. The game re-shows controls when the window opens, so this runs + every frame. +- *Tastenbelegung* is drawn by the mod: rows per binding, grouped + (Kampfkünste, Waffensets, Bewegung, Aktionen), a key box per row. A click on + a box captures the next key press (`input::SetKeyCapture`, swallowed before + the game sees it): Esc cancels, Backspace unbinds, a bindable key is taken + (and removed from any other action). The page works on a copy loaded when + the window opens; OK (`0x717460`) saves it to `[keys]` in `sacred_re.ini`, + Cancel drops it. +- Movement keys (W/A/S/D) are bindings now (`MoveUp/Left/Down/Right`); + `mods/wasd.cpp` polls the bound keys. + +## Follow-ups (2026-10-18) + +- Pages moved down 24 px (`kContentShift`) for room below the tab headers. +- *Alle zurücksetzen* (left of OK / Cancel) sets every binding back to its + default; saved with OK like any other change. +- Potion bindings *Trank 1–5* and *Trank 1 für Gefährten* (default: no key). + They call `cEngine::UseActionSlot` (`0x613040`, thiscall (kind, on ally), + `ret 8`) on the engine tick. The game's own potion hotkeys are off: its + engine hotkey switch (`cEngine::ReceiveEvent`, index bytes `0x619adc` per + upper-case character − 0x20, targets `0x619ac0`) had Space → kind 1 (Shift: + 5), B → kind 1 on the weakest ally, Q 2, W 3, E 4, R 5; those six entries + now point at index 6 (no hotkey). `ActionSlot3` (W's action on G) is gone. +- The window is drawn like the save game / export screens: black beside the + 1024 frame (`hud::RenderFramed`). +- Key bindings page: a backdrop (`0x724ed0`) and a frame (`0x739f90`) per + column, created with the window's controls and shown only on that page. + (Drawing the window's existing ones at other rects did not fit: a frame's + border pieces keep the place they were made at.) +- Pages move down 24 px under the tab headers. A slider (cUI_Scrollbar, + vtable `0x895664`) moves with its parts: minus, plus, track and knob are + embedded controls (+0x94 / +0x110 / +0x18c / +0x208) with rects in window + coordinates, and the knob is placed from +0x78 / +0x7c each frame. +- Key page layout (2026-10-19): the two frames span the other pages' frame + area (top row's top to the bottom of *Internet-Optionen*); the hints sit + in the bottom row next to *Alle zurücksetzen*, since they hold for the + whole page. +- Potion kinds are named by the bottom bar's tooltip texts: + `PotionTooltipText` `0x43fa90` (kind) → text id by key, e.g. + `Heiltrank [SPC]` (kinds 1–5: Heiltrank, + Sog der Hölle, Trank des Mentors, Antidot der Viper, Trank der + Konzentration / Teleportationstrank; read in `scripts/de/SRglbl.res`). + Shown, the tags are colour codes (backslash, letter, 8 hex digits: + `\dFFFFFF80Heiltrank \wFFFFFFFF[SPC]`); `game::PotionKindName` strips them and the key; the tooltips get the bound key + in the brackets, or none (`game::SetTextOverride`, mods/potioncounts.cpp). +- No posted game keys any more: *Spiel speichern* calls what the UI manager's + S case (`0x757775`) does: save window built (manager slot `0x2b`), + `CanOpenWindow` `0x75a690`, engine event (vtable `0x88eb38`, 0x13, 5, 1), + then window command *show* (0x11) for `UI_WND_SAVEGAME` + (`game/ui/windows.h`, shared with escape.cpp's *hide* 0x12). + +## Karte, Minikarte, Beschriftungen (2026-10-19) + +Request: M (map), Tab (minimap) and Alt (text overlay) bindable too. + +- M: the UI manager's letter switch (`cUI_Manager::ReceiveEvent` 0x757120, + upper-case character − 'C', index bytes 0x75868c, targets 0x758660) shows + `UI_WND_MEGAMAP` (slot 0x25) unless a blocking window is open; while the map + is open its own branch (cmp 'M' at 0x757f86) hides it. Switched off (M → + index 10, the compare → -1); the *Karte* binding does the same checks and + sends the window command. +- Tab: the in-game window procedure 0x8110d0 shows `UI_WND_OVERVIEWMAP` on + key down (switch on vk − 9: index bytes 0x812124, case 0x81146d) and hides + it on key up (0x812270, case 0x8117f8). Switched off (Tab → the "other key" + indices 4 / 3); *Minikarte (halten)* shows / hides it on the bound key's + down / up. +- Alt: no message, the game polls `GetAsyncKeyState(VK_MENU)` for its name + overlay in seven places (DrawObjectLabels 0x627260, PickObjectAt 0x626d30, + cEngine 0x60f1d0 / 0x611f10 / 0x617360, cCreature 0x599880, the map's debug + teleport 0x6c7ec0). Their `call [GetAsyncKeyState]` read our pointer + instead: `LabelsKeyState` returns the bound key's state. +- Alt and F10 come as WM_SYSKEYDOWN: the key capture takes those too. +- Key page: 20 lines per column (line 25 px) to fit the three rows. diff --git a/docs/teleport-potion.md b/docs/teleport-potion.md index bf99f31..3b48fba 100644 --- a/docs/teleport-potion.md +++ b/docs/teleport-potion.md @@ -19,10 +19,13 @@ one of them. ## Mod (src/mods/teleportpotion.cpp) -- Every item type of potion kind 5 counts. Their names (text 0x428700 of each - type) are replaced in every game text (`game::ReplaceInTexts`): the item - names and the bottom bar's quick-select tooltip ("Trank der Konzentration - [R]", its own text id). The shortest name is replaced, so stage words stay. +- Every item type of potion kind 5 counts. Their name, "Trank der + Konzentration", is replaced in every game text from the DLL's start + (`game::ReplaceInTexts`, applied where the game fetches a text): the item + names (stage words in front stay) and the bottom bar's quick-select tooltip + ("Trank der Konzentration [R]", its own text id). Before (until + 2026-10-19) the names were collected from the item types at game start, so + the main menu (options' key bindings) still had the old name. - cCreature::UseItem (hooked in itemstack.cpp) asks `teleportpotion::UseItem` first: for the hero's Teleportationstrank it posts the teleporter's event with the hero's mask instead of the drink. diff --git a/docs/todo.md b/docs/todo.md index 50444f3..35da57e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -118,7 +118,7 @@ in-game texts/tooltips that name the old keys): | Key | Action | Temporary key (sacred_re.ini) | |---|---|---| -| W | action slot 3 (`cEngine::UseActionSlot`, like Q/E/R/B/Space) | G (`[keys] ActionSlot3`) | +| W | potion kind 3 (`cEngine::UseActionSlot`, like Q/E/R/B/Space) | game potion keys switched off; `[keys] Potion1-5` (docs/options.md) | | A | pick up items nearby (creature event 0x113) | Q (`[keys] PickUp`, 2026-10-17; was V) | | S | save game window (`UI_WND_SAVEGAME`) | K (`[keys] SaveGame`) | | D | unbound | - | diff --git a/src/game/engine/items.cpp b/src/game/engine/items.cpp index e593187..bd74475 100644 --- a/src/game/engine/items.cpp +++ b/src/game/engine/items.cpp @@ -1,6 +1,7 @@ #include "game/engine/items.h" #include "game/engine/stream.h" +#include "game/ui/texts.h" namespace game { namespace { @@ -48,4 +49,25 @@ uint32_t CloneItem(uint32_t id) { return item ? CreateItem(ItemType(item), SerializeItem(item), item[kItemOwner]) : 0; } +std::wstring PotionKindName(int kind) { + // Shown as "\dFFFFFF80Heiltrank \wFFFFFFFF[SPC]" (the resource's + // tags become a backslash, a letter and 8 hex digits): without + // the colour codes and the key. + constexpr size_t kColourCodeLength = 10; + const std::wstring text = ShownText(PotionTooltipText(kind)); + std::wstring name; + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] == L'\\') { + i += kColourCodeLength - 1; + } else if (text[i] == L'[') { + break; + } else { + name += text[i]; + } + } + const size_t first = name.find_first_not_of(L' '); + const size_t last = name.find_last_not_of(L' '); + return first == std::wstring::npos ? std::wstring() : name.substr(first, last - first + 1); +} + } // namespace game diff --git a/src/game/engine/items.h b/src/game/engine/items.h index 32717f8..4eaf403 100644 --- a/src/game/engine/items.h +++ b/src/game/engine/items.h @@ -128,3 +128,23 @@ uint32_t CreateItem(uint32_t type, const std::vector& data, uint32_t ow uint32_t CloneItem(uint32_t id); } // namespace game + +#include + +namespace game { + +// 0x43fa90 (ret 4; ecx unused, it asks the text manager): the text id of the +// bottom bar's tooltip for potion kind `kind` (1..kPotionKinds), by text key +// (0x94a8d0 ...), e.g. "Heiltrank [SPC]"; +// 0 for other kinds. Kinds: Heiltrank, Sog der Hölle, Trank des Mentors, +// Antidot der Viper, Trank der Konzentration (Teleportationstrank here). +inline uint32_t PotionTooltipText(int kind) { + using Fn = uint32_t(__fastcall*)(void*, void*, int); + return reinterpret_cast(0x0043fa90)(*reinterpret_cast(kItemTypeTable), nullptr, kind); +} + +// The name of potion kind `kind` as the bar's tooltip shows it (with our text +// changes), without its colour codes and key. +std::wstring PotionKindName(int kind); + +} // namespace game diff --git a/src/game/engine/objects.h b/src/game/engine/objects.h index d174ac9..cb4b9d6 100644 --- a/src/game/engine/objects.h +++ b/src/game/engine/objects.h @@ -73,11 +73,13 @@ inline void* GetPlayerHero() { } // cKernel::PostEvent (0x808930) on Kernel_Instance() (0x808490): the kernel -// copies the event and dispatches it (key events right away). -inline void PostEvent(void* event) { +// copies the event and dispatches it (key events right away). `a`: 1 as the +// window procedure posts key events, 0 as the UI manager's S hotkey posts its +// engine event. +inline void PostEvent(void* event, int a = 1) { using InstanceFn = void*(__cdecl*)(); using PostFn = void(__fastcall*)(void* kernel, void* edx, void* event, int a, int b); - reinterpret_cast(0x00808930)(reinterpret_cast(0x00808490)(), nullptr, event, 1, 0); + reinterpret_cast(0x00808930)(reinterpret_cast(0x00808490)(), nullptr, event, a, 0); } } // namespace game diff --git a/src/game/ui/draw.h b/src/game/ui/draw.h new file mode 100644 index 0000000..158dd33 --- /dev/null +++ b/src/game/ui/draw.h @@ -0,0 +1,36 @@ +#pragma once +// Untextured quads on the UI's Direct3D 7 device (the argument of the UI's +// render methods, vf14). + +#include + +#include + +namespace game { + +constexpr int kDeviceDrawPrimitive = 25; // IDirect3DDevice7 vtable slots +constexpr int kDeviceSetTexture = 35; +constexpr DWORD kTriangleStrip = 5; +constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1 + +struct ScreenVertex { + float x, y, z, rhw; + uint32_t diffuse, specular; + 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) { + 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}}; + reinterpret_cast(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, kFvfScreenColourTexture, + quad, 4, 0); +} + +} // namespace game diff --git a/src/game/ui/tabs.h b/src/game/ui/tabs.h index 4e2551e..f00dc82 100644 --- a/src/game/ui/tabs.h +++ b/src/game/ui/tabs.h @@ -57,6 +57,13 @@ inline uint8_t* TabControl(void* sheet) { return *reinterpret_cast(static_cast(sheet) + kTabSheetControl); } +constexpr size_t kTabControlActive = 0x84; // u16 + +inline uint16_t ActiveTab(void* sheet) { + const uint8_t* control = TabControl(sheet); + return control ? *reinterpret_cast(control + kTabControlActive) : 0; +} + // The tab entries of a cUI_TabControl. inline uint8_t* TabEntries(uint8_t* control) { return *reinterpret_cast(control + kTabControlTabs); diff --git a/src/game/ui/text.h b/src/game/ui/text.h index 352c496..0999ab5 100644 --- a/src/game/ui/text.h +++ b/src/game/ui/text.h @@ -3,6 +3,9 @@ #include +// First, so its DrawText macro renames game::DrawText the same way everywhere. +#include + namespace game { // Screen rect as the UI uses it (cUI_Control2::GetScreenRect 0x731c70, diff --git a/src/game/ui/texts.cpp b/src/game/ui/texts.cpp index 7d7f83a..af8be99 100644 --- a/src/game/ui/texts.cpp +++ b/src/game/ui/texts.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -57,6 +58,15 @@ std::vector>& Replacements() { // By id; nodes stay put, so the strings handed out stay valid. std::unordered_map g_shown; +// SetTextOverride: by id. A replaced text is kept (the game may still hold +// the string it got); overrides change rarely (key bindings). +struct Override { + std::wstring text; + GameString string; +}; +std::unordered_map> g_overrides; +std::vector> g_retiredOverrides; + std::wstring Capitalize(std::wstring text) { bool wordStart = true; for (wchar_t& c : text) { @@ -126,6 +136,26 @@ std::wstring GameText(uint32_t id) { : std::wstring(); } +std::wstring ShownText(uint32_t id) { + std::lock_guard guard(g_lock); + return Transform(id, GameText(id)); +} + +void SetTextOverride(uint32_t id, const std::wstring& text) { + std::lock_guard guard(g_lock); + std::unique_ptr& entry = g_overrides[id]; + if (entry && entry->text == text) { + return; + } + if (entry) { + g_retiredOverrides.push_back(std::move(entry)); + } + entry = std::make_unique(); + entry->text = text; + const wchar_t* begin = entry->text.c_str(); + entry->string = {begin, begin + entry->text.size(), begin + entry->text.size()}; +} + std::wstring GameTextByKey(const char* key) { // The key's hash (0x80e010, cdecl), looked up "by key" as GetTextByKey does. using HashFn = uint32_t(__cdecl*)(const char*); @@ -159,6 +189,9 @@ const void* __fastcall TextManager_GetText(void* self, void* edx, uint32_t id) { } const void* original = TextManager_GetText_original(self, edx, id); std::lock_guard guard(g_lock); + if (const auto found = g_overrides.find(id); found != g_overrides.end()) { + return &found->second->string; + } if (!original || (Replacements().empty() && !CapitalizedIds().count(id))) { return original; } diff --git a/src/game/ui/texts.h b/src/game/ui/texts.h index b11dac4..b9350f5 100644 --- a/src/game/ui/texts.h +++ b/src/game/ui/texts.h @@ -23,6 +23,13 @@ void ReplaceInTexts(const std::wstring& from, const std::wstring& to); // The game's text `id` without our changes (empty if there is none). std::wstring GameText(uint32_t id); +// Text `id` as the game shows it, with our changes (without overrides). +std::wstring ShownText(uint32_t id); + +// Shows `text` for the game's text `id` from now on (e.g. a tooltip naming a +// key binding); setting the same text again costs nothing. +void SetTextOverride(uint32_t id, const std::wstring& text); + // The game's text under key `key` (TextManager::GetTextByKey 0x672fd0), e.g. // "UI_ATTRIB_STRENGTH"; empty if there is none. std::wstring GameTextByKey(const char* key); diff --git a/src/game/ui/windows.h b/src/game/ui/windows.h new file mode 100644 index 0000000..46855fb --- /dev/null +++ b/src/game/ui/windows.h @@ -0,0 +1,51 @@ +#pragma once +// Opening and closing the UI manager's windows by name, as the game's own +// hotkeys and buttons do. + +#include +#include + +namespace game { + +constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager* + +inline uint32_t* UiManager() { + return *reinterpret_cast(kUiManager); +} + +// cEventUI2 window commands (code at +4, subtype 5 at +8, names at +0x28): the +// UI manager's event handler (vf00, 0x757120) routes them by window name. +// Measured: HandleEsc hides with 0x12 (docs/escape.md); the S hotkey +// (0x757775) shows the save game window with 0x11. (Sent to the window's own +// handler a command was not understood the same way: the trader changed its +// stock instead of closing.) +enum class WindowCommand : uint32_t { Show = 0x11, Hide = 0x12 }; + +inline void SendWindowCommand(uint32_t* manager, const char* name, WindowCommand command) { + constexpr uint32_t kEventUiVtable = 0x0088edd0; + constexpr uint32_t kWindowSubtype = 5; + constexpr size_t kEventUiSize = 0x80; + // Names, written by 0x5904e0 (sender at +0, window at +0x20 of this). + constexpr size_t kEventUiNames = 0x28; + constexpr size_t kEventUiText = 0x68; // a string, 0x423920 / 0x416660 + alignas(4) uint8_t event[kEventUiSize] = {}; + *reinterpret_cast(event) = kEventUiVtable; + *reinterpret_cast(event + 4) = static_cast(command); + *reinterpret_cast(event + 8) = kWindowSubtype; + using SetNamesFn = void(__fastcall*)(void*, void*, const char* window, const char* sender); + reinterpret_cast(0x005904e0)(event + kEventUiNames, nullptr, name, ""); + using StringFn = void(__fastcall*)(void*, void*); + reinterpret_cast(0x00423920)(event + kEventUiText, nullptr); // construct + using HandleFn = int(__fastcall*)(void*, void*, void*); + reinterpret_cast((*reinterpret_cast(manager))[0])(manager, nullptr, event); + reinterpret_cast(0x00416660)(event + kEventUiText, nullptr); // destroy +} + +// cUI_Manager 0x75a690 (thiscall (1), ret 4): false while a window that +// blocks others is open (the S hotkey asks it first). +inline bool UiCanOpenWindow(uint32_t* manager) { + using Fn = bool(__fastcall*)(void*, void*, int); + return reinterpret_cast(0x0075a690)(manager, nullptr, 1); +} + +} // namespace game diff --git a/src/mods/escape.cpp b/src/mods/escape.cpp index f30096a..c6b5e44 100644 --- a/src/mods/escape.cpp +++ b/src/mods/escape.cpp @@ -13,6 +13,7 @@ #include #include "game/ui/control.h" +#include "game/ui/windows.h" #include "mods/hud.h" #include "re/hook.h" #include "re/log.h" @@ -54,39 +55,10 @@ void CloseDialog(uint8_t* tooltip) { } constexpr size_t kControlFlags = 0x10; // bit 0: visible -// cEventUI2 as HandleEsc builds it (event at [esp+10h] there). -constexpr uint32_t kEventUiVtable = 0x0088edd0; -constexpr uint32_t kEventUiWindowCommand = 0x12; -constexpr uint32_t kWindowCommandHide = 5; -constexpr size_t kEventUiSize = 0x80; -// Names, written by 0x5904e0 (sender at +0, window at +0x20 of this): measured -// in the UI commands the game sends when a window is closed with its button or -// hotkey (window name at +0x48). -constexpr size_t kEventUiNames = 0x28; -constexpr size_t kEventUiText = 0x68; // a string, 0x423920 / 0x416660 - bool Visible(const uint8_t* window) { return window && (window[kControlFlags] & 1) != 0; } -// The window command goes to the UI manager's event handler (vf00, -// 0x757120), which routes it by name, as HandleEsc does for the inventory. -// (Sent to the window's own handler it was not understood the same way: the -// trader changed its stock instead of closing.) -void Hide(uint32_t* manager, const char* name) { - alignas(4) uint8_t event[kEventUiSize] = {}; - *reinterpret_cast(event) = kEventUiVtable; - *reinterpret_cast(event + 4) = kEventUiWindowCommand; - *reinterpret_cast(event + 8) = kWindowCommandHide; - using SetNamesFn = void(__fastcall*)(void*, void*, const char* window, const char* sender); - reinterpret_cast(0x005904e0)(event + kEventUiNames, nullptr, name, ""); - using StringFn = void(__fastcall*)(void*, void*); - reinterpret_cast(0x00423920)(event + kEventUiText, nullptr); // construct - using HandleFn = int(__fastcall*)(void*, void*, void*); - reinterpret_cast((*reinterpret_cast(manager))[0])(manager, nullptr, event); - reinterpret_cast(0x00416660)(event + kEventUiText, nullptr); // destroy -} - } // namespace // True if Esc closed windows (the game's handling is skipped). @@ -108,7 +80,7 @@ bool CloseWindows(uint32_t* manager) { for (const ClosableWindow& w : kClosable) { auto* window = reinterpret_cast(manager[w.slot]); if (Visible(window)) { - Hide(manager, w.name); + game::SendWindowCommand(manager, w.name, game::WindowCommand::Hide); closed = true; } } diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp index d2ce431..791146b 100644 --- a/src/mods/hud.cpp +++ b/src/mods/hud.cpp @@ -694,8 +694,7 @@ HUD_EVENT(0x006f8960, UiNetworkInfo_HandleEvent, Anchor::Center) HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center) HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center) HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game -HUD_EVENT(0x00716b30, UiOptions_HandleEvent, Anchor::Center) -HUD_RENDER3(0x00716640, UiOptions_Render, Anchor::Center) +// cUI_Options (0x716b30 / 0x716640): hooked by mods/options.cpp. HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center) HUD_RENDER_FRAMED(0x0071dd80, UiSavegame_Render) HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center) @@ -778,6 +777,13 @@ int RenderCentred(void* window, int (*body)(void* context), void* context) { return body(context); } +int RenderFramed(void* window, void* device, int (*body)(void* context), void* context) { + const int dx = OffsetFor(Anchor::Center); + FrameScope frame(window, device, dx); + DrawOffset offset(window, dx); + return body(context); +} + int HandleCentred(void* event, int (*body)(void* context), void* context) { EventOffset offset(event, OffsetFor(Anchor::Center)); return body(context); diff --git a/src/mods/hud.h b/src/mods/hud.h index 402cb7c..14701d2 100644 --- a/src/mods/hud.h +++ b/src/mods/hud.h @@ -26,5 +26,8 @@ int VisibleDialogTooltips(void** out, int max); // shifted) or event handler (mouse position shifted by the window's offset). int RenderCentred(void* window, int (*body)(void* context), void* context); int HandleCentred(void* event, int (*body)(void* context), void* context); +// RenderCentred for full-screen windows: black beside the 1024 frame, drawn +// with a viewport on it (as the save game and export screens). +int RenderFramed(void* window, void* device, int (*body)(void* context), void* context); } // namespace mods::hud diff --git a/src/mods/input.cpp b/src/mods/input.cpp index 8b8de10..1b1ffe2 100644 --- a/src/mods/input.cpp +++ b/src/mods/input.cpp @@ -6,22 +6,30 @@ // 0x6e1970). This mod reads the raw key messages instead (virtual keys, // independent of the keyboard layout): a bound key's messages are swallowed // and its action runs. Skill keys cast their slot (src/mods/skillkeys.cpp), -// weapon set keys select it on the taskbar; keys standing in for an original -// hotkey post that hotkey's key event to the game's event kernel, the way the window -// procedure does (the kernel dispatches it right away, on the window thread). +// weapon set keys select it on the taskbar; the other actions call the game +// function its hotkey ran. The game's own hotkeys for bound actions are +// switched off (potions) or kept from it (the keys are swallowed). // // While a text field has the keyboard, keys pass through unchanged. (Keys // with Ctrl / Alt held used to pass too; GetKeyState reports Alt as held after // Alt+Tab until it is pressed again in the game, which switched the bindings // off. Alt+key arrives as WM_SYSKEYDOWN and is not handled here anyway.) // -// sacred_re.ini (key names: A-Z, 0-9, F1-F24, Space, None): +// The bindings are set on the options window's key bindings tab +// (mods/options.cpp) and kept in sacred_re.ini (key names: kKeyNames below, +// None): // [keys] // SkillSlot0=0 ... SkillSlot6=6 ; cast combat-art slot (0 = right mouse button art) // WeaponSet1=F1 ... WeaponSet7=F7 ; select weapon set -// ActionSlot3=G ; what W did (W is movement) +// MoveUp=W, MoveLeft=A, MoveDown=S, MoveRight=D ; direction movement (mods/wasd.cpp polls them) +// Potion1=None ... Potion5=None ; drink a potion of kind 1-5 +// AllyPotion=None ; kind 1 on the weakest ally (the game's own potion keys +// ; Space/B/Q/W/E/R are switched off) // PickUp=Q ; what A did (pick up items nearby; Q's own action slot is given up) // SaveGame=K ; what S did (save game window) +// Map=M ; open / close the map (the game's M is switched off) +// OverviewMap=Tab ; the overview map while held (the game's Tab is switched off) +// Labels=Alt ; names of items and creatures while held (the game's Alt is switched off) #include "mods/input.h" @@ -29,14 +37,16 @@ #include #include #include +#include #include #include "game/engine/events.h" #include "game/engine/objects.h" +#include "game/engine/items.h" #include "game/engine/orders.h" +#include "game/ui/windows.h" #include "mods/skillbar.h" #include "mods/skillkeys.h" -#include "mods/wasd.h" #include "re/config.h" #include "re/hook.h" #include "re/log.h" @@ -45,69 +55,109 @@ namespace mods::input { namespace { -// --- sacred.exe --------------------------------------------------------------- -constexpr uint32_t kUiManager = 0x017ecb3c; // cUI_Manager* -constexpr uint32_t kKeyModifiers = 0x0182cdec; // u16, as the in-game window procedure keeps it -constexpr uint32_t kEventKeyVtable = 0x00895568; - -// cEvent_key: +4 1 = pressed, 0 = released; +6 / +8 the key (character when -// pressed, virtual key when released); +0xa modifiers. -struct KeyEvent { - uint32_t vtable; - uint16_t pressed; - uint16_t raw; - uint16_t key; - uint16_t modifiers; -}; -static_assert(sizeof(KeyEvent) == 0xc); - -// Posts the key event the window procedure would post for `key` (an original -// hotkey letter or digit). -void PostGameKey(char key, bool pressed) { - KeyEvent event = {}; - event.vtable = kEventKeyVtable; - event.pressed = pressed ? 1 : 0; - // Pressed: the character (lower case, as without Shift); released: the - // virtual key (upper case). - const auto code = static_cast(pressed ? tolower(key) : toupper(key)); - event.raw = code; - event.key = code; - event.modifiers = *reinterpret_cast(kKeyModifiers); - game::PostEvent(&event); -} - // --- bindings ------------------------------------------------------------------ -enum class Kind { SkillSlot, WeaponSet, GameKey, PickUp }; +// Move: a held key polled by mods/wasd.cpp; its messages are only swallowed. +// Labels: a held key the game polls (LabelsKeyState); its messages are only +// swallowed. +enum class Kind { SkillSlot, WeaponSet, Move, Potion, PickUp, SaveGame, Map, OverviewMap, Labels }; +// Potion index: the potion kind, with this flag on the weakest ally. +constexpr int kAllyPotion = 0x100; struct Binding { const char* name; const char* fallback; + const wchar_t* label; Kind kind; - int index; // slot / set (0-based) - char gameKey; // GameKey: the original hotkey + int index; // slot / set (0-based), Move direction int vk = 0; }; +// Display order (the key bindings page shows them in groups by kind). Binding g_bindings[] = { - {"SkillSlot0", "0", Kind::SkillSlot, 0, 0}, - {"SkillSlot1", "1", Kind::SkillSlot, 1, 0}, - {"SkillSlot2", "2", Kind::SkillSlot, 2, 0}, - {"SkillSlot3", "3", Kind::SkillSlot, 3, 0}, - {"SkillSlot4", "4", Kind::SkillSlot, 4, 0}, - {"SkillSlot5", "5", Kind::SkillSlot, 5, 0}, - {"SkillSlot6", "6", Kind::SkillSlot, 6, 0}, - {"WeaponSet1", "F1", Kind::WeaponSet, 0, 0}, - {"WeaponSet2", "F2", Kind::WeaponSet, 1, 0}, - {"WeaponSet3", "F3", Kind::WeaponSet, 2, 0}, - {"WeaponSet4", "F4", Kind::WeaponSet, 3, 0}, - {"WeaponSet5", "F5", Kind::WeaponSet, 4, 0}, - {"WeaponSet6", "F6", Kind::WeaponSet, 5, 0}, - {"WeaponSet7", "F7", Kind::WeaponSet, 6, 0}, - {"ActionSlot3", "G", Kind::GameKey, 0, 'W'}, - {"PickUp", "Q", Kind::PickUp, 0, 0}, - {"SaveGame", "K", Kind::GameKey, 0, 'S'}, + {"SkillSlot0", "0", L"Kampfkunst 0 (Rechtsklick)", Kind::SkillSlot, 0}, + {"SkillSlot1", "1", L"Kampfkunst 1", Kind::SkillSlot, 1}, + {"SkillSlot2", "2", L"Kampfkunst 2", Kind::SkillSlot, 2}, + {"SkillSlot3", "3", L"Kampfkunst 3", Kind::SkillSlot, 3}, + {"SkillSlot4", "4", L"Kampfkunst 4", Kind::SkillSlot, 4}, + {"SkillSlot5", "5", L"Kampfkunst 5", Kind::SkillSlot, 5}, + {"SkillSlot6", "6", L"Kampfkunst 6", Kind::SkillSlot, 6}, + {"WeaponSet1", "F1", L"Waffenset 1", Kind::WeaponSet, 0}, + {"WeaponSet2", "F2", L"Waffenset 2", Kind::WeaponSet, 1}, + {"WeaponSet3", "F3", L"Waffenset 3", Kind::WeaponSet, 2}, + {"WeaponSet4", "F4", L"Waffenset 4", Kind::WeaponSet, 3}, + {"WeaponSet5", "F5", L"Waffenset 5", Kind::WeaponSet, 4}, + {"WeaponSet6", "F6", L"Waffenset 6", Kind::WeaponSet, 5}, + {"WeaponSet7", "F7", L"Waffenset 7", Kind::WeaponSet, 6}, + {"MoveUp", "W", L"Nach oben", Kind::Move, static_cast(Move::Up)}, + {"MoveLeft", "A", L"Nach links", Kind::Move, static_cast(Move::Left)}, + {"MoveDown", "S", L"Nach unten", Kind::Move, static_cast(Move::Down)}, + {"MoveRight", "D", L"Nach rechts", Kind::Move, static_cast(Move::Right)}, + {"Potion1", "None", L"Trank 1", Kind::Potion, 1}, + {"Potion2", "None", L"Trank 2", Kind::Potion, 2}, + {"Potion3", "None", L"Trank 3", Kind::Potion, 3}, + {"Potion4", "None", L"Trank 4", Kind::Potion, 4}, + {"Potion5", "None", L"Trank 5", Kind::Potion, 5}, + {"AllyPotion", "None", L"Trank 1", Kind::Potion, 1 | kAllyPotion}, + {"PickUp", "Q", L"Gegenstände aufheben", Kind::PickUp, 0}, + {"SaveGame", "K", L"Spiel speichern", Kind::SaveGame, 0}, + {"Map", "M", L"Karte", Kind::Map, 0}, + {"OverviewMap", "Tab", L"Minikarte (halten)", Kind::OverviewMap, 0}, + {"Labels", "Alt", L"Beschriftungen (halten)", Kind::Labels, 0}, }; +constexpr int kBindingCount = static_cast(std::size(g_bindings)); + +// Keys that can be bound besides A-Z, 0-9 and F1-F24 (named by their letter / +// number): ini name, shown name. Esc and Backspace stay free for the key +// bindings page (cancel / unbind), Enter for the game's chat. +struct NamedKey { + int vk; + const char* ini; + const wchar_t* label; +}; +constexpr NamedKey kKeyNames[] = { + {VK_SPACE, "Space", L"Leertaste"}, {VK_TAB, "Tab", L"Tab"}, + {VK_MENU, "Alt", L"Alt"}, + {VK_INSERT, "Insert", L"Einfg"}, {VK_DELETE, "Delete", L"Entf"}, + {VK_HOME, "Home", L"Pos1"}, {VK_END, "End", L"Ende"}, + {VK_PRIOR, "PageUp", L"Bild auf"}, {VK_NEXT, "PageDown", L"Bild ab"}, + {VK_UP, "Up", L"Pfeil hoch"}, {VK_DOWN, "Down", L"Pfeil runter"}, + {VK_LEFT, "Left", L"Pfeil links"}, {VK_RIGHT, "Right", L"Pfeil rechts"}, + {VK_NUMPAD0, "Num0", L"Num 0"}, {VK_NUMPAD1, "Num1", L"Num 1"}, + {VK_NUMPAD2, "Num2", L"Num 2"}, {VK_NUMPAD3, "Num3", L"Num 3"}, + {VK_NUMPAD4, "Num4", L"Num 4"}, {VK_NUMPAD5, "Num5", L"Num 5"}, + {VK_NUMPAD6, "Num6", L"Num 6"}, {VK_NUMPAD7, "Num7", L"Num 7"}, + {VK_NUMPAD8, "Num8", L"Num 8"}, {VK_NUMPAD9, "Num9", L"Num 9"}, +}; + +bool LetterOrDigit(int vk) { + return (vk >= 'A' && vk <= 'Z') || (vk >= '0' && vk <= '9'); +} + +bool FunctionKey(int vk) { + return vk >= VK_F1 && vk <= VK_F24; +} + +const NamedKey* NamedKeyFor(int vk) { + for (const NamedKey& key : kKeyNames) { + if (key.vk == vk) { + return &key; + } + } + return nullptr; +} + +// The ini name of a bindable key ("None" for 0). +std::string IniName(int vk) { + if (LetterOrDigit(vk)) { + return std::string(1, static_cast(vk)); + } + if (FunctionKey(vk)) { + return "F" + std::to_string(vk - VK_F1 + 1); + } + const NamedKey* key = NamedKeyFor(vk); + return key ? key->ini : "None"; +} int ParseKey(const std::string& text) { if (text.size() == 1 && isalnum(static_cast(text[0]))) { @@ -119,8 +169,10 @@ int ParseKey(const std::string& text) { return VK_F1 + n - 1; } } - if (_stricmp(text.c_str(), "Space") == 0) { - return VK_SPACE; + for (const NamedKey& key : kKeyNames) { + if (_stricmp(text.c_str(), key.ini) == 0) { + return key.vk; + } } return 0; // None / unknown } @@ -145,11 +197,14 @@ const Binding* BindingFor(int vk) { } // Keys taken away from the game although nothing is bound to them: the digit -// row (the game's slot keys) and the movement keys. +// row (the game's slot keys). bool Reserved(int vk) { - return (vk >= '0' && vk <= '9') || mods::wasd::IsMovementKey(vk); + return vk >= '0' && vk <= '9'; } +// The key bindings page's key capture. +void (*g_capture)(int vk) = nullptr; + // Pick up items nearby: the order the engine's A hotkey sends (creature order // 0x113, cEngine::ReceiveEvent 0x618c41; the hero collects, mode +0xfc 0xc), // given to the controlled creature on the next engine tick. (Posting the A @@ -163,8 +218,30 @@ constexpr uint32_t kCollectRingLimit = 0x004ec8c6; constexpr uint8_t kCollectRingsOriginal = 0xec; // -20 constexpr uint8_t kCollectRings = 0xd8; // -40 +// Drink a potion: cEngine::UseActionSlot (0x613040, thiscall (potion kind, +// on an ally), ret 8), as the engine's potion hotkeys call it, on the next +// engine tick. Those hotkeys (cEngine::ReceiveEvent, a jump table on the +// upper-case character - 0x20: index bytes at 0x619adc, targets at 0x619ac0) +// are switched off: Space kind 1 (Shift: 5), B kind 1 on the weakest ally, +// Q 2, W 3, E 4, R 5 now go to index 6, the table's "no hotkey". +constexpr uint32_t kHotkeyIndices = 0x00619adc; +constexpr uint8_t kNoHotkey = 6; +constexpr re::BytePatch kPotionHotkeys[] = { + {kHotkeyIndices + ' ' - 0x20, 0, 0}, {kHotkeyIndices + 'B' - 0x20, 0, 1}, + {kHotkeyIndices + 'E' - 0x20, 0, 2}, {kHotkeyIndices + 'Q' - 0x20, 0, 3}, + {kHotkeyIndices + 'R' - 0x20, 0, 4}, {kHotkeyIndices + 'W' - 0x20, 0, 5}, +}; +std::atomic g_potion{0}; + +void UseActionSlot(uint8_t* engine, int kind, bool ally) { + using UseFn = int(__fastcall*)(void*, void*, int, int); + reinterpret_cast(0x00613040)(engine, nullptr, kind, ally ? 1 : 0); +} void OnTick(uint8_t* engine) { + if (const int potion = g_potion.exchange(0)) { + UseActionSlot(engine, potion & 0xff, (potion & kAllyPotion) != 0); + } if (!g_pickUp.exchange(false)) { return; } @@ -175,6 +252,107 @@ void OnTick(uint8_t* engine) { } } +// The save game window, as the UI manager's S hotkey opens it (0x757775): +// only with the window built (manager slot 0x2b) and no blocking window open; +// it posts an engine event (code 0x13, 5, 1; with 0 for the kernel's second +// argument) and shows UI_WND_SAVEGAME. +constexpr int kSaveGameSlot = 0x2b; +constexpr uint32_t kEngineEventVtable = 0x0088eb38; +constexpr uint32_t kEngineEventSaveGame = 0x13; + +void OpenSaveGame() { + uint32_t* ui = game::UiManager(); + if (!ui || !ui[kSaveGameSlot] || !game::UiCanOpenWindow(ui)) { + return; + } + uint32_t event[9] = {kEngineEventVtable, kEngineEventSaveGame, 5, 1}; + game::PostEvent(event, 0); + game::SendWindowCommand(ui, "UI_WND_SAVEGAME", game::WindowCommand::Show); +} + +// The map (cUI_Megamap, manager slot 0x25), as the UI manager's M hotkey +// toggles it (cUI_Manager::ReceiveEvent 0x757120): open, M closes it; else +// nothing while one of the windows it checks first is open (slots 0x2b save +// game, 0x2c, 0x2f options, or the flag 0x17e82c4); it opens unless the flag +// 0x17e8264 is set or slot 0x36 is open. That hotkey is switched off: its +// letter switch (upper-case character - 'C': index bytes at 0x75868c, targets +// at 0x758660) sends M to index 10, "no hotkey", and the closing branch +// compares with -1 instead of 'M' (cmp eax, 4Dh at 0x757f86). +constexpr int kMegamapSlot = 0x25; +constexpr int kMapBlockingSlots[] = {0x2b, 0x2c, 0x2f}; +constexpr uint32_t kMapBlockedFlag = 0x017e82c4; +constexpr uint32_t kMapDisabledFlag = 0x017e8264; +constexpr int kMapDisabledSlot = 0x36; +constexpr uint32_t kUiLetterIndices = 0x0075868c; +constexpr uint8_t kUiLetterMap = 5; +constexpr uint8_t kUiNoHotkey = 10; +constexpr uint32_t kUiMapCloseCompare = 0x00757f86; +constexpr size_t kControlFlagsOffset = 0x10; // bit 0: visible + +bool WindowShown(const uint32_t* ui, int slot) { + const auto* window = reinterpret_cast(static_cast(ui[slot])); + return window && (*reinterpret_cast(window + kControlFlagsOffset) & 1) != 0; +} + +bool Flag(uint32_t address) { + return *reinterpret_cast(static_cast(address)) != 0; +} + +void ToggleMap() { + uint32_t* ui = game::UiManager(); + if (!ui || !ui[kMegamapSlot]) { + return; + } + if (WindowShown(ui, kMegamapSlot)) { + game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Hide); + return; + } + for (const int slot : kMapBlockingSlots) { + if (WindowShown(ui, slot)) { + return; + } + } + if (!Flag(kMapBlockedFlag) && !Flag(kMapDisabledFlag) && !WindowShown(ui, kMapDisabledSlot)) { + game::SendWindowCommand(ui, "UI_WND_MEGAMAP", game::WindowCommand::Show); + } +} + +// The overview map (cUI_OverviewMap) while the key is held, as the game's +// window procedure (0x8110d0) shows it on Tab down (0x81146d) and hides it on +// Tab up (0x8117f8). Both are switched off: their switches on the virtual key +// - 9 (down: index bytes at 0x812124, targets at 0x812110; up: 0x812270 / +// 0x812260) send Tab to their "other key" index. +constexpr uint32_t kKeyDownIndices = 0x00812124; +constexpr uint32_t kKeyUpIndices = 0x00812270; +constexpr uint8_t kKeyDownOther = 4; +constexpr uint8_t kKeyUpOther = 3; + +void ShowOverviewMap(bool show) { + if (uint32_t* ui = game::UiManager()) { + game::SendWindowCommand(ui, "UI_WND_OVERVIEWMAP", + show ? game::WindowCommand::Show : game::WindowCommand::Hide); + } +} + +// Labels: the game asks GetAsyncKeyState(VK_MENU) for its name overlay +// (cWorldView0::DrawObjectLabels 0x627260 and PickObjectAt 0x626d30, cEngine +// 0x60f1d0 / 0x611f10 / 0x617360, cCreature 0x599880; and the map's debug +// teleport, cUI_Megamap 0x6c7ec0); those calls (call [import]) ask +// LabelsKeyState instead. +constexpr uint32_t kGetAsyncKeyStateImport = 0x0088e2b0; +constexpr uint32_t kLabelsKeyCalls[] = {0x00599ebf, 0x0060f227, 0x00612015, 0x006176ec, + 0x00626ffe, 0x00627299, 0x006c80b0}; + +SHORT WINAPI LabelsKeyState(int) { + for (const Binding& binding : g_bindings) { + if (binding.kind == Kind::Labels) { + return binding.vk ? GetAsyncKeyState(binding.vk) : 0; + } + } + return 0; +} +SHORT(WINAPI* const g_labelsKeyState)(int) = &LabelsKeyState; + struct Registration { Registration() { game::OnEngineTick(&OnTick); @@ -190,12 +368,24 @@ void Press(const Binding& binding, int vk) { case Kind::WeaponSet: mods::skillbar::SelectWeaponSet(slot); break; - case Kind::GameKey: - PostGameKey(binding.gameKey, true); + case Kind::SaveGame: + OpenSaveGame(); break; case Kind::PickUp: g_pickUp = true; break; + case Kind::Potion: + g_potion = binding.index; + break; + case Kind::Map: + ToggleMap(); + break; + case Kind::OverviewMap: + ShowOverviewMap(true); + break; + case Kind::Move: + case Kind::Labels: + break; } } @@ -204,11 +394,16 @@ void Release(const Binding& binding, int vk) { case Kind::SkillSlot: mods::skillkeys::KeyReleased(vk); break; - case Kind::WeaponSet: - case Kind::PickUp: + case Kind::OverviewMap: + ShowOverviewMap(false); break; - case Kind::GameKey: - PostGameKey(binding.gameKey, false); + case Kind::WeaponSet: + case Kind::Move: + case Kind::Potion: + case Kind::PickUp: + case Kind::SaveGame: + case Kind::Map: + case Kind::Labels: break; } } @@ -222,6 +417,17 @@ bool g_swallowChar = false; void Startup() { LoadBindings(); + if (re::PatchAll(kPotionHotkeys, kNoHotkey) != 0) { + re::Log("input: potion hotkey patch FAILED"); + } + if (!re::PatchInstructionByte(kUiLetterIndices + 'M' - 'C', 0, kUiLetterMap, kUiNoHotkey) || + !re::PatchInstructionByte(kUiMapCloseCompare, 2, 'M', 0xff) || + !re::PatchInstructionByte(kKeyDownIndices + VK_TAB - 9, 0, 0, kKeyDownOther) || + !re::PatchInstructionByte(kKeyUpIndices + VK_TAB - 9, 0, 0, kKeyUpOther) || + re::PatchAll(kLabelsKeyCalls, kGetAsyncKeyStateImport, + static_cast(reinterpret_cast(&g_labelsKeyState))) != 0) { + re::Log("input: map / overview map / labels key patch FAILED"); + } if (!re::PatchInstructionByte(kCollectRingLimit, 2, kCollectRingsOriginal, kCollectRings)) { re::Log("input: pickup range patch FAILED"); } @@ -252,7 +458,7 @@ uint32_t ControlFlags(const uint8_t* control) { } uint8_t* FocusedTextField() { - auto* ui = *reinterpret_cast(kUiManager); + auto* ui = reinterpret_cast(game::UiManager()); void* focus = ui ? *reinterpret_cast(ui + kUiFocus) : nullptr; if (!focus || focus != g_drawnEdit.load() || GetTickCount() - g_drawnEditTick.load() > kDrawnRecentlyMs) { return nullptr; @@ -269,7 +475,7 @@ uint8_t* FocusedTextField() { // Only the focused control counts: a window draws several edit controls per // frame (measured: the save window), the last one drawn is not the focus. void NoteEditDrawn(void* edit) { - auto* ui = *reinterpret_cast(kUiManager); + auto* ui = reinterpret_cast(game::UiManager()); if (ui && edit == *reinterpret_cast(ui + kUiFocus) && (ControlFlags(static_cast(edit)) & kControlVisible) != 0) { g_drawnEdit = edit; @@ -281,11 +487,119 @@ bool TypingIntoTextField() { return FocusedTextField() != nullptr; } +int MovementKey(Move direction) { + for (const Binding& binding : g_bindings) { + if (binding.kind == Kind::Move && binding.index == static_cast(direction)) { + return binding.vk; + } + } + return 0; +} + +int BindingCount() { + return kBindingCount; +} + +std::wstring BindingLabel(int index) { + const Binding& binding = g_bindings[index]; + if (binding.kind != Kind::Potion) { + return binding.label; + } + // The potion kind's name as the game shows it ("Trank N" until known). + std::wstring name = game::PotionKindName(binding.index & 0xff); + if (name.empty()) { + name = binding.label; + } + return (binding.index & kAllyPotion) ? name + L" für Gefährten" : name; +} + +int PotionKey(int kind) { + for (const Binding& binding : g_bindings) { + if (binding.kind == Kind::Potion && binding.index == kind) { + return binding.vk; + } + } + return 0; +} + +const wchar_t* BindingGroup(int index) { + switch (g_bindings[index].kind) { + case Kind::SkillSlot: + return L"Kampfkünste"; + case Kind::WeaponSet: + return L"Waffensets"; + case Kind::Move: + return L"Bewegung"; + case Kind::Potion: + return L"Tränke"; + case Kind::PickUp: + case Kind::SaveGame: + case Kind::Map: + case Kind::OverviewMap: + case Kind::Labels: + break; + } + return L"Aktionen"; +} + +int BindingKey(int index) { + return g_bindings[index].vk; +} + +int DefaultBindingKey(int index) { + return ParseKey(g_bindings[index].fallback); +} + +void SetBindingKeys(const int* keys) { + for (int i = 0; i < kBindingCount; ++i) { + Binding& binding = g_bindings[i]; + binding.vk = Bindable(keys[i]) ? keys[i] : 0; + re::SetConfigString("keys", binding.name, IniName(binding.vk).c_str()); + } + re::Log("input: key bindings saved"); +} + +bool Bindable(int vk) { + return LetterOrDigit(vk) || FunctionKey(vk) || NamedKeyFor(vk); +} + +std::wstring KeyLabel(int vk) { + if (LetterOrDigit(vk)) { + return std::wstring(1, static_cast(vk)); + } + if (FunctionKey(vk)) { + return L"F" + std::to_wstring(vk - VK_F1 + 1); + } + const NamedKey* key = NamedKeyFor(vk); + return key ? key->label : L""; +} + +void SetKeyCapture(void (*handler)(int vk)) { + g_capture = handler; +} + bool HandleWindowMessage(HWND, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { + case WM_SYSKEYDOWN: + // Alt and F10 arrive as system keys: only the key capture takes them. + if (g_capture) { + if ((lParam & (1 << 30)) == 0) { + g_capture(static_cast(wParam & 0xff)); + } + return true; + } + return false; case WM_KEYDOWN: { const int vk = static_cast(wParam & 0xff); const bool repeat = (lParam & (1 << 30)) != 0; + if (g_capture) { + g_taken[vk] = true; + g_swallowChar = true; + if (!repeat) { + g_capture(vk); + } + return true; + } if (!repeat) { g_taken[vk] = false; if (FocusedTextField()) { diff --git a/src/mods/input.h b/src/mods/input.h index 46cbaf8..89c2d38 100644 --- a/src/mods/input.h +++ b/src/mods/input.h @@ -1,5 +1,7 @@ #pragma once +#include + #include namespace mods::input { @@ -14,4 +16,32 @@ void NoteEditDrawn(void* edit); // handled and must not reach the game. bool HandleWindowMessage(HWND window, UINT message, WPARAM wParam, LPARAM lParam); +// The movement keys (polled by mods/wasd.cpp); 0 when unbound. +enum class Move { Up, Left, Down, Right }; +int MovementKey(Move direction); + +// The key bound to drinking potion kind `kind` (1..5); 0 when unbound. +int PotionKey(int kind); + +// --- key bindings page (mods/options.cpp) -------------------------------------- + +// The bindable actions in display order, grouped (a group's actions follow +// each other). +int BindingCount(); +std::wstring BindingLabel(int index); // potions: the game's name of the kind +const wchar_t* BindingGroup(int index); +int BindingKey(int index); // virtual key, 0 = none +int DefaultBindingKey(int index); + +// Sets all bindings (`keys[BindingCount()]`) and writes them to sacred_re.ini. +void SetBindingKeys(const int* keys); + +// Whether `vk` can be bound, and its name as shown ("Leertaste", "F1", ...). +bool Bindable(int vk); +std::wstring KeyLabel(int vk); + +// While set, every key press goes to `handler` (on the window thread) instead +// of the game or the bindings; its key messages are swallowed. +void SetKeyCapture(void (*handler)(int vk)); + } // namespace mods::input diff --git a/src/mods/options.cpp b/src/mods/options.cpp new file mode 100644 index 0000000..4d5f899 --- /dev/null +++ b/src/mods/options.cpp @@ -0,0 +1,616 @@ +// Options window in three tabs: Allgemein, Beute, Tastenbelegung (request of +// 2026-10-18). 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 sacred_re.ini. +// +// The window (cUI_Options, vtable 0x89505c, constructor 0x716570 building its +// controls in 0x7184e0) is laid out for 1024x768: six frames in two columns +// (frame x 0x40 / 0x220, rows y 0x40 / 0x120 / 0x200, a frame label above +// each), OK / Cancel at the bottom right. All its controls are in the vector +// at +0x90. A cUI_TabSheet of the game's (the inventory's header look) above +// the frames switches between pages: each control belongs to the page of the +// frame it sits in (by position), hidden controls draw nothing and take no +// events (frames have their own draw flag, see kFrameDrawn). The auto-pickup +// frame (left column, middle row) moves to the top row of the Beute page, the +// general frame below it moves up into its place. +// +// The key bindings page is drawn here: a row per binding with its key in a +// box; a click on the box waits for a key (Esc: cancel, Backspace: no key), +// a key bound to another action is taken from it. OK saves the bindings +// (cUI_Options apply 0x717460), Cancel drops the changes. See docs/options.md. + +#include +#include +#include +#include +#include + +#include + +#include "game/ui/control.h" +#include "game/ui/draw.h" +#include "game/ui/events.h" +#include "game/ui/tabs.h" +#include "game/ui/text.h" +#include "game/ui/texts.h" +#include "mods/hud.h" +#include "mods/input.h" +#include "re/hook.h" +#include "re/log.h" + +namespace mods::options { +namespace { + +// --- cUI_Options ----------------------------------------------------------------- +constexpr size_t kWindowControls = 0x90; // vector of its controls: begin, +4 end +constexpr size_t kCancelButton = 0x158; +constexpr size_t kOkButton = 0x15c; +// 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; +// 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) +// 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. +constexpr int kColumnSplit = 0x200; // left column frames end at x 0x1d4 +constexpr int kMiddleRowTop = 0xf8; // auto-pickup label y 0x100, frame from y 0x10c +constexpr int kBottomRowTop = 0x1d8; // general label y 0x1e0, frame from y 0x1ec +constexpr int kRowStep = 0x120 - 0x40; // middle row frame y - top row frame y + +// The tab headers sit 0x14 + their height above the sheet (cUI_TabSheet::Layout +// 0x729210); with the sheet at the top frames' border they end at y 0x18, +// 8 pixels above the frame labels. The pages move down for some room. +constexpr game::UiRect kSheetRect = {0x2c, 0x2c, 0, 0}; +constexpr int kContentShift = 24; + +enum Page : uint16_t { kPageCommon, kPageLoot, kPageKeys }; +enum class Group { Always, Common, Loot, Keys }; + +struct Control { + uint8_t* control; + Group group; + bool hiddenByUs = false; +}; + +struct Window { + uint8_t* window = nullptr; + void* sheet = nullptr; + uint8_t* resetButton = nullptr; + std::vector controls; + bool wasVisible = false; +}; + +std::vector g_windows; + +Window* Find(void* window) { + for (Window& w : g_windows) { + if (w.window == window) { + return &w; + } + } + return nullptr; +} + +int32_t& ControlX(uint8_t* control) { + return *reinterpret_cast(control + game::kControlRect); +} +int32_t& ControlY(uint8_t* control) { + return *reinterpret_cast(control + game::kControlRect + 4); +} + +bool IsFrame(const uint8_t* control) { + return *reinterpret_cast(control) == kFrameVtable; +} + +// cUI_Scrollbar (vtable 0x895664, the volume and map transparency sliders): +// its parts are embedded controls with rects in the window's coordinates, +// placed once when it is made; its render (0x7527c0) puts the knob at +// +0x78 / +0x7c (track start) plus the value each frame. +constexpr uint32_t kScrollbarVtable = 0x00895664; +constexpr size_t kScrollbarParts[] = {0x94, 0x110, 0x18c, 0x208}; // minus, plus, track, knob +constexpr size_t kScrollbarKnobY = 0x7c; + +void MoveDown(uint8_t* control, int dy) { + ControlY(control) += dy; + if (*reinterpret_cast(control) == kScrollbarVtable) { + for (const size_t part : kScrollbarParts) { + ControlY(control + part) += dy; + } + *reinterpret_cast(control + kScrollbarKnobY) += dy; + } +} + +uint8_t* Field(uint8_t* window, size_t offset) { + return *reinterpret_cast(window + offset); +} + +// Sorts the controls into pages, moves the auto-pickup and general frames. +void Classify(Window& w) { + auto** begin = *reinterpret_cast(w.window + kWindowControls); + auto** end = *reinterpret_cast(w.window + kWindowControls + 4); + const uint8_t* ok = Field(w.window, kOkButton); + const uint8_t* cancel = Field(w.window, kCancelButton); + for (auto** it = begin; it && it != end; ++it) { + uint8_t* control = *it; + if (!control) { + continue; + } + const int x = ControlX(control); + const int y = ControlY(control); + Group group = Group::Common; + if (control == ok || control == cancel) { + group = Group::Always; + } else if (x < kColumnSplit && y >= kMiddleRowTop && y < kBottomRowTop) { + group = Group::Loot; + MoveDown(control, -kRowStep); + } else if (x < kColumnSplit && y >= kBottomRowTop) { + MoveDown(control, -kRowStep); + } + if (group != Group::Always) { + MoveDown(control, kContentShift); + } + w.controls.push_back({control, group}); + } +} + +void CreateTabs(Window& w) { + static const uint32_t kNames[3] = {game::RegisterText(L"Allgemein"), game::RegisterText(L"Beute"), + game::RegisterText(L"Tastenbelegung")}; + static const char* const kPageNames[3] = {"UI_TABSHEET_OPTIONS_COMMON", "UI_TABSHEET_OPTIONS_LOOT", + "UI_TABSHEET_OPTIONS_KEYS"}; + w.sheet = game::NewTabSheet("UI_TABSHEET_OPTIONS", kSheetRect, w.window); + if (!w.sheet) { + return; + } + // Pages: empty controls; the window's own controls are shown and hidden. + for (int page = 0; page < 3; ++page) { + game::AddTab(w.sheet, kNames[page], game::NewTabSheet(kPageNames[page], {0, 0, 0, 0}, w.sheet)); + } + game::SetActiveTab(w.sheet, kPageCommon); + game::AddWindowControl(w.window, w.sheet); +} + +bool OnPage(Group group, uint16_t page) { + return group == Group::Always || (group == Group::Common && page == kPageCommon) || + (group == Group::Loot && page == kPageLoot) || (group == Group::Keys && page == kPageKeys); +} + +// Every frame: the game shows its controls again when the window opens. +void ShowPage(Window& w, uint16_t page) { + for (Control& c : w.controls) { + const bool show = OnPage(c.group, page); + if (!show && game::ControlShown(c.control)) { + game::SetControlShown(c.control, false); + c.hiddenByUs = true; + } else if (show && c.hiddenByUs) { + game::SetControlShown(c.control, true); + c.hiddenByUs = false; + } + if (IsFrame(c.control)) { + c.control[kFrameDrawn] = show ? 1 : 0; + } + } +} + +// --- key bindings page --------------------------------------------------------- + +constexpr int kMaxBindings = 64; +int g_pending[kMaxBindings] = {}; // the page's keys until OK +bool g_pendingLoaded = false; +std::atomic g_capturing{-1}; // binding waiting for a key +int g_hover = -1; // binding whose box is under the mouse + +constexpr uint16_t kFont = 1; +constexpr uint32_t kTextColour = 0xffe6d6aa; // as the tab headers' normal text (cUI_TabControl::Render) +constexpr uint32_t kHeadingColour = 0xfff0c060; +constexpr uint32_t kUnboundColour = 0xffb4b4b4; +constexpr uint32_t kBoxColour = 0xc0141414; +constexpr uint32_t kBoxHoverColour = 0xc0343434; +constexpr uint32_t kBoxCaptureColour = 0xc0604818; + +// Two framed columns, groups kept together (window coordinates). The frames +// line up with the other pages': from the top row's frame top to the bottom +// of the lowest frame (Internet-Optionen, y 0x200, height 0x40). +constexpr int kFrameX[2] = {0x40, 0x220}; +constexpr int kFrameWidth = 0x180; +constexpr int kFrameTop = 0x40 + kContentShift; +constexpr int kFrameHeight = 0x200 + 0x40 + kContentShift - kFrameTop; +constexpr int kTextInset = 0x10; // as the game's labels in its frames +constexpr int kLine = 25; +constexpr int kLinesPerColumn = 20; +constexpr int kBoxHeight = 22; +constexpr int kTop = kFrameTop + (kFrameHeight - (kLinesPerColumn * kLine - (kLine - kBoxHeight))) / 2; +constexpr int kBoxWidth = 0x80; +constexpr int kBoxX = kFrameWidth - 2 * kTextInset - kBoxWidth; // from the column +constexpr int kLabelWidth = kBoxX - 8; +// The bottom row: the hints (they hold for the whole page), then "Alle +// zurücksetzen", a button like Cancel (x 0x25c, y 0x284) and OK next to them. +// The buttons' ornaments reach kButtonOrnament beyond their rects (measured +// in a screenshot). +constexpr int kBottomY = 0x284; +constexpr int kBottomHeight = 37; +constexpr int kCancelX = 0x25c; +constexpr int kButtonOrnament = 0x21; +constexpr int kResetWidth = 140; // fits its caption +constexpr int kResetX = kCancelX - 2 * kButtonOrnament - 12 - kResetWidth; +// The hints box: from the frames backdrop edge, a little taller than the row. +constexpr int kHintX = 0x40 - kFrameBorder; +constexpr int kHintMargin = 4; +constexpr game::UiRect kHintRect = {kHintX, kBottomY - kHintMargin, kResetX - kButtonOrnament - 8 - kHintX, + kBottomHeight + 2 * kHintMargin}; +const wchar_t* const kHints[2] = {L"Feld anklicken, dann Taste drücken.", L"Esc: abbrechen, Rücktaste: leeren."}; + +enum class LineKind { Heading, Binding }; + +struct Line { + LineKind kind; + int binding; // Binding: its index + std::wstring text; + int column; + int line; +}; + +std::vector Layout() { + std::vector lines; + const int count = std::min(input::BindingCount(), kMaxBindings); + int column = 0; + int line = 0; + for (int i = 0; i < count;) { + const wchar_t* group = input::BindingGroup(i); + int size = 0; + while (i + size < count && input::BindingGroup(i + size) == group) { + ++size; + } + if (column == 0 && line > 0 && line + 1 + size > kLinesPerColumn) { + column = 1; + line = 0; + } + lines.push_back({LineKind::Heading, -1, group, column, line++}); + for (int k = 0; k < size; ++k) { + lines.push_back({LineKind::Binding, i + k, input::BindingLabel(i + k), column, line++}); + } + ++line; // gap between groups + i += size; + } + return lines; +} + +game::UiRect LineRect(void* window, const Line& line, int dx, int width) { + const game::UiRect origin = game::ControlScreenRect(window); + return {origin.x + kFrameX[line.column] + kTextInset + dx, origin.y + kTop + line.line * kLine, + static_cast(width), static_cast(kBoxHeight)}; +} + +game::UiRect BoxRect(void* window, const Line& line) { + return LineRect(window, line, kBoxX, kBoxWidth); +} + +game::UiRect OnWindow(void* window, const game::UiRect& r) { + const game::UiRect origin = game::ControlScreenRect(window); + return {origin.x + r.x, origin.y + r.y, r.w, r.h}; +} + +bool Inside(const game::UiRect& r, int x, int y) { + return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h; +} + +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); +} + +void StopCapture() { + g_capturing = -1; + input::SetKeyCapture(nullptr); +} + +// The key pressed while a box waits (window thread). +void OnCapturedKey(int vk) { + const int row = g_capturing; + if (row < 0) { + StopCapture(); + return; + } + if (vk == VK_ESCAPE) { + StopCapture(); + } else if (vk == VK_BACK) { + g_pending[row] = 0; + StopCapture(); + } else if (input::Bindable(vk)) { + for (int& key : g_pending) { + if (key == vk) { + key = 0; + } + } + g_pending[row] = vk; + StopCapture(); + } +} + +void LoadPending() { + const int count = std::min(input::BindingCount(), kMaxBindings); + for (int i = 0; i < count; ++i) { + g_pending[i] = input::BindingKey(i); + } + g_pendingLoaded = true; +} + +void DrawKeysPage(const Window& w, void* device) { + void* window = w.window; + for (const Line& line : Layout()) { + if (line.kind != LineKind::Binding) { + const game::UiRect rect = LineRect(window, line, 0, kBoxX + kBoxWidth); + game::DrawText(device, kFont, game::CenterLineVertically(kFont, rect), line.text.c_str(), kHeadingColour, + 0); + continue; + } + const game::UiRect label = LineRect(window, line, 0, kLabelWidth); + game::DrawText(device, kFont, game::CenterLineVertically(kFont, label), line.text.c_str(), kTextColour, 0); + const game::UiRect box = BoxRect(window, line); + const bool capturing = g_capturing == line.binding; + const uint32_t fill = capturing ? kBoxCaptureColour : g_hover == line.binding ? kBoxHoverColour : kBoxColour; + FillBox(device, box, fill); + const int vk = g_pending[line.binding]; + const std::wstring key = capturing ? L"Taste drücken" : vk ? input::KeyLabel(vk) : L"–"; + game::DrawText(device, kFont, game::CenterLineVertically(kFont, box), key.c_str(), + vk || capturing ? kTextColour : kUnboundColour, game::kTextCenter); + } + const game::UiRect hints = OnWindow(window, kHintRect); + FillBox(device, hints, kBoxColour); + const int lineHeight = game::FontLineHeight(kFont); + game::UiRect hint = {hints.x, hints.y + (hints.h - 2 * lineHeight) / 2, hints.w, static_cast(lineHeight)}; + for (const wchar_t* text : kHints) { + game::DrawText(device, kFont, hint, text, kTextColour, game::kTextCenter); + hint.y += lineHeight; + } +} + +// "Alle zurücksetzen": every binding back to its default (saved with OK). +void ResetPending() { + const int count = std::min(input::BindingCount(), kMaxBindings); + for (int i = 0; i < count; ++i) { + g_pending[i] = input::DefaultBindingKey(i); + } +} + +// The binding whose box is at (x, y), -1 if none. +int BoxAt(void* window, int x, int y) { + for (const Line& line : Layout()) { + if (line.kind == LineKind::Binding && Inside(BoxRect(window, line), x, y)) { + return line.binding; + } + } + return -1; +} + +// cUI_Button2 (vtable 0x895308, 0xbc bytes, constructor 0x72a180, plain +// ret). 0x7184e0 sets up Cancel and OK the same way: images (+0x54..+0x87), +// sounds (0x72acf0 -> +0x90..+0x9f), caption (0x72ad60 (text id, font 1) -> +// +0xa4 / +0xa0), caption offset (0x72ad40 -> +0xb0), name (0x732090). +constexpr size_t kButtonSize = 0xbc; +constexpr size_t kButtonLook = 0x54; +constexpr size_t kButtonLookEnd = 0x88; +constexpr size_t kButtonSounds = 0x90; +constexpr size_t kButtonSoundsEnd = 0xa0; +constexpr size_t kButtonCaptionOffset = 0xb0; // x, y (int16) +constexpr uint32_t kButtonFlags = 5; // 0x731e70, as 0x7184e0 passes + +void PushControl(uint8_t* window, void* control) { + using PushFn = void(__fastcall*)(void*, void*, void**); + reinterpret_cast(0x006bcf50)(window + kWindowControls, nullptr, &control); +} + +// "Alle zurücksetzen": a button with Cancel's look. +uint8_t* CreateResetButton(uint8_t* window) { + static const uint32_t kCaption = game::RegisterText(L"Alle zurücksetzen"); + uint8_t* cancel = Field(window, kCancelButton); + void* memory = cancel ? game::GameNew(kButtonSize) : nullptr; + if (!memory) { + return nullptr; + } + using ConstructFn = uint8_t*(__fastcall*)(void*, void*); + uint8_t* button = reinterpret_cast(0x0072a180)(memory, nullptr); + using FlagsFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(0x00731e70)(button, nullptr, kButtonFlags); + std::copy(cancel + kButtonLook, cancel + kButtonLookEnd, button + kButtonLook); + std::copy(cancel + kButtonSounds, cancel + kButtonSoundsEnd, button + kButtonSounds); + std::copy(cancel + kButtonCaptionOffset, cancel + kButtonCaptionOffset + 4, button + kButtonCaptionOffset); + auto* rect = reinterpret_cast(button + game::kControlRect); + *rect = *reinterpret_cast(cancel + game::kControlRect); + rect->x = kResetX; + rect->w = kResetWidth; + using CaptionFn = void(__fastcall*)(void*, void*, uint32_t, uint32_t); + reinterpret_cast(0x0072ad60)(button, nullptr, kCaption, 1); + using NameFn = void(__fastcall*)(void*, void*, const char*); + reinterpret_cast(0x00732090)(button, nullptr, "UI_BTN_KEYS_RESET"); + PushControl(window, button); + return button; +} + +// The key bindings page's own controls, shown only there: a backdrop and +// frame around each column, the reset button. +void CreateKeyControls(Window& w) { + static const char* const kFrameNames[2] = {"UI_FRAME_KEYS_LEFT", "UI_FRAME_KEYS_RIGHT"}; + auto* controls = w.window + kWindowControls; + const auto existing = static_cast(*reinterpret_cast(controls + 4) - + *reinterpret_cast(controls)); + for (int column = 0; column < 2; ++column) { + game::UiRect frame = {kFrameX[column], kFrameTop, kFrameWidth, kFrameHeight}; + game::UiRect backdrop = {frame.x - kFrameBorder, frame.y - kFrameBorder, + static_cast(frame.w + 2 * kFrameBorder), + static_cast(frame.h + 2 * kFrameBorder)}; + using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t); + reinterpret_cast(0x00724ed0)(w.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(w.window, reinterpret_cast(0x00739f90)(memory, nullptr, kFrameNames[column], + kMainFrame, &frame, 1)); + } + } + w.resetButton = CreateResetButton(w.window); + // Everything after the window's own controls is ours. + auto** begin = *reinterpret_cast(controls); + auto** end = *reinterpret_cast(controls + 4); + for (auto** it = begin + existing; it < end; ++it) { + if (*it) { + w.controls.push_back({*it, Group::Keys}); + } + } +} + +// --- hooks --------------------------------------------------------------------- + +struct RenderCall { + uint8_t* window; + void* device; + int a; + int b; + int(__fastcall* original)(void*, void*, void*, int, int); +}; + +int RenderBody(void* context) { + auto* call = static_cast(context); + const int result = call->original(call->window, nullptr, call->device, call->a, call->b); + Window* w = Find(call->window); + if (!w || !w->sheet) { + return result; + } + const bool visible = game::ControlShown(call->window); + if (visible && !w->wasVisible) { + LoadPending(); + } + w->wasVisible = visible; + const uint16_t page = game::ActiveTab(w->sheet); + if (!visible || page != kPageKeys) { + if (g_capturing >= 0) { + StopCapture(); + } + g_hover = -1; + } + if (!visible) { + return result; + } + ShowPage(*w, page); + if (page == kPageKeys && g_pendingLoaded) { + DrawKeysPage(*w, call->device); + } + return result; +} + +struct EventCall { + uint8_t* window; + void* event; + int(__fastcall* original)(void*, void*, void*); +}; + +int EventBody(void* context) { + auto* call = static_cast(context); + Window* w = Find(call->window); + if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys && + g_pendingLoaded) { + switch (game::MouseEventKind(call->event)) { + case game::MouseEvent::Move: { + const int x = game::EventField(call->event, 0xc); + const int y = game::EventField(call->event, 0x10); + g_hover = BoxAt(call->window, x, y); + break; + } + case game::MouseEvent::ButtonDown: { + const int x = game::EventField(call->event, game::kMouseButtonX); + const int y = game::EventField(call->event, game::kMouseButtonY); + const bool left = game::EventField(call->event, game::kMouseButton) == game::kLeftButton; + if (g_capturing >= 0) { + StopCapture(); + } + if (left && w->resetButton && Inside(game::ControlScreenRect(w->resetButton), x, y)) { + ResetPending(); // the button still gets the click (pressed look, sound) + break; + } + const int box = BoxAt(call->window, x, y); + if (left && box >= 0) { + g_capturing = box; + input::SetKeyCapture(&OnCapturedKey); + return 1; + } + break; + } + default: + break; + } + } + return call->original(call->window, nullptr, call->event); +} + +} // namespace +} // namespace mods::options + +namespace game { + +using namespace mods::options; + +// cUI_Options::cUI_Options (0x716570, ret 0x18): (name, rect, parent, flags). +void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent, + uint32_t flags); +RE_HOOK(0x00716570, UiOptions_Construct); +void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, UiRect rect, void* parent, + uint32_t flags) { + void* window = UiOptions_Construct_original(self, edx, name, rect, parent, flags); + if (window) { + g_windows.push_back({static_cast(window)}); + Window& w = g_windows.back(); + Classify(w); + CreateKeyControls(w); + CreateTabs(w); + re::Log("options: tabs created (%u controls)", static_cast(w.controls.size())); + } + return window; +} + +// cUI_Options destructor (0x7165f0, thiscall, plain ret). +void __fastcall UiOptions_Destruct(void* self, void* edx); +RE_HOOK(0x007165f0, UiOptions_Destruct); +void __fastcall UiOptions_Destruct(void* self, void* edx) { + std::erase_if(g_windows, [self](const Window& w) { return w.window == self; }); + UiOptions_Destruct_original(self, edx); +} + +// cUI_Options::vf44 (0x716640, ret 0xc): renders the window (cUI_Window2 +// render first), drives the sliders; black beside the frame like the +// save game window. +int __fastcall UiOptions_Render(void* self, void* edx, void* device, int a, int b); +RE_HOOK(0x00716640, UiOptions_Render); +int __fastcall UiOptions_Render(void* self, void*, void* device, int a, int b) { + RenderCall call{static_cast(self), device, a, b, UiOptions_Render_original}; + return mods::hud::RenderFramed(self, device, &RenderBody, &call); +} + +// cUI_Options::HandleEvent (0x716b30, ret 4); centred like the other windows. +int __fastcall UiOptions_HandleEvent(void* self, void* edx, void* event); +RE_HOOK(0x00716b30, UiOptions_HandleEvent); +int __fastcall UiOptions_HandleEvent(void* self, void*, void* event) { + EventCall call{static_cast(self), event, UiOptions_HandleEvent_original}; + return mods::hud::HandleCentred(event, &EventBody, &call); +} + +// cUI_Options apply (0x717460, thiscall, plain ret): OK writes the settings. +void __fastcall UiOptions_Apply(void* self, void* edx); +RE_HOOK(0x00717460, UiOptions_Apply); +void __fastcall UiOptions_Apply(void* self, void* edx) { + UiOptions_Apply_original(self, edx); + if (g_pendingLoaded) { + mods::input::SetBindingKeys(g_pending); + } +} + +} // namespace game diff --git a/src/mods/potioncounts.cpp b/src/mods/potioncounts.cpp index ce6de87..67dd1b2 100644 --- a/src/mods/potioncounts.cpp +++ b/src/mods/potioncounts.cpp @@ -9,13 +9,21 @@ // control behind it (+0x3ac). GetPotionRef walks the inventory grid and asks // ItemPotionKind for each item's kind; the count here does the same and adds // up the potion stacks (itemstack.cpp). +// +// Their tooltips (game::PotionTooltipText, set on the slots by the taskbar, +// 0x6e6060; shown as "\dFFFFFF80Heiltrank \wFFFFFFFF[SPC]") name the key +// bound to the kind (mods/input.cpp) instead of the game's own, which is +// switched off; no key part while unbound. #include #include +#include #include "game/engine/items.h" #include "game/engine/objects.h" #include "game/ui/text.h" +#include "game/ui/texts.h" +#include "mods/input.h" #include "mods/itemstack.h" #include "mods/skillslots.h" #include "re/hook.h" @@ -59,6 +67,26 @@ void RenderCounts(uint8_t* taskbar, void* renderer) { } } +// The game's tooltip with its "[key]" end replaced by the bound key's. +std::wstring TooltipWithKey(uint32_t text, int vk) { + std::wstring tooltip = game::ShownText(text); + if (const size_t key = tooltip.rfind(L'['); key != std::wstring::npos && tooltip.back() == L']') { + tooltip.erase(key); + } + if (vk) { + tooltip += L"[" + input::KeyLabel(vk) + L"]"; + } + return tooltip; +} + +void UpdateTooltips() { + for (int kind = 1; kind <= game::kPotionKinds; ++kind) { + if (const uint32_t text = game::PotionTooltipText(kind)) { + game::SetTextOverride(text, TooltipWithKey(text, input::PotionKey(kind))); + } + } +} + } // namespace } // namespace mods::potioncounts @@ -69,5 +97,6 @@ void __fastcall UiTaskbar_RenderPotionSlots(void* self, void* edx, void* rendere UiTaskbar_RenderPotionSlots_original(self, edx, renderer); if (self) { mods::potioncounts::RenderCounts(static_cast(self), renderer); + mods::potioncounts::UpdateTooltips(); } } diff --git a/src/mods/respec.cpp b/src/mods/respec.cpp index 3fe9014..590002d 100644 --- a/src/mods/respec.cpp +++ b/src/mods/respec.cpp @@ -30,6 +30,7 @@ #include "game/engine/arts.h" #include "game/engine/objects.h" #include "game/engine/skills.h" +#include "game/ui/draw.h" #include "game/ui/events.h" #include "game/ui/text.h" #include "mods/hud.h" @@ -182,37 +183,14 @@ void Revert(uint8_t* panel, uint8_t* arts, int row) { re::Log("respec: skill %x removed, %u to choose", skill, *reinterpret_cast(arts + kSkillChoices)); } -// IDirect3DDevice7 as the UI's render methods get it (their vf14 argument). -constexpr int kDeviceDrawPrimitive = 25; -constexpr int kDeviceSetTexture = 35; -constexpr DWORD kTriangleStrip = 5; -constexpr DWORD kFvfScreenColourTexture = 0x1c4; // XYZRHW | DIFFUSE | SPECULAR | TEX1 -struct Vertex { - float x, y, z, rhw; - uint32_t diffuse, specular; - float u, v; -}; - // Turns the + that `minus` drew into a −. void CoverArm(void* device, uint8_t* minus) { const game::UiRect rect = game::ControlScreenRect(minus); const float sx = rect.w / kIconSize; const float sy = rect.h / kIconSize; - 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); for (const Patch& patch : kArmPatches) { - const float x0 = rect.x + patch.x0 * sx; - const float x1 = rect.x + patch.x1 * sx; - const float y0 = rect.y + patch.y0 * sy; - const float y1 = rect.y + patch.y1 * sy; - Vertex quad[4] = {{x0, y0, 0, 1, kDiscGrey, 0, 0, 0}, - {x1, y0, 0, 1, kDiscGrey, 0, 1, 0}, - {x0, y1, 0, 1, kDiscGrey, 0, 0, 1}, - {x1, y1, 0, 1, kDiscGrey, 0, 1, 1}}; - reinterpret_cast(vtable[kDeviceDrawPrimitive])(device, kTriangleStrip, - kFvfScreenColourTexture, quad, 4, 0); + game::FillRect(device, rect.x + patch.x0 * sx, rect.y + patch.y0 * sy, rect.x + patch.x1 * sx, + rect.y + patch.y1 * sy, kDiscGrey); } } diff --git a/src/mods/teleportpotion.cpp b/src/mods/teleportpotion.cpp index b06bb57..9d1ff68 100644 --- a/src/mods/teleportpotion.cpp +++ b/src/mods/teleportpotion.cpp @@ -34,6 +34,7 @@ #include "mods/skillslots.h" #include "re/hook.h" #include "re/log.h" +#include "re/patch.h" namespace mods::teleportpotion { namespace { @@ -109,36 +110,18 @@ void OnTick(uint8_t*) { } } -// The yellow potions' names become kName in every text (names, the bottom -// bar's tooltip): the shortest name, so stage words in front ("Großer ...") -// stay, and any name that does not contain it. -void OnGameStart() { - static bool renamed = false; - if (renamed) { - return; +// Every kind 5 potion is a Teleportationstrank in every text, from the start: +// the yellow ones ("Trank der Konzentration", also the bottom bar's tooltip) +// and the stamina ones, kind 5 as well ("Schwacher / Starker / Kompletter +// Ausdauertrank", measured 2026-10-19); stage words in front stay. +constexpr const wchar_t* kOriginalNames[] = {L"Trank der Konzentration", L"Ausdauertrank"}; + +void Startup() { + for (const wchar_t* name : kOriginalNames) { + game::ReplaceInTexts(name, kName); } - std::vector names; - for (uint32_t type = 1; type < game::kItemTypeCount; ++type) { - if (IsTeleportPotion(type)) { - std::wstring name = game::GameText(game::ItemNameText(type)); - if (!name.empty() && std::find(names.begin(), names.end(), name) == names.end()) { - names.push_back(std::move(name)); - } - } - } - std::sort(names.begin(), names.end(), [](const auto& a, const auto& b) { return a.size() < b.size(); }); - std::vector replaced; - for (const std::wstring& name : names) { - const bool covered = std::any_of(replaced.begin(), replaced.end(), - [&](const std::wstring& r) { return name.find(r) != std::wstring::npos; }); - if (!covered) { - game::ReplaceInTexts(name, kName); - replaced.push_back(name); - } - } - renamed = !names.empty(); - re::Log("teleportpotion: %zu potion names, %zu replaced", names.size(), replaced.size()); } +RE_STARTUP(TeleportPotionStartup, Startup); void OnGameEnd() { g_pending = 0; @@ -147,7 +130,6 @@ void OnGameEnd() { struct Registration { Registration() { - game::OnGameStart(&OnGameStart); game::OnGameEnd(&OnGameEnd); game::OnEngineTick(&OnTick); } diff --git a/src/mods/wasd.cpp b/src/mods/wasd.cpp index f84872d..2688e62 100644 --- a/src/mods/wasd.cpp +++ b/src/mods/wasd.cpp @@ -25,11 +25,10 @@ // Releasing the keys sends the target-less walk order a released mouse button // sends, which stops it in place. // -// W, A and S are game hotkeys; the key bindings (src/mods/input.cpp) keep -// W/A/S/D away from the game and move those actions to other keys. Typing into -// a text field (chat, console, save names) is left alone. See docs/wasd.md. - -#include "mods/wasd.h" +// The movement keys are key bindings (default W/A/S/D, src/mods/input.cpp, +// which keeps them away from the game; the game's W/A/S actions moved to +// other keys). Typing into a text field (chat, console, save names) is left +// alone. See docs/wasd.md. #include #include @@ -70,8 +69,11 @@ constexpr double kSlideAngles[] = {30.0, 60.0, 90.0}; constexpr uint32_t kEngineMouseHeld = 0x10; constexpr uint32_t kEngineInputBlocked = 0x4000; +using mods::input::Move; +using mods::input::MovementKey; + bool KeyDown(int key) { - return (GetAsyncKeyState(key) & 0x8000) != 0; + return key != 0 && (GetAsyncKeyState(key) & 0x8000) != 0; } void SendWalk(uint8_t* engine, void* creature, int x, int y) { @@ -110,10 +112,10 @@ bool HeldDirection(const uint8_t* engine, Direction& dir) { int y = 0; const uint32_t flags = *reinterpret_cast(engine + kEngineFlags); if ((flags & (kEngineMouseHeld | kEngineInputBlocked)) == 0 && !mods::input::TypingIntoTextField()) { - if (KeyDown('W')) { x -= 1; y -= 1; } - if (KeyDown('S')) { x += 1; y += 1; } - if (KeyDown('A')) { x -= 1; y += 1; } - if (KeyDown('D')) { x += 1; y -= 1; } + if (KeyDown(MovementKey(Move::Up))) { x -= 1; y -= 1; } + if (KeyDown(MovementKey(Move::Down))) { x += 1; y += 1; } + if (KeyDown(MovementKey(Move::Left))) { x -= 1; y += 1; } + if (KeyDown(MovementKey(Move::Right))) { x += 1; y -= 1; } } if (x == 0 && y == 0) { return false; @@ -213,9 +215,6 @@ struct Registration { } // namespace -bool IsMovementKey(int vk) { - return (vk == 'W' || vk == 'A' || vk == 'S' || vk == 'D'); -} // Before each update of a creature: steer the controlled one. void BeforeCreatureUpdate(uint8_t* creature) { diff --git a/src/mods/wasd.h b/src/mods/wasd.h deleted file mode 100644 index d663921..0000000 --- a/src/mods/wasd.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once - -namespace mods::wasd { - -// Whether `vk` is one of the movement keys W/A/S/D. -bool IsMovementKey(int vk); - -} // namespace mods::wasd diff --git a/src/re/config.cpp b/src/re/config.cpp index f0af6cd..20d8f64 100644 --- a/src/re/config.cpp +++ b/src/re/config.cpp @@ -47,4 +47,13 @@ std::string ConfigString(const char* section, const char* key, const char* fallb return narrow; } +void SetConfigString(const char* section, const char* key, const char* value) { + if (g_path.empty()) { + return; + } + wchar_t wide[256]; + MultiByteToWideChar(CP_UTF8, 0, value, -1, wide, static_cast(std::size(wide))); + WritePrivateProfileStringW(Widen(section).c_str(), Widen(key).c_str(), wide, g_path.c_str()); +} + } // namespace re diff --git a/src/re/config.h b/src/re/config.h index 31d8ace..71a887f 100644 --- a/src/re/config.h +++ b/src/re/config.h @@ -11,5 +11,7 @@ const std::wstring& ConfigPath(); std::wstring ModuleDirectory(); int ConfigInt(const char* section, const char* key, int fallback); std::string ConfigString(const char* section, const char* key, const char* fallback); +// Writes `key=value` into the ini (value as UTF-8). +void SetConfigString(const char* section, const char* key, const char* value); } // namespace re diff --git a/tools/names.txt b/tools/names.txt index dac6fa2..47d082c 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -309,3 +309,14 @@ 0x0070e650 cUI_Character::OnConfirmButton # the right-hand button (Exportieren, ...) 0x007f01a0 HeroSlots_DeleteHero # (slot + 1), thiscall on 0x7d7c40(), ret 4: deletes Save\Hero%02d.pax 0x00731e70 cUI_Control2::SetFlags # (flags), ret 4: bit 0 = visible (show) +0x00716570 cUI_Options::cUI_Options # (name, rect, parent, flags) ret 0x18 +0x007165f0 cUI_Options::~cUI_Options +0x007184e0 cUI_Options::CreateControls # frames, labels, buttons, sliders (vector +0x90) +0x00717ea0 cUI_Options::LoadSettings # settings -> controls, on show +0x00717460 cUI_Options::Apply # controls -> settings (OK) +0x00739f90 cUI_Frame::cUI_Frame +0x0043fa90 PotionTooltipText # (kind 1..5), ret 4: bar tooltip text id by key ("Heiltrank [SPC]") +0x00724ed0 cUI_Window2::AddBackdrop # (rect*, ARGB), ret 8: translucent cUI_Static into +0x90 +0x0075a690 cUI_Manager::CanOpenWindow # (1), ret 4: false while a blocking window is open +0x007320c0 cUI_Control2::SetTooltipText # (text id) -> +0x20, ret 4 +0x007320d0 cUI_Control2::TooltipText # +0x20 once hovered long enough, else 0