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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -181,11 +181,15 @@ public static class LayoutImporter
|
|||
// widgets via FindElement and bind LinesProvider without injecting new runtime nodes.
|
||||
//
|
||||
// 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
|
||||
// double-draw the bar art). All other child types are built normally.
|
||||
// carry the 3-slice sprite ids + the ShowDetail icon overlays, not text content;
|
||||
// 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
|
||||
// (no text children). This loop finds nothing for them → no change to vitals.
|
||||
// For vitals this loop builds exactly the cur/max number label (0x100000EB/ED/EF,
|
||||
// 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)
|
||||
{
|
||||
if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful
|
|||
private readonly Dictionary<uint, (string Text, UiMeterLabelAlign Align)> _stateLabels = new();
|
||||
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>
|
||||
public uint ElementId { get; set; }
|
||||
|
||||
|
|
@ -114,8 +131,54 @@ public sealed class UiMeter : UiElement, IUiDatStateful
|
|||
_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)
|
||||
{
|
||||
// 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 hasLabel = _stateLabels.TryGetValue(stateId, out var caption);
|
||||
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.
|
||||
// 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.
|
||||
//
|
||||
// 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);
|
||||
if (detail)
|
||||
DrawDetailIcon(ctx, resolve, _detailBackSprite, _detailBackRect, Width);
|
||||
if (pct is not null && p > 0f)
|
||||
{
|
||||
DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p);
|
||||
if (detail)
|
||||
DrawDetailIcon(ctx, resolve, _detailFrontSprite, _detailFrontRect, Width * p);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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"/>,
|
||||
/// pieceX+<paramref name="pieceW"/>], with the texture repeating every
|
||||
/// <paramref name="nativeW"/> px (UV-repeat — the UI texture is GL_REPEAT-wrapped).
|
||||
|
|
|
|||
|
|
@ -386,6 +386,22 @@ public sealed class UiText : UiElement, IUiDatStateful
|
|||
&& TryColor(color, out Vector4 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
|
||||
// when the incoming state carries its own 0x17 (the friends row's
|
||||
// status cell: 'Online'/'Offline' with per-state colors). Resolved
|
||||
|
|
|
|||
199
tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
Normal file
199
tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue