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>
/// ///
/// <para> /// <para>
/// The expand-detail overlay present in the front container carries ONLY named /// The expand-detail icon overlay present in EACH container (back + front)
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the /// carries ONLY named states ("HideDetail"/"ShowDetail") — no "" DirectState
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it /// entry — so the <c>TryGetValue("")</c> filter in <see cref="SliceIds"/>
/// automatically. /// excludes it from slice extraction; <see cref="DetailOverlay"/> then absorbs
/// it into <see cref="UiMeter.ConfigureDetailOverlay"/> for the retail
/// HideDetail/ShowDetail click toggle.
/// </para> /// </para>
/// </summary> /// </summary>
public static class DatWidgetFactory public static class DatWidgetFactory
@ -76,6 +78,15 @@ public static class DatWidgetFactory
UiElement e = info.Type switch UiElement e = info.Type switch
{ {
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80) 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 1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840) 2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840)
{ {
@ -446,8 +457,11 @@ public static class DatWidgetFactory
/// │ ├── left-cap image (→ front-left sprite) /// │ ├── left-cap image (→ front-left sprite)
/// │ ├── center image (→ front-tile sprite) /// │ ├── center image (→ front-tile sprite)
/// │ ├── right-cap image (→ front-right sprite) /// │ ├── right-cap image (→ front-right sprite)
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED) /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState —
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController) /// │ 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> /// </code>
/// </para> /// </para>
/// ///
@ -508,6 +522,35 @@ public static class DatWidgetFactory
m.FrontLeft = fl; m.FrontLeft = fl;
m.FrontTile = ft; m.FrontTile = ft;
m.FrontRight = fr; 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("")) else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey(""))
{ {
@ -617,6 +660,18 @@ public static class DatWidgetFactory
=> container.Children.Count(c => => container.Children.Count(c =>
c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3; 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) private static bool HasStatefulFill(ElementInfo container)
=> container.States.Any(pair => => container.States.Any(pair =>
pair.Key != UiStateInfo.DirectStateId pair.Key != UiStateInfo.DirectStateId

View file

@ -181,11 +181,15 @@ public static class LayoutImporter
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes. // widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
// //
// Type-3 children are SKIPPED here because BuildMeter already consumed them (they // Type-3 children are SKIPPED here because BuildMeter already consumed them (they
// carry the 3-slice sprite ids, not text content; building them again would // carry the 3-slice sprite ids + the ShowDetail icon overlays, not text content;
// double-draw the bar art). All other child types are built normally. // building them again would double-draw the bar art). All other child types are
// built normally.
// //
// Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children // For vitals this loop builds exactly the cur/max number label (0x100000EB/ED/EF,
// (no text children). This loop finds nothing for them → no change to vitals. // Type 0 → merged Type 12 → UiText): VitalsController binds its LinesProvider, and
// the HideDetail/ShowDetail cascade from the window root reaches it THROUGH the
// meter (UiMeter.TrySetRetailState forwards to stateful children), flipping its
// per-state 0x3B visibility exactly like retail's PassToChildren SetState walk.
foreach (var child in info.Children) foreach (var child in info.Children)
{ {
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter

View 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;
}
}

View file

@ -25,6 +25,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful
private readonly Dictionary<uint, (string Text, UiMeterLabelAlign Align)> _stateLabels = new(); private readonly Dictionary<uint, (string Text, UiMeterLabelAlign Align)> _stateLabels = new();
private (string Text, UiMeterLabelAlign Align)? _activeStateLabel; private (string Text, UiMeterLabelAlign Align)? _activeStateLabel;
// Vitals ShowDetail icon overlays (see ConfigureDetailOverlay).
private bool _detailConfigured;
private bool _detailPassToChildren;
private uint _detailBackSprite;
private uint _detailFrontSprite;
private (float X, float Y, float W, float H) _detailBackRect;
private (float X, float Y, float W, float H) _detailFrontRect;
/// <summary>True when this meter absorbed the vitals detail-icon overlays. Exposed for tests.</summary>
internal bool HasDetailOverlay => _detailConfigured;
/// <summary>The dim back-container detail icon (ShowDetail media). Exposed for tests.</summary>
internal uint DetailBackSprite => _detailBackSprite;
/// <summary>The bright fill-clipped front-container detail icon. Exposed for tests.</summary>
internal uint DetailFrontSprite => _detailFrontSprite;
/// <summary>The back overlay's authored meter-local rect. Exposed for tests.</summary>
internal (float X, float Y, float W, float H) DetailBackRect => _detailBackRect;
/// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary> /// <summary>Dat element id, set by the layout importer so duplicated page copies can be scoped.</summary>
public uint ElementId { get; set; } public uint ElementId { get; set; }
@ -114,8 +131,54 @@ public sealed class UiMeter : UiElement, IUiDatStateful
_stateLabels[stateId] = (text, align); _stateLabels[stateId] = (text, align);
} }
/// <summary>
/// Registers the vitals "detail" icon overlays absorbed from the meter's
/// two slice containers (the <c>0x100004A9</c> children — health heart
/// 0x06007490/91, stamina sword 0x06007492/93, mana scepter 0x06007494/95).
/// Retail authors each icon TWICE: a dim version in the BACK container
/// (drawn unclipped over the empty track) and a bright version in the
/// FRONT container (fill-clipped with the rest of the front layer —
/// <c>UIElement_Meter::DrawChildren @0x0046FBD0</c> clips the whole
/// <c>m_pcChildImage</c> child, element id 2, to the 0x69 fraction), so
/// the icon itself fills up with the vital. Both overlays author media
/// ONLY for <c>ShowDetail</c> (<c>HideDetail</c> authors File=0), so they
/// draw solely in that state. Rects are the overlays' authored X/Y/W/H
/// local to the meter (the containers span the meter at 0,0).
/// </summary>
internal void ConfigureDetailOverlay(
uint backSprite, float backX, float backY, float backW, float backH,
uint frontSprite, float frontX, float frontY, float frontW, float frontH,
bool passToChildren)
{
_detailBackSprite = backSprite;
_detailBackRect = (backX, backY, backW, backH);
_detailFrontSprite = frontSprite;
_detailFrontRect = (frontX, frontY, frontW, frontH);
_detailConfigured = backSprite != 0 || frontSprite != 0;
_detailPassToChildren = passToChildren;
}
public bool TrySetRetailState(uint stateId) public bool TrySetRetailState(uint stateId)
{ {
// Vitals detail toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00
// flips the window root between HideDetail 0x10000006 and ShowDetail
// 0x10000007; the meter receives the state through the authored
// PassToChildren cascade — UIElement::SetState @0x00464E70). The
// meter's own HideDetail/ShowDetail StateDescs are media-less and
// exist purely to keep propagating (PassToChildren=true), so forward
// to the absorbed text child (whose per-state 0x3B hides the numbers
// in ShowDetail) and let OnDraw key the icon overlays off the state.
if (_detailConfigured
&& stateId is RetailUiStateIds.HideDetail or RetailUiStateIds.ShowDetail)
{
ActiveRetailStateId = stateId;
if (_detailPassToChildren)
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
return true;
}
bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId); bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId);
bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption); bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption);
if (!hasFill && !hasLabel) if (!hasFill && !hasLabel)
@ -198,9 +261,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful
// drawn at FULL width too but horizontally CLIPPED to the fill fraction. // drawn at FULL width too but horizontally CLIPPED to the fill fraction.
// The front carries its own right-cap (shown at 100%); clipping below 100% // The front carries its own right-cap (shown at 100%); clipping below 100%
// removes it and reveals the back track's right-cap — retail's scissor-fill. // removes it and reveals the back track's right-cap — retail's scissor-fill.
//
// ShowDetail icon overlays ride their authored containers: the dim BACK
// icon draws over the full track (its 0x100004A9 child draws AFTER the
// three slice children — higher ReadOrder within the back container);
// the bright FRONT icon is clipped with the rest of the front layer to
// the fill fraction (retail clips the whole element-id-2 child).
bool detail = _detailConfigured
&& ActiveRetailStateId == RetailUiStateIds.ShowDetail;
DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width);
if (detail)
DrawDetailIcon(ctx, resolve, _detailBackSprite, _detailBackRect, Width);
if (pct is not null && p > 0f) if (pct is not null && p > 0f)
{
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
if (detail)
DrawDetailIcon(ctx, resolve, _detailFrontSprite, _detailFrontRect, Width * p);
}
} }
} }
else else
@ -302,6 +379,30 @@ public sealed class UiMeter : UiElement, IUiDatStateful
ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One); ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One);
} }
/// <summary>
/// Draws one ShowDetail icon overlay at its authored meter-local rect,
/// horizontally clipped to <paramref name="clipW"/> local px (the back icon
/// passes the full width; the front icon passes <c>Width * fraction</c>,
/// mirroring retail's whole-front-container fill clip). The visible portion
/// is UV-cropped so the icon reveals left-to-right with the fill. Height is
/// clamped to the meter's box — retail clips children to the parent rect
/// (the stamina FRONT overlay authors H=28 in a 16px bar; retail shows 16).
/// </summary>
private void DrawDetailIcon(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint spriteId, (float X, float Y, float W, float H) rect, float clipW)
{
if (spriteId == 0 || rect.W <= 0f || rect.H <= 0f) return;
var (tex, _, _) = resolve(spriteId);
if (tex == 0) return;
float visibleW = MathF.Min(rect.W, clipW - rect.X);
if (visibleW <= 0f) return;
float h = MathF.Min(rect.H, Height - rect.Y);
if (h <= 0f) return;
float u1 = visibleW / rect.W;
ctx.DrawSprite(tex, rect.X, rect.Y, visibleW, h, 0f, 0f, u1, 1f, Vector4.One);
}
/// <summary>Draw a slice over local [<paramref name="pieceX"/>, /// <summary>Draw a slice over local [<paramref name="pieceX"/>,
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every /// pieceX+<paramref name="pieceW"/>], with the texture repeating every
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped). /// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).

View file

@ -386,6 +386,22 @@ public sealed class UiText : UiElement, IUiDatStateful
&& TryColor(color, out Vector4 resolvedColor)) && TryColor(color, out Vector4 resolvedColor))
DefaultColor = resolvedColor; DefaultColor = resolvedColor;
// Per-state Invisible (dat property 0x3B): retail's SetState applies the
// committed state's properties through UIElement::OnSetAttribute, whose
// case 8 (@0x00462DAE, property id 0x33 + 8 = 0x3B) is
// `SetVisible(value == 0)`. Same NAMED-states-only scoping as
// UiDatElement.TrySetRetailState (a DirectState 0x3B is the
// construction-time "authored invisible" class — #408, separately
// gated). First consumer here: the vitals cur/max number labels
// (0x100000EB/ED/EF) author HideDetail={0x3B:false} /
// ShowDetail={0x3B:true} — the numbers hide when the click toggle
// switches the window to the graphical icon mode.
if (stateId != UiStateInfo.DirectStateId
&& state is not null
&& state.Properties.Values.TryGetValue(0x3Bu, out var invisibleProp)
&& invisibleProp.Kind == UiPropertyKind.Bool)
Visible = !invisibleProp.BoolValue;
// Retail's state cascade also swaps the element's AUTHORED string // Retail's state cascade also swaps the element's AUTHORED string
// when the incoming state carries its own 0x17 (the friends row's // when the incoming state carries its own 0x17 (the friends row's
// status cell: 'Online'/'Offline' with per-state colors). Resolved // status cell: 'Online'/'Offline' with per-state colors). Resolved

View file

@ -0,0 +1,199 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// The retail vitals numeric/graphical click toggle, against the committed
/// vitals fixture (<c>vitals_2100006C.json</c>) — no dats, no GL.
///
/// Retail mechanism (fully derived 2026-08-17):
/// <list type="bullet">
/// <item><description><c>gmVitalsUI::ListenToElementMessage @0x004BFC00</c> —
/// mouse press (msg 0x1C) with dwParam1 7 (left) or 0xA (right) flips
/// <c>SetState(m_state == HideDetail ? ShowDetail : HideDetail)</c>.</description></item>
/// <item><description><c>UIElement::SetState @0x00464E70</c> — the state
/// cascades through the authored <c>PassToChildren</c> chain (root and meters
/// author media-less HideDetail/ShowDetail with PassToChildren=true).</description></item>
/// <item><description>The cur/max labels author per-state 0x3B (invisible —
/// <c>UIElement::OnSetAttribute</c> case 8 @0x00462DAE is
/// <c>SetVisible(value == 0)</c>): HideDetail={0x3B:false} (numbers shown),
/// ShowDetail={0x3B:true} (numbers hidden).</description></item>
/// <item><description>The 0x100004A9 overlay children author icon media only
/// for ShowDetail (heart 0x06007490/91, sword 0x06007492/93, scepter
/// 0x06007494/95) — dim back icon unclipped, bright front icon fill-clipped.</description></item>
/// <item><description><c>UIElement_Dragbar @0x0046C850</c> /
/// <c>UIElement_Resizebar @0x0046B930</c> consume presses (return 2) — a
/// window move/resize press never toggles.</description></item>
/// <item><description>Not persisted: <c>gmGamePlayUI::SaveScreenLayout
/// @0x004EAD50</c> writes window rects only; each window resets to the
/// authored Undef default per session.</description></item>
/// </list>
/// </summary>
[Trait("Category", "Conformance")]
public class VitalsDetailToggleTests
{
private const uint DragBarTop = 0x1000063Cu; // Type 2 — WindowMoveHandle
private const uint GripTopLeft = 0x1000063Bu; // Type 9 — UiResizeGrip
// ── Import shape ─────────────────────────────────────────────────────────
[Fact]
public void VitalsTree_RootIsVitalsRootWidget()
{
var layout = FixtureLoader.LoadVitals();
Assert.IsType<UiVitalsRoot>(layout.Root);
}
[Fact]
public void VitalsTree_MetersAbsorbDetailIconOverlays()
{
var layout = FixtureLoader.LoadVitals();
// MeterId → (dim back icon, bright front icon) from the authored
// 0x100004A9 ShowDetail media (format doc §11 + installed-DAT probe).
(uint MeterId, uint Back, uint Front)[] cases =
[
(VitalsController.Health, 0x06007490u, 0x06007491u), // heart
(VitalsController.Stamina, 0x06007492u, 0x06007493u), // sword
(VitalsController.Mana, 0x06007494u, 0x06007495u), // scepter
];
foreach (var (meterId, back, front) in cases)
{
var m = Assert.IsType<UiMeter>(layout.FindElement(meterId));
Assert.True(m.HasDetailOverlay);
Assert.Equal(back, m.DetailBackSprite);
Assert.Equal(front, m.DetailFrontSprite);
}
// Health's authored overlay rect: 18x16 at x=66 (the heart).
var health = Assert.IsType<UiMeter>(layout.FindElement(VitalsController.Health));
Assert.Equal((66f, 0f, 18f, 16f), health.DetailBackRect);
}
// ── The press toggle ─────────────────────────────────────────────────────
[Fact]
public void Press_TogglesUndefThenHideDetailThenShowDetail()
{
var layout = FixtureLoader.LoadVitals();
var root = Assert.IsType<UiVitalsRoot>(layout.Root);
var stamina = Assert.IsType<UiMeter>(layout.FindElement(VitalsController.Stamina));
var staminaText = Assert.IsType<UiText>(layout.FindElement(VitalsController.StaminaText));
// Login default: authored DefaultState is Undef and nothing calls
// SetState — numbers visible, no icons.
Assert.True(staminaText.Visible);
// Press 1 (from Undef): retail lands on HideDetail — visually identical
// (labels author {0x3B:false} there), never back to Undef afterwards.
Press(root, root);
Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.HideDetail, stamina.ActiveRetailStateId);
Assert.True(staminaText.Visible);
// Press 2: ShowDetail — the graphical mode. Numbers hidden, icons on.
Press(root, root);
Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.ShowDetail, stamina.ActiveRetailStateId);
Assert.False(staminaText.Visible);
// Press 3: back to numeric.
Press(root, root);
Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId);
Assert.True(staminaText.Visible);
}
[Fact]
public void RightPress_TogglesLikeLeftPress()
{
// Retail accepts dwParam1 7 (left) OR 0xA (right) — @0x004BFC19.
var layout = FixtureLoader.LoadVitals();
var root = Assert.IsType<UiVitalsRoot>(layout.Root);
Press(root, root, UiEventType.RightDown);
Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId);
Press(root, root, UiEventType.RightDown);
Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId);
}
[Fact]
public void Press_CascadesToAllThreeLabels()
{
var layout = FixtureLoader.LoadVitals();
var root = Assert.IsType<UiVitalsRoot>(layout.Root);
Press(root, root); // Undef → HideDetail
Press(root, root); // HideDetail → ShowDetail
foreach (uint textId in new[]
{
VitalsController.HealthText,
VitalsController.StaminaText,
VitalsController.ManaText,
})
{
var text = Assert.IsType<UiText>(layout.FindElement(textId));
Assert.False(text.Visible);
}
}
// ── Chrome exclusions ────────────────────────────────────────────────────
[Fact]
public void Press_OnDragBar_DoesNotToggle()
{
// Retail UIElement_Dragbar::ListenToElementMessage returns 2 (consumed)
// for every message — the press never reaches gmVitalsUI.
var layout = FixtureLoader.LoadVitals();
var root = Assert.IsType<UiVitalsRoot>(layout.Root);
var dragBar = layout.FindElement(DragBarTop);
Assert.NotNull(dragBar);
Assert.True(dragBar!.WindowMoveHandle);
uint before = root.ActiveRetailStateId;
Press(root, dragBar);
Assert.Equal(before, root.ActiveRetailStateId);
}
[Fact]
public void Press_OnResizeGrip_DoesNotToggle()
{
// Retail UIElement_Resizebar::ListenToElementMessage returns 2 likewise.
var layout = FixtureLoader.LoadVitals();
var root = Assert.IsType<UiVitalsRoot>(layout.Root);
var grip = layout.FindElement(GripTopLeft);
Assert.NotNull(grip);
Assert.IsType<UiResizeGrip>(grip);
uint before = root.ActiveRetailStateId;
Press(root, grip!);
Assert.Equal(before, root.ActiveRetailStateId);
}
// ── Independence: each window keeps its own state ─────────────────────────
[Fact]
public void TwoWindows_ToggleIndependently()
{
// Retail: gmFloatyVitalsUI and gmFloatySideVitalsUI each carry their
// own m_state; toggling one never touches the other.
var a = FixtureLoader.LoadVitals();
var b = FixtureLoader.LoadVitals();
var rootA = Assert.IsType<UiVitalsRoot>(a.Root);
var rootB = Assert.IsType<UiVitalsRoot>(b.Root);
Press(rootA, rootA);
Press(rootA, rootA);
Assert.Equal(RetailUiStateIds.ShowDetail, rootA.ActiveRetailStateId);
Assert.NotEqual(RetailUiStateIds.ShowDetail, rootB.ActiveRetailStateId);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Delivers a press to <paramref name="root"/> as UiRoot's bubble
/// walk would: the hit target rides in <see cref="UiEvent.Target"/>.</summary>
private static void Press(UiVitalsRoot root, UiElement target,
int type = UiEventType.MouseDown)
=> root.OnEvent(new UiEvent(target.EventId, target, type));
}