The login tunnel now covers from the first world-facing frame (the sky-void backdrop can never present pre-tunnel) and holds through an atomic tunnel-to-world swap at reveal completion — the void is structurally unreachable on both edges, pinned by frame-sequence tests across WorldSceneRenderer/WorldRevealCoordinator/LocalPlayerTeleport- Controller/RuntimeWorldTransitState. Vitals detail icons draw at their authored centered offsets in both stacked and side-by-side layouts. Implemented and live-probed by the fix agent; finalized by the lead after the agent parked post-verification (gates re-run green: App 5512/3, Runtime 1747/0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
333 lines
14 KiB
C#
333 lines
14 KiB
C#
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);
|
|
}
|
|
|
|
[Fact]
|
|
public void VitalsTree_OverlaysCarryTheAuthoredCenterAnchors()
|
|
{
|
|
// The 0x100004A9 overlays author LeftEdge=3 / RightEdge=3 — retail's
|
|
// CENTER anchor modes (UIElement::UpdateForParentSizeChange
|
|
// @ 0x004627ca / 0x00462827) — with Top=1/Bottom=1, inside the
|
|
// 150x16 slice containers. These are what keep the icons centered
|
|
// when the vitals window resizes the meters (2026-08-17 gate:
|
|
// absorbed overlays previously kept only the authored rect, pinning
|
|
// the icon at its authored X on any wider meter).
|
|
var layout = FixtureLoader.LoadVitals();
|
|
foreach (uint meterId in new[]
|
|
{
|
|
VitalsController.Health,
|
|
VitalsController.Stamina,
|
|
VitalsController.Mana,
|
|
})
|
|
{
|
|
var m = Assert.IsType<UiMeter>(layout.FindElement(meterId));
|
|
foreach (UiMeterDetailOverlaySpec overlay in new[] { m.DetailBack, m.DetailFront })
|
|
{
|
|
Assert.Equal(3u, overlay.LeftMode);
|
|
Assert.Equal(1u, overlay.TopMode);
|
|
Assert.Equal(3u, overlay.RightMode);
|
|
Assert.Equal(1u, overlay.BottomMode);
|
|
Assert.Equal(150f, overlay.ParentW);
|
|
Assert.Equal(16f, overlay.ParentH);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Icon placement: authored at authored size, centered on resize ────
|
|
|
|
[Fact]
|
|
public void DetailIcon_AuthoredRectStandsAtTheAuthoredMeterSize()
|
|
{
|
|
// Retail reflows a child ONLY when its parent actually resizes; at
|
|
// the authored size the designer's hand-placed rect stands — the
|
|
// stamina sword authors X=32 where the mode-3 center formula yields
|
|
// 33, so recomputing unconditionally would shift the un-resized
|
|
// window by a pixel.
|
|
var sword = new UiMeterDetailOverlaySpec(
|
|
0x06007492u, 32f, 0f, 85f, 16f, 3u, 1u, 3u, 1u, 150f, 16f);
|
|
Assert.Equal(
|
|
(32f, 0f, 85f, 16f),
|
|
UiMeter.ComputeDetailOverlayRect(in sword, 150f, 16f));
|
|
}
|
|
|
|
[Theory]
|
|
// Heart 18x16: near = W/2 - 9, far = W/2 + 9 - 1 -> width preserved.
|
|
[InlineData(66f, 18f, 300f, 141f, 18f)]
|
|
[InlineData(66f, 18f, 220f, 101f, 18f)]
|
|
// Scepter 100x16: near = W/2 - 50.
|
|
[InlineData(25f, 100f, 300f, 100f, 100f)]
|
|
public void DetailIcon_CentersOnAWiderMeter(
|
|
float authoredX, float authoredW, float meterW,
|
|
float expectedX, float expectedW)
|
|
{
|
|
var overlay = new UiMeterDetailOverlaySpec(
|
|
0x06007490u, authoredX, 0f, authoredW, 16f, 3u, 1u, 3u, 1u, 150f, 16f);
|
|
(float x, float y, float w, float h) =
|
|
UiMeter.ComputeDetailOverlayRect(in overlay, meterW, 16f);
|
|
Assert.Equal(expectedX, x);
|
|
Assert.Equal(0f, y);
|
|
Assert.Equal(expectedW, w);
|
|
Assert.Equal(16f, h);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetailIcon_OddWidthLosesOnePixelExactlyLikeRetail()
|
|
{
|
|
// Sword 85px: retail's integer halves (>>1) give near = 150 - 42 =
|
|
// 108 and far = 150 + 42 - 1 = 191 on a 300px meter -> width 84,
|
|
// one pixel narrower than authored. Faithful, not a bug.
|
|
var sword = new UiMeterDetailOverlaySpec(
|
|
0x06007492u, 32f, 0f, 85f, 16f, 3u, 1u, 3u, 1u, 150f, 16f);
|
|
(float x, float _, float w, float _) =
|
|
UiMeter.ComputeDetailOverlayRect(in sword, 300f, 16f);
|
|
Assert.Equal(108f, x);
|
|
Assert.Equal(84f, w);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── End-to-end: the REAL mouse path (hit test + bubble) ──────────────────
|
|
|
|
[Fact]
|
|
public void MouseDown_OnWindowBody_TogglesThroughTheRealHitPath()
|
|
{
|
|
var (root, vitalsRoot) = MountedWindow();
|
|
|
|
// Body press: the stamina meter's center (meters are ClickThrough, so
|
|
// the hit lands on the window content and bubbles to the vitals root).
|
|
root.OnMouseDown(UiMouseButton.Left, 90, 59);
|
|
root.OnMouseUp(UiMouseButton.Left, 90, 59);
|
|
Assert.Equal(RetailUiStateIds.HideDetail, vitalsRoot.ActiveRetailStateId);
|
|
|
|
// Right press toggles too (retail dwParam1 0xA).
|
|
root.OnMouseDown(UiMouseButton.Right, 90, 59);
|
|
root.OnMouseUp(UiMouseButton.Right, 90, 59);
|
|
Assert.Equal(RetailUiStateIds.ShowDetail, vitalsRoot.ActiveRetailStateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseDown_OnTopDragBar_MovesArmWithoutToggling()
|
|
{
|
|
var (root, vitalsRoot) = MountedWindow();
|
|
|
|
// The authored top drag bar (0x1000063C) spans content-local
|
|
// (5,0)-(155,5) → canvas y just below the window top. Retail's
|
|
// Dragbar consumes the press; the toggle must not fire.
|
|
root.OnMouseDown(UiMouseButton.Left, 90, 32);
|
|
root.OnMouseUp(UiMouseButton.Left, 90, 32);
|
|
Assert.NotEqual(RetailUiStateIds.HideDetail, vitalsRoot.ActiveRetailStateId);
|
|
Assert.NotEqual(RetailUiStateIds.ShowDetail, vitalsRoot.ActiveRetailStateId);
|
|
}
|
|
|
|
private static (UiRoot Root, UiVitalsRoot VitalsRoot) MountedWindow()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var layout = FixtureLoader.LoadVitals();
|
|
RetailWindowFrame.Mount(root, layout.Root, static _ => (0u, 0, 0),
|
|
new RetailWindowFrame.Options
|
|
{
|
|
WindowName = WindowNames.Vitals,
|
|
Chrome = RetailWindowChrome.Imported,
|
|
Left = 10f,
|
|
Top = 30f,
|
|
ResizeX = true,
|
|
ResizeY = false,
|
|
MinWidth = 40f,
|
|
ContentClickThrough = false,
|
|
});
|
|
return (root, Assert.IsType<UiVitalsRoot>(layout.Root));
|
|
}
|
|
|
|
// ── 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));
|
|
}
|