acdream/tests/AcDream.App.Tests/Rendering/TextRendererLinearTwinTests.cs
Erik 308f40a3fb 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>
2026-08-15 11:05:59 +02:00

106 lines
4.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}