fix(ui): Campaign LA gate round 2 — character-select scales as one authored canvas

Third iteration on the screen, completing AD-98. The previous substitution
stretched only the root BACKGROUND while the child widgets stayed at their
authored 800x600 pixel positions - and the background painting carries
visual anchors (the World/Characters captions are art), so the user gate
showed captions overlapping the listbox and every widget misaligned
against the stretched art.

Retail model (established at 71bf24fb): fixed-canvas pre-world screens
render at authored 800x600 and the whole composed frame stretches once at
presentation; the blitter has no stretch mode. Our equivalent now does the
same one stage earlier:

- UiRoot.FixedCanvasSize: while the char-select screen is active, the
  retained tree lays out in its authored canvas and Draw scopes a uniform
  scale onto TextRenderer.CanvasScale; the mouse entry points apply the
  exact inverse so MouseX/MouseY and every hit test live in canvas space.
- TextRenderer.AppendQuad is the single emission chokepoint - sprites,
  rects, AND glyphs scale together, including retail-authentic non-uniform
  aspect distortion and stretched text. World-space HUD stays native (the
  scale resets outside UiRoot.Draw).
- CharacterManagementUiController stops resizing Root to the viewport;
  activate/deactivate/dispose set and clear the host canvas.
- UiDatElement returns to retail-pure copy-or-tile; the interim
  StretchOwnBackgroundToFill flag is deleted.
- AD-98 updated to describe the completed substitution.

Tests: canvas-scale quad math, inverse input mapping (window click lands
on the canvas-space widget), degenerate-size guards, controller keeps
authored extent + sets/clears the canvas. App suite 5085/6 skips; live-DAT
char-select probes 3/3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 10:39:19 +02:00
parent 71bf24fb6f
commit 73041d7015
8 changed files with 277 additions and 106 deletions

View file

@ -9,19 +9,34 @@ 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"/>.
/// Campaign LA gate round 2 (register AD-98): the root KEEPS its authored
/// 800×600 extent (retail never resizes it — zero edge anchors), and while
/// the screen is active the HOST carries the fixed canvas so the whole tree
/// — widgets, glyphs, and the painted background whose art contains the
/// World/Characters captions — stretches together. Resizing the root while
/// stretching only the art is exactly the misalignment the 2026-08-15 user
/// gate caught. Dispose must release the canvas so in-world UI returns to
/// native pixels.
/// </summary>
[Fact]
public void Constructor_MarksRootBackgroundToStretch_NotTile()
public void ActiveScreen_KeepsAuthoredRootExtent_AndSetsHostFixedCanvas()
{
using var environment = new EnvironmentHarness();
var environment = new EnvironmentHarness();
try
{
UiElement root = environment.Controller.Root;
Assert.Equal(800f, root.Width);
Assert.Equal(600f, root.Height);
Assert.Equal(
new Vector2(root.Width, root.Height),
environment.Host.FixedCanvasSize);
}
finally
{
environment.Dispose();
}
var root = Assert.IsType<UiDatElement>(environment.Controller.Root);
Assert.True(root.StretchOwnBackgroundToFill);
Assert.Null(environment.Host.FixedCanvasSize);
}
[Fact]

View file

@ -23,16 +23,14 @@ public class UiDatElementTests
}
/// <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.
/// 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 StretchOwnBackgroundToFill_False_TilesUvPastOne_WhenRectExceedsNativeSize()
public void OwnBackground_TilesUvPastOne_WhenRectExceedsNativeSize()
{
var info = new ElementInfo { Width = 1920, Height = 1080 };
info.StateMedia[""] = (0x06007576u, 1);
@ -58,43 +56,14 @@ public class UiDatElementTests
}
/// <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.
/// 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 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()
public void CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored()
{
var info = new ElementInfo { Width = 800, Height = 600 };
info.StateMedia[""] = (0x06007576u, 1);
@ -104,13 +73,23 @@ public class UiDatElementTests
Top = 0,
Width = 800,
Height = 600,
StretchOwnBackgroundToFill = true,
};
(TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext();
e.DrawSelfAndChildren(ctx);
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);
}

View file

@ -0,0 +1,121 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign LA gate round 2 (register AD-98): retail renders fixed-canvas
/// pre-world screens (char select, authored 800×600) at authored size and
/// stretches the whole composed frame at presentation — its UI blitter has no
/// stretch mode. acdream's equivalent is <see cref="UiRoot.FixedCanvasSize"/>:
/// draw applies one uniform scale at the renderer's quad chokepoint, and the
/// mouse entry points apply the exact inverse so hit-testing lives in canvas
/// space. These tests pin the scale math and the inverse input mapping — the
/// half that, if wrong, makes the user click on art and hit nothing.
/// </summary>
public class UiRootFixedCanvasTests
{
[Fact]
public void CanvasScale_IsOne_WithoutFixedCanvas()
{
var root = new UiRoot { Width = 1920f, Height = 1080f };
Assert.Equal(Vector2.One, root.CanvasScale);
}
[Fact]
public void CanvasScale_IsWindowOverCanvas_WhenFixed()
{
var root = new UiRoot
{
Width = 1920f,
Height = 1080f,
FixedCanvasSize = new Vector2(800f, 600f),
};
Assert.Equal(new Vector2(2.4f, 1.8f), root.CanvasScale);
}
[Fact]
public void CanvasScale_IsOne_ForDegenerateCanvasOrWindow()
{
var zeroCanvas = new UiRoot
{
Width = 1920f,
Height = 1080f,
FixedCanvasSize = new Vector2(0f, 600f),
};
Assert.Equal(Vector2.One, zeroCanvas.CanvasScale);
var zeroWindow = new UiRoot
{
Width = 0f,
Height = 0f,
FixedCanvasSize = new Vector2(800f, 600f),
};
Assert.Equal(Vector2.One, zeroWindow.CanvasScale);
}
/// <summary>
/// The load-bearing inverse: a click at WINDOW coordinates must land on the
/// widget whose authored CANVAS rect the user visually clicked. The button
/// sits at canvas (300,400)+(120×40); at a 1920×1080 window over an 800×600
/// canvas it appears at window (720,720)-(1008,792). Clicking window
/// (860,750) — canvas (358,417) — must click it; clicking window (300,400)
/// — canvas (125,222), visually empty — must not.
/// </summary>
[Fact]
public void MouseInput_MapsWindowCoordsToCanvasSpace()
{
var root = new UiRoot
{
Width = 1920f,
Height = 1080f,
FixedCanvasSize = new Vector2(800f, 600f),
};
int clicks = 0;
var button = new UiButton(
new ElementInfo { Width = 120, Height = 40 },
_ => (0u, 0, 0))
{
Left = 300f,
Top = 400f,
Width = 120f,
Height = 40f,
OnClick = () => clicks++,
};
root.AddChild(button);
root.OnMouseDown(UiMouseButton.Left, 860, 750);
root.OnMouseUp(UiMouseButton.Left, 860, 750);
Assert.Equal(1, clicks);
Assert.Equal(358, root.MouseX);
Assert.Equal(417, root.MouseY);
root.OnMouseDown(UiMouseButton.Left, 300, 400);
root.OnMouseUp(UiMouseButton.Left, 300, 400);
Assert.Equal(1, clicks);
}
[Fact]
public void MouseInput_IsUntouched_WithoutFixedCanvas()
{
var root = new UiRoot { Width = 1920f, Height = 1080f };
int clicks = 0;
var button = new UiButton(
new ElementInfo { Width = 120, Height = 40 },
_ => (0u, 0, 0))
{
Left = 300f,
Top = 400f,
Width = 120f,
Height = 40f,
OnClick = () => clicks++,
};
root.AddChild(button);
root.OnMouseDown(UiMouseButton.Left, 360, 420);
root.OnMouseUp(UiMouseButton.Left, 360, 420);
Assert.Equal(1, clicks);
}
}