From 73041d7015e0964d7971e902b08354c6d6314159 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 10:39:19 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20Campaign=20LA=20gate=20round=202=20?= =?UTF-8?q?=E2=80=94=20character-select=20scales=20as=20one=20authored=20c?= =?UTF-8?q?anvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/TextRenderer.cs | 28 +++- .../Layout/CharacterManagementUiController.cs | 32 ++--- src/AcDream.App/UI/Layout/UiDatElement.cs | 54 +++----- src/AcDream.App/UI/UiRoot.cs | 44 +++++++ .../CharacterManagementUiControllerTests.cs | 33 +++-- .../UI/Layout/UiDatElementTests.cs | 69 ++++------ .../UI/UiRootFixedCanvasTests.cs | 121 ++++++++++++++++++ 8 files changed, 277 insertions(+), 106 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 981a496f..aef92786 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -189,7 +189,7 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | -| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic 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 ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. 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, and `UiDatElement.StretchOwnBackgroundToFill` makes that resized root's own background draw as one UV-0..1 quad instead of tiling. | `src/AcDream.App/UI/Layout/UiDatElement.cs` (`StretchOwnBackgroundToFill`, `OnDraw`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (constructor) | Reproducing retail's real mechanism (a fixed 800x600 UI render target scaled at presentation) would touch the render/swapchain pipeline (`GameWindow`, framebuffer setup) far beyond a background-draw fix; resizing the mounted root and stretching only ITS OWN background quad reaches the identical visual result (no tiling, non-uniform fill matching every resolution) confirmed against the installed DAT's zero edge-anchors and the decompiled blitter's copy-or-tile-only behavior. | If acdream ever gains a genuine fixed-resolution UI render target + present-time scale pass, this flag becomes redundant (every root would already present pre-stretched) and should be deleted along with the per-root resize in `CharacterManagementUiController`. Until then, any OTHER screen-level root mounted the same way (a future login/disconnected/datapatch screen) needs the same flag set explicitly — it is not automatic for arbitrary `UiDatElement`s. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020 (`UIElement::SetIsRootElement`); `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf` | +| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic 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 ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (activate/deactivate/dispose set+clear the canvas) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). Any future fixed-canvas screen (login/disconnected/datapatch) sets `UiRoot.FixedCanvasSize` while active — per-screen opt-in, not automatic. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored` | | AD-97 | **Filed 2026-08-14 at Campaign LA slice LA7a (character-restore request tail).** Retail's `CharacterRestore` request (`0xF7D9`) is ≥16 bytes: `CPlayerSystem::RestoreCharacter @0x0055d760` is, in the PDB-paired binary, `push 0x008173B4; push 0x008173B4; push guid; call Proto_UI::SendAdminRestoreCharacter @0x00546cf0`, and the callee packs BOTH constant `PStringBase*` arguments (`PStringBase::Pack @0x004fc6f0` emits ≥4 bytes even empty). Binary Ninja renders the two pushes as an uninitialized `edx` local plus `this` — a rendering artifact around constant `0x008173B4` (all 3 of its other pseudo-C appearances sit in provably-broken decompiles), but the arguments are real. acdream sends the 8-byte guid-only form. What the two constant strings contain is unresolved (a live cdb `db poi(0x008173b4)` would settle it). | `src/AcDream.Core.Net/Messages/CharacterRestore.cs` (`BuildRequestBody`) | ACE reads only `ReadUInt32()` and ignores any tail (`CharacterHandler.cs:331-385`), and holtburger ships guid-only from a real client command path against ACE successfully — the tail is unread by every server we can test against, and packing two strings whose CONTENT we cannot verify would be a guess. | A byte-capture comparison against a real retail client differs from offset 8; a future server that validates the full retail shape would reject our 8-byte request. | `CPlayerSystem::RestoreCharacter @0x0055d760` (binary bytes, not the BN rendering); `Proto_UI::SendAdminRestoreCharacter @0x00546cf0`; `PStringBase::Pack @0x004fc6f0`; ACE `CharacterHandler.cs:331-385`; holtburger `character_selection.rs:79-82`; LA7a Opus review F1 (2026-08-14) | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 8d64280d..4aac444e 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -201,6 +201,23 @@ public sealed class TextRenderer : IDisposable }); } + /// + /// Campaign LA gate round 2 (register AD-98): uniform canvas scale applied + /// to every emitted quad — sprites, rects, AND glyphs — at the single + /// emission chokepoint (). Retail renders its + /// fixed-canvas pre-world screens (char select's authored 800×600, root + /// 0x1000039A, zero edge anchors) at authored size and stretches the whole + /// composed frame once at presentation; its UI blitter has no stretch mode + /// at all (Graphic::Draw @0x00693b20 is copy-or-tile only). We have no + /// present-time frame stretch, so the equivalent lives here: while a + /// fixed-canvas screen is active, sets this for the + /// duration of its Draw and everything scales together — including retail's + /// characteristic non-uniform aspect distortion and stretched glyphs. + /// UVs and colors are untouched. Always reset to One outside UiRoot.Draw + /// so the world-space HUD keeps native pixels. + /// + internal Vector2 CanvasScale = Vector2.One; + /// Begin a HUD pass. Call once per frame before any Draw* calls. public void Begin(Vector2 screenSize) { @@ -388,10 +405,19 @@ public sealed class TextRenderer : IDisposable return ns; } - private static void AppendQuad(List buf, + private void AppendQuad(List buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) { + // AD-98 canvas stretch — see CanvasScale's doc comment. Applied after + // all canvas-space clipping, so geometry and UVs stay consistent. + if (CanvasScale != Vector2.One) + { + x *= CanvasScale.X; + y *= CanvasScale.Y; + w *= CanvasScale.X; + h *= CanvasScale.Y; + } // Two triangles (6 verts). CCW in pixel space is clockwise in NDC // because the vertex shader flips Y, so OpenGL's default front-face // is GL_CCW — we rely on cull-face being disabled during HUD pass. diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs index ff5b34a7..bc242a25 100644 --- a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -38,6 +38,7 @@ internal sealed class CharacterManagementUiController : IDisposable private readonly List _rows = []; private readonly Dictionary _rowIds = []; + private Vector2 _authoredCanvas; private RuntimeGenerationToken _lastGeneration; private long _lastRevision = long.MinValue; private uint _deleteDialogContext; @@ -74,24 +75,22 @@ internal sealed class CharacterManagementUiController : IDisposable Root.Left = 0f; Root.Top = 0f; - Root.Anchors = AnchorEdges.Left | AnchorEdges.Top - | AnchorEdges.Right | AnchorEdges.Bottom; - if (host.Width > 0f) - Root.Width = host.Width; - if (host.Height > 0f) - Root.Height = host.Height; Root.ClickThrough = 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; + // Campaign LA gate round 2 (register AD-98): the root KEEPS its authored + // 800×600 extent — retail never resizes it (zero edge anchors, verified + // against the installed DAT) and its blitter has no stretch mode; the + // whole composed screen stretches once at presentation. Our equivalent: + // while this screen is active, the host stretches the ENTIRE canvas — + // widgets, glyphs, and the painted background (which carries the + // "World"/"Characters" captions as art) — as one unit via + // UiRoot.FixedCanvasSize. Resizing the root here instead is exactly the + // half-substitution that misaligned the widgets against the stretched + // art at the 2026-08-15 user gate. + _authoredCanvas = new Vector2( + Root.Width > 0f ? Root.Width : 800f, + Root.Height > 0f ? Root.Height : 600f); // Create Character belongs to a future campaign. Keep retail's // authored control in place and visibly ghosted; do not hide it or @@ -246,6 +245,7 @@ internal sealed class CharacterManagementUiController : IDisposable { _active = true; Root.Visible = true; + _host.FixedCanvasSize = _authoredCanvas; _host.BringToFront(Root); } @@ -310,6 +310,7 @@ internal sealed class CharacterManagementUiController : IDisposable } finally { + _host.FixedCanvasSize = null; _enter.OnClick = null; _delete.OnClick = null; _restore.OnClick = null; @@ -665,6 +666,7 @@ internal sealed class CharacterManagementUiController : IDisposable { _active = false; Root.Visible = false; + _host.FixedCanvasSize = null; } foreach (UiButton row in _rows) { diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 2277e087..a132e6b1 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -206,11 +206,9 @@ public class UiDatElement : UiElement, IUiDatStateful public uint? RuntimeImageTexture { get; set; } /// - /// When true, this element's OWN active-state background media draws as ONE quad - /// stretched to exactly fill / - /// (UV span 0,0 .. 1,1) instead of the native-pixel TILE formula every other - /// uses. Default false — every ordinary dat chrome/ - /// container element (corners, edges, drag bars, tab backdrops) keeps tiling. + /// Retail background-blit ground truth (Campaign LA gate round 2, register + /// AD-98). Every element draws its own media with the native-pixel TILE + /// formula below — retail has no per-element stretch, and neither do we. /// /// /// Campaign LA gate round 2 (issue found in the live client: the LA8 @@ -247,21 +245,16 @@ public class UiDatElement : UiElement, IUiDatStateful /// /// /// - /// acdream has no offscreen fixed-resolution UI render target / present-time scale - /// pass — 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. + /// acdream's equivalent of that present-time stretch is + /// : while a fixed-canvas + /// screen (char select) is active, the WHOLE retained tree — this tile draw + /// included — is scaled uniformly at the renderer's quad chokepoint, with the + /// inverse applied to mouse input. Elements therefore keep their authored + /// canvas-space sizes here, and the tile formula stays exactly retail's: + /// inside the authored canvas an element never exceeds its media's native + /// span unless retail itself tiled it. /// /// - public bool StretchOwnBackgroundToFill { get; set; } - protected override void OnDraw(UiRenderContext ctx) { if (MediaVisible && RuntimeImageTexture is uint runtimeTexture) @@ -290,23 +283,14 @@ public class UiDatElement : UiElement, IUiDatStateful var (tex, tw, th) = _resolve(file); if (tex != 0 && tw != 0 && th != 0) { - if (StretchOwnBackgroundToFill) - { - // 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); - } + // 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; NOT ImgTex::TileCSI, + // which is land-surface-only (corrected citation, see the class + // doc). Overlay/Alphablend use the same blit (the sprite shader + // already alpha-blends). No Stretch mode exists in DrawModeType; + // whole-canvas stretching happens at UiRoot.FixedCanvasSize. + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); } } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 6ca8c472..fe3246b1 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -32,6 +32,32 @@ public sealed class UiRoot : UiElement /// Single owner for named retained-window lifecycle and raise policy. public RetailWindowManager WindowManager { get; } + /// + /// Campaign LA gate round 2 (register AD-98): when set, the retained tree + /// is laid out in this fixed authored canvas (the char-select screen's + /// 800×600) and the whole tree — widgets, glyphs, art — is stretched to + /// the window as one unit, matching retail's present-time frame stretch + /// for fixed-canvas pre-world screens. Draw applies the scale at the + /// renderer's quad chokepoint; the mouse entry points apply the inverse, + /// so / and every hit test live + /// in canvas space. Null (the in-world default) is native 1:1. + /// + public Vector2? FixedCanvasSize { get; set; } + + /// Window→canvas stretch factor; One when no fixed canvas is set. + public Vector2 CanvasScale => + FixedCanvasSize is { X: > 0f, Y: > 0f } canvas && Width > 0f && Height > 0f + ? new Vector2(Width / canvas.X, Height / canvas.Y) + : Vector2.One; + + private (int x, int y) MapWindowToCanvas(int x, int y) + { + Vector2 scale = CanvasScale; + return scale == Vector2.One + ? (x, y) + : ((int)MathF.Round(x / scale.X), (int)MathF.Round(y / scale.Y)); + } + // ── Device-level state ─────────────────────────────────────────────── public int MouseX { get; private set; } public int MouseY { get; private set; } @@ -370,6 +396,21 @@ public sealed class UiRoot : UiElement } public void Draw(UiRenderContext ctx) + { + // AD-98 fixed-canvas stretch: scope the renderer's canvas scale to + // exactly this tree's draws (world-space HUD stays native). + ctx.TextRenderer.CanvasScale = CanvasScale; + try + { + DrawCore(ctx); + } + finally + { + ctx.TextRenderer.CanvasScale = Vector2.One; + } + } + + private void DrawCore(UiRenderContext ctx) { // Render children (panels) sorted by z-order — modal last so it // sits on top. @@ -401,6 +442,7 @@ public sealed class UiRoot : UiElement public void OnMouseMove(int x, int y) { + (x, y) = MapWindowToCanvas(x, y); int dx = x - MouseX; int dy = y - MouseY; MouseX = x; @@ -552,6 +594,7 @@ public sealed class UiRoot : UiElement public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0) { + (x, y) = MapWindowToCanvas(x, y); MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: true); _pressX = x; _pressY = y; @@ -707,6 +750,7 @@ public sealed class UiRoot : UiElement public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0) { + (x, y) = MapWindowToCanvas(x, y); MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: false); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs index d7c77b57..d1f73ead 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementUiControllerTests.cs @@ -9,19 +9,34 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class CharacterManagementUiControllerTests { /// - /// 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 - /// . + /// 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. /// [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(environment.Controller.Root); - Assert.True(root.StretchOwnBackgroundToFill); + Assert.Null(environment.Host.FixedCanvasSize); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs index 9543e166..ac4d8f89 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs @@ -23,16 +23,14 @@ public class UiDatElementTests } /// - /// 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 '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. /// [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 } /// - /// 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. /// [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); - } - - /// - /// 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. - /// - [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); } diff --git a/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs b/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs new file mode 100644 index 00000000..79a6a6a9 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs @@ -0,0 +1,121 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// 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 : +/// 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. +/// +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); + } + + /// + /// 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. + /// + [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); + } +}