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:
parent
73041d7015
commit
308f40a3fb
6 changed files with 346 additions and 1 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-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-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-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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -484,6 +484,12 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
d.DebugFont,
|
||||
d.HostQuiescence));
|
||||
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);
|
||||
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
|
||||
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
|
||||
|
|
|
|||
|
|
@ -218,6 +218,21 @@ public sealed class TextRenderer : IDisposable
|
|||
/// </summary>
|
||||
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>
|
||||
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,
|
||||
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
|
||||
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
|
||||
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,20 @@ public sealed class TextureCache
|
|||
// GPU texture objects/slots until process exit.
|
||||
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 bool _destinationRevealUploadPriority;
|
||||
|
||||
|
|
@ -359,6 +373,14 @@ public sealed class TextureCache
|
|||
|
||||
IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat);
|
||||
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);
|
||||
}
|
||||
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>
|
||||
/// 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
|
||||
|
|
@ -994,6 +1070,15 @@ public sealed class TextureCache
|
|||
|
||||
_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
|
||||
// entry's IGpuTexture.Dispose() releases the underlying GL name
|
||||
// through the device's own retirement queue, so only the memory-
|
||||
|
|
|
|||
106
tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs
Normal file
106
tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
124
tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs
Normal file
124
tests/AcDream.App.Tests/Rendering/TextureCacheLinearTwinTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue