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
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