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() {