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) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-16 00:39:29 +02:00
1 parent 1fd89524d3
commit a7f8496b2b
6 files changed
+165 -4

No files matched your search

+8 -2
View File
@@ -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.)
+16
View File
@@ -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.
+32
View File
@@ -12,6 +12,7 @@
#include <cstdint>
#include <cstring>
#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<HideFn>(0x00731ee0)(tooltip, nullptr, kHideVisible);
using AnswerFn = void(__fastcall*)(void*, void*, uint32_t owner, uint32_t answer);
reinterpret_cast<AnswerFn>(0x0049a090)(reinterpret_cast<void*>(kDialogManager), nullptr,
*reinterpret_cast<uint32_t*>(tooltip + kTooltipDialogOwner), kNoAnswer);
*reinterpret_cast<uint32_t*>(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<uint8_t*>(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<uint8_t*>(dialogs[i]));
}
if (dialogCount > 0) {
return true;
}
bool closed = false;
for (const ClosableWindow& w : kClosable) {
auto* window = reinterpret_cast<uint8_t*>(manager[w.slot]);
+98 -2
View File
@@ -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<const uint8_t*>(window);
if ((w[0x10] & 1) == 0) {
return; // hidden windows render too (and some cover the whole frame)
}
const int x = *reinterpret_cast<const int32_t*>(w + 0x24) + dx;
const int y = *reinterpret_cast<const int32_t*>(w + 0x28);
const int width = *reinterpret_cast<const uint16_t*>(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<const uint8_t*>(t.tooltip);
if ((p[kControlFlags] & 1) == 0) {
continue;
}
const int width = *reinterpret_cast<const uint16_t*>(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<uint8_t*>(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 {
+4
View File
@@ -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
+7
View File
@@ -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)