feat(ui): vitals — click toggles retail's numeric/graphical detail modes
Port of gmVitalsUI's press toggle, derived end-to-end from the named
retail decomp + the authored DAT data (installed-DAT probe 2026-08-17):
- gmVitalsUI::ListenToElementMessage @0x004BFC00: mouse press (msg 0x1C,
dwParam1 7=left or 0xA=right — the same param pair the spellbook's
select/favorite handler @0x0048C033 disambiguates) flips
SetState(m_state == HideDetail ? ShowDetail : HideDetail). Both floaty
subclasses (gmFloatyVitalsUI 0x1000004D / gmFloatySideVitalsUI
0x10000056) inherit it verbatim.
- UIElement::SetState @0x00464E70 cascades through the authored
PassToChildren chain: root and meters author media-less
HideDetail/ShowDetail StateDescs with PassToChildren=true.
- HideDetail (0x10000006) = the NUMERIC mode: the cur/max labels author
{0x3B:false} (0x3B = invisible; UIElement::OnSetAttribute case 8
@0x00462DAE is SetVisible(value == 0)), the 0x100004A9 overlays author
File=0.
- ShowDetail (0x10000007) = the GRAPHICAL mode: labels author {0x3B:true}
(numbers hidden); each bar shows its authored icon pair — dim back icon
unclipped over the track, bright front icon clipped with the front
container to the fill fraction (UIElement_Meter::DrawChildren
@0x0046FBD0 clips the whole element-id-2 child; m_pcChildImage =
GetChildRecursive(this, 2) @0x0046F7E3). Health heart 0x06007490/91
(18x16 @66,0), stamina sword 0x06007492/93 (85x16 @32,0), mana scepter
0x06007494/95 (100x16 @25,0) — identical authoring in both 0x2100006C
and 0x21000075.
- Initial state is the authored Undef (numbers visible, no icons —
visually HideDetail); retail's first press lands on HideDetail, then
the pair toggles forever. NOT persisted: SaveScreenLayout @0x004EAD50
writes window rects only, and no PlayerModule option is touched — the
mode resets per session, per window.
- Presses on drag bars / resize grips do not toggle: retail's
UIElement_Dragbar @0x0046C850 and UIElement_Resizebar @0x0046B930
consume the press (return 2) before it can bubble to the root.
Implementation: new UiVitalsRoot behavioral widget registered for the
three gmVitals class ids (press handler + state flip over the existing
UiDatElement state machine); UiMeter absorbs the two 0x100004A9 overlays
(ConfigureDetailOverlay + ShowDetail-keyed draw, back unclipped / front
fill-clipped) and forwards the detail states to its absorbed text child;
UiText.ApplyDatState gains the same named-state-only 0x3B honor
UiDatElement already had (the DirectState 0x3B class stays gated — #408).
8 new fixture-driven conformance tests (toggle sequence, right-press,
label cascade, chrome exclusions, per-window independence, overlay
extraction). App suite Release live-DAT: 5495 passed / 3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
302d90209d
commit
306a1670d3
6 changed files with 480 additions and 10 deletions
95
src/AcDream.App/UI/Layout/UiVitalsRoot.cs
Normal file
95
src/AcDream.App/UI/Layout/UiVitalsRoot.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Behavioral root widget for the vitals windows — retail's <c>gmVitalsUI</c>
|
||||
/// family (<c>gmVitalsUI</c> 0x10000009, <c>gmFloatyVitalsUI</c> 0x1000004D =
|
||||
/// the stacked window LayoutDesc 0x2100006C, <c>gmFloatySideVitalsUI</c>
|
||||
/// 0x10000056 = the side-by-side window LayoutDesc 0x21000075; registrations
|
||||
/// @0x004BFE10 / @0x004CED90 / @0x004D0490).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The click toggle</b> (<c>gmVitalsUI::ListenToElementMessage @0x004BFC00</c>,
|
||||
/// inherited verbatim by both floaty subclasses): on <c>Element_mouse_press</c>
|
||||
/// (0x1C) with dwParam1 7 (left) or 0xA (right) the root flips
|
||||
/// <c>SetState(m_state == HideDetail ? ShowDetail : HideDetail)</c>.
|
||||
/// State semantics from the authored data + <c>UIElement::OnSetAttribute</c>
|
||||
/// case 8 (0x3B = invisible, <c>SetVisible(value == 0)</c> @0x00462DAE):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Undef</b> (login default; DefaultState is authored
|
||||
/// Undef and nothing calls SetState at init): numbers visible, no icons —
|
||||
/// visually identical to HideDetail.</description></item>
|
||||
/// <item><description><b>HideDetail</b> (0x10000006): the numeric mode — the
|
||||
/// cur/max labels author {0x3B:false} (visible); the icon overlays author
|
||||
/// File=0 (nothing).</description></item>
|
||||
/// <item><description><b>ShowDetail</b> (0x10000007): the graphical mode —
|
||||
/// labels author {0x3B:true} (hidden); each bar shows its authored icon pair
|
||||
/// (dim back + bright fill-clipped front: heart / sword / scepter).</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The first press from Undef lands on HideDetail (retail's exact expression:
|
||||
/// <c>ecx = (m_state == 0x10000006); SetState(ecx + 0x10000006)</c> — any
|
||||
/// state that is not HideDetail, including the initial Undef, goes to
|
||||
/// HideDetail first), so the first click appears to do nothing and the second
|
||||
/// enters icon mode. NOT persisted anywhere: <c>gmGamePlayUI::SaveScreenLayout
|
||||
/// @0x004EAD50</c> writes only window rects, and no PlayerModule option is
|
||||
/// touched — the mode resets to Undef every session, per window.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Press routing:</b> retail broadcasts the press to the pressed element
|
||||
/// and forwards up the parent chain (<c>UIElement::ListenToElementMessage
|
||||
/// @0x00462340</c> → ForwardElementMessage), but <c>UIElement_Dragbar</c>
|
||||
/// (@0x0046C850) and <c>UIElement_Resizebar</c> (@0x0046B930) both return 2
|
||||
/// (consumed) unconditionally — a press that starts a window move or resize
|
||||
/// never reaches the vitals root. Mirrored here: presses whose hit target is a
|
||||
/// move handle or resize grip are ignored. The handler returns false so the
|
||||
/// event keeps bubbling, matching retail's fall-through to the base handler.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiVitalsRoot : UiDatElement
|
||||
{
|
||||
/// <summary>gmVitalsUI registered element class (@0x004BFE1A).</summary>
|
||||
public const uint GmVitalsClassId = 0x10000009u;
|
||||
/// <summary>gmFloatyVitalsUI registered element class (@0x004CED9A) — the stacked window root.</summary>
|
||||
public const uint GmFloatyVitalsClassId = 0x1000004Du;
|
||||
/// <summary>gmFloatySideVitalsUI registered element class (@0x004D049A) — the side-by-side window root.</summary>
|
||||
public const uint GmFloatySideVitalsClassId = 0x10000056u;
|
||||
|
||||
public UiVitalsRoot(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
: base(info, resolve)
|
||||
{
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type is UiEventType.MouseDown or UiEventType.RightDown
|
||||
&& !PressConsumedByChrome(e.Target))
|
||||
{
|
||||
// gmVitalsUI::ListenToElementMessage @0x004BFC04:
|
||||
// this->SetState(m_state == HideDetail ? ShowDetail : HideDetail)
|
||||
// then falls through to the base handler (keep bubbling → false).
|
||||
TrySetRetailState(
|
||||
ActiveRetailStateId == RetailUiStateIds.HideDetail
|
||||
? RetailUiStateIds.ShowDetail
|
||||
: RetailUiStateIds.HideDetail);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the pressed element is (or sits inside) a window-move handle
|
||||
/// or resize grip — the two retail element classes that consume the press
|
||||
/// before it can bubble to the vitals root.
|
||||
/// </summary>
|
||||
private bool PressConsumedByChrome(UiElement? target)
|
||||
{
|
||||
for (UiElement? w = target; w is not null && w != this; w = w.Parent)
|
||||
if (w.WindowMoveHandle || w is UiResizeGrip)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue