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:
parent
71bf24fb6f
commit
73041d7015
8 changed files with 277 additions and 106 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
121
tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs
Normal file
121
tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue