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:
parent
ece2104189
commit
f2d7562c86
8 changed files with 416 additions and 158 deletions
113
docs/ISSUES.md
113
docs/ISSUES.md
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 —
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue