fix(ui): Campaign LA gate round 2 — character-select scales as one authored canvas
Third iteration on the screen, completing AD-98. The previous substitution
stretched only the root BACKGROUND while the child widgets stayed at their
authored 800x600 pixel positions - and the background painting carries
visual anchors (the World/Characters captions are art), so the user gate
showed captions overlapping the listbox and every widget misaligned
against the stretched art.
Retail model (established at 71bf24fb): fixed-canvas pre-world screens
render at authored 800x600 and the whole composed frame stretches once at
presentation; the blitter has no stretch mode. Our equivalent now does the
same one stage earlier:
- UiRoot.FixedCanvasSize: while the char-select screen is active, the
retained tree lays out in its authored canvas and Draw scopes a uniform
scale onto TextRenderer.CanvasScale; the mouse entry points apply the
exact inverse so MouseX/MouseY and every hit test live in canvas space.
- TextRenderer.AppendQuad is the single emission chokepoint - sprites,
rects, AND glyphs scale together, including retail-authentic non-uniform
aspect distortion and stretched text. World-space HUD stays native (the
scale resets outside UiRoot.Draw).
- CharacterManagementUiController stops resizing Root to the viewport;
activate/deactivate/dispose set and clear the host canvas.
- UiDatElement returns to retail-pure copy-or-tile; the interim
StretchOwnBackgroundToFill flag is deleted.
- AD-98 updated to describe the completed substitution.
Tests: canvas-scale quad math, inverse input mapping (window click lands
on the canvas-space widget), degenerate-size guards, controller keeps
authored extent + sets/clears the canvas. App suite 5085/6 skips; live-DAT
char-select probes 3/3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
71bf24fb6f
commit
73041d7015
8 changed files with 277 additions and 106 deletions
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,23 +283,14 @@ 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
|
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||||
{
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs
Normal file
121
tests/AcDream.App.Tests/UI/UiRootFixedCanvasTests.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA gate round 2 (register AD-98): retail renders fixed-canvas
|
||||||
|
/// pre-world screens (char select, authored 800×600) at authored size and
|
||||||
|
/// stretches the whole composed frame at presentation — its UI blitter has no
|
||||||
|
/// stretch mode. acdream's equivalent is <see cref="UiRoot.FixedCanvasSize"/>:
|
||||||
|
/// draw applies one uniform scale at the renderer's quad chokepoint, and the
|
||||||
|
/// mouse entry points apply the exact inverse so hit-testing lives in canvas
|
||||||
|
/// space. These tests pin the scale math and the inverse input mapping — the
|
||||||
|
/// half that, if wrong, makes the user click on art and hit nothing.
|
||||||
|
/// </summary>
|
||||||
|
public class UiRootFixedCanvasTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CanvasScale_IsOne_WithoutFixedCanvas()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 1920f, Height = 1080f };
|
||||||
|
Assert.Equal(Vector2.One, root.CanvasScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanvasScale_IsWindowOverCanvas_WhenFixed()
|
||||||
|
{
|
||||||
|
var root = new UiRoot
|
||||||
|
{
|
||||||
|
Width = 1920f,
|
||||||
|
Height = 1080f,
|
||||||
|
FixedCanvasSize = new Vector2(800f, 600f),
|
||||||
|
};
|
||||||
|
Assert.Equal(new Vector2(2.4f, 1.8f), root.CanvasScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanvasScale_IsOne_ForDegenerateCanvasOrWindow()
|
||||||
|
{
|
||||||
|
var zeroCanvas = new UiRoot
|
||||||
|
{
|
||||||
|
Width = 1920f,
|
||||||
|
Height = 1080f,
|
||||||
|
FixedCanvasSize = new Vector2(0f, 600f),
|
||||||
|
};
|
||||||
|
Assert.Equal(Vector2.One, zeroCanvas.CanvasScale);
|
||||||
|
|
||||||
|
var zeroWindow = new UiRoot
|
||||||
|
{
|
||||||
|
Width = 0f,
|
||||||
|
Height = 0f,
|
||||||
|
FixedCanvasSize = new Vector2(800f, 600f),
|
||||||
|
};
|
||||||
|
Assert.Equal(Vector2.One, zeroWindow.CanvasScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The load-bearing inverse: a click at WINDOW coordinates must land on the
|
||||||
|
/// widget whose authored CANVAS rect the user visually clicked. The button
|
||||||
|
/// sits at canvas (300,400)+(120×40); at a 1920×1080 window over an 800×600
|
||||||
|
/// canvas it appears at window (720,720)-(1008,792). Clicking window
|
||||||
|
/// (860,750) — canvas (358,417) — must click it; clicking window (300,400)
|
||||||
|
/// — canvas (125,222), visually empty — must not.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void MouseInput_MapsWindowCoordsToCanvasSpace()
|
||||||
|
{
|
||||||
|
var root = new UiRoot
|
||||||
|
{
|
||||||
|
Width = 1920f,
|
||||||
|
Height = 1080f,
|
||||||
|
FixedCanvasSize = new Vector2(800f, 600f),
|
||||||
|
};
|
||||||
|
int clicks = 0;
|
||||||
|
var button = new UiButton(
|
||||||
|
new ElementInfo { Width = 120, Height = 40 },
|
||||||
|
_ => (0u, 0, 0))
|
||||||
|
{
|
||||||
|
Left = 300f,
|
||||||
|
Top = 400f,
|
||||||
|
Width = 120f,
|
||||||
|
Height = 40f,
|
||||||
|
OnClick = () => clicks++,
|
||||||
|
};
|
||||||
|
root.AddChild(button);
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 860, 750);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 860, 750);
|
||||||
|
Assert.Equal(1, clicks);
|
||||||
|
Assert.Equal(358, root.MouseX);
|
||||||
|
Assert.Equal(417, root.MouseY);
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 300, 400);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 300, 400);
|
||||||
|
Assert.Equal(1, clicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MouseInput_IsUntouched_WithoutFixedCanvas()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 1920f, Height = 1080f };
|
||||||
|
int clicks = 0;
|
||||||
|
var button = new UiButton(
|
||||||
|
new ElementInfo { Width = 120, Height = 40 },
|
||||||
|
_ => (0u, 0, 0))
|
||||||
|
{
|
||||||
|
Left = 300f,
|
||||||
|
Top = 400f,
|
||||||
|
Width = 120f,
|
||||||
|
Height = 40f,
|
||||||
|
OnClick = () => clicks++,
|
||||||
|
};
|
||||||
|
root.AddChild(button);
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 360, 420);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 360, 420);
|
||||||
|
Assert.Equal(1, clicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue