From a7f8496b2bad7829a028e4aa5d26a1471dbbbcbb Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Fri, 16 Oct 2026 00:39:29 +0200 Subject: [PATCH] Quest dialogs: stay centred, clickable, closable with Esc - Quest dialogs are tooltips. A tooltip now keeps the offset it gets when it becomes visible (the hovered window's, or centred for dialogs) and records it for hit tests; its own event handler is shifted too. Hidden windows no longer count as hovered. - Esc closes a visible dialog the way its answer buttons do: hide, then cDialogManager::Answer with no answer (ends the input lock, nothing chosen). - Autotest can select a save game row (-Save). - Names for the UI manager, tooltip and dialog functions. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/escape.md | 10 ++++- docs/hud.md | 16 +++++++ src/mods/escape.cpp | 32 ++++++++++++++ src/mods/hud.cpp | 100 +++++++++++++++++++++++++++++++++++++++++++- src/mods/hud.h | 4 ++ tools/names.txt | 7 ++++ 6 files changed, 165 insertions(+), 4 deletions(-) diff --git a/docs/escape.md b/docs/escape.md index 9d03653..554dabc 100644 --- a/docs/escape.md +++ b/docs/escape.md @@ -29,5 +29,11 @@ HandleEsc is hooked: unless the ESC menu is open, every visible window of a table (slot, name) gets that hide command, and Esc does nothing else. If none is open, the game handles Esc as before. -Not covered: dialogs that are not one of these windows, e.g. quest dialogs -(standalone buttons and texts in the UI manager, docs/hud.md). +Quest dialogs are tooltips (docs/hud.md) that block all input until answered. +A click on one of their entries (cUI_Tooltip::HandleEvent 0x6e71e2) hides the +tooltip (0x731ee0, flag 1), calls cDialogManager::Answer (0x49a090 on +0xaaaf00, (owner = tooltip +0x21cc, answer)) and clears 0xab5314. Answer sends +every player hero a cEvent_creature 0x10c (ends the input lock) and passes +answers below 100000 to the quest script (0x46bd80). Esc does the same with +answer 0xffffffff: the dialog closes, nothing is chosen. (Hiding the tooltip +alone left the input lock; measured with a write watch on its flags.) diff --git a/docs/hud.md b/docs/hud.md index e85104a..ec88243 100644 --- a/docs/hud.md +++ b/docs/hud.md @@ -70,3 +70,19 @@ clicks only hit at the unshifted position. `cUI_Button2`, `cUI_Static2`, `cUI_StaticText64`, `cUI_StaticText64FX` (render and event) and `cUI_TextLines` (render) are hooked as centred windows too; inside other windows they are in that window's scope already (outermost wins). + +## Tooltips and quest dialogs (2026-10-16) + +Quest dialogs are tooltips: `cUI_Manager::tooltipCreate` (`0x75a870`) hands +out a panel from a pool at cUI_Manager +0x124 (`UI_WND_TOOLTIP`, created +1024x768, flags 0x804 while hidden); the quest dialog used one at 136,306, +752x157 (flags 0x805). Tooltips used to be drawn with the offset of the moved +window under the cursor in every frame, and without recording an offset for +hit tests: dialogs jumped to the 1024 frame when the cursor left the UI, and +their clicks only hit at the unshifted position. Now a tooltip keeps the +offset it gets when it becomes visible (the hovered window's, or the centre +when no moved window is under the cursor: a dialog) and records it for hit +tests. Visible dialog tooltips: `mods::hud::VisibleDialogTooltips`. + +The standalone control hooks (buttons, texts) above turned out not to be what +the quest dialog is made of; they stay, for top-level controls in general. diff --git a/src/mods/escape.cpp b/src/mods/escape.cpp index b4b552c..5beabfc 100644 --- a/src/mods/escape.cpp +++ b/src/mods/escape.cpp @@ -12,6 +12,7 @@ #include #include +#include "mods/hud.h" #include "re/hook.h" #include "re/log.h" @@ -31,6 +32,27 @@ constexpr ClosableWindow kClosable[] = { {0x31, "UI_WND_HORSE"}, {0x35, "UI_WND_CUBE"}, }; constexpr int kEscMenuSlot = 0x2c; +// A dialog tooltip answers a click on one of its entries (cUI_Tooltip vf10, +// 0x6e71e2): it hides itself (cUI_Control2 hide 0x731ee0, flag 1), tells the +// dialog manager (0x49a090 on 0xaaaf00, (owner +0x21cc, answer)) and clears +// 0xab5314. The manager sends every player hero a cEvent_creature 0x10c, which +// ends the dialog's input lock, and passes answers below 100000 to the quest +// script (0x46bd80). Esc does the same with an answer of 100000 or more: the +// dialog closes and the heroes are free, nothing is chosen. +constexpr uint32_t kHideVisible = 1; +constexpr uint32_t kDialogManager = 0x00aaaf00; +constexpr size_t kTooltipDialogOwner = 0x21cc; +constexpr uint32_t kNoAnswer = 0xffffffff; // >= 100000: no script callback +constexpr uint32_t kDialogPending = 0x00ab5314; + +void CloseDialog(uint8_t* tooltip) { + using HideFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(0x00731ee0)(tooltip, nullptr, kHideVisible); + using AnswerFn = void(__fastcall*)(void*, void*, uint32_t owner, uint32_t answer); + reinterpret_cast(0x0049a090)(reinterpret_cast(kDialogManager), nullptr, + *reinterpret_cast(tooltip + kTooltipDialogOwner), kNoAnswer); + *reinterpret_cast(kDialogPending) = 0; +} constexpr size_t kControlFlags = 0x10; // bit 0: visible // cEventUI2 as HandleEsc builds it (event at [esp+10h] there). @@ -73,6 +95,16 @@ bool CloseWindows(uint32_t* manager) { if (Visible(reinterpret_cast(manager[kEscMenuSlot]))) { return false; } + // Quest dialogs are tooltips (src/mods/hud.cpp) that block all input + // until answered: closed first. + void* dialogs[8]; + const int dialogCount = mods::hud::VisibleDialogTooltips(dialogs, 8); + for (int i = 0; i < dialogCount; ++i) { + CloseDialog(static_cast(dialogs[i])); + } + if (dialogCount > 0) { + return true; + } bool closed = false; for (const ClosableWindow& w : kClosable) { auto* window = reinterpret_cast(manager[w.slot]); diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp index 27107d7..b7a7b12 100644 --- a/src/mods/hud.cpp +++ b/src/mods/hud.cpp @@ -159,6 +159,20 @@ volatile LONG g_hoverOffset = 0; volatile DWORD g_hoverTime = 0; constexpr DWORD kHoverExpiryMs = 150; +int HoverOffset(); + +// Tooltips seen so far: whether visible, and the offset chosen when they +// became visible. +struct TooltipState { + void* tooltip; + bool visible; + bool dialog; + int dx; +}; +constexpr int kTooltipCount = 64; +TooltipState g_tooltips[kTooltipCount] = {}; +constexpr size_t kControlFlags = 0x10; // bit 0: visible + int HoverOffset() { return GetTickCount() - g_hoverTime < kHoverExpiryMs ? g_hoverOffset : 0; } @@ -170,6 +184,9 @@ void TrackHover(void* window, int dx) { return; } const auto* w = static_cast(window); + if ((w[0x10] & 1) == 0) { + return; // hidden windows render too (and some cover the whole frame) + } const int x = *reinterpret_cast(w + 0x24) + dx; const int y = *reinterpret_cast(w + 0x28); const int width = *reinterpret_cast(w + 0x2c); @@ -400,6 +417,64 @@ void OnDevice(void* device) { } } +// Dialogs among the tooltips: visible and at least kDialogMinWidth wide (the +// quest dialog: 752; hover tooltips and speech bubbles are narrow). The +// hover rule above cannot tell: a full-screen window drawn while hidden counts +// as hovered whenever the cursor is inside the 1024 frame. +constexpr int kDialogMinWidth = 400; + +int VisibleDialogTooltipsImpl(void** out, int max) { + int count = 0; + for (const TooltipState& t : g_tooltips) { + if (!t.tooltip) { + continue; + } + const auto* p = static_cast(t.tooltip); + if ((p[kControlFlags] & 1) == 0) { + continue; + } + const int width = *reinterpret_cast(p + 0x2c); + if (width >= kDialogMinWidth && count < max) { + out[count++] = t.tooltip; + } + } + return count; +} + +int KnownTooltipOffset(void* tooltip) { + for (const TooltipState& t : g_tooltips) { + if (t.tooltip == tooltip) { + return t.dx; + } + } + return OffsetFor(Anchor::Center); +} + +int TooltipOffset(void* tooltip) { + TooltipState* state = nullptr; + for (TooltipState& t : g_tooltips) { + if (t.tooltip == tooltip) { + state = &t; + break; + } + if (!state && !t.tooltip) { + state = &t; + } + } + if (!state) { + return HoverOffset(); + } + const bool visible = (static_cast(tooltip)[kControlFlags] & 1) != 0; + if (state->tooltip != tooltip || (visible && !state->visible)) { + const int hover = HoverOffset(); + state->tooltip = tooltip; + state->dialog = hover == 0; + state->dx = hover != 0 ? hover : OffsetFor(Anchor::Center); + } + state->visible = visible; + return state->dx; +} + } // namespace mods::hud // --- HUD windows --------------------------------------------------------------- @@ -516,11 +591,28 @@ HUD_EVENT(0x00753360, UiStatic_HandleEvent, Anchor::Center) HUD_RENDER(0x007530a0, UiStatic_Render, Anchor::Center) HUD_RENDER(0x007228b0, UiTextLines_Render, Anchor::Center) -// Tooltips: placed by the hovered window in its own layout (see TrackHover). +// Tooltips (cUI_Tooltip, a pool at cUI_Manager +0x124, one panel each). A +// tooltip is either a hover tooltip placed by the window under the cursor in +// its own layout, or a dialog of its own, e.g. a quest dialog (laid out in the +// 1024 frame, measured: rect 136,306 752x157). When a tooltip becomes visible +// it keeps an offset until it is hidden: the hovered window's (TrackHover) if +// a moved window is under the cursor, otherwise the centre (a dialog). Taking +// the hover offset on every frame made dialogs jump to the 1024 frame when +// the cursor left the UI, and without a recorded offset their hit tests used +// none, so clicks only worked at the unshifted position. +// Its event handler (vf10 0x6e7120) tests the click itself before it calls +// the shared window handler: it needs the same offset as its drawing. +int __fastcall UiTooltip_HandleEvent(void* self, void* edx, void* event); +RE_HOOK(0x006e7120, UiTooltip_HandleEvent); +int __fastcall UiTooltip_HandleEvent(void* self, void* edx, void* event) { + mods::hud::EventOffset offset(event, mods::hud::KnownTooltipOffset(self)); + return UiTooltip_HandleEvent_original(self, edx, event); +} + int __fastcall UiTooltip_Render(void* self, void* edx, void* device); RE_HOOK(0x006e7300, UiTooltip_Render); int __fastcall UiTooltip_Render(void* self, void* edx, void* device) { - mods::hud::DrawOffset offset(nullptr, mods::hud::HoverOffset()); + mods::hud::DrawOffset offset(self, mods::hud::TooltipOffset(self)); return UiTooltip_Render_original(self, edx, device); } @@ -544,6 +636,10 @@ int HitTestX(void* window, int x) { return x - OffsetOf(root); } +int VisibleDialogTooltips(void** out, int max) { + return VisibleDialogTooltipsImpl(out, max); +} + } // namespace mods::hud namespace game { diff --git a/src/mods/hud.h b/src/mods/hud.h index cdff196..c978e1e 100644 --- a/src/mods/hud.h +++ b/src/mods/hud.h @@ -13,4 +13,8 @@ int CursorOffsetX(); // window, a private copy with x shifted by CursorOffsetX(). void* CursorFor(void* cursor); +// Tooltips that are dialogs of their own (e.g. quest dialogs: they became +// visible while no moved window was under the cursor) and are visible now. +int VisibleDialogTooltips(void** out, int max); + } // namespace mods::hud diff --git a/tools/names.txt b/tools/names.txt index ea0c8dc..cdbe972 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -167,3 +167,10 @@ 0x00536f20 cCreature::DistanceTo # (object id) 0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player 0x00ad1b48 g_nearbyCreatureCount +0x0049a090 cDialogManager::Answer # (owner, answer): cEvent_creature 0x10c to all heroes (ends the dialog lock); answers < 100000 -> quest script 0x46bd80 +0x006e7120 cUI_Tooltip::HandleEvent # dialog answers: click on an entry (+0x2188 list) -> hide, cDialogManager::Answer +0x0075a870 cUI_Manager::TooltipCreate # a panel from the pool at +0x124 +0x00756ba0 cUI_Manager::HandleEsc +0x00731ee0 cUI_Control2::ClearFlags # (flags): bit 0 = visible (hide) +0x00aaaf00 g_dialogManager +0x006360e0 cRecordCache::Get # map at +0xde5c, LRU (see docs/performance.md)