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:
1 parent
1fd89524d3
commit
a7f8496b2b
6 files changed
+165
-4
No files matched your search
+8
-2
@@ -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
|
table (slot, name) gets that hide command, and Esc does nothing else. If none
|
||||||
is open, the game handles Esc as before.
|
is open, the game handles Esc as before.
|
||||||
|
|
||||||
Not covered: dialogs that are not one of these windows, e.g. quest dialogs
|
Quest dialogs are tooltips (docs/hud.md) that block all input until answered.
|
||||||
(standalone buttons and texts in the UI manager, docs/hud.md).
|
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
@@ -70,3 +70,19 @@ clicks only hit at the unshifted position. `cUI_Button2`, `cUI_Static2`,
|
|||||||
`cUI_StaticText64`, `cUI_StaticText64FX` (render and event) and
|
`cUI_StaticText64`, `cUI_StaticText64FX` (render and event) and
|
||||||
`cUI_TextLines` (render) are hooked as centred windows too; inside other
|
`cUI_TextLines` (render) are hooked as centred windows too; inside other
|
||||||
windows they are in that window's scope already (outermost wins).
|
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.
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "mods/hud.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
@@ -31,6 +32,27 @@ constexpr ClosableWindow kClosable[] = {
|
|||||||
{0x31, "UI_WND_HORSE"}, {0x35, "UI_WND_CUBE"},
|
{0x31, "UI_WND_HORSE"}, {0x35, "UI_WND_CUBE"},
|
||||||
};
|
};
|
||||||
constexpr int kEscMenuSlot = 0x2c;
|
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
|
constexpr size_t kControlFlags = 0x10; // bit 0: visible
|
||||||
|
|
||||||
// cEventUI2 as HandleEsc builds it (event at [esp+10h] there).
|
// 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]))) {
|
if (Visible(reinterpret_cast<uint8_t*>(manager[kEscMenuSlot]))) {
|
||||||
return false;
|
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;
|
bool closed = false;
|
||||||
for (const ClosableWindow& w : kClosable) {
|
for (const ClosableWindow& w : kClosable) {
|
||||||
auto* window = reinterpret_cast<uint8_t*>(manager[w.slot]);
|
auto* window = reinterpret_cast<uint8_t*>(manager[w.slot]);
|
||||||
|
|||||||
+98
-2
@@ -159,6 +159,20 @@ volatile LONG g_hoverOffset = 0;
|
|||||||
volatile DWORD g_hoverTime = 0;
|
volatile DWORD g_hoverTime = 0;
|
||||||
constexpr DWORD kHoverExpiryMs = 150;
|
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() {
|
int HoverOffset() {
|
||||||
return GetTickCount() - g_hoverTime < kHoverExpiryMs ? g_hoverOffset : 0;
|
return GetTickCount() - g_hoverTime < kHoverExpiryMs ? g_hoverOffset : 0;
|
||||||
}
|
}
|
||||||
@@ -170,6 +184,9 @@ void TrackHover(void* window, int dx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const auto* w = static_cast<const uint8_t*>(window);
|
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 x = *reinterpret_cast<const int32_t*>(w + 0x24) + dx;
|
||||||
const int y = *reinterpret_cast<const int32_t*>(w + 0x28);
|
const int y = *reinterpret_cast<const int32_t*>(w + 0x28);
|
||||||
const int width = *reinterpret_cast<const uint16_t*>(w + 0x2c);
|
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
|
} // namespace mods::hud
|
||||||
|
|
||||||
// --- HUD windows ---------------------------------------------------------------
|
// --- HUD windows ---------------------------------------------------------------
|
||||||
@@ -516,11 +591,28 @@ HUD_EVENT(0x00753360, UiStatic_HandleEvent, Anchor::Center)
|
|||||||
HUD_RENDER(0x007530a0, UiStatic_Render, Anchor::Center)
|
HUD_RENDER(0x007530a0, UiStatic_Render, Anchor::Center)
|
||||||
HUD_RENDER(0x007228b0, UiTextLines_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);
|
int __fastcall UiTooltip_Render(void* self, void* edx, void* device);
|
||||||
RE_HOOK(0x006e7300, UiTooltip_Render);
|
RE_HOOK(0x006e7300, UiTooltip_Render);
|
||||||
int __fastcall UiTooltip_Render(void* self, void* edx, void* device) {
|
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);
|
return UiTooltip_Render_original(self, edx, device);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +636,10 @@ int HitTestX(void* window, int x) {
|
|||||||
return x - OffsetOf(root);
|
return x - OffsetOf(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int VisibleDialogTooltips(void** out, int max) {
|
||||||
|
return VisibleDialogTooltipsImpl(out, max);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace mods::hud
|
} // namespace mods::hud
|
||||||
|
|
||||||
namespace game {
|
namespace game {
|
||||||
|
|||||||
@@ -13,4 +13,8 @@ int CursorOffsetX();
|
|||||||
// window, a private copy with x shifted by CursorOffsetX().
|
// window, a private copy with x shifted by CursorOffsetX().
|
||||||
void* CursorFor(void* cursor);
|
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
|
} // namespace mods::hud
|
||||||
@@ -167,3 +167,10 @@
|
|||||||
0x00536f20 cCreature::DistanceTo # (object id)
|
0x00536f20 cCreature::DistanceTo # (object id)
|
||||||
0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player
|
0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player
|
||||||
0x00ad1b48 g_nearbyCreatureCount
|
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)
|
||||||
Reference in new issue
Block a user