#416 (char-select roster highlight never cleared on hover-leave): three decomp-grounded mechanisms replace the media-keyed _availableStates approximation. - UIElement_Button::UpdateState_ @0x00471CF0: the button machine commits ONLY states authored on the button's OWN ElementDesc (AccessStateDesc gate); unauthored requests no-op, preserving custom semantic states. - UIElement::SetState @0x00464E70: an unauthored state id is coerced to state 0 (the unnamed base state) and committed — ported into UiDatElement.TrySetRetailState with the base-descriptor PassToChildren cascade arm. - The SetState media rule @0x004651c0: a committed state replaces the playing media ONLY when its media array is non-empty. UiButton now keeps per-face-segment media states under that rule (segments model retail's PassToChildren children), and LayoutImporter records the raw MediaCount including the File=0 draw-nothing images the drawable filter drops — the roster bar children's base state is exactly such an image, and it is what clears the bar. The row template truth (probe, installed DAT): the row authors EMPTY Normal/rollover/Highlight descriptors with PassToChildren; the three bar children author rollover/Highlight media, NO Normal state, and a File=0 base image. An empty-media Normal_pressed still never blanks a Normal-art button (the media rule keeps the previous art — the exact behavior the old gate approximated), and the Appearance spins' property-only Highlight now genuinely commits: label recolors, arrow art lingers — the retail split AP-222 approximated with a requested-keyed label hack, now retired. Live-verified at char select: hover +alex shows the grey bar, moving off clears it, the selected row keeps its amber bar. #415 (probe wait world-* verbs dead): the filed snapshot-reset diagnosis was wrong — the automation bridge simply never bound without ACDREAM_AUTOMATION_ARTIFACT_DIR. A facts-only WorldRevealFactsAutomationRuntime now binds whenever the retained UI exists; checkpoint/screenshot verbs still require the artifact directory and now report that instead of a generic timeout. App tests 5568/3 skips, Runtime 1756/0, UI.Abstractions 926/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
304 lines
12 KiB
C#
304 lines
12 KiB
C#
using System.Numerics;
|
||
using AcDream.App.Rendering;
|
||
using AcDream.App.Rendering.Gpu;
|
||
using AcDream.App.Tests.Rendering.Gpu;
|
||
using AcDream.App.UI;
|
||
using AcDream.App.UI.Layout;
|
||
namespace AcDream.App.Tests.UI.Layout;
|
||
|
||
public class UiDatElementTests
|
||
{
|
||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||
{
|
||
public IGpuFrame? CurrentFrame => null;
|
||
}
|
||
|
||
private static (TextRenderer renderer, UiRenderContext ctx) BuildRenderContext()
|
||
{
|
||
var device = new RecordingGpuDevice();
|
||
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
||
renderer.Begin(new Vector2(1920f, 1080f));
|
||
var ctx = new UiRenderContext(renderer, new Vector2(1920f, 1080f));
|
||
return (renderer, ctx);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail's blit is copy-or-tile only (Graphic::Draw @0x00693b20 — no
|
||
/// stretch mode exists), so an element grown past its media's native size
|
||
/// tiles: u1 = Width/tw. Whole-screen stretching is NOT this layer's job —
|
||
/// it happens uniformly at UiRoot.FixedCanvasSize / TextRenderer.CanvasScale
|
||
/// (register AD-98), covered by the test below.
|
||
/// </summary>
|
||
[Fact]
|
||
public void OwnBackground_TilesUvPastOne_WhenRectExceedsNativeSize()
|
||
{
|
||
var info = new ElementInfo { Width = 1920, Height = 1080 };
|
||
info.StateMedia[""] = (0x06007576u, 1);
|
||
var e = new UiDatElement(info, _ => (7u, 800, 600))
|
||
{
|
||
Left = 0,
|
||
Top = 0,
|
||
Width = 1920,
|
||
Height = 1080,
|
||
};
|
||
|
||
(TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext();
|
||
e.DrawSelfAndChildren(ctx);
|
||
|
||
var (texture, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts);
|
||
Assert.Equal(7u, texture);
|
||
// Vertex layout: x,y,u,v,r,g,b,a, 6 verts/quad. TextRenderer.AppendQuad emits
|
||
// vertex index 1 as (x+w, y+h, u1, v1) — the (u1,v1) far corner.
|
||
float uMax = verts[1 * 8 + 2];
|
||
float vMax = verts[1 * 8 + 3];
|
||
Assert.Equal(1920f / 800f, uMax, 3);
|
||
Assert.Equal(1080f / 600f, vMax, 3);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AD-98 whole-canvas stretch: with the renderer's CanvasScale set (the
|
||
/// char-select 800×600 canvas on a 1920×1080 window), an element drawn at
|
||
/// authored size emits a quad scaled by exactly (2.4, 1.8) in GEOMETRY while
|
||
/// its UVs stay authored (0..1 here) — one stretched image, no tiling, the
|
||
/// same math retail's present-time frame stretch produces.
|
||
/// </summary>
|
||
[Fact]
|
||
public void CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored()
|
||
{
|
||
var info = new ElementInfo { Width = 800, Height = 600 };
|
||
info.StateMedia[""] = (0x06007576u, 1);
|
||
var e = new UiDatElement(info, _ => (7u, 800, 600))
|
||
{
|
||
Left = 0,
|
||
Top = 0,
|
||
Width = 800,
|
||
Height = 600,
|
||
};
|
||
|
||
(TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext();
|
||
renderer.CanvasScale = new Vector2(1920f / 800f, 1080f / 600f);
|
||
try
|
||
{
|
||
e.DrawSelfAndChildren(ctx);
|
||
}
|
||
finally
|
||
{
|
||
renderer.CanvasScale = Vector2.One;
|
||
}
|
||
|
||
var (_, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts);
|
||
// Far corner (vertex 1): geometry scaled to the window, UVs authored.
|
||
Assert.Equal(1920f, verts[1 * 8 + 0], 3);
|
||
Assert.Equal(1080f, verts[1 * 8 + 1], 3);
|
||
Assert.Equal(1f, verts[1 * 8 + 2], 3);
|
||
Assert.Equal(1f, verts[1 * 8 + 3], 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void ActiveMedia_PrefersNamedStateOverDirect()
|
||
{
|
||
var info = new ElementInfo();
|
||
info.StateMedia[""] = (0x06000001, 1); // DirectState (DrawMode Normal=1)
|
||
info.StateMedia["ShowDetail"] = (0x06000002, 3); // named (Alphablend=3)
|
||
var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "ShowDetail" };
|
||
Assert.Equal(0x06000002u, e.ActiveMedia().File);
|
||
Assert.Equal(3, e.ActiveMedia().DrawMode);
|
||
e.ActiveState = "";
|
||
Assert.Equal(0x06000001u, e.ActiveMedia().File);
|
||
Assert.Equal(1, e.ActiveMedia().DrawMode);
|
||
}
|
||
|
||
[Fact]
|
||
public void ActiveMedia_NoMedia_ReturnsZero()
|
||
{
|
||
var e = new UiDatElement(new ElementInfo(), _ => (0, 0, 0));
|
||
Assert.Equal(0u, e.ActiveMedia().File);
|
||
Assert.Equal(0, e.ActiveMedia().DrawMode);
|
||
}
|
||
|
||
[Fact]
|
||
public void ActiveMedia_MissingNamedState_FallsBackToDirect()
|
||
{
|
||
var info = new ElementInfo();
|
||
info.StateMedia[""] = (0x06000005, 1);
|
||
var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "NoSuchState" };
|
||
Assert.Equal(0x06000005u, e.ActiveMedia().File);
|
||
}
|
||
|
||
// ── G1 tests: DefaultStateName + "Normal" implicit default ───────────────
|
||
|
||
/// <summary>
|
||
/// Task G1 change 5: when an element has no DefaultStateName but does have a "Normal"
|
||
/// state sprite, the ctor should default ActiveState to "Normal" so the element
|
||
/// renders its normal-state sprite without requiring explicit state assignment.
|
||
/// </summary>
|
||
[Fact]
|
||
public void UiDatElement_DefaultsActiveStateToNormal_WhenNormalPresent()
|
||
{
|
||
var info = new ElementInfo();
|
||
info.StateMedia["Normal"] = (0x0000AAAAu, 1);
|
||
info.StateMedia["Hover"] = (0x0000BBBBu, 1);
|
||
|
||
var e = new UiDatElement(info, _ => (0, 0, 0));
|
||
|
||
// Should have defaulted to "Normal" state.
|
||
Assert.Equal(0x0000AAAAu, e.ActiveMedia().File);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"),
|
||
/// it takes priority over the "Normal" implicit default.
|
||
/// </summary>
|
||
[Fact]
|
||
public void UiDatElement_DefaultsActiveStateToDefaultStateName_WhenSet()
|
||
{
|
||
var info = new ElementInfo { DefaultStateName = "Minimized" };
|
||
info.StateMedia["Minimized"] = (0x0000BBBBu, 1);
|
||
info.StateMedia["Maximized"] = (0x0000CCCCu, 1);
|
||
info.StateMedia["Normal"] = (0x0000DDDDu, 1);
|
||
|
||
var e = new UiDatElement(info, _ => (0, 0, 0));
|
||
|
||
// DefaultStateName "Minimized" wins over "Normal" implicit default.
|
||
Assert.Equal(0x0000BBBBu, e.ActiveMedia().File);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Task G1 change 5: elements with only a DirectState sprite and no "Normal" state
|
||
/// should still default to "" (DirectState) — no regression for chrome/grip elements.
|
||
/// </summary>
|
||
[Fact]
|
||
public void UiDatElement_NoDefaultStateName_NoNormal_DefaultsToDirectState()
|
||
{
|
||
var info = new ElementInfo();
|
||
info.StateMedia[""] = (0x06007777u, 1); // DirectState only (e.g. vitals chrome corner)
|
||
|
||
var e = new UiDatElement(info, _ => (0, 0, 0));
|
||
|
||
// No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState).
|
||
Assert.Equal(0x06007777u, e.ActiveMedia().File);
|
||
}
|
||
|
||
[Fact]
|
||
public void NumericStateBridge_SetsNamedStateWithoutChangingGeometry()
|
||
{
|
||
var info = new ElementInfo { X = 4, Y = 5, Width = 30, Height = 40 };
|
||
info.States[RetailUiStateIds.ShowDetail] = new UiStateInfo
|
||
{
|
||
Id = RetailUiStateIds.ShowDetail,
|
||
Name = "ShowDetail",
|
||
Image = new UiImageMedia(0x06000009u, 3),
|
||
};
|
||
info.StateMedia["ShowDetail"] = (0x06000009u, 3);
|
||
var element = new UiDatElement(info, _ => (0u, 0, 0))
|
||
{
|
||
Left = info.X,
|
||
Top = info.Y,
|
||
Width = info.Width,
|
||
Height = info.Height,
|
||
};
|
||
|
||
Assert.True(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
|
||
|
||
Assert.Equal(RetailUiStateIds.ShowDetail, element.ActiveRetailStateId);
|
||
Assert.Equal((0x06000009u, 3), element.ActiveMedia());
|
||
Assert.Equal((4f, 5f, 30f, 40f),
|
||
(element.Left, element.Top, element.Width, element.Height));
|
||
}
|
||
|
||
[Fact]
|
||
public void NumericStateBridge_MissingStateCommitsBaseState()
|
||
{
|
||
// Retail UIElement::SetState @0x00464E70 coerces an UNAUTHORED state
|
||
// id to state 0 (the unnamed base state) and commits it — it never
|
||
// preserves the current state (#416; this test previously codified
|
||
// the pre-decomp misreading).
|
||
var info = new ElementInfo { DefaultStateName = "Normal" };
|
||
info.StateMedia["Normal"] = (0x06000001u, 1);
|
||
var element = new UiDatElement(info, _ => (0u, 0, 0));
|
||
|
||
Assert.True(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
|
||
Assert.Equal("", element.ActiveState);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #416 — the character-select roster row shape: the highlight-bar
|
||
/// children (0x10000481/82/83 in 0x21000004) author Normal_rollover
|
||
/// media but NO 'Normal' state, so the row's PassToChildren hover-leave
|
||
/// cascade commits an unauthored Normal on them. Retail's state-0 arm
|
||
/// clears the bar; the old refusal latched it highlighted forever.
|
||
/// </summary>
|
||
[Fact]
|
||
public void TrySetRetailState_UnauthoredNormal_ClearsRolloverMedia()
|
||
{
|
||
var info = new ElementInfo();
|
||
info.States[UiStateInfo.DirectStateId] =
|
||
new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||
info.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
|
||
info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
|
||
var element = new UiDatElement(info, _ => (0u, 0, 0));
|
||
|
||
Assert.True(element.TrySetRetailState(2u));
|
||
Assert.Equal("Normal_rollover", element.ActiveState);
|
||
|
||
Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
|
||
Assert.Equal("", element.ActiveState);
|
||
Assert.Equal((0u, 0), element.ActiveMedia());
|
||
}
|
||
|
||
/// <summary>
|
||
/// The state-0 fallback must NOT honor a base-DirectState Invisible
|
||
/// (dat 0x3B) — that is the #408 construction-time class, gated
|
||
/// separately; retail's per-state Invisible honor applies to NAMED
|
||
/// authored states only.
|
||
/// </summary>
|
||
[Fact]
|
||
public void TrySetRetailState_UnauthoredStateFallback_DoesNotHonorBaseInvisible()
|
||
{
|
||
var info = new ElementInfo();
|
||
var baseState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||
baseState.Properties.Values[0x3Bu] = new UiPropertyValue
|
||
{
|
||
Kind = UiPropertyKind.Bool,
|
||
BoolValue = true,
|
||
};
|
||
info.States[UiStateInfo.DirectStateId] = baseState;
|
||
info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
|
||
var element = new UiDatElement(info, _ => (0u, 0, 0));
|
||
Assert.True(element.Visible);
|
||
|
||
Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
|
||
|
||
Assert.Equal("", element.ActiveState);
|
||
Assert.True(element.Visible);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Retail's state-0 arm cascades state 0 to children when the BASE
|
||
/// descriptor authors PassToChildren (m_desc.m_bPassToChildren,
|
||
/// @0x00464eca).
|
||
/// </summary>
|
||
[Fact]
|
||
public void TrySetRetailState_UnauthoredStateFallback_CascadesBasePerBaseDescriptor()
|
||
{
|
||
var childInfo = new ElementInfo();
|
||
childInfo.StateMedia[""] = (0x06000002u, 1);
|
||
childInfo.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
|
||
childInfo.States[2u] = new UiStateInfo { Id = 2u, Name = "Normal_rollover" };
|
||
var child = new UiDatElement(childInfo, _ => (0u, 0, 0));
|
||
Assert.True(child.TrySetRetailState(2u));
|
||
|
||
var parentInfo = new ElementInfo();
|
||
parentInfo.States[UiStateInfo.DirectStateId] = new UiStateInfo
|
||
{
|
||
Id = UiStateInfo.DirectStateId,
|
||
PassToChildren = true,
|
||
};
|
||
var parent = new UiDatElement(parentInfo, _ => (0u, 0, 0));
|
||
parent.AddChild(child);
|
||
|
||
Assert.True(parent.TrySetRetailState(UiButtonStateMachine.Normal));
|
||
|
||
Assert.Equal("", child.ActiveState);
|
||
}
|
||
}
|