The LA8 char-select root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=
BottomEdge=0 ("no anchor") in the installed DAT — confirmed via the new
CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf
gate — so retail's own UIElement::UpdateForParentSizeChange (0x00462640) never
resizes this element; it stays a fixed 800x600 rect in retail's own tree.
Retail's generic UI sprite blit, Graphic::Draw (0x00693b20) dispatching to
Graphic::PutImage (0x00693a30) for an exact/undersized destination or a
modulo-wrapped tile loop otherwise, has no third "stretch" mode — confirmed
against BlitMode (acclient.h ~3135) and MD_Data_Image::m_drawMode/DrawModeType,
both COLOR-blend selectors, not tile-vs-stretch geometry modes. The prior
"Normal -> tile, matching ImgTex::TileCSI" citation in UiDatElement was a
mis-attribution: ImgTex::TileCSI (0x0053e740) is called exclusively from
TexMerge::CopyAndTile/ImgTex::CopyCSI for LAND-SURFACE terrain texture
compositing, never from the UI element system.
Given the dat authors zero resize anchors and the blitter can only copy or
tile, the only way retail's whole pre-world scene (background + buttons +
listbox together) fills an arbitrary window resolution is that these
fixed-canvas "flow" screens render at 800x600 and the WHOLE FRAME is
stretched once at presentation — outside the UI sprite system entirely.
acdream has no offscreen fixed-resolution UI render target / present-time
scale pass; CharacterManagementUiController's constructor instead resizes
the MOUNTED ROOT element itself to the live viewport, which is why its own
background tiled (Width/tw > 1 at any resolution above 800x600, wrapped by
GL_REPEAT).
Fix: UiDatElement gains StretchOwnBackgroundToFill (default false, every
ordinary chrome/container element keeps tiling) — when set, the element's
own DirectState background draws as one UV-0..1 quad instead of the native
tile formula. CharacterManagementUiController sets it on Root right where
Root is resized to the host viewport, reaching the same visual result as
retail's present-time stretch (no tiling, no aspect-preserving letterbox)
through a different mechanism. Divergence register row AD-98 records the
substitution.
Tests: three new UiDatElementTests pin the UV-span mechanism generically
(tile past 1.0 when unset and rect exceeds native size; clamped to 1.0 when
set; byte-identical to the old tile formula when rect equals native size,
so every unaffected panel is untouched). CharacterManagementUiControllerTests
pins Root.StretchOwnBackgroundToFill == true post-construction. The live-DAT
gate confirms the root's zero edge-anchors and Type=3 against the installed
DAT. AcDream.App.Tests: 5084 passed / 3 skipped with ACDREAM_PROBE_LIVE_MOUNT=1
(5081/6 skipped without it — the 3 live-DAT-gated tests skip).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
9.1 KiB
C#
240 lines
9.1 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>
|
|
/// Campaign LA gate round 2: the char-select root's background (native 800x600,
|
|
/// resolved from a JPEG surface) was drawn with the ordinary UiDatElement TILE
|
|
/// UV formula (u1 = Width/tw) after CharacterManagementUiController resized the
|
|
/// root to the live viewport — at 1920x1080 that produces u1 = 2.4, v1 = 1.8,
|
|
/// which GL_REPEAT wraps into a visibly tiled background instead of one stretched
|
|
/// image. See <see cref="UiDatElement.StretchOwnBackgroundToFill"/>'s doc comment
|
|
/// for the retail mechanism this substitutes.
|
|
/// </summary>
|
|
[Fact]
|
|
public void StretchOwnBackgroundToFill_False_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>
|
|
/// Campaign LA gate round 2 fix: with the flag set, the SAME oversized rect draws
|
|
/// as one quad spanning UV 0..1 — a single stretched image, matching retail's
|
|
/// observed (never-tiled) char-select background.
|
|
/// </summary>
|
|
[Fact]
|
|
public void StretchOwnBackgroundToFill_True_ClampsUvToOne_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,
|
|
StretchOwnBackgroundToFill = true,
|
|
};
|
|
|
|
(TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext();
|
|
e.DrawSelfAndChildren(ctx);
|
|
|
|
var (texture, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts);
|
|
Assert.Equal(7u, texture);
|
|
float uMax = verts[1 * 8 + 2];
|
|
float vMax = verts[1 * 8 + 3];
|
|
Assert.Equal(1f, uMax, 3);
|
|
Assert.Equal(1f, vMax, 3);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The flag must not change anything for an element whose rect already matches
|
|
/// its native texture size (every ordinary panel/window root today) — stretch
|
|
/// (UV 0..1) and tile (UV Width/tw) are numerically identical at that size, so
|
|
/// this only changes behavior for elements deliberately grown past their art.
|
|
/// </summary>
|
|
[Fact]
|
|
public void StretchOwnBackgroundToFill_True_MatchesTile_WhenRectEqualsNativeSize()
|
|
{
|
|
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,
|
|
StretchOwnBackgroundToFill = true,
|
|
};
|
|
|
|
(TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext();
|
|
e.DrawSelfAndChildren(ctx);
|
|
|
|
var (_, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts);
|
|
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_MissingStatePreservesCurrentState()
|
|
{
|
|
var info = new ElementInfo { DefaultStateName = "Normal" };
|
|
info.StateMedia["Normal"] = (0x06000001u, 1);
|
|
var element = new UiDatElement(info, _ => (0u, 0, 0));
|
|
|
|
Assert.False(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
|
|
Assert.Equal("Normal", element.ActiveState);
|
|
}
|
|
}
|