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:
Erik 2026-08-17 10:34:56 +02:00
parent 302d90209d
commit 306a1670d3
6 changed files with 480 additions and 10 deletions

View file

@ -27,10 +27,12 @@ namespace AcDream.App.UI.Layout;
/// </para>
///
/// <para>
/// The expand-detail overlay present in the front container carries ONLY named
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
/// automatically.
/// The expand-detail icon overlay present in EACH container (back + front)
/// carries ONLY named states ("HideDetail"/"ShowDetail") — no "" DirectState
/// entry — so the <c>TryGetValue("")</c> filter in <see cref="SliceIds"/>
/// excludes it from slice extraction; <see cref="DetailOverlay"/> then absorbs
/// it into <see cref="UiMeter.ConfigureDetailOverlay"/> for the retail
/// HideDetail/ShowDetail click toggle.
/// </para>
/// </summary>
public static class DatWidgetFactory
@ -76,6 +78,15 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
// The vitals window roots — gmVitalsUI (0x10000009 @0x004BFE10),
// gmFloatyVitalsUI (0x1000004D, stacked 0x2100006C), and
// gmFloatySideVitalsUI (0x10000056, side-by-side 0x21000075). All
// three share the inherited HideDetail/ShowDetail press toggle
// (gmVitalsUI::ListenToElementMessage @0x004BFC00) — see UiVitalsRoot.
UiVitalsRoot.GmVitalsClassId
or UiVitalsRoot.GmFloatyVitalsClassId
or UiVitalsRoot.GmFloatySideVitalsClassId
=> new UiVitalsRoot(info, resolve),
1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840)
{
@ -446,8 +457,11 @@ public static class DatWidgetFactory
/// │ ├── left-cap image (→ front-left sprite)
/// │ ├── center image (→ front-tile sprite)
/// │ ├── right-cap image (→ front-right sprite)
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState —
/// │ absorbed as the bright fill-clipped detail icon; the back
/// │ container has a matching dim one — see ConfigureDetailOverlay)
/// └── text label (Type 0) (built as a real UiText child by LayoutImporter's meter
/// carve-out; Fill/Label providers bound by VitalsController)
/// </code>
/// </para>
///
@ -508,6 +522,35 @@ public static class DatWidgetFactory
m.FrontLeft = fl;
m.FrontTile = ft;
m.FrontRight = fr;
// The expand-detail icon overlays (the 0x100004A9 child of EACH
// container — dim icon in the back track, bright icon in the
// fill-clipped front layer; health heart 0x06007490/91, stamina
// sword 0x06007492/93, mana scepter 0x06007494/95). They author
// media only for the named ShowDetail state (HideDetail authors
// File=0), which is why SliceIds' DirectState filter already
// excludes them from the slice extraction above. Absorb them into
// the meter alongside the slices so the HideDetail/ShowDetail
// click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00)
// can draw retail's graphical vitals mode. PassToChildren on the
// meter's own media-less HideDetail/ShowDetail StateDescs gates
// the cascade to the absorbed number label
// (UIElement::SetState @0x00464E70).
ElementInfo? backOverlay = DetailOverlay(containers[0]);
ElementInfo? frontOverlay = DetailOverlay(containers[1]);
if (backOverlay is not null || frontOverlay is not null)
{
bool passToChildren = info.States.Values.Any(
static s => s.Name is "HideDetail" or "ShowDetail" && s.PassToChildren);
m.ConfigureDetailOverlay(
backOverlay is not null ? backOverlay.StateMedia["ShowDetail"].File : 0u,
backOverlay?.X ?? 0f, backOverlay?.Y ?? 0f,
backOverlay?.Width ?? 0f, backOverlay?.Height ?? 0f,
frontOverlay is not null ? frontOverlay.StateMedia["ShowDetail"].File : 0u,
frontOverlay?.X ?? 0f, frontOverlay?.Y ?? 0f,
frontOverlay?.Width ?? 0f, frontOverlay?.Height ?? 0f,
passToChildren);
}
}
else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey(""))
{
@ -617,6 +660,18 @@ public static class DatWidgetFactory
=> container.Children.Count(c =>
c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3;
/// <summary>
/// Finds a container's expand-detail icon overlay: the child that authors a
/// non-zero image for the named ShowDetail state and NO DirectState image
/// (the 0x100004A9 shape in both vitals layouts 0x2100006C / 0x21000075).
/// Returns null when the container has none (non-vitals meters).
/// </summary>
private static ElementInfo? DetailOverlay(ElementInfo container)
=> container.Children.FirstOrDefault(static c =>
!c.StateMedia.ContainsKey("")
&& c.StateMedia.TryGetValue("ShowDetail", out var media)
&& media.File != 0);
private static bool HasStatefulFill(ElementInfo container)
=> container.States.Any(pair =>
pair.Key != UiStateInfo.DirectStateId