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:
parent
936077d576
commit
71bf24fb6f
6 changed files with 256 additions and 5 deletions
File diff suppressed because one or more lines are too long
|
|
@ -83,6 +83,16 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
Root.ClickThrough = false;
|
Root.ClickThrough = false;
|
||||||
Root.Visible = false;
|
Root.Visible = false;
|
||||||
|
|
||||||
|
// Campaign LA gate round 2: this root is resized to the live viewport just
|
||||||
|
// above, which is bigger than its authored 800x600 canvas at almost every
|
||||||
|
// real resolution. Its own DirectState background (RenderSurface 0x06007576)
|
||||||
|
// must scale to fill that resized rect, not tile — see
|
||||||
|
// UiDatElement.StretchOwnBackgroundToFill's doc comment for the retail
|
||||||
|
// mechanism (a fixed-canvas screen stretched once at presentation) this
|
||||||
|
// substitutes.
|
||||||
|
if (Root is UiDatElement rootBackground)
|
||||||
|
rootBackground.StretchOwnBackgroundToFill = true;
|
||||||
|
|
||||||
// Create Character belongs to a future campaign. Keep retail's
|
// Create Character belongs to a future campaign. Keep retail's
|
||||||
// authored control in place and visibly ghosted; do not hide it or
|
// authored control in place and visibly ghosted; do not hide it or
|
||||||
// invent an action.
|
// invent an action.
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,63 @@ public class UiDatElement : UiElement, IUiDatStateful
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint? RuntimeImageTexture { get; set; }
|
public uint? RuntimeImageTexture { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, this element's OWN active-state background media draws as ONE quad
|
||||||
|
/// stretched to exactly fill <see cref="UiElement.Width"/>/<see cref="UiElement.Height"/>
|
||||||
|
/// (UV span 0,0 .. 1,1) instead of the native-pixel TILE formula every other
|
||||||
|
/// <see cref="UiDatElement"/> uses. Default false — every ordinary dat chrome/
|
||||||
|
/// container element (corners, edges, drag bars, tab backdrops) keeps tiling.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Campaign LA gate round 2 (issue found in the live client: the LA8
|
||||||
|
/// character-select background repeated across the window instead of scaling
|
||||||
|
/// with it).</b> Retail's generic UI sprite blit —
|
||||||
|
/// <c>Graphic::Draw</c> (acclient 0x00693b20) dispatching to
|
||||||
|
/// <c>Graphic::PutImage</c> (0x00693a30) for an exact/undersized destination, or a
|
||||||
|
/// modulo-wrapped tile loop otherwise — has exactly two behaviors, copy or tile;
|
||||||
|
/// it can never scale a source image up to a larger destination. This is confirmed
|
||||||
|
/// against two candidate "draw-mode" fields that could have carried a stretch bit
|
||||||
|
/// and don't: <c>BlitMode</c> (acclient.h ~line 3135 — Blit_Normal/3Alpha/4Alpha/
|
||||||
|
/// Colorize/Multiply/Screen/Grayscale/NOP are all COLOR-BLEND selectors) and
|
||||||
|
/// <c>MD_Data_Image::m_drawMode</c>/<c>DrawModeType</c> (Undefined/Normal/Overlay/
|
||||||
|
/// Alphablend — also a blend selector; the "Normal → tile" reading in
|
||||||
|
/// <c>docs/research/2026-06-15-layoutdesc-format.md</c> §6 cited
|
||||||
|
/// <c>ImgTex::TileCSI</c> (0x0053e740), but that function is exclusively called from
|
||||||
|
/// <c>TexMerge::CopyAndTile</c>/<c>ImgTex::CopyCSI</c> for LAND-SURFACE terrain
|
||||||
|
/// texture compositing (<c>TerrainTex</c>) — never from the UI element system; the
|
||||||
|
/// citation was a coincidental name match, not the real call site).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The LA8 root itself (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0
|
||||||
|
/// ("no anchor" — confirmed against the installed DAT via
|
||||||
|
/// <c>CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf</c>),
|
||||||
|
/// so retail's own <c>UIElement::UpdateForParentSizeChange</c> (0x00462640) never
|
||||||
|
/// touches this element's size at all — it stays a fixed 800x600 rect. The only way
|
||||||
|
/// retail's whole pre-world "flow" scene (background AND buttons AND listbox
|
||||||
|
/// together — "the background scales with the root") can still fill an arbitrary
|
||||||
|
/// window resolution edge-to-edge, with the generic sprite blit only ever able to
|
||||||
|
/// copy-or-tile, is that these screens render into a fixed, authored-size (800x600)
|
||||||
|
/// target and the WHOLE FRAME is stretched once at presentation — a step entirely
|
||||||
|
/// outside the UIRegion/<c>Graphic::Draw</c> sprite system.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// acdream has no offscreen fixed-resolution UI render target / present-time scale
|
||||||
|
/// pass — <see cref="AcDream.App.UI.Layout.CharacterManagementUiController"/> instead
|
||||||
|
/// resizes the MOUNTED ROOT ELEMENT itself to the live viewport (see its
|
||||||
|
/// constructor) so the screen still fills the window. This flag is the acknowledged
|
||||||
|
/// divergence for that substitution (register row: acdream resizes the element,
|
||||||
|
/// retail stretches the presented frame) — it makes the resized ROOT's own
|
||||||
|
/// background draw as one stretched quad so the VISUAL RESULT matches retail's
|
||||||
|
/// present-time stretch (no tiling) even though the MECHANISM differs. Set only on
|
||||||
|
/// a screen-level mounted root, never on an ordinary descendant/chrome element —
|
||||||
|
/// those keep the native tile formula, which IS what retail's own blit does for
|
||||||
|
/// content that lives inside the (in retail) fixed 800x600 canvas.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public bool StretchOwnBackgroundToFill { get; set; }
|
||||||
|
|
||||||
protected override void OnDraw(UiRenderContext ctx)
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
{
|
{
|
||||||
if (MediaVisible && RuntimeImageTexture is uint runtimeTexture)
|
if (MediaVisible && RuntimeImageTexture is uint runtimeTexture)
|
||||||
|
|
@ -233,12 +290,25 @@ public class UiDatElement : UiElement, IUiDatStateful
|
||||||
var (tex, tw, th) = _resolve(file);
|
var (tex, tw, th) = _resolve(file);
|
||||||
if (tex != 0 && tw != 0 && th != 0)
|
if (tex != 0 && tw != 0 && th != 0)
|
||||||
{
|
{
|
||||||
// Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI
|
if (StretchOwnBackgroundToFill)
|
||||||
// texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the
|
{
|
||||||
// sprite shader already alpha-blends). No Stretch mode exists in DrawModeType.
|
// One quad, UV 0..1 — see StretchOwnBackgroundToFill's doc comment
|
||||||
|
// for the retail mechanism this substitutes (a fixed-canvas screen
|
||||||
|
// stretched once at presentation).
|
||||||
|
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped
|
||||||
|
// UI texture) — retail's Graphic::Draw/Graphic::PutImage (0x00693b20/
|
||||||
|
// 0x00693a30) copy-or-tile blit; see StretchOwnBackgroundToFill's doc
|
||||||
|
// comment for the corrected citation (NOT ImgTex::TileCSI, which is
|
||||||
|
// land-surface-only). Overlay/Alphablend use the same blit (the sprite
|
||||||
|
// shader already alpha-blends). No Stretch mode exists in DrawModeType.
|
||||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DrawLabel(ctx);
|
DrawLabel(ctx);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,49 @@ public sealed class CharacterManagementLiveDatTests
|
||||||
+ string.Join('\n', unresolved));
|
+ 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)
|
private static ElementInfo? FindById(ElementInfo info, uint id)
|
||||||
{
|
{
|
||||||
if (info.Id == id) return info;
|
if (info.Id == id) return info;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,22 @@ namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
public sealed class CharacterManagementUiControllerTests
|
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]
|
[Fact]
|
||||||
public void AuthoredChildContract_PreservesRuntimeOrderGreyTailHighlightAndButtonMatrix()
|
public void AuthoredChildContract_PreservesRuntimeOrderGreyTailHighlightAndButtonMatrix()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
public class UiDatElementTests
|
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]
|
[Fact]
|
||||||
public void ActiveMedia_PrefersNamedStateOverDirect()
|
public void ActiveMedia_PrefersNamedStateOverDirect()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue