fix(plugin-ui): Slice A residuals — padding-drag pin, live dock detection, single clamper, chord text, register row

Opus re-review of 4fada238e (Slice A review-fix round) left seven residuals.
All seven addressed here, in one commit per the residual-round contract:

NEW-1 (test-coverage): no test pinned the Draggable:true->false change
itself. Added Drag_StartingOnShelfPadding_DoesNotMoveTheShelf, pressing the
shelf's own PADDING (left of the first button's Left=4, below the 12px grip)
and dragging 100px through the real UiRoot press/move/release path. Proven
to fail against the pre-4fada238e shelf: temporarily setting Draggable=true
in the ctor and re-running this test moved the shelf from Left=760 to 764
(clamped to parent.Width-Width) instead of leaving it in place — reverted
after confirming.

NEW-2 (correctness): RetailWindowLayoutPersistence.ClampAllToScreen clamps
generically to screen.Width-handle.Width, 4px (OuterPadding) off what the
shelf's own dock formula (parent.Width-Width-OuterPadding) would produce for
the same new parent size. A screen shrink that triggers that clamp used to
flip _userPositioned permanently even though nothing a user would call
"dragging" happened. PluginSidePanel.OnHandleMoved now re-derives "still
docked" at comparison time as a function of the CURRENT parent size: either
the live dock formula's own result, or what ClampAllToScreen's clamp would
produce from the PREVIOUS docked position — only a position matching
NEITHER flips _userPositioned. New test
ClampAllToScreen_AfterShrinkingTheRoot_DoesNotFlipAnchoring_ButARealDragStillDoes
shrinks the root, runs ClampAllToScreen, confirms the shelf still anchors
top-right through a later reflow and a collapse, then confirms a genuine
grip drag afterward still flips anchoring.

NEW-3 (decision, documented): KeepWindowReachable's per-tick clamp and
ClampAllToScreen's screen-resize sweep both touch plugin windows. Evidence
gathered and recorded as a doc comment on KeepWindowReachable:
RetailWindowManager.MoveTo (line 178) already short-circuits an unchanged
position before ever raising Moved; persistence's ScreenSize and Host.Root's
size derive from the same d.Window.Size and are reconciled every frame
(UiHost.Draw sets Root.Width/Height from screenSize); and because
RetainedGameplayUiFrame.Render ticks BEFORE it draws in the same frame,
ClampAllToScreen always resolves a screen-resize's clamp before
KeepWindowReachable ever observes the new size next tick, making
KeepWindowReachable's own pass a structural no-op for that case (never a
second write). KeepWindowReachable is NOT dropped, though: it is the only
reachability guarantee for (a) a plugin window whose geometry is mutated
directly rather than through MoveTo — pinned by the pre-existing
FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas test, which
exercises exactly that with no screen resize at all — and (b) a host wired
with no RetailWindowLayoutPersistence at all. Decision: keep both; they do
not race.

NEW-4 (robustness): RetailWindowLayoutPersistence._attached is mutated
mid-session by WindowRegistered/WindowUnregistered (a callback invoked from
inside one of these loops — e.g. Apply -> Show()/Hide() -> a controller
unregistering another window — could otherwise mutate the list being
enumerated). All five bare `foreach (... in _attached)` loops
(RestoreAllCore, ClampAllToScreen, SaveAll, SaveNamed, RestoreNamed) now
snapshot with .ToArray(), matching Dispose's existing pattern.

NEW-5 (retail-faithfulness): the plugin-shelf hide message hard-coded
"Shift+Ctrl+F1" even though InputAction.TogglePluginManager is rebindable
through Configure Keyboard. RetailUiRuntime.PluginShelfHiddenMessage now
looks up the CURRENT binding via the live InputDispatcher and formats it
through Layout.RetailKeyNames.Describe — the exact formatter Configure
Keyboard's own row captions already use
(Layout/KeyboardConfigController.cs:284) — falling back to an honest
"bind it in Configure Keyboard" message when the action is unbound or no
dispatcher is wired.

NEW-6 (bookkeeping): added divergence-register row IA-27 for the plugin
shelf's repurposing of retail's plugin-manager chord (Shift+Ctrl+F1),
its ASCII </> toggle glyphs, its two acdream-authored system messages, and
its Draggable=false grip-only drag model — citing PluginSidePanel.cs and
the RetailUiRuntime.cs TogglePluginManager case. Header count bumped
23->24 active IA rows.

NEW-7 (correctness): the one-time dock wrote Left directly, so the
first-run docked position went unsaved until some later, unrelated event
happened to trigger a save. PluginSidePanel.OnTick now routes the one-time
dock through the retained-window handle's MoveTo when registered (falling
back to the direct field write when unregistered, as before) —
_dockLeft/_dockTop are set to the TARGET position BEFORE calling MoveTo so
the synchronous OnHandleMoved re-entry it triggers (NEW-2's logic) reads the
new dock position and does not flip _userPositioned. New test
FreshShelf_OneTick_SavesTheDockedPositionImmediately confirms the store has
the docked X/Y after exactly one tick and that a subsequent collapse still
preserves the right (not left) edge.

Verification: dotnet build src/AcDream.App (Release) green; targeted filter
(PluginSidePanel|RetailWindowLayout|Markup|UiRootInput|KeyboardConfig) 145
passed / 2 pre-existing Lane=Manual failures (unrelated, gated on
ACDREAM_PROBE_LIVE_MOUNT=1); full tests/AcDream.App.Tests suite: 7306
passed / 97 skipped / 36 failed — same 36 pre-existing failures by name
(installed-DAT/Cathedral/Linux/Lane=Manual probe tests), +3 passed vs the
7303 baseline (exactly the three new tests this round added).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 14:21:31 +02:00
parent 4fada238e6
commit 718005b210
5 changed files with 378 additions and 10 deletions

View file

@ -45,7 +45,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 1. Intentional architecture (IA) — 23 active rows (IA-26 filed 2026-08-23 — the pack-gated procedural night sky replacing the stretched DAT star layer; IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement keyed by the DAT-classified `WeatherKind`, not the raw day-group index, with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
## 1. Intentional architecture (IA) — 24 active rows (IA-27 filed 2026-09-06 — the plugin shelf's repurposing of retail's plugin-manager chord (Shift+Ctrl+F1) for acdream's own plugin-window shelf, since acdream has no plugin manager to delegate it to; IA-26 filed 2026-08-23 — the pack-gated procedural night sky replacing the stretched DAT star layer; IA-25 filed 2026-08-22 for Campaign VM VM6's opt-in weather-driven foliage wind — a render-only vertex displacement keyed by the DAT-classified `WeatherKind`, not the raw day-group index, with no authored retail wind direction to read; IA-24 filed 2026-08-22 for Campaign AR's opt-in real-time sun/moon directional shadows; IA-23 filed 2026-08-17 at the night-round review fix round (F8) — the House tab's not-yet-expired purchase-restriction line renders .NET's culture-default `DateTime.ToString()` where retail renders the C runtime's `strftime("%c", localtime(...))`, a different formatting engine producing a different-shaped (but equivalent-intent) date string; IA-22 filed 2026-08-13 — the #391 user-directed modern-only curated resolution list + desktop-mode default, replacing retail's full adapter enumeration + authored 800x600 default)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -72,6 +72,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-23 | **Filed 2026-08-17 at the night-round review fix round (F8).** `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`'s not-yet-expired branch renders `"You may buy another landscape house at " + strftime("%c", localtime(timestamp + 0x278d00)) + ". This restriction does not apply to apartments."` — byte-decoded from raw pushed literals at `@0x004a3265`/`@0x004a321d`/`@0x004a3235` (all three text pieces confirmed; a prior filing had wrongly called this "unrecoverable"). This port renders the SAME three pieces, in the same order, with the same expiry-timestamp math, but formats the middle date/time piece with .NET's culture-default `DateTime.ToString()` (no explicit format string) rather than the C runtime's `strftime("%c", ...)` — the two engines do not share a format table, so the RENDERED SHAPE of the date/time differs (e.g. .NET's short numeric date+time vs the CRT's `Ddd Mon DD HH:MM:SS YYYY`-style locale string) even though both express "the process's own locale's full date+time" and use the SAME underlying instant (local time, matching retail's `localtime()`). | `src/AcDream.Runtime/Gameplay/RuntimeHouseState.cs` (`Recompute`'s not-expired branch) | Both are "whatever the process locale says" full date+time strings; no game-logic reads or parses this text back, it is pure chat-scroll presentation, so a differently-shaped (but equally legible) date string carries no functional risk | A retail-side-by-side visual comparison will show a differently formatted date/time (not a byte-identical `strftime("%c")` reproduction) — cosmetic only | `gmHouseUI::DisplayPurchaseTimeText @0x004a3110`; `strftime`/`localtime` CRT calls at `@0x004a322c`/`@0x004a3216` |
| IA-24 | **Filed 2026-08-22, Campaign AR; amended 2026-09-04, Campaign OVERHAUL S5-c2 and its graphical closeout; amended 2026-09-05, Campaign OVERHAUL S5 issue #469.** An explicitly selected atmospheric render pack adds cascaded real-time directional shadows from terrain, trees, buildings, players, monsters, and other retained outdoor casters. The one shadow direction follows the visible authored sun, then the dominant haloed moon (`0x01001F6A`), then the secondary moon (`0x01001F67`); a moon supplies direction only while colour/energy remains retail's single interpolated `SkyTimeOfDay.DirColor × DirBright` channel. Its active terrain/object projection borrows retail's **prior successfully completed** landscape visibility by exact set reference, then selects ordinary objects by S2's retained retail CELLARRAY intersection and building shells by their outdoor placement `EffectCellId`; missing membership and no completed view fail closed, with no resident/frustum fallback. **The authored, unnormalized `uLights` direction remains the sole base-light direction in both receiver vertex shaders (`mesh_atmospheric.vert`, `terrain_atmospheric.vert`) in every shadow-gate state, exactly matching their plain counterparts. The selected celestial source drives only the opt-in shadow and volumetric projection. Its direction may therefore visibly differ from the authored base-light angle; that light/shadow-angle separation is the deliberate cost of preserving retail lighting while eliminating the former binary whole-world relight across pack re-enable.** Retail renders none of these real-time object-shadow maps and does not expose a second moon light. | `src/AcDream.App/Rendering/Packs/AuthoredCelestialShadowSource.cs`; `src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs`; `src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs`; pack-only receiver shaders; evidence `docs/research/2026-08-22-dereth-celestial-shadow-sources.md` and OVERHAUL packet §§14,16,31 | This is the user-requested headline graphics enhancement and is strictly opt-in. Borrowing the prior completed retail landscape product and exact S2 membership narrows pack-on work without inventing another visibility owner. The retail path remains the default and authoritative fallback; pack-off does not build, select, upload, or draw shadow work and does not change `SceneLighting`. One selected source reuses one cascade array, so moon support does not multiply shadow resources. | Pack-on output intentionally differs from retail and deliberately trails camera visibility by one successfully completed world view. A wrong CELLARRAY/EffectCell identity, stale prior-view transaction, celestial transform, or authored-light/celestial projection separation would visibly add, omit, misalign, or redirect shadows; any whole-world base-light jump across pack gating or any pack-off output change would violate the campaign's primary safety contract. | Landscape input: `LScape::draw_check_blocks @0x00505F80`, `LScape::landcell_check @0x005050A0`, `CLandCell::IsInView @0x00532CB0`; celestial: `SkyDesc::GetLighting @0x00500A80`, `GameSky::UseTime @0x005075B0`; installed Region `0x13000000`; cited research notes |
| IA-25 | **Filed 2026-08-22, Campaign VM VM6.** An explicitly selected atmospheric render pack sways procedural-scenery foliage (trees/bushes — entity ids in the `0x8XXYYIII` `ProceduralSceneryIdAllocator` namespace) in `mesh_atmospheric.vert` and the four `directional_shadow_world_*` caster vertex shaders, driven by a weather-table lean/branch/flutter vertex displacement (`foliage_wind.glsl`, `FoliageWindModel` CPU mirror) whose mean/gust strength is looked up per DAT-classified `AcDream.Core.World.WeatherKind` (Clear/Overcast/Rain/Snow/Storm — the same classification `WeatherState.cs` already derives from the active day group's authored name, not the day group's raw index, which carries no weather meaning by itself) and eases toward its target over `WeatherSystem.TransitionSeconds` (10 s) so a weather change never snaps. Retail's fixed-function renderer applies no per-vertex wind displacement to any scenery mesh — Dereth's trees are static geometry. Wind direction (`wind-direction-degrees`, default 225°) is a plain pack default: there is no authored retail wind direction to read (no wind data exists in retail at all). Render-only: `WorldPicker` picks the undisplaced mesh, so a swaying leaf can be up to `lean + branch` metres from its pick volume at the moment of a click; foliage subsets are cosmetic scenery, not interactable in retail either. | `src/AcDream.App/Rendering/Shaders/foliage_wind.glsl`; `src/AcDream.App/Rendering/Wb/FoliageWindClassification.cs`; `src/AcDream.App/Rendering/Packs/FoliageWindModel.cs`; `src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs` (`ResolveFoliageWind`); `src/AcDream.App/Rendering/Packs/BuiltInAtmosphericRenderPack.cs` (wind settings + `FoliageWindByWeather`) | Explicitly opt-in graphics enhancement — the retail path (`mesh_modern`, `terrain_modern`) never reads `BatchData.flags` bits 1/2 and is pixel-identical with the pack off. The classification never touches Runtime/Core physics — the collision BSP is the trunk, and picking against the undisplaced mesh has no gameplay consequence since foliage is not interactable. | Pack-on output intentionally differs from retail (moving foliage where retail has none). A wrong classification bit would sway a non-foliage object or leave a real tree still; a caster/receiver clock or amplitude mismatch would visibly misalign a leaf's shadow from the leaf itself. Pack-off output changing would violate the campaign's primary safety contract. | None — retail applies no vertex wind displacement to any geometry; `ProceduralSceneryIdAllocator` (top-nibble-0x8 entity-id namespace, existing acdream mechanism, not retail) |
| IA-27 | **Filed 2026-09-06, Slice A of docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md.** The plugin shelf (`PluginSidePanel`, the right-edge strip of plugin-window buttons) repurposes retail's plugin-manager chord (`InputAction.TogglePluginManager`, Shift+Ctrl+F1) as its own hide/show toggle — retail delegates that chord to an externally shipped plugin manager acdream does not have, so this is the honest home for it. The shelf's collapse toggle draws plain ASCII `<`/`>` glyphs (no retail visual counterpart), reports hide/show through two acdream-authored system messages (naming the CURRENT chord bound to the action, since Configure Keyboard lets the player rebind it), and is itself registered `Draggable=false` — only its dedicated `WindowMoveHandle` grip strip across the top drags it; the shelf's own padding and entry buttons never arm `UiRoot`'s whole-window drag fallback. | `src/AcDream.App/UI/PluginSidePanel.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` (the `TogglePluginManager` case) | acdream has no plugin manager to delegate the chord to, and no other affordance names or toggles plugin-window visibility; explicit product direction (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md Slice A). | A retail-parity comparison of the exact Shift+Ctrl+F1 keystroke's effect, the `<`/`>` glyphs, or the two system messages will show acdream-only behavior with no retail analogue to match against. | None — retail's plugin-manager chord opens an external binary outside acdream's scope; no in-client retail action to cite. |
---

View file

@ -140,6 +140,25 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
/// happens to leave the shelf exactly where the dock formula already had
/// it, pass through without flipping <see cref="_userPositioned"/>.
/// </summary>
/// <remarks>
/// Residual round finding NEW-2 (2026-09-06): a stored <see cref="_dockLeft"/>
/// stops being a reliable "still docked" oracle the moment the PARENT
/// resizes, because <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/>
/// does not know about this shelf's own dock formula — it clamps generically
/// to <c>screen.Width - handle.Width</c>
/// (<c>RetailWindowLayoutPersistence.cs</c>, the <c>ClampAllToScreen</c>
/// method), 4px (<see cref="OuterPadding"/>) to the right of what
/// <see cref="OnTick"/>'s dock formula (<c>parent.Width - Width -
/// OuterPadding</c>) would have produced for the SAME new parent width. A
/// screen shrink that triggers that clamp therefore left <c>Left !=
/// _dockLeft</c> even though the shelf never moved through anything a user
/// would call "dragging it" — a false "user positioned" flip. <see cref="OnHandleMoved"/>
/// now re-derives what "still docked" means for the CURRENT parent size at
/// comparison time (the live dock formula's result, AND the clamp
/// <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/> would have
/// produced from the PREVIOUS docked position) instead of only checking
/// against the stale field values captured before the resize.
/// </remarks>
private float _dockLeft;
private float _dockTop;
@ -320,10 +339,33 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
// whichever corner Reflow decides based on _userPositioned.
if (!_initialDockApplied && !_userPositioned && parent.Width > 0f)
{
Left = MathF.Max(0f, parent.Width - Width - OuterPadding);
float dockLeft = MathF.Max(0f, parent.Width - Width - OuterPadding);
float dockTop = Top;
// Review fix round residual NEW-7: _dockLeft/_dockTop are set
// to the TARGET dock position BEFORE the MoveTo call below,
// not after. handle.MoveTo raises Moved synchronously
// (RetailWindowManager.MoveTo -> UiRoot.NotifyWindowMoved ->
// RetailWindowManager.OnWindowMoved -> handle.NotifyMoved),
// which re-enters OnHandleMoved on this same call stack; that
// handler's "is this still the dock position" check (see its
// own doc, NEW-2) needs _dockLeft/_dockTop to already read the
// NEW target so it recognizes the move as the dock placement
// itself rather than a user drag.
_dockLeft = dockLeft;
_dockTop = dockTop;
_initialDockApplied = true;
_dockLeft = Left;
_dockTop = Top;
// NEW-7: route the one-time dock through the retained-window
// handle when the shelf is registered, so the first-run
// docked position is captured by RetailWindowLayoutPersistence
// (subscribed to Moved) immediately, rather than sitting
// unsaved until some LATER unrelated event fires a save.
// Unregistered use (pre-Slice-A tests that construct the
// panel without registering it as a window) falls back to the
// direct field write, exactly as before.
if (_handle is { } handle)
handle.MoveTo(dockLeft, dockTop);
else
Left = dockLeft;
}
}
@ -357,8 +399,41 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
_windows.WindowRegistered -= OnWindowRegistered;
}
/// <summary>
/// Decides whether the position <see cref="RetailWindowHandle.Moved"/>
/// just landed on is still "docked" or a genuine user move. See NEW-2
/// (the <see cref="_dockLeft"/>/<see cref="_dockTop"/> remarks) for why a
/// straight <c>Left != _dockLeft</c> check goes wrong across a display
/// resize: <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/>'s
/// generic reachability clamp can land the shelf 4px
/// (<see cref="OuterPadding"/>) off the dock formula's own result for the
/// new parent size. Two candidates count as "still docked" for TODAY'S
/// parent size: (1) the live dock formula's own result, and (2) what
/// <c>ClampAllToScreen</c>'s <c>Math.Clamp(handle.Left, 0,
/// screen.Width - handle.Width)</c> would produce when applied to the
/// PREVIOUS docked <see cref="_dockLeft"/> — i.e. exactly the clamp a
/// display-change reachability sweep performs on a shelf that was docked
/// before the resize. Only a position matching NEITHER is a real drag.
/// </summary>
private void OnHandleMoved(RetailWindowHandle _)
{
if (Parent is { } parent)
{
float currentDockLeft = MathF.Max(0f, parent.Width - Width - OuterPadding);
float reachabilityClampOfPriorDock = Math.Clamp(
_dockLeft, 0f, MathF.Max(0f, parent.Width - Width));
bool stillDocked = Top == _dockTop
&& (Left == currentDockLeft || Left == reachabilityClampOfPriorDock);
if (stillDocked)
{
// Re-anchor: a later genuine drag away from THIS (possibly
// clamp-adjusted) position must still be detected.
_dockLeft = Left;
_dockTop = Top;
return;
}
}
if (Left != _dockLeft || Top != _dockTop)
_userPositioned = true;
}
@ -414,6 +489,73 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
Reflow();
}
/// <summary>
/// Per-tick reachability clamp for one plugin window entry, called from
/// both <see cref="Add"/> (the first tick a window may not get for a
/// while if entries are added but the shelf's own OnTick has not run) and
/// <see cref="OnTick"/>'s per-entry loop.
/// </summary>
/// <remarks>
/// Residual round finding NEW-3 (2026-09-06): plugin windows are ALSO
/// attached to <see cref="RetailWindowLayoutPersistence"/> (they are
/// ordinary registered windows), whose own
/// <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/> performs a
/// generic screen-driven reachability sweep over every attached window,
/// including these. Evidence gathered before deciding whether that makes
/// this method redundant:
/// <list type="bullet">
/// <item>
/// <see cref="RetailWindowManager.MoveTo"/> (<c>RetailWindowManager.cs</c>
/// line 178: <c>if (frame.Left == left &amp;&amp; frame.Top == top) return
/// true;</c>) already short-circuits a call that would not change
/// anything, BEFORE it ever raises <c>Moved</c> — so neither clamper can
/// write settings.json (via <see cref="RetailWindowLayoutPersistence.OnChanged"/>)
/// when the position it computes is already where the handle sits.
/// </item>
/// <item>
/// Persistence's screen size (<c>RetailUiPersistenceBindings.ScreenSize</c>,
/// wired from <c>d.Window.Size</c> in
/// <c>InteractionRetainedUiComposition.cs</c>) and this method's <c>parent</c>
/// (<c>Host.Root</c>) both derive from the identical live window size —
/// <c>UiHost.Draw</c> (<c>UiHost.cs</c> line 96-97) sets
/// <c>Root.Width/Height = screenSize</c> every frame — so in steady state
/// they are the same value. During an active resize they can differ for
/// exactly one frame: <see cref="RetainedGameplayUiFrame.Render"/>
/// (<c>PrivatePresentationRenderer.cs</c> lines 162-165) calls
/// <c>Tick</c> (which is what reaches this method, via <see cref="OnTick"/>)
/// BEFORE <c>Draw</c> in the SAME frame, so this method always observes
/// <c>parent.Width/Height</c> from the PREVIOUS frame's <c>Draw</c> —
/// while <see cref="RetailUiRuntime.Draw"/>'s
/// <see cref="RetailWindowLayoutPersistence.ClampAllToScreen"/> call
/// (<c>RetailUiRuntime.cs</c> lines 1019-1024) runs against the CURRENT
/// frame's real size, resolves the window's position for that size FIRST,
/// and only THEN does <c>Host.Draw</c> publish that size into
/// <c>Root.Width/Height</c>. By the time this method next sees the new
/// size, <c>ClampAllToScreen</c> has already settled the position for it
/// — so this method's own pass for the screen-resize case is a structural
/// no-op (the short-circuit above fires), never a second write.
/// </item>
/// <item>
/// This method is NOT redundant, though: it is the only reachability
/// guarantee for (a) a plugin window whose OWN geometry fields are
/// written directly rather than through <see cref="RetailWindowHandle.MoveTo"/>
/// — <c>PluginSidePanelTests.FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas</c>
/// pins exactly this (no screen resize occurs there at all — a plugin
/// window's <c>Left</c>/<c>Top</c> are mutated directly, the way a
/// plugin's own content code could) — and (b) a host wired with no
/// <see cref="RetailWindowLayoutPersistence"/> at all (<c>layoutStore is
/// null</c> in <c>InteractionRetainedUiComposition.cs</c> — persistence,
/// and therefore <c>ClampAllToScreen</c>, may not exist).
/// </item>
/// </list>
/// Decision: keep this per-tick clamp as the SOLE owner of "position
/// drift not caused by a screen-size change," and leave
/// <c>ClampAllToScreen</c>'s parallel coverage of these same handles in
/// place (it is persistence's generic, non-opt-outable screen-resize
/// sweep for every registered window) — the two do not race per the
/// ordering evidence above, and removing either one regresses a real,
/// currently-green invariant.
/// </remarks>
private static void KeepWindowReachable(RetailWindowHandle handle)
{
if (handle.OuterFrame.Parent is not { } parent

View file

@ -1070,8 +1070,19 @@ public sealed class RetailUiRuntime : IDisposable
// back (no menu entry, no button) — tell the user the
// exact chord that reopens it. The show branch stays
// silent; showing something is its own feedback.
//
// NEW-5 (residual round): TogglePluginManager is rebindable
// through Configure Keyboard, so a literal "Shift+Ctrl+F1"
// here would lie the moment a player rebinds it. Format the
// CURRENT binding through Layout.RetailKeyNames.Describe —
// the same retail GetNameFromKey pipeline (DAT table
// override -> OS-localized name -> DIK spelling) Configure
// Keyboard's own key-button captions use
// (Layout/KeyboardConfigController.cs line 284:
// `_describe = new RetailKeyNames(resolveString).Describe`)
// — reused here rather than writing a second formatter.
_bindings.Options.DisplaySystemMessage(
"Plugin shelf hidden. Press Shift+Ctrl+F1 to show it again.");
PluginShelfHiddenMessage());
}
else
{
@ -3674,6 +3685,37 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[UI] retail Configure Keyboard screen from gmKeyboardUI LayoutDesc 0x21000009.");
}
/// <summary>
/// NEW-5 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
/// Slice A): the plugin-shelf hide message must name whatever chord is
/// CURRENTLY bound to <see cref="AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager"/>
/// — Configure Keyboard lets the player rebind it — formatted through the
/// same <see cref="Layout.RetailKeyNames.Describe"/> pipeline Configure
/// Keyboard's own row captions use
/// (<c>Layout/KeyboardConfigController.cs</c> line 284), not a second
/// formatter. Falls back to an honest "no binding" message when the
/// action has been cleared, or when no live <see cref="InputDispatcher"/>
/// is wired (headless/no-window hosts) to ask.
/// </summary>
private string PluginShelfHiddenMessage()
{
InputDispatcher? dispatcher = _bindings.Keyboard?.Dispatcher;
Binding? bound = dispatcher?.Bindings
.ForAction(AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager)
.Cast<Binding?>()
.FirstOrDefault();
if (bound is not { } binding)
{
return "Plugin shelf hidden. Bind Toggle Plugin Manager in "
+ "Configure Keyboard to show it again.";
}
var strings = new DatStringResolver(_bindings.Assets.Dats);
string chordText = new Layout.RetailKeyNames((tableId, stringId) =>
strings.Resolve(tableId, stringId)).Describe(binding.Chord);
return $"Plugin shelf hidden. Press {chordText} to show it again.";
}
private static string UnmappedKeyBindingsPath(string keyBindingsFilePath)
{
string? dir = System.IO.Path.GetDirectoryName(keyBindingsFilePath);

View file

@ -91,7 +91,13 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
_restoring = true;
try
{
foreach (RetailWindowHandle handle in _attached)
// NEW-4 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// Slice A): _attached is now mutated mid-session by WindowRegistered/
// WindowUnregistered (a plugin window or the shelf can register/
// unregister from inside a callback this very loop invokes — e.g.
// Apply -> Show()/Hide() -> a controller reacting by unregistering
// another window), so every loop over it snapshots first.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
UiWindowLayout fallback = Capture(handle);
UiWindowLayout? saved = _store.LoadWindowLayout(
@ -147,7 +153,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
_restoring = true;
try
{
foreach (RetailWindowHandle handle in _attached)
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
float maxX = MathF.Max(0f, screen.Width - handle.Width);
float maxY = MathF.Max(0f, screen.Height - handle.Height);
@ -171,7 +178,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
if (!CanPersist(character)) return;
var screen = ValidScreenSize();
string resolution = ResolutionKey(screen);
foreach (RetailWindowHandle handle in _attached)
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
}
@ -180,7 +188,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(profileName);
foreach (RetailWindowHandle handle in _attached)
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
_store.SaveNamedWindowLayout(profileName, handle.Name, Capture(handle));
}
@ -194,7 +203,8 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
_restoring = true;
try
{
foreach (RetailWindowHandle handle in _attached)
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
UiWindowLayout? saved = _store.LoadNamedWindowLayout(
profileName, handle.Name, Capture(handle));

View file

@ -243,6 +243,49 @@ public sealed class PluginSidePanelTests
Assert.Equal(leftAfterDrag, shelf.Left);
}
[Fact]
public void Drag_StartingOnShelfPadding_DoesNotMoveTheShelf()
{
// NEW-1 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// Slice A): pins the actual Draggable:true -> false change 4fada238e made.
// A press on the shelf's own PADDING — left of the first entry button's
// Left=4 (OuterPadding), below the 12px grip band — must never promote
// to a whole-window drag now that Draggable is false; only the grip's
// WindowMoveHandle subtree can start a move (see the class doc). Proven
// to fail against the PRE-4fada238e shelf: temporarily flipping the
// ctor's `Draggable = false;` to `Draggable = true;` and re-running this
// test moved the shelf by the full drag delta (100,100) instead of
// leaving it in place, confirming this test actually exercises the
// fixed behavior — see the commit message for the exact numbers observed.
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L); // establishes the initial right-edge dock
int pressX = (int)shelf.Left + 2; // left of button.Left (4)
int pressY = (int)shelf.Top + 20; // below the grip band (12)
float leftBefore = shelf.Left;
float topBefore = shelf.Top;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX + 100, pressY + 100);
root.OnMouseUp(UiMouseButton.Left, pressX + 100, pressY + 100);
Assert.Equal(leftBefore, shelf.Left);
Assert.Equal(topBefore, shelf.Top);
}
[Fact]
public void Drag_Refused_WhileUiLocked()
{
@ -705,6 +748,136 @@ public sealed class PluginSidePanelTests
button => Assert.True(button.Top + button.Height <= shelf.Height));
}
[Fact]
public void FreshShelf_OneTick_SavesTheDockedPositionImmediately()
{
// NEW-7 (residual round): the one-time dock used to write Left
// directly, so the FIRST-run docked position went unsaved until some
// later, unrelated event happened to fire a save. Routing the dock
// through the retained-window handle's MoveTo (when registered)
// means RetailWindowLayoutPersistence — subscribed to Moved — picks
// it up on the very first tick.
string directory = Path.Combine(
Path.GetTempPath(), "acdream-plugin-shelf-tests-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "settings.json");
try
{
var store = new SettingsStore(path);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
root.Tick(0.016d, 16L); // the one-time dock happens inside this tick
float expectedLeft = 800f - shelf.Width - 4f;
Assert.Equal(expectedLeft, shelf.Left);
Assert.Equal(116f, shelf.Top);
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", WindowNames.PluginShelf, default));
Assert.Equal((expectedLeft, 116f), (saved.X, saved.Y));
// NEW-7 also asks: does routing the dock through MoveTo (which
// synchronously re-enters OnHandleMoved, NEW-2) itself flip
// _userPositioned? If it had, a later width change (collapse)
// would preserve the LEFT edge instead of the RIGHT edge.
float rightEdgeBefore = shelf.Left + shelf.Width;
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
Assert.Equal(rightEdgeBefore, shelf.Left + shelf.Width, precision: 3);
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ClampAllToScreen_AfterShrinkingTheRoot_DoesNotFlipAnchoring_ButARealDragStillDoes()
{
// NEW-2 (residual round): a screen shrink that forces
// RetailWindowLayoutPersistence.ClampAllToScreen to re-clamp the
// still-docked shelf must not be mistaken for a user drag, even
// though ClampAllToScreen's generic clamp formula
// (screen.Width - handle.Width) lands 4px (OuterPadding) off the
// dock formula's own result (parent.Width - Width - OuterPadding)
// for the SAME new size.
string directory = Path.Combine(
Path.GetTempPath(), "acdream-plugin-shelf-tests-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(directory, "settings.json");
try
{
var store = new SettingsStore(path);
var root = new UiRoot { Width = 800f, Height = 600f };
using var shelf = new PluginSidePanel(
root.WindowManager, _ => (0u, 0, 0), font: null);
root.AddChild(shelf);
root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
var frame = new UiPanel { Width = 200f, Height = 100f };
root.AddChild(frame);
RetailWindowHandle pluginHandle = root.WindowManager.Register(
"plugin:acdream.test:main", frame);
shelf.Add(
new PluginUiOwner("acdream.test", "Test Plugin"),
new PluginPanelDescriptor("main", "Test Plugin"),
pluginHandle);
root.Tick(0.016d, 16L); // dock at Left=760 (800-36-4) under an 800-wide parent
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (760, 600));
// Shrink the parent so the shelf's docked position no longer
// fits, then run the SAME reachability sweep
// RetailUiRuntime.Draw's #390 edge detector runs on a live resize.
root.Width = 760f;
persistence.ClampAllToScreen();
// Clamped hard to the new edge (760-36=724) — 4px right of what
// the dock formula itself would produce (760-36-4=720).
Assert.Equal(724f, shelf.Left);
float rightEdgeBefore = shelf.Left + shelf.Width;
root.Tick(0.016d, 16L); // a later reflow — still docked, right edge holds
Assert.Equal(rightEdgeBefore, shelf.Left + shelf.Width, precision: 3);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
Assert.Equal(rightEdgeBefore, shelf.Left + shelf.Width, precision: 3);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false));
// A REAL drag away from the edge afterward must still flip anchoring.
int pressX = (int)shelf.Left + 10;
int pressY = (int)shelf.Top + 5;
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
root.OnMouseMove(pressX - 100, pressY);
root.OnMouseUp(UiMouseButton.Left, pressX - 100, pressY);
float leftAfterDrag = shelf.Left;
Assert.NotEqual(724f, leftAfterDrag);
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
Assert.Equal(leftAfterDrag, shelf.Left);
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ZeroMovementGripReleaseDoesNotFlipAnchoring_ButARealDragDoes()
{