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

@ -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-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<ContentProfile>` 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-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList<ContentProfile>` 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-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<char>*` 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-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<char>*` 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 | | 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 |

View file

@ -201,6 +201,23 @@ public sealed class TextRenderer : IDisposable
}); });
} }
/// <summary>
/// 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 (<see cref="AppendQuad"/>). 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, <see cref="UiRoot"/> 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.
/// </summary>
internal Vector2 CanvasScale = Vector2.One;
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary> /// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
public void Begin(Vector2 screenSize) public void Begin(Vector2 screenSize)
{ {
@ -388,10 +405,19 @@ public sealed class TextRenderer : IDisposable
return ns; return ns;
} }
private static void AppendQuad(List<float> buf, private void AppendQuad(List<float> buf,
float x, float y, float w, float h, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color) 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 // Two triangles (6 verts). CCW in pixel space is clockwise in NDC
// because the vertex shader flips Y, so OpenGL's default front-face // 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. // is GL_CCW — we rely on cull-face being disabled during HUD pass.

View file

@ -38,6 +38,7 @@ internal sealed class CharacterManagementUiController : IDisposable
private readonly List<UiButton> _rows = []; private readonly List<UiButton> _rows = [];
private readonly Dictionary<UiButton, uint> _rowIds = []; private readonly Dictionary<UiButton, uint> _rowIds = [];
private Vector2 _authoredCanvas;
private RuntimeGenerationToken _lastGeneration; private RuntimeGenerationToken _lastGeneration;
private long _lastRevision = long.MinValue; private long _lastRevision = long.MinValue;
private uint _deleteDialogContext; private uint _deleteDialogContext;
@ -74,24 +75,22 @@ internal sealed class CharacterManagementUiController : IDisposable
Root.Left = 0f; Root.Left = 0f;
Root.Top = 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.ClickThrough = false;
Root.Visible = false; Root.Visible = false;
// Campaign LA gate round 2: this root is resized to the live viewport just // Campaign LA gate round 2 (register AD-98): the root KEEPS its authored
// above, which is bigger than its authored 800x600 canvas at almost every // 800×600 extent — retail never resizes it (zero edge anchors, verified
// real resolution. Its own DirectState background (RenderSurface 0x06007576) // against the installed DAT) and its blitter has no stretch mode; the
// must scale to fill that resized rect, not tile — see // whole composed screen stretches once at presentation. Our equivalent:
// UiDatElement.StretchOwnBackgroundToFill's doc comment for the retail // while this screen is active, the host stretches the ENTIRE canvas —
// mechanism (a fixed-canvas screen stretched once at presentation) this // widgets, glyphs, and the painted background (which carries the
// substitutes. // "World"/"Characters" captions as art) — as one unit via
if (Root is UiDatElement rootBackground) // UiRoot.FixedCanvasSize. Resizing the root here instead is exactly the
rootBackground.StretchOwnBackgroundToFill = true; // 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 // 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
@ -246,6 +245,7 @@ internal sealed class CharacterManagementUiController : IDisposable
{ {
_active = true; _active = true;
Root.Visible = true; Root.Visible = true;
_host.FixedCanvasSize = _authoredCanvas;
_host.BringToFront(Root); _host.BringToFront(Root);
} }
@ -310,6 +310,7 @@ internal sealed class CharacterManagementUiController : IDisposable
} }
finally finally
{ {
_host.FixedCanvasSize = null;
_enter.OnClick = null; _enter.OnClick = null;
_delete.OnClick = null; _delete.OnClick = null;
_restore.OnClick = null; _restore.OnClick = null;
@ -665,6 +666,7 @@ internal sealed class CharacterManagementUiController : IDisposable
{ {
_active = false; _active = false;
Root.Visible = false; Root.Visible = false;
_host.FixedCanvasSize = null;
} }
foreach (UiButton row in _rows) foreach (UiButton row in _rows)
{ {

View file

@ -206,11 +206,9 @@ public class UiDatElement : UiElement, IUiDatStateful
public uint? RuntimeImageTexture { get; set; } public uint? RuntimeImageTexture { get; set; }
/// <summary> /// <summary>
/// When true, this element's OWN active-state background media draws as ONE quad /// Retail background-blit ground truth (Campaign LA gate round 2, register
/// stretched to exactly fill <see cref="UiElement.Width"/>/<see cref="UiElement.Height"/> /// AD-98). Every element draws its own media with the native-pixel TILE
/// (UV span 0,0 .. 1,1) instead of the native-pixel TILE formula every other /// formula below — retail has no per-element stretch, and neither do we.
/// <see cref="UiDatElement"/> uses. Default false — every ordinary dat chrome/
/// container element (corners, edges, drag bars, tab backdrops) keeps tiling.
/// ///
/// <para> /// <para>
/// <b>Campaign LA gate round 2 (issue found in the live client: the LA8 /// <b>Campaign LA gate round 2 (issue found in the live client: the LA8
@ -247,21 +245,16 @@ public class UiDatElement : UiElement, IUiDatStateful
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// acdream has no offscreen fixed-resolution UI render target / present-time scale /// acdream's equivalent of that present-time stretch is
/// pass — <see cref="AcDream.App.UI.Layout.CharacterManagementUiController"/> instead /// <see cref="AcDream.App.UI.UiRoot.FixedCanvasSize"/>: while a fixed-canvas
/// resizes the MOUNTED ROOT ELEMENT itself to the live viewport (see its /// screen (char select) is active, the WHOLE retained tree — this tile draw
/// constructor) so the screen still fills the window. This flag is the acknowledged /// included — is scaled uniformly at the renderer's quad chokepoint, with the
/// divergence for that substitution (register row: acdream resizes the element, /// inverse applied to mouse input. Elements therefore keep their authored
/// retail stretches the presented frame) — it makes the resized ROOT's own /// canvas-space sizes here, and the tile formula stays exactly retail's:
/// background draw as one stretched quad so the VISUAL RESULT matches retail's /// inside the authored canvas an element never exceeds its media's native
/// present-time stretch (no tiling) even though the MECHANISM differs. Set only on /// span unless retail itself tiled it.
/// 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> /// </para>
/// </summary> /// </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)
@ -290,25 +283,16 @@ 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)
{ {
if (StretchOwnBackgroundToFill) // TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped
{ // UI texture) — retail's Graphic::Draw/Graphic::PutImage
// One quad, UV 0..1 — see StretchOwnBackgroundToFill's doc comment // (0x00693b20/0x00693a30) copy-or-tile blit; NOT ImgTex::TileCSI,
// for the retail mechanism this substitutes (a fixed-canvas screen // which is land-surface-only (corrected citation, see the class
// stretched once at presentation). // doc). Overlay/Alphablend use the same blit (the sprite shader
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, 1, 1, Vector4.One); // already alpha-blends). No Stretch mode exists in DrawModeType;
} // whole-canvas stretching happens at UiRoot.FixedCanvasSize.
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);
} }

View file

@ -32,6 +32,32 @@ public sealed class UiRoot : UiElement
/// <summary>Single owner for named retained-window lifecycle and raise policy.</summary> /// <summary>Single owner for named retained-window lifecycle and raise policy.</summary>
public RetailWindowManager WindowManager { get; } public RetailWindowManager WindowManager { get; }
/// <summary>
/// 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 <see cref="MouseX"/>/<see cref="MouseY"/> and every hit test live
/// in canvas space. Null (the in-world default) is native 1:1.
/// </summary>
public Vector2? FixedCanvasSize { get; set; }
/// <summary>Window→canvas stretch factor; One when no fixed canvas is set.</summary>
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 ─────────────────────────────────────────────── // ── Device-level state ───────────────────────────────────────────────
public int MouseX { get; private set; } public int MouseX { get; private set; }
public int MouseY { get; private set; } public int MouseY { get; private set; }
@ -370,6 +396,21 @@ public sealed class UiRoot : UiElement
} }
public void Draw(UiRenderContext ctx) 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 // Render children (panels) sorted by z-order — modal last so it
// sits on top. // sits on top.
@ -401,6 +442,7 @@ public sealed class UiRoot : UiElement
public void OnMouseMove(int x, int y) public void OnMouseMove(int x, int y)
{ {
(x, y) = MapWindowToCanvas(x, y);
int dx = x - MouseX; int dx = x - MouseX;
int dy = y - MouseY; int dy = y - MouseY;
MouseX = x; MouseX = x;
@ -552,6 +594,7 @@ public sealed class UiRoot : UiElement
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0) public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
{ {
(x, y) = MapWindowToCanvas(x, y);
MouseX = x; MouseY = y; MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: true); UpdateButtonFlag(btn, down: true);
_pressX = x; _pressY = y; _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) public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0)
{ {
(x, y) = MapWindowToCanvas(x, y);
MouseX = x; MouseY = y; MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: false); UpdateButtonFlag(btn, down: false);

View file

@ -9,19 +9,34 @@ namespace AcDream.App.Tests.UI.Layout;
public sealed class CharacterManagementUiControllerTests public sealed class CharacterManagementUiControllerTests
{ {
/// <summary> /// <summary>
/// Campaign LA gate round 2: the constructor resizes Root to the host viewport /// Campaign LA gate round 2 (register AD-98): the root KEEPS its authored
/// (see the constructor's Root.Width/Height block) — its own background must /// 800×600 extent (retail never resizes it — zero edge anchors), and while
/// therefore draw stretched, not tiled, or it visibly repeats at any resolution /// the screen is active the HOST carries the fixed canvas so the whole tree
/// bigger than the authored 800x600 canvas. See /// — widgets, glyphs, and the painted background whose art contains the
/// <see cref="AcDream.App.UI.Layout.UiDatElement.StretchOwnBackgroundToFill"/>. /// 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> /// </summary>
[Fact] [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.Null(environment.Host.FixedCanvasSize);
Assert.True(root.StretchOwnBackgroundToFill);
} }
[Fact] [Fact]

View file

@ -23,16 +23,14 @@ public class UiDatElementTests
} }
/// <summary> /// <summary>
/// Campaign LA gate round 2: the char-select root's background (native 800x600, /// Retail's blit is copy-or-tile only (Graphic::Draw @0x00693b20 — no
/// resolved from a JPEG surface) was drawn with the ordinary UiDatElement TILE /// stretch mode exists), so an element grown past its media's native size
/// UV formula (u1 = Width/tw) after CharacterManagementUiController resized the /// tiles: u1 = Width/tw. Whole-screen stretching is NOT this layer's job —
/// root to the live viewport — at 1920x1080 that produces u1 = 2.4, v1 = 1.8, /// it happens uniformly at UiRoot.FixedCanvasSize / TextRenderer.CanvasScale
/// which GL_REPEAT wraps into a visibly tiled background instead of one stretched /// (register AD-98), covered by the test below.
/// image. See <see cref="UiDatElement.StretchOwnBackgroundToFill"/>'s doc comment
/// for the retail mechanism this substitutes.
/// </summary> /// </summary>
[Fact] [Fact]
public void StretchOwnBackgroundToFill_False_TilesUvPastOne_WhenRectExceedsNativeSize() public void OwnBackground_TilesUvPastOne_WhenRectExceedsNativeSize()
{ {
var info = new ElementInfo { Width = 1920, Height = 1080 }; var info = new ElementInfo { Width = 1920, Height = 1080 };
info.StateMedia[""] = (0x06007576u, 1); info.StateMedia[""] = (0x06007576u, 1);
@ -58,43 +56,14 @@ public class UiDatElementTests
} }
/// <summary> /// <summary>
/// Campaign LA gate round 2 fix: with the flag set, the SAME oversized rect draws /// AD-98 whole-canvas stretch: with the renderer's CanvasScale set (the
/// as one quad spanning UV 0..1 — a single stretched image, matching retail's /// char-select 800×600 canvas on a 1920×1080 window), an element drawn at
/// observed (never-tiled) char-select background. /// 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> /// </summary>
[Fact] [Fact]
public void StretchOwnBackgroundToFill_True_ClampsUvToOne_WhenRectExceedsNativeSize() public void CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored()
{
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 }; var info = new ElementInfo { Width = 800, Height = 600 };
info.StateMedia[""] = (0x06007576u, 1); info.StateMedia[""] = (0x06007576u, 1);
@ -104,13 +73,23 @@ public class UiDatElementTests
Top = 0, Top = 0,
Width = 800, Width = 800,
Height = 600, Height = 600,
StretchOwnBackgroundToFill = true,
}; };
(TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext(); (TextRenderer renderer, UiRenderContext ctx) = BuildRenderContext();
renderer.CanvasScale = new Vector2(1920f / 800f, 1080f / 600f);
try
{
e.DrawSelfAndChildren(ctx); e.DrawSelfAndChildren(ctx);
}
finally
{
renderer.CanvasScale = Vector2.One;
}
var (_, verts) = Assert.Single(renderer.DebugSpriteSegmentVerts); 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 + 2], 3);
Assert.Equal(1f, verts[1 * 8 + 3], 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);
}
}