fix(ui): Campaign LA gate round 2 — character-select root background stretches, never tiles

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>
This commit is contained in:
Erik 2026-08-15 09:42:53 +02:00
parent 936077d576
commit 71bf24fb6f
6 changed files with 256 additions and 5 deletions

View file

@ -219,6 +219,49 @@ public sealed class CharacterManagementLiveDatTests
+ string.Join('\n', unresolved));
}
/// <summary>
/// Campaign LA gate round 2 (background-tiling investigation): the raw dat
/// authors NO edge anchors at all on the char-select root (0x1000039A) — every
/// one of LeftEdge/TopEdge/RightEdge/BottomEdge is 0 ("no anchor" per
/// <c>UIElement::UpdateForParentSizeChange</c>, acclient 0x00462640). Retail's
/// own edge-anchor resize mechanism therefore NEVER touches this element's size;
/// it stays a fixed 800x600 rect in retail's own widget tree. This is the pivot
/// fact behind <see cref="AcDream.App.UI.Layout.UiDatElement.StretchOwnBackgroundToFill"/>:
/// since the dat itself asks for no resize, whatever makes the char-select scene
/// fill an arbitrary window resolution in retail (background AND buttons AND
/// listbox together) cannot be a per-element anchor/draw-mode difference — it has
/// to be an out-of-band presentation-time scale of the whole fixed-size frame.
/// acdream instead resizes the MOUNTED root itself (CharacterManagementUiController's
/// constructor) to reach the same visual fill, which is why the background needs
/// its own explicit stretch flag rather than an authored draw-mode bit.
/// </summary>
[InstalledDatFact]
public void RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
uint layoutDid = RetailDataIdResolver.Resolve(
dats,
CharacterManagementUiController.RootEnum,
5u);
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(
dats,
layoutDid,
CharacterManagementUiController.RootElementId));
Assert.Equal(0u, rootInfo.Left);
Assert.Equal(0u, rootInfo.Top);
Assert.Equal(0u, rootInfo.Right);
Assert.Equal(0u, rootInfo.Bottom);
Assert.Equal(3u, rootInfo.Type); // UIElement_Field — generic container, not a custom gm*UI class id
}
private static ElementInfo? FindById(ElementInfo info, uint id)
{
if (info.Id == id) return info;

View file

@ -8,6 +8,22 @@ namespace AcDream.App.Tests.UI.Layout;
public sealed class CharacterManagementUiControllerTests
{
/// <summary>
/// Campaign LA gate round 2: the constructor resizes Root to the host viewport
/// (see the constructor's Root.Width/Height block) — its own background must
/// therefore draw stretched, not tiled, or it visibly repeats at any resolution
/// bigger than the authored 800x600 canvas. See
/// <see cref="AcDream.App.UI.Layout.UiDatElement.StretchOwnBackgroundToFill"/>.
/// </summary>
[Fact]
public void Constructor_MarksRootBackgroundToStretch_NotTile()
{
using var environment = new EnvironmentHarness();
var root = Assert.IsType<UiDatElement>(environment.Controller.Root);
Assert.True(root.StretchOwnBackgroundToFill);
}
[Fact]
public void AuthoredChildContract_PreservesRuntimeOrderGreyTailHighlightAndButtonMatrix()
{

View file

@ -1,9 +1,120 @@
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()
{