chore(plugin-ui): review cleanup — hermetic memo tests, shelf button anchors, outline pin, bounded miss cache; file #486/#487; correct #461

- Split the two hermetic RetailMarkupIconResolver memoization tests (and
  their counting fakes) out of the Lane=InstalledDat class into a new
  untagged RetailMarkupIconResolverMemoizationTests.cs so CI's portable
  filter (Lane!=InstalledDat) actually runs them.
- PluginSidePanel: move the entry button's Anchors = AnchorEdges.None from
  the Add() call site into PluginShelfButton's own constructor (same
  comment carried over) so a second construction path cannot miss it.
- UiRectOutlinePainterOrderTests: assert the back panel's border segment
  carries exactly 4 quads (24 vertices, FloatsPerVertex each) so a partial
  outline cannot pass the painter-order check.
- RetailMarkupIconResolver: document the type as UI-thread-only (every
  caller is a draw-time icon source) and bound the MISS cache to 256
  entries with FIFO eviction — HIT entries stay unbounded (bounded by the
  DAT's own surface count already). New test proves the 257th distinct
  miss evicts the first (re-probe count rises); verified failing first
  against the un-bounded code (Expected 258, Actual 257) before restoring
  the fix.
- docs/plugin-ui-markup.md: split the icon-binding row's failure mode into
  Build-time (missing property only — the binder never checks CLR type)
  vs. draw-time (a resolved value that cannot convert to a number throws
  from the draw, not from Build).
- docs/ISSUES.md: filed #486 (credits picture scroll frozen by the
  per-draw anchor pass) and #487 (radar compass tokens candidate, same
  mechanism, unconfirmed); corrected #461's causality — the graceful
  logout/reveal-cancel log lines are printed by LiveSessionController.Tick's
  catch -> StopAfterFailure -> StopCore AFTER the motion-update exception,
  then it rethrows, so the logout is a consequence of the crash, not its
  cause; real chain is the #462 stalled login-reveal materialization
  leaving PlayerMovementController in RuntimeOwnedDormant outside its
  SetPosition ground phase when an inbound 0xF74C arrives.
- Plan doc: recorded the three fix-round commits' verdicts (all PASS) and
  the Smoke-plugin cleanup commit SHA in the Review ledger, plus a pointer
  to the two newly filed issues.

Verified: dotnet build AcDream.slnx -c Release (0/0), targeted filter
85/0/0, full App suite 7364 passed / 97 skipped / 36 failed (36 pre-existing
InstalledDat/Manual/Linux-only failures, unchanged by name from baseline;
net +1 passed test from the new eviction test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 17:09:46 +02:00
parent ece2104189
commit f2d7562c86
8 changed files with 416 additions and 158 deletions

View file

@ -39,6 +39,65 @@ confirmed closed by the owner, 11 need a focused live gate, and 43 are safe to
remain closed. See
[`docs/research/2026-08-28-owner-closed-issue-validity-audit.md`](research/2026-08-28-owner-closed-issue-validity-audit.md).
## #486 — Credits picture scroll is frozen by the per-draw anchor pass
**Status:** OPEN — found 2026-09-06 by the Opus review of `47eb2d575` (the
plugin-shelf anchor fix), while confirming no other controller shares the
same mechanism.
**Severity:** LOW (credits screen only)
**Component:** `src/AcDream.App/UI/Layout/CreditsUiController.cs`
**Description.** `AddPicture` (~331-345) creates each scrolling `UiPanel`
picture with the DEFAULT `Left|Top` anchors (never `AnchorEdges.None`), and
`ScrollPictures` (~293-297) writes `picture.Top` every tick to move it up the
screen. `UiElement.ApplyAnchor` (`UiElement.cs` ~829-856) runs for every
anchored child on every draw: it captures the Left/Top/Width/Height margins
ONCE on the first draw, then re-applies that frozen snapshot on every later
draw — silently overwriting whatever `ScrollPictures` had just written. After
the first draw, the picture's position can no longer actually change, so the
credits scroll freezes.
`_textArea.Top` (~208-209) has the same write pattern but a different
mechanism gap: `_textArea` is the IMPORTED-layout flavour (its
`UiElement.LayoutPolicy` is set, from `ImportedLayout`/`LayoutImporter`), so
`ApplyAnchor` takes the `LayoutPolicy.Apply` branch instead of the anchor-
margin capture — but nothing ever calls `ResetAnchorCapture()`/
`UiLayoutPolicy.Rebase` after `ScrollPictures` writes `Top`, so the imported
layout's own baseline goes stale the same way.
**Fix direction:** `Anchors = AnchorEdges.None` on `AddPicture`'s
programmatically-repositioned children (the controller is their sole layout
owner, exactly the reasoning `PluginSidePanel` already documents for its own
runtime-positioned children); `ResetAnchorCapture()`/`Rebase` on `_textArea`
after each deliberate `Top` write for the imported-layout element. Precedent:
`PluginSidePanel` (commit `47eb2d575`), `UiItemList.cs:170-172`,
`MapPageController.cs:235-249` (the same fix already landed for other
runtime-repositioned imported/programmatic elements).
## #487 — Radar compass tokens may be pinned by the anchor pass (candidate)
**Status:** OPEN — CANDIDATE, found 2026-09-06 by the Opus review of
`47eb2d575`, not confirmed by a live gate.
**Severity:** LOW
**Component:** `src/AcDream.App/UI/Layout/RadarController.cs`
**Description.** `ApplyPresentation` (~130-144) repositions the four
imported compass-letter tokens (`token.Element.Left`/`.Top`) on every
heading change, and `CreateToken` (~185-194) never marks them as runtime-
positioned — same imported-layout (`LayoutPolicy`) shape as
`MapPageController.PrepareIcon`'s F1 finding, which needed the same fix.
**Symptom if real:** compass letters (N/E/S/W or similar) that never
rotate with the player's heading, staying pinned at their first-draw
position — the same class of bug as #486, just on the radar's imported
tokens instead of the credits screen's programmatic pictures.
**Not established:** whether `ApplyAnchor`'s snapshot-freeze actually bites
here — it depends on whether these tokens' `LayoutPolicy`/anchor state ends
up captured before `ApplyPresentation`'s first write, which needs the
owner's eyes on a live heading change to confirm. Filed as a candidate
rather than assumed broken.
## #485 — Gitea portable CI races console capture and selects Vulkan-only tests
**Status:** DONE — correction implemented and locally verified, 2026-09-06;
@ -732,11 +791,55 @@ movement controller cannot be mutated.` at `PlayerMovementController.EnsureConfi
then the crash — a `0xF74C`/`0xF625`-class motion update for the local player arrived after the
controller was retired by the logout, and the inbound route still forwards it to the controller.
**Fix direction:** the inbound motion route must consult the controller's lifetime (the same
sealed/retired state `EnsureConfigurationMutable` throws on) and drop local-player motion once the
session is logging out — the J5.4/J5.7 terminal ledger owns that state; retail discards inbound
movement for a character that has left the world. Add the ordered-teardown test: logout confirmed →
a late `UpdateMotion` for the local player → no throw, no mutation.
**Corrected root cause (2026-09-06).** The original "logout → crash" framing above has the causality
backwards. Verified against `.claude/worktrees/peaceful-blackburn-5333f0/logs/selfgate-20260903-165745-g3c/`
(`client.log`, `client.err.log`) and `tools/overhaul-selfgate/route-g3c.txt` in that same worktree:
`LiveSessionController.Tick` (`LiveSessionController.cs:715-751`) runs `_operations.Tick(scope.Session)`
inside a `try` at line 730; THAT call is what throws (the `WorldSession.ProcessDatagram``OnMotion`
`EnsureConfigurationMutable` chain above). The `catch (Exception tickError)` at line 735 calls
`StopAfterFailure(tickError)` (line 737), which runs `StopCore()` (line 1001) — `StopCore` is what
prints `[session] graceful logout requested`/`confirmed` (`WorldSession.cs:3759/3761`) and cancels the
Login reveal — and only THEN does `StopAfterFailure` rethrow, unwinding out through `GameWindow.OnUpdate`
to the unhandled-exception crash. So the log's `[session] graceful logout requested/confirmed` and
`[world-reveal] event=cancel` lines are a CONSEQUENCE of the already-thrown exception (the crash-recovery
path's own teardown attempt), not its trigger — nothing about logging out caused this crash.
The real chain: the login reveal into `0xA9B40176` stalled (#462: `ready=True materialized=False` for
180 s — `client.log:204-205` never advance to `materialized=True`). The local player's
`PlayerMovementController` therefore never left `PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant`
(`PlayerMovementController.cs:135`) — the one path that clears it,
`RuntimeLocalPlayerPhysicsPublicationState`'s dormant-activation dispatch
(`RuntimeSetPositionState.TryApplyDormantLocalActivationCommit`, bracketed by
`PlayerMovementController.BeginDormantSetPositionGroundPhase`/`EndDormantSetPositionGroundPhase`
`RuntimeLocalPlayerPhysicsPublicationState.cs:590-625`), never ran because it is gated on the same
reveal materialization that never completed. The route's `wait world-visible` step then timed out and
the script's next verb (`command /teleloc ...`) made ACE send a `0xF74C` for the player. That reached
`LiveEntityNetworkUpdateController.OnMotion``PlayerMovementController.SetLastMoveWasAutonomous`
`EnsureConfigurationMutable` (`PlayerMovementController.cs:1038-1049`), whose predicate allows mutation
only for `StandalonePublished`/`CandidatePreparing`/`RuntimePublished`, OR `RuntimeOwnedDormant` while
`_dormantSetPositionGroundPhase` is true (i.e. actively inside that one bracketed activation window) —
by elimination, a `RuntimeOwnedDormant` controller OUTSIDE that window is the only lifecycle state a
live `_controller` can be in here, and it throws.
Retail's `CPhysics::SetObjectMovement @0x00509690` stores the autonomous byte and calls
`unpack_movement` as soon as the object exists — there is no dormant window at all. acdream's accepted
equivalent for exactly this "motion arrived before the entity finished materializing" shape already
exists for every OTHER entity: `RuntimeEntityObjectLifetime.TryApplyMotion`
(`RuntimeEntityObjectLifetime.cs:1568-1606`) checks `TryGetPendingInitialResidence` for the guid and, if
the entity still has one, enqueues the motion as a deferred continuation
(`EnqueueDormant(..., RuntimeInitialCreateContinuationKind.Movement, ...)`) instead of applying it
immediately or throwing. The dormant LOCAL player bypasses this retention entirely: its motion is routed
through the player-specific `LiveEntityNetworkUpdateController.OnMotion``PlayerMovementController`
path, not through the guid-keyed `RuntimeEntityObjectLifetime.TryApplyMotion` retention every other
entity already gets.
**Fix direction:** route an accepted local-player motion that arrives while the controller is dormant
into that SAME kind of retention (replayed once `TryApplyDormantLocalActivationCommit` actually
activates the controller), never a drop guard and never a silent no-op — a workaround here would just
trade a crash for silently losing a real wire motion. Test: dormant controller + inbound player
`0xF74C` → no throw, and the motion is applied after activation completes (not dropped). This defect is
reachable in ordinary play only through the #462 stalled-materialization window, so fix #462 alongside
it — without #462, the dormant window this bug lives in should not exist in the first place.
## #460 — Shutdown hangs windowless at 100 % of one core after a close request that follows an aborted automation script (Nanto pose)

View file

@ -303,3 +303,14 @@ all four places, list icon column aligned with rows.
same commit; see `chore(plugins): remove the Smoke gate plugin; MossTank
shelf icon 0x06002C41`. MossTank's own panels defaulting to
`StartVisible = true` remains open — same look, still out of scope here.
- **Fix-round commits + closing cleanup (2026-09-06):** `47eb2d575` (shelf
children must not anchor — PASS, connected gate confirmed the `<`/`>`
toggle survives collapse/reflow), `761a7519f` (retained-UI rect outlines
composite in painter order — PASS, connected gate confirmed the MossTank
border no longer draws over the inventory paperdoll), `ce05c4fb0` (Slice B
residuals — shelf icon sink without magenta, validated icon bindings,
negative ids, memoized DID resolves — PASS; its one SHOULD-FIX carryover,
the memoization tests sitting in a `Lane=InstalledDat` class where CI never
ran them, is this session's cleanup item 1), and the cleanup commit
`ece210418` (Smoke gate plugin removal, referenced by message above).
Latent anchor-pass owners filed by this cleanup pass: #486, #487.

View file

@ -70,7 +70,7 @@ check those four against the markup by eye.
| `list items`, `menu items` | Throws | `IEnumerable<string>` |
| `list colors` | **Silent** if omitted (no color override); throws if present but mistyped | `IEnumerable<uint>` **or** `IEnumerable<int>` (shared `BindUintList`) |
| `list icons` (Slice B) | Throws if present but mistyped; omitting it entirely means no icon column at all. A negative `int` element is **silent**: it maps to `0u` (no icon for that row), matching the scalar `did`/`spell`/`item` row above | `IEnumerable<uint>` **or** `IEnumerable<int>` |
| `<icon>`/`<button icon>` `did`/`spell`/`item` bindings (Slice B) | Throws (missing/mistyped property) — but a resolved value that is negative or above `uint.MaxValue` is **silent**: it maps to `0u` (draws nothing) rather than throwing `OverflowException` at draw time | any integral type (`uint`, `int`, `long`, `ushort`, a nullable of one, …) via `Convert.ToUInt32` |
| `<icon>`/`<button icon>` `did`/`spell`/`item` bindings (Slice B) | **Build-time:** throws only for a missing bound property (or, for a literal, one that isn't valid hex/decimal) — the binder never checks the property's static CLR type. **Draw-time:** a resolved value that is negative or above `uint.MaxValue` is **silent** (maps to `0u`, draws nothing); a resolved value that cannot convert to a number at all throws `InvalidCastException`/`FormatException` from the draw, not from `Build` | any integral type (`uint`, `int`, `long`, `ushort`, a nullable of one, …) via `Convert.ToUInt32` |
| `list selected` | Throws (required int reader) | `int` |
| `tab selected`, `toggle checked` | Throws (required bool reader) | `bool` |
| root `panel visible` | Throws (required bool reader; see the root-only note below) | `bool` |
@ -79,6 +79,8 @@ check those four against the markup by eye.
| `field onchange`, `field onsubmit`, `menu onchange` | Throws | `Action<string>` |
| `list onchange` | Throws | `Action<int>` |
The icon-id row is the one binding here whose failure mode depends on WHEN you look: a typo'd property name is caught immediately at `Build`, but a property that exists yet holds the wrong kind of value at runtime is only ever discovered later, from inside a live draw.
## Elements
Every element name is validated at `Build`: an unknown or miscased tag (e.g.

View file

@ -52,6 +52,16 @@ public interface IMarkupIconResolver
/// (<c>ToolbarRuntimeBindings.Objects</c>/<c>MagicRuntimeBindings.Objects</c> —
/// both <c>d.Inventory.Objects</c>, the same instance) — no second texture
/// cache or object lookup is introduced.
///
/// <para>
/// <b>UI-thread-only.</b> <see cref="_resolvedDidCache"/>/<see cref="_missOrder"/>
/// are plain mutable collections with no locking: every caller of
/// <see cref="ResolveDid"/> is a draw-time icon source (<see cref="UiMarkupIcon"/>,
/// <see cref="UiMarkupList"/>, <see cref="PluginSidePanel.PluginShelfButton"/>),
/// which only ever run on the single UI/render thread that ticks
/// <see cref="UiRoot"/>. Do not call this from a background thread or a
/// plugin worker without adding synchronization first.
/// </para>
/// </summary>
public sealed class RetailMarkupIconResolver : IMarkupIconResolver
{
@ -60,6 +70,17 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver
private readonly IconComposer _icons;
private readonly ClientObjectTable _objects;
/// <summary>
/// Upper bound on cached MISS entries (see <see cref="_missOrder"/>).
/// Chosen generously above any real plugin icon-id population (Slice B's
/// own doc estimate: "small... nothing like the world's full RenderSurface
/// population") — this is a leak guard against a misbehaving plugin markup
/// that binds a different bogus/unresolvable DID every frame (e.g. an
/// id computed from a changing counter), not a tuning knob for the normal
/// case.
/// </summary>
private const int MaxCachedMisses = 256;
/// <summary>
/// Residual round finding N4 (perf): memoizes <see cref="ResolveDid"/>'s
/// result per DID, including the <c>(0, 0, 0)</c> miss. Without this, an
@ -71,14 +92,26 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver
/// <see cref="TextureCache.GetOrUploadRenderSurface"/> every frame too,
/// even though that call's own cache already made the SECOND upload
/// free — the wasted cost was the per-frame database-lock re-entry, not
/// a duplicate GPU upload. A plain <see cref="Dictionary{TKey,TValue}"/>
/// is fine (unbounded, no eviction): the set of distinct plugin icon
/// DIDs one process ever asks for is small (per-plugin descriptor icons
/// plus whatever a plugin's own markup binds), nothing like the world's
/// full RenderSurface population.
/// a duplicate GPU upload.
///
/// <para>
/// HIT entries (a real installed RenderSurface) are never evicted: that
/// population is bounded by the DAT's own real surface count, nothing
/// like a leak. MISS entries ARE bounded (<see cref="MaxCachedMisses"/>,
/// FIFO via <see cref="_missOrder"/>) because a miss key has no such
/// natural ceiling — a plugin author who binds a bad or ever-changing id
/// would otherwise grow this dictionary by one entry per distinct id,
/// forever, for the lifetime of the process.
/// </para>
/// </summary>
private readonly Dictionary<uint, (uint tex, int w, int h)> _resolvedDidCache = new();
/// <summary>FIFO of MISS keys currently sitting in <see cref="_resolvedDidCache"/>,
/// oldest-first. A key enters here exactly once per miss (re-entering only
/// after its prior entry was evicted), so its count always equals the
/// number of miss entries currently cached — see <see cref="ResolveDid"/>.</summary>
private readonly Queue<uint> _missOrder = new();
public RetailMarkupIconResolver(
IDatReaderWriter dats,
TextureCache textureCache,
@ -119,18 +152,31 @@ public sealed class RetailMarkupIconResolver : IMarkupIconResolver
return cached;
(uint tex, int w, int h) result;
bool isMiss;
if (!_dats.Portal.TryGet<RenderSurface>(did, out _)
&& !_dats.HighRes.TryGet<RenderSurface>(did, out _))
{
result = (0u, 0, 0);
isMiss = true;
}
else
{
uint tex = _textureCache.GetOrUploadRenderSurface(did, out int w, out int h, nearest: true);
result = (tex, w, h);
isMiss = false;
}
_resolvedDidCache[did] = result;
if (isMiss)
{
// Bound only the miss population (see MaxCachedMisses's doc):
// evict the OLDEST cached miss once a new one would exceed the
// cap, so a runaway distinct-miss-per-frame source cannot grow
// this dictionary without bound.
_missOrder.Enqueue(did);
if (_missOrder.Count > MaxCachedMisses)
_resolvedDidCache.Remove(_missOrder.Dequeue());
}
return result;
}

View file

@ -304,13 +304,6 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
{
Width = ButtonExtent,
Height = ButtonExtent,
// Anchors = None for the same reason as _grip/_toggle above:
// Reflow() is the sole owner of each entry button's Left/Top
// (row-wrap column/row placement), rewritten on every add/
// remove/collapse. Left at the default anchor, ApplyAnchor would
// freeze a button at whatever column/row it first drew in and
// never let a later removal's row-wrap actually move it.
Anchors = AnchorEdges.None,
};
button.Click += () =>
{
@ -862,6 +855,15 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
DatFont = font;
Outline = true;
BorderThickness = 1f;
// Anchors = None for the same reason as _grip/_toggle above:
// Reflow() is the sole owner of each entry button's Left/Top
// (row-wrap column/row placement), rewritten on every add/
// remove/collapse. Left at the default anchor, ApplyAnchor would
// freeze a button at whatever column/row it first drew in and
// never let a later removal's row-wrap actually move it. Set in
// the ctor (rather than at the Add() call site) so a second
// construction path cannot miss it.
Anchors = AnchorEdges.None;
_handle.Shown += OnVisibilityChanged;
_handle.Hidden += OnVisibilityChanged;
RefreshPresentation();

View file

@ -1,5 +1,3 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
@ -10,7 +8,6 @@ using AcDream.Content;
using AcDream.Core.Items;
using AcDream.Plugin.Abstractions;
using DatReaderWriter;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Options;
using Xunit;
@ -144,141 +141,4 @@ public sealed class RetailMarkupIconResolverInstalledDatTests
{
public IGpuFrame? CurrentFrame => null;
}
/// <summary>
/// Residual round finding N4 (perf): <see cref="RetailMarkupIconResolver.ResolveDid"/>
/// used to probe the DAT (two cache misses + two B-tree lookups under
/// <c>DatDatabaseWrapper</c>'s database lock) on EVERY call for an
/// unresolvable id — including once per frame from a draw-time icon
/// source, forever. These two tests use a bare-bones fake
/// <see cref="IDatReaderWriter"/>/<see cref="IDatDatabase"/> that counts
/// <c>TryGet</c> calls directly, rather than the InstalledDat lane
/// above — the property under test is call COUNT, not decode
/// correctness, so no real DAT files are needed here.
/// </summary>
[Fact]
public void ResolveDid_RepeatedUnresolvableId_ProbesTheDatExactlyOnce()
{
var dats = new CountingDatReaderWriter();
var device = new RecordingGpuDevice();
using var cache = new TextureCache(device, dats);
var icons = new IconComposer(dats, cache);
var objects = new ClientObjectTable();
var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects);
(uint tex1, int w1, int h1) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
(uint tex2, int w2, int h2) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
(uint tex3, int w3, int h3) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
Assert.Equal((0u, 0, 0), (tex1, w1, h1));
Assert.Equal((0u, 0, 0), (tex2, w2, h2));
Assert.Equal((0u, 0, 0), (tex3, w3, h3));
// Without memoization this would be 3 (one probe pair per call);
// with it, the miss is cached after the first resolve.
Assert.Equal(1, dats.Portal.TryGetCallCount);
Assert.Equal(1, dats.HighRes.TryGetCallCount);
}
[Fact]
public void ResolveDid_DifferentIds_ProbeIndependently()
{
// A per-id cache must not collapse distinct ids into one entry.
var dats = new CountingDatReaderWriter();
var device = new RecordingGpuDevice();
using var cache = new TextureCache(device, dats);
var icons = new IconComposer(dats, cache);
var objects = new ClientObjectTable();
var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects);
resolver.ResolveDid(0x06000001u);
resolver.ResolveDid(0x06000002u);
resolver.ResolveDid(0x06000001u);
Assert.Equal(2, dats.Portal.TryGetCallCount);
Assert.Equal(2, dats.HighRes.TryGetCallCount);
}
/// <summary>Always misses (<c>TryGet</c> returns <see langword="false"/>),
/// counting how many times it was asked.</summary>
private sealed class CountingDatDatabase : IDatDatabase
{
public int TryGetCallCount { get; private set; }
public DatDatabase Db => throw new NotImplementedException();
public int Iteration => 0;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
throw new NotImplementedException();
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
where T : IDBObj
{
TryGetCallCount++;
value = default;
return false;
}
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) =>
throw new NotImplementedException();
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) =>
throw new NotImplementedException();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotImplementedException();
public void Dispose() { }
}
/// <summary>
/// Only <see cref="Portal"/>/<see cref="HighRes"/> are exercised by
/// <see cref="RetailMarkupIconResolver.ResolveDid"/>'s miss path — every
/// other member throws if a future change starts touching it, so this
/// fake fails loudly rather than silently returning nonsense.
/// </summary>
private sealed class CountingDatReaderWriter : IDatReaderWriter
{
public CountingDatDatabase Portal { get; } = new();
public CountingDatDatabase HighRes { get; } = new();
IDatDatabase IDatReaderWriter.Portal => Portal;
IDatDatabase IDatReaderWriter.HighRes => HighRes;
public string SourceDirectory => string.Empty;
public IDatDatabase Cell => throw new NotImplementedException();
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions => throw new NotImplementedException();
public IDatDatabase Language => throw new NotImplementedException();
public IDatDatabase Local => throw new NotImplementedException();
public ReadOnlyDictionary<uint, uint> RegionFileMap => throw new NotImplementedException();
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) =>
throw new NotImplementedException();
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
throw new NotImplementedException();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotImplementedException();
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
throw new NotImplementedException();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
throw new NotImplementedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj =>
throw new NotImplementedException();
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
where T : IDBObj =>
throw new NotImplementedException();
public void Dispose() { }
}
}

View file

@ -0,0 +1,221 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using AcDream.Content;
using AcDream.Core.Items;
using DatReaderWriter;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Options;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Hermetic companions to <see cref="RetailMarkupIconResolverInstalledDatTests"/>,
/// split into their own untagged (no <c>Lane=InstalledDat</c>) class so CI's
/// portable filter (<c>.gitea/workflows/ci.yml</c>, <c>Lane!=InstalledDat</c>)
/// actually runs them: unlike the rest of that class, these two never touch a
/// real DAT — the property under test is DAT-probe call COUNT via a
/// bare-bones fake <see cref="IDatReaderWriter"/>/<see cref="IDatDatabase"/>,
/// not decode correctness, so no installed retail DAT directory is required.
/// </summary>
public sealed class RetailMarkupIconResolverMemoizationTests
{
/// <summary>
/// A Decal-habit "add the block prefix again" mistake applied to an
/// already-full DID: <c>0x06000165</c> (Melee Defense's real installed
/// icon, <c>SampleData.cs:69</c>) plus another <c>0x06000000</c> lands at
/// <c>0x0C000165</c> — a value almost certainly absent from both Portal
/// and HighRes.
/// </summary>
private const uint DecalHabitDoubleNormalizedId = 0x0C000165u;
/// <summary>
/// Residual round finding N4 (perf): <see cref="RetailMarkupIconResolver.ResolveDid"/>
/// used to probe the DAT (two cache misses + two B-tree lookups under
/// <c>DatDatabaseWrapper</c>'s database lock) on EVERY call for an
/// unresolvable id — including once per frame from a draw-time icon
/// source, forever. These two tests use a bare-bones fake
/// <see cref="IDatReaderWriter"/>/<see cref="IDatDatabase"/> that counts
/// <c>TryGet</c> calls directly, rather than the InstalledDat lane
/// on <see cref="RetailMarkupIconResolverInstalledDatTests"/> — the
/// property under test is call COUNT, not decode correctness, so no real
/// DAT files are needed here.
/// </summary>
[Fact]
public void ResolveDid_RepeatedUnresolvableId_ProbesTheDatExactlyOnce()
{
var dats = new CountingDatReaderWriter();
var device = new RecordingGpuDevice();
using var cache = new TextureCache(device, dats);
var icons = new IconComposer(dats, cache);
var objects = new ClientObjectTable();
var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects);
(uint tex1, int w1, int h1) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
(uint tex2, int w2, int h2) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
(uint tex3, int w3, int h3) = resolver.ResolveDid(DecalHabitDoubleNormalizedId);
Assert.Equal((0u, 0, 0), (tex1, w1, h1));
Assert.Equal((0u, 0, 0), (tex2, w2, h2));
Assert.Equal((0u, 0, 0), (tex3, w3, h3));
// Without memoization this would be 3 (one probe pair per call);
// with it, the miss is cached after the first resolve.
Assert.Equal(1, dats.Portal.TryGetCallCount);
Assert.Equal(1, dats.HighRes.TryGetCallCount);
}
[Fact]
public void ResolveDid_DifferentIds_ProbeIndependently()
{
// A per-id cache must not collapse distinct ids into one entry.
var dats = new CountingDatReaderWriter();
var device = new RecordingGpuDevice();
using var cache = new TextureCache(device, dats);
var icons = new IconComposer(dats, cache);
var objects = new ClientObjectTable();
var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects);
resolver.ResolveDid(0x06000001u);
resolver.ResolveDid(0x06000002u);
resolver.ResolveDid(0x06000001u);
Assert.Equal(2, dats.Portal.TryGetCallCount);
Assert.Equal(2, dats.HighRes.TryGetCallCount);
}
/// <summary>
/// <see cref="RetailMarkupIconResolver"/>'s MISS cache is bounded
/// (<c>MaxCachedMisses</c> = 256, FIFO eviction) so a plugin markup
/// binding a different bogus/unresolvable DID every frame cannot grow it
/// without bound — unlike HIT entries, which are left uncapped because
/// that population is bounded by the DAT's own real surface count. Fills
/// the cache with 256 distinct misses, proves the oldest is STILL served
/// from cache (no re-probe) right up to the cap, then proves a 257th
/// distinct miss evicts it — a repeat of the evicted id must re-probe
/// the DAT (the call count rises again) rather than serve a stale hit.
/// </summary>
[Fact]
public void ResolveDid_TwoHundredFiftySeventhDistinctMiss_EvictsTheFirst()
{
var dats = new CountingDatReaderWriter();
var device = new RecordingGpuDevice();
using var cache = new TextureCache(device, dats);
var icons = new IconComposer(dats, cache);
var objects = new ClientObjectTable();
var resolver = new RetailMarkupIconResolver(dats, cache, icons, objects);
// Fill the 256-entry miss cache with distinct ids (starting at 1;
// did == 0 short-circuits before ever touching the DAT).
for (uint id = 1; id <= 256; id++)
resolver.ResolveDid(id);
Assert.Equal(256, dats.Portal.TryGetCallCount);
Assert.Equal(256, dats.HighRes.TryGetCallCount);
// Still cached at the cap: a repeat of the OLDEST id must not re-probe.
resolver.ResolveDid(1u);
Assert.Equal(256, dats.Portal.TryGetCallCount);
Assert.Equal(256, dats.HighRes.TryGetCallCount);
// A 257th DISTINCT miss pushes the cache over its cap, evicting the
// oldest entry (id 1).
resolver.ResolveDid(257u);
Assert.Equal(257, dats.Portal.TryGetCallCount);
Assert.Equal(257, dats.HighRes.TryGetCallCount);
// id 1 was evicted: asking again must re-probe the DAT (the call
// count rises again), rather than serve the now-gone cache entry.
resolver.ResolveDid(1u);
Assert.Equal(258, dats.Portal.TryGetCallCount);
Assert.Equal(258, dats.HighRes.TryGetCallCount);
}
/// <summary>Always misses (<c>TryGet</c> returns <see langword="false"/>),
/// counting how many times it was asked.</summary>
private sealed class CountingDatDatabase : IDatDatabase
{
public int TryGetCallCount { get; private set; }
public DatDatabase Db => throw new NotImplementedException();
public int Iteration => 0;
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
throw new NotImplementedException();
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
where T : IDBObj
{
TryGetCallCount++;
value = default;
return false;
}
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) =>
throw new NotImplementedException();
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) =>
throw new NotImplementedException();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotImplementedException();
public void Dispose() { }
}
/// <summary>
/// Only <see cref="Portal"/>/<see cref="HighRes"/> are exercised by
/// <see cref="RetailMarkupIconResolver.ResolveDid"/>'s miss path — every
/// other member throws if a future change starts touching it, so this
/// fake fails loudly rather than silently returning nonsense.
/// </summary>
private sealed class CountingDatReaderWriter : IDatReaderWriter
{
public CountingDatDatabase Portal { get; } = new();
public CountingDatDatabase HighRes { get; } = new();
IDatDatabase IDatReaderWriter.Portal => Portal;
IDatDatabase IDatReaderWriter.HighRes => HighRes;
public string SourceDirectory => string.Empty;
public IDatDatabase Cell => throw new NotImplementedException();
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions => throw new NotImplementedException();
public IDatDatabase Language => throw new NotImplementedException();
public IDatDatabase Local => throw new NotImplementedException();
public ReadOnlyDictionary<uint, uint> RegionFileMap => throw new NotImplementedException();
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) =>
throw new NotImplementedException();
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
throw new NotImplementedException();
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotImplementedException();
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
throw new NotImplementedException();
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
throw new NotImplementedException();
[return: MaybeNull]
public T Get<T>(uint fileId) where T : IDBObj =>
throw new NotImplementedException();
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
where T : IDBObj =>
throw new NotImplementedException();
public void Dispose() { }
}
}

View file

@ -103,6 +103,19 @@ public sealed class UiRectOutlinePainterOrderTests
"the back panel's border segment must be submitted BEFORE the front sprite's " +
"segment so it composites underneath it, matching the actual paint order");
// UiRenderContext.DrawRectOutline draws exactly 4 sides (top, bottom,
// left, right), each one quad (2 triangles = 6 vertices) via
// TextRenderer.AppendQuad; consecutive same-texture (None) DrawSprite
// calls batch into the ONE segment found above. A partial outline —
// e.g. a side silently dropped or clipped away — must not pass by
// merely having SOME untextured geometry in the right bucket at the
// right order; it must have all four sides' worth of vertices.
const int expectedQuads = 4;
const int expectedVertices = expectedQuads * 6;
Assert.Equal(
expectedVertices * TextRenderer.FloatsPerVertex,
segs[outlineIndex].Verts.Count);
// No outline geometry may land in TextRenderer's separate untextured
// rect bucket at all: that bucket always flushes AFTER every sprite
// segment regardless of submission order, which is exactly the bug —