fix(ui): Campaign LA gate round 2 — fixed-canvas stretch filters bilinearly like retail's presentation blit

AD-98's fixed-canvas stretch (73041d70) scales every retained-UI quad at
TextRenderer.AppendQuad, but the live gate reported it JAGGED — text
especially. Cause: dat-font glyph atlases and IconComposer's composited
icons upload nearest (TextureCache.UploadUiTexture's UiNearestRepeat
sampler) — correct at the native 1:1 scale (pixel-exact retail art), but
aliased once magnified 2.4x1.8. Chrome/background art was already fine:
it uploads through GpuSamplerDescription.WorldRepeat (linear) by default.
Retail's own fixed-canvas presentation is a single bilinear-filtered
frame blit, never a per-texture stretch — this closes that gap one step
earlier, at the source texture, without adding RHI surface area.

- TextureCache.GetOrCreateLinearUiTwin: lazily registers a SECOND table
  slot for a nearest handle's IGpuTexture, sampled WorldRepeat (linear)
  instead of nearest — no re-decode, no re-upload, no extra memory-ledger
  bytes. Returns the handle unchanged for anything never registered
  nearest (chrome, UiTextureTableHandle.None), so it's a cheap
  unconditional probe. Twin slots are released in Dispose without
  double-disposing the shared texture.
- TextRenderer.LinearTwinResolver + the DrawSprite chokepoint: swaps a
  sprite's texture handle through the resolver only while
  CanvasScale != One. At CanvasScale == One the resolver is never even
  called — zero overhead on the ordinary in-world/UI path.
- InteractionRetainedUiComposition wires the resolver to TextureCache
  right after every UiHost acquisition (the lease can hand back a host
  from a prior session against a fresh TextureCache).
- AD-98's register row gets one added sentence recording the fix.

Tests: TextRendererLinearTwinTests pins the renderer-side handle-swap
seam GPU-free (segment handle selection); TextureCacheLinearTwinTests
pins twin creation/reuse/dispose against RecordingGpuDevice. App suite
5097/3 skips (Release, ACDREAM_PROBE_LIVE_MOUNT=1 live-DAT probes
included). Full solution builds clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 11:05:59 +02:00
parent 73041d7015
commit 308f40a3fb
6 changed files with 346 additions and 1 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. **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-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). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. 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

@ -484,6 +484,12 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.DebugFont, d.DebugFont,
d.HostQuiescence)); d.HostQuiescence));
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired); checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
// AD-98 filtering fidelity: re-wired unconditionally on every
// composition, same as the UiLocked assignment below — the lease can
// hand back a HOST from a previous session while d.TextureCache is a
// fresh instance for this one, so a stale resolver would keep
// resolving twins against a disposed TextureCache.
host.TextRenderer.LinearTwinResolver = d.TextureCache.GetOrCreateLinearUiTwin;
inputCapture = d.RetainedInputCapture.Bind(host.Root); inputCapture = d.RetainedInputCapture.Bind(host.Root);
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound); checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server

View file

@ -218,6 +218,21 @@ public sealed class TextRenderer : IDisposable
/// </summary> /// </summary>
internal Vector2 CanvasScale = Vector2.One; internal Vector2 CanvasScale = Vector2.One;
/// <summary>
/// Campaign LA gate round 2 (register AD-98 filtering fidelity): resolves a
/// UI texture handle to its linear-sampled twin
/// (<see cref="TextureCache.GetOrCreateLinearUiTwin"/>), consulted by
/// <see cref="DrawSprite"/> only while <see cref="CanvasScale"/> is not One.
/// Wired once by the composition root right after <c>TextureCache</c> exists;
/// left null by any test/host that never sets it, in which case a scaled
/// draw keeps sampling its original slot — nearest stays nearest, exactly
/// today's (jagged) behavior, rather than throwing. Nearest-sampled dat-font
/// glyphs and composited icons are the only handles this ever changes —
/// see the resolver's own doc comment for why chrome/background art passes
/// through unchanged.
/// </summary>
internal Func<uint, uint>? LinearTwinResolver { get; set; }
/// <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)
{ {
@ -365,6 +380,15 @@ public sealed class TextRenderer : IDisposable
public void DrawSprite(uint texture, float x, float y, float w, float h, public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint) float u0, float v0, float u1, float v1, Vector4 tint)
{ {
// AD-98 filtering fidelity: while a fixed-canvas screen is stretching
// every quad (CanvasScale != One), sample nearest-registered handles
// through their linear twin instead — see LinearTwinResolver's doc
// comment. The resolver itself is the identity for any handle that
// isn't a nearest-sampled UI texture, so this is safe to call
// unconditionally rather than needing its own "is this nearest" check.
if (CanvasScale != Vector2.One && LinearTwinResolver is { } resolve)
texture = resolve(texture);
SpriteSeg seg = OverlayMode SpriteSeg seg = OverlayMode
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture) ? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture); : NextSpriteSeg(_spriteSegs, ref _segUsed, texture);

View file

@ -53,6 +53,20 @@ public sealed class TextureCache
// GPU texture objects/slots until process exit. // GPU texture objects/slots until process exit.
private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new(); private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new();
// Campaign LA gate round 2 (AD-98 filtering fidelity): the ORIGINAL IGpuTexture
// behind every handle UploadUiTexture registered nearest (dat-font glyph
// atlases, IconComposer's composited icons). Populated at upload time so
// GetOrCreateLinearUiTwin never has to search either keyed family above to
// find the pixels a twin should reuse. Chrome/background art (nearest: false)
// never enters this table — it already samples GpuSamplerDescription.WorldRepeat
// (linear) and has no twin to create.
private readonly Dictionary<uint, IGpuTexture> _nearestUiTextureSources = new();
// The LINEAR-sampled twin handle for a nearest handle, created lazily by
// GetOrCreateLinearUiTwin on its first request and reused after. Empty for
// the lifetime of a session that never activates a fixed-canvas screen.
private readonly Dictionary<uint, uint> _linearUiTwinHandles = new();
private readonly CompositeTextureArrayCache? _compositeTextures; private readonly CompositeTextureArrayCache? _compositeTextures;
private bool _destinationRevealUploadPriority; private bool _destinationRevealUploadPriority;
@ -359,6 +373,14 @@ public sealed class TextureCache
IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat); IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat);
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
uint handle = UiTextureTableHandle.FromSlot(slot);
if (nearest)
{
// AD-98 filtering fidelity: remember the source texture under its
// handle so a fixed-canvas screen can request a linear twin of it
// later without re-decoding. See GetOrCreateLinearUiTwin.
_nearestUiTextureSources[handle] = texture;
}
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height); return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
} }
catch catch
@ -368,6 +390,60 @@ public sealed class TextureCache
} }
} }
/// <summary>
/// Campaign LA gate round 2 (register AD-98): the LINEAR-sampled twin of a
/// nearest-sampled UI texture handle, created and table-registered the first
/// time it is requested and reused after.
///
/// <para>
/// Nearest is correct at the UI's native 1:1 scale — it is what makes
/// dat-font glyphs and composited item icons pixel-exact retail art. Retail's
/// own fixed-canvas pre-world screens never stretch a source texture at all:
/// they compose at authored size and the WHOLE FRAME goes through a single
/// bilinear-filtered presentation blit (see
/// <see cref="AcDream.App.UI.UiRoot.FixedCanvasSize"/>'s doc comment for the
/// retail citation). acdream has no present-time frame stretch to hang that
/// on, so the equivalent has to live one step earlier, at the source texture:
/// while <see cref="TextRenderer.CanvasScale"/> is scaling the composed quads
/// themselves, this method gives a nearest handle a same-pixels twin sampled
/// LINEAR instead, so the stretch softens the way retail's frame blit did
/// rather than aliasing.
/// </para>
///
/// <para>
/// Returns <paramref name="handle"/> UNCHANGED for anything this cache never
/// registered nearest — chrome/background art already samples
/// <see cref="GpuSamplerDescription.WorldRepeat"/> (linear) and has nothing to
/// swap, and <see cref="UiTextureTableHandle.None"/> (DrawFill's untextured
/// branch) is not a texture at all. Callers do not need to know which case
/// they're in: this is a cheap dictionary probe either way, so
/// <see cref="TextRenderer.DrawSprite"/> can call it unconditionally whenever
/// the canvas is scaled.
/// </para>
///
/// <para>
/// The twin reuses the ORIGINAL <see cref="IGpuTexture"/> — no re-decode, no
/// second upload, no additional bytes tracked in the memory ledger — and
/// occupies one more device texture-table slot, exactly the shape
/// <see cref="RegisterWorldSurface"/>'s (surface, wrap) keying already uses to
/// register one texture under two samplers. Lazy: a session that never
/// activates a fixed-canvas screen never creates one.
/// </para>
/// </summary>
internal uint GetOrCreateLinearUiTwin(uint handle)
{
if (!_nearestUiTextureSources.TryGetValue(handle, out IGpuTexture? texture))
return handle;
if (_linearUiTwinHandles.TryGetValue(handle, out uint twin))
return twin;
IGpuSampler linearSampler = _device.CreateSampler(GpuSamplerDescription.WorldRepeat);
GpuTextureSlot twinSlot = _device.RegisterTexture(texture, linearSampler);
uint twinHandle = UiTextureTableHandle.FromSlot(twinSlot);
_linearUiTwinHandles[handle] = twinHandle;
return twinHandle;
}
/// <summary> /// <summary>
/// The identity a UI upload is accounted under. There is no GL name on the /// The identity a UI upload is accounted under. There is no GL name on the
/// Vulkan-only backend, so a descending synthetic counter supplies one; the /// Vulkan-only backend, so a descending synthetic counter supplies one; the
@ -994,6 +1070,15 @@ public sealed class TextureCache
_paletteIndexedByTexture.Clear(); _paletteIndexedByTexture.Clear();
// Campaign LA gate round 2 (AD-98): linear twin slots. Each one is a
// SECOND table registration of a texture another family below owns and
// disposes — release the slot here, before that texture goes away, and
// never touch the texture itself (that would double-dispose it).
foreach (uint twinHandle in _linearUiTwinHandles.Values)
_device.ReleaseTextureSlot(UiTextureTableHandle.ToSlot(twinHandle));
_linearUiTwinHandles.Clear();
_nearestUiTextureSources.Clear();
// RenderSurface (UI sprite) textures — Campaign V slice V4a: each // RenderSurface (UI sprite) textures — Campaign V slice V4a: each
// entry's IGpuTexture.Dispose() releases the underlying GL name // entry's IGpuTexture.Dispose() releases the underlying GL name
// through the device's own retirement queue, so only the memory- // through the device's own retirement queue, so only the memory-

View file

@ -0,0 +1,106 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign LA gate round 2 (register AD-98 filtering fidelity, 2026-08-15):
/// pins <see cref="TextRenderer.DrawSprite"/>'s texture-swap seam —
/// <see cref="TextRenderer.LinearTwinResolver"/>, consulted only while
/// <see cref="TextRenderer.CanvasScale"/> is not <see cref="Vector2.One"/> — the
/// half of the fix that doesn't need a live GPU. The user-visible symptom this
/// answers: the fixed-canvas char-select stretch (73041d70) reported JAGGED
/// text, because dat-font glyph atlases upload nearest (pixel-exact at 1:1) and
/// stayed nearest even while every quad was being magnified 2.4×1.8. The actual
/// linear-twin CREATION lives in <c>TextureCache.GetOrCreateLinearUiTwin</c>
/// (GPU-backed, see <c>TextureCacheLinearTwinTests</c>); this file proves the
/// RENDERER SIDE of the seam — segment handle selection — using a fake resolver
/// so the assertion doesn't depend on TextureCache's own wiring being correct.
/// </summary>
public sealed class TextRendererLinearTwinTests
{
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static TextRenderer BuildRenderer()
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
return renderer;
}
[Fact]
public void CanvasScaleOne_DrawSprite_NeverConsultsResolver_KeepsOriginalHandle()
{
// At the native 1:1 scale (every in-world/UI frame today) the resolver
// must not even be CALLED, not just "called and return the same value" —
// a resolver that throws proves the ordinary path pays zero overhead for
// a feature it never activates, exactly the "lazy" design constraint the
// Campaign LA round-2 follow-up was scoped to.
TextRenderer renderer = BuildRenderer();
renderer.LinearTwinResolver = _ => throw new System.InvalidOperationException(
"LinearTwinResolver must not be consulted while CanvasScale == One.");
renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(5u, seg.Texture);
}
[Fact]
public void CanvasScaleNotOne_DrawSprite_SwapsHandleThroughResolver()
{
// The fixed-canvas case: CanvasScale is set by UiRoot.Draw for the
// duration of a fixed-canvas screen's tree. A nearest-registered dat-font
// glyph handle (5) must draw through its linear twin (999), not itself.
TextRenderer renderer = BuildRenderer();
renderer.CanvasScale = new Vector2(2.4f, 1.8f);
renderer.LinearTwinResolver = handle => handle == 5u ? 999u : handle;
renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(999u, seg.Texture);
}
[Fact]
public void CanvasScaleNotOne_ResolverIsIdentityForUnknownHandles()
{
// Chrome/background art (never registered nearest) and
// UiTextureTableHandle.None (DrawFill's untextured branch) are the
// majority of scaled-canvas draws. TextureCache.GetOrCreateLinearUiTwin
// returns them unchanged; this pins that TextRenderer forwards whatever
// the resolver returns without a separate "was this swapped" branch.
TextRenderer renderer = BuildRenderer();
renderer.CanvasScale = new Vector2(2.4f, 1.8f);
renderer.LinearTwinResolver = handle => handle == 5u ? 999u : handle;
renderer.DrawSprite(7u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(7u, seg.Texture);
}
[Fact]
public void CanvasScaleNotOne_NoResolverWired_KeepsOriginalHandle_DoesNotThrow()
{
// A host/test that never wires LinearTwinResolver (every existing
// TextRenderer construction site before this change, and any future
// test double) must keep drawing — nearest stays nearest, the
// pre-existing (if jagged) behavior, rather than a null-reference
// failure the moment a fixed-canvas screen activates.
TextRenderer renderer = BuildRenderer();
renderer.CanvasScale = new Vector2(2.4f, 1.8f);
renderer.DrawSprite(5u, 0, 0, 10, 10, 0, 0, 1, 1, Vector4.One);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(5u, seg.Texture);
}
}

View file

@ -0,0 +1,124 @@
using System.Linq;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign LA gate round 2 (register AD-98 filtering fidelity, 2026-08-15):
/// pins <see cref="TextureCache.GetOrCreateLinearUiTwin"/> — the GPU-table half
/// of the fixed-canvas jagged-text fix (<c>TextRendererLinearTwinTests</c> pins
/// the renderer-side handle swap this method feeds).
///
/// <para>
/// Uses <see cref="TextureCache.UploadRgba8"/> rather than
/// <see cref="TextureCache.GetOrUploadRenderSurface"/> to get a nearest-sampled
/// handle without any DAT fixture — <c>UploadRgba8</c> never touches the injected
/// <c>IDatReaderWriter</c>, so the simple <c>TextureCache(device, dats)</c>
/// constructor can run with <see langword="null"/>/<see cref="RecordingGpuDevice"/>
/// and no live GPU, matching this suite's existing renderer-test-double idiom.
/// </para>
/// </summary>
public sealed class TextureCacheLinearTwinTests
{
private static (RecordingGpuDevice device, TextureCache cache) Build()
{
var device = new RecordingGpuDevice();
// UploadRgba8/GetOrCreateLinearUiTwin/Dispose never touch the dats
// reference — see the class doc comment.
var cache = new TextureCache(device, dats: null!);
// RecordingGpuDevice's own constructor registers a 1x1 default-white
// placeholder (DefaultTextureSlot) — clear that registration out of the
// recorded call log so each test's assertions only see the
// registrations IT caused. Clear() only wipes the call log, not the
// slot/sampler state, so DefaultTextureSlot itself is untouched.
device.Clear();
return (device, cache);
}
[Fact]
public void UnknownHandle_ReturnsUnchanged()
{
// Chrome/background art (never registered nearest) and any handle this
// cache never saw (including UiTextureTableHandle.None == 0) pass
// through untouched — there is nothing to swap.
(_, TextureCache cache) = Build();
Assert.Equal(0u, cache.GetOrCreateLinearUiTwin(0u));
Assert.Equal(12345u, cache.GetOrCreateLinearUiTwin(12345u));
}
[Fact]
public void NearestHandle_GetsADifferentTwinHandle_SampledLinear()
{
(RecordingGpuDevice device, TextureCache cache) = Build();
byte[] rgba = new byte[4 * 4 * 4];
uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true);
uint twinHandle = cache.GetOrCreateLinearUiTwin(nearestHandle);
Assert.NotEqual(0u, twinHandle);
Assert.NotEqual(nearestHandle, twinHandle);
// The SECOND registration recorded against the device (the first is the
// original nearest upload) must carry a linear filter — the whole point
// of the twin.
var registrations = device.OfKind<GpuRecordedTextureRegistration>().ToList();
Assert.Equal(2, registrations.Count);
Assert.Equal(GpuFilter.Nearest, registrations[0].Sampler.MinFilter);
Assert.Equal(GpuFilter.Linear, registrations[1].Sampler.MinFilter);
Assert.Equal(GpuFilter.Linear, registrations[1].Sampler.MagFilter);
// Both registrations name the SAME underlying texture — the twin reuses
// the original decoded pixels rather than re-uploading.
Assert.Equal(registrations[0].TextureName, registrations[1].TextureName);
}
[Fact]
public void NearestHandle_RepeatedRequest_ReturnsTheSameCachedTwin()
{
(RecordingGpuDevice device, TextureCache cache) = Build();
byte[] rgba = new byte[4 * 4 * 4];
uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true);
uint first = cache.GetOrCreateLinearUiTwin(nearestHandle);
uint second = cache.GetOrCreateLinearUiTwin(nearestHandle);
Assert.Equal(first, second);
// Exactly one twin registration — the second request must not create
// another device-table slot.
Assert.Equal(2, device.OfKind<GpuRecordedTextureRegistration>().Count());
}
[Fact]
public void NonNearestUpload_HasNoTwin()
{
// UploadRgba8's default (nearest: false) mirrors chrome/background art:
// it already samples GpuSamplerDescription.WorldRepeat (linear), so it
// never enters the nearest-source table and GetOrCreateLinearUiTwin
// hands the same handle straight back.
(_, TextureCache cache) = Build();
byte[] rgba = new byte[4 * 4 * 4];
uint linearHandle = cache.UploadRgba8(rgba, 4, 4, nearest: false);
Assert.Equal(linearHandle, cache.GetOrCreateLinearUiTwin(linearHandle));
}
[Fact]
public void Dispose_ReleasesTheTwinSlot()
{
(RecordingGpuDevice device, TextureCache cache) = Build();
byte[] rgba = new byte[4 * 4 * 4];
uint nearestHandle = cache.UploadRgba8(rgba, 4, 4, nearest: true);
uint twinHandle = cache.GetOrCreateLinearUiTwin(nearestHandle);
GpuTextureSlot twinSlot = UiTextureTableHandle.ToSlot(twinHandle);
cache.Dispose();
Assert.Contains(
device.OfKind<GpuRecordedTextureRelease>(),
release => release.Slot == twinSlot.Index);
}
}