diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 2c7334d6c..131ee70c0 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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. |
---
diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs
index 2d0cfb912..385928679 100644
--- a/src/AcDream.App/UI/PluginSidePanel.cs
+++ b/src/AcDream.App/UI/PluginSidePanel.cs
@@ -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 .
///
+ ///
+ /// Residual round finding NEW-2 (2026-09-06): a stored
+ /// stops being a reliable "still docked" oracle the moment the PARENT
+ /// resizes, because
+ /// does not know about this shelf's own dock formula — it clamps generically
+ /// to screen.Width - handle.Width
+ /// (RetailWindowLayoutPersistence.cs, the ClampAllToScreen
+ /// method), 4px () to the right of what
+ /// 's dock formula (parent.Width - Width -
+ /// OuterPadding) would have produced for the SAME new parent width. A
+ /// screen shrink that triggers that clamp therefore left Left !=
+ /// _dockLeft even though the shelf never moved through anything a user
+ /// would call "dragging it" — a false "user positioned" flip.
+ /// now re-derives what "still docked" means for the CURRENT parent size at
+ /// comparison time (the live dock formula's result, AND the clamp
+ /// would have
+ /// produced from the PREVIOUS docked position) instead of only checking
+ /// against the stale field values captured before the resize.
+ ///
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;
}
+ ///
+ /// Decides whether the position
+ /// just landed on is still "docked" or a genuine user move. See NEW-2
+ /// (the / remarks) for why a
+ /// straight Left != _dockLeft check goes wrong across a display
+ /// resize: 's
+ /// generic reachability clamp can land the shelf 4px
+ /// () 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
+ /// ClampAllToScreen's Math.Clamp(handle.Left, 0,
+ /// screen.Width - handle.Width) would produce when applied to the
+ /// PREVIOUS docked — 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.
+ ///
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();
}
+ ///
+ /// Per-tick reachability clamp for one plugin window entry, called from
+ /// both (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
+ /// 's per-entry loop.
+ ///
+ ///
+ /// Residual round finding NEW-3 (2026-09-06): plugin windows are ALSO
+ /// attached to (they are
+ /// ordinary registered windows), whose own
+ /// performs a
+ /// generic screen-driven reachability sweep over every attached window,
+ /// including these. Evidence gathered before deciding whether that makes
+ /// this method redundant:
+ ///
+ /// -
+ /// (RetailWindowManager.cs
+ /// line 178: if (frame.Left == left && frame.Top == top) return
+ /// true;) already short-circuits a call that would not change
+ /// anything, BEFORE it ever raises Moved — so neither clamper can
+ /// write settings.json (via )
+ /// when the position it computes is already where the handle sits.
+ ///
+ /// -
+ /// Persistence's screen size (RetailUiPersistenceBindings.ScreenSize,
+ /// wired from d.Window.Size in
+ /// InteractionRetainedUiComposition.cs) and this method's parent
+ /// (Host.Root) both derive from the identical live window size —
+ /// UiHost.Draw (UiHost.cs line 96-97) sets
+ /// Root.Width/Height = screenSize every frame — so in steady state
+ /// they are the same value. During an active resize they can differ for
+ /// exactly one frame:
+ /// (PrivatePresentationRenderer.cs lines 162-165) calls
+ /// Tick (which is what reaches this method, via )
+ /// BEFORE Draw in the SAME frame, so this method always observes
+ /// parent.Width/Height from the PREVIOUS frame's Draw —
+ /// while 's
+ /// call
+ /// (RetailUiRuntime.cs lines 1019-1024) runs against the CURRENT
+ /// frame's real size, resolves the window's position for that size FIRST,
+ /// and only THEN does Host.Draw publish that size into
+ /// Root.Width/Height. By the time this method next sees the new
+ /// size, ClampAllToScreen 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.
+ ///
+ /// -
+ /// 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
+ /// — PluginSidePanelTests.FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas
+ /// pins exactly this (no screen resize occurs there at all — a plugin
+ /// window's Left/Top are mutated directly, the way a
+ /// plugin's own content code could) — and (b) a host wired with no
+ /// at all (layoutStore is
+ /// null in InteractionRetainedUiComposition.cs — persistence,
+ /// and therefore ClampAllToScreen, may not exist).
+ ///
+ ///
+ /// Decision: keep this per-tick clamp as the SOLE owner of "position
+ /// drift not caused by a screen-size change," and leave
+ /// ClampAllToScreen'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.
+ ///
private static void KeepWindowReachable(RetailWindowHandle handle)
{
if (handle.OuterFrame.Parent is not { } parent
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index fc031471d..fc1f8687f 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -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.");
}
+ ///
+ /// 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
+ /// — Configure Keyboard lets the player rebind it — formatted through the
+ /// same pipeline Configure
+ /// Keyboard's own row captions use
+ /// (Layout/KeyboardConfigController.cs line 284), not a second
+ /// formatter. Falls back to an honest "no binding" message when the
+ /// action has been cleared, or when no live
+ /// is wired (headless/no-window hosts) to ask.
+ ///
+ private string PluginShelfHiddenMessage()
+ {
+ InputDispatcher? dispatcher = _bindings.Keyboard?.Dispatcher;
+ Binding? bound = dispatcher?.Bindings
+ .ForAction(AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager)
+ .Cast()
+ .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);
diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
index 9a0353dd5..ca1bf6556 100644
--- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
+++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
@@ -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));
diff --git a/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs b/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
index ba931e580..9105c07b5 100644
--- a/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
+++ b/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
@@ -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(
+ 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()
{