fix(ui): share primary panel placement
Port gmPanelUI's persistent parent placement semantics across Inventory, Character, and Magic while preserving each imported child's content and resize policy. Synchronize typed retained-window handles so drag, switching, close/reopen, and layout persistence all observe one canonical location; keep effect panels independent. User-verified in normal Release. Release build and all 5,770 tests passed with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
124e046976
commit
3f3cfdac30
7 changed files with 460 additions and 9 deletions
|
|
@ -44,6 +44,47 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #221 — Primary UI panels retained independent window positions
|
||||||
|
|
||||||
|
**Status:** DONE — 2026-07-17
|
||||||
|
**Severity:** MEDIUM
|
||||||
|
**Filed:** 2026-07-17
|
||||||
|
**Component:** retained UI / panel lifecycle / persistence
|
||||||
|
|
||||||
|
**Description:** Moving Inventory and then opening Character/Skills or Magic
|
||||||
|
opened the replacement panel at its own stale position. Retail presents these
|
||||||
|
as different child contents of one persistent main panel.
|
||||||
|
|
||||||
|
**Root cause / status:** `RetailPanelUiController` already ported
|
||||||
|
`gmPanelUI`'s one-active-child and restore-previous behavior, but the imported
|
||||||
|
LayoutDesc children were mounted as independently positioned retained window
|
||||||
|
frames. The controller now owns one canonical placement for Inventory (7),
|
||||||
|
Character (11), and Magic (13). Moving any primary child synchronizes its
|
||||||
|
hidden siblings through `RetailWindowHandle.MoveTo`, so ordinary per-character
|
||||||
|
layout persistence observes the same position. Switching and closing capture
|
||||||
|
that canonical placement; Helpful/Harmful effect panels retain their separate
|
||||||
|
placement and restore-previous behavior.
|
||||||
|
|
||||||
|
**Files:** `src/AcDream.App/UI/Layout/RetailPanelUiController.cs`;
|
||||||
|
`src/AcDream.App/UI/RetailUiRuntime.cs`;
|
||||||
|
`tests/AcDream.App.Tests/UI/Layout/RetailPanelUiControllerTests.cs`;
|
||||||
|
`tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs`.
|
||||||
|
|
||||||
|
**Research:**
|
||||||
|
`docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md`.
|
||||||
|
|
||||||
|
**Acceptance:** Connected normal-Release visual gate passed 2026-07-17:
|
||||||
|
Inventory, Character/Skills, and Spellbook/Components retain one position
|
||||||
|
across switching, dragging, closing, and reopening.
|
||||||
|
|
||||||
|
**Acceptance:** Open Inventory, move it, then switch directly and after closing
|
||||||
|
to Character/Skills and Spellbook/Components. Every replacement must open at
|
||||||
|
the most recently moved main-panel position. Move another panel and repeat in
|
||||||
|
the opposite direction. Helpful/Harmful effects must not overwrite that
|
||||||
|
position.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #220 — Local player glides before locomotion animation starts
|
## #220 — Local player glides before locomotion animation starts
|
||||||
|
|
||||||
**Status:** DONE 2026-07-17 — user-confirmed connected visual gate
|
**Status:** DONE 2026-07-17 — user-confirmed connected visual gate
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
|
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
|
||||||
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
|
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
|
||||||
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
|
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
|
||||||
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). `gmPanelUI` primary children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical placement and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from the retail UI deep-dives and named `gmPanelUI` methods. Separate child wrappers preserve each LayoutDesc's content/resize policy while typed handle synchronization gives Inventory/Character/Magic the same persistent parent position | Edge-case input semantics can differ silently; if retail relies on parent-size anchoring across a panel switch, separate child extents may resize differently even though placement and visibility match | `gmPanelUI::SetupChildren` 0x004BC9E0; `RecvNotice_SetPanelVisibility` 0x004BC6F0; docs/research/retail-ui/04-input-events.md |
|
||||||
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
||||||
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
||||||
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
||||||
|
|
|
||||||
113
docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md
Normal file
113
docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Retail shared main-panel placement — pseudocode
|
||||||
|
|
||||||
|
**Date:** 2026-07-17
|
||||||
|
**Symptom:** moving Inventory and then opening Character/Skills or Magic opens
|
||||||
|
the new panel at its own stale saved position instead of the position just used.
|
||||||
|
|
||||||
|
## Retail oracle
|
||||||
|
|
||||||
|
Named Sept-2013 retail symbols:
|
||||||
|
|
||||||
|
- `gmPanelUI::SetupChildren @ 0x004BC9E0`
|
||||||
|
- `gmPanelUI::RecvNotice_SetPanelVisibility @ 0x004BC6F0`
|
||||||
|
- `gmPanelUI::PostInit @ 0x004BD010`
|
||||||
|
- `gmPanelUI::ResizeTo @ 0x004BC6E0`
|
||||||
|
|
||||||
|
Verbatim `acclient.h` ownership:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gmPanelUI : UIElement_Field
|
||||||
|
SmartArray<PanelChildInfo> m_childrenInfoArray
|
||||||
|
UIElement* m_currentlyShownPanel
|
||||||
|
UIElement* m_previouslyShownPanel
|
||||||
|
UIElement* m_pPanelPagesFrame
|
||||||
|
bool m_bStretchMode
|
||||||
|
|
||||||
|
PanelChildInfo
|
||||||
|
UIElement* child
|
||||||
|
uint panelID
|
||||||
|
```
|
||||||
|
|
||||||
|
`SetupChildren` resolves each known panel element recursively, stores the
|
||||||
|
child pointer together with its property-`0x10000029` panel ID, and initially
|
||||||
|
hides every child. It does not create independent top-level windows.
|
||||||
|
|
||||||
|
## Retail behavior
|
||||||
|
|
||||||
|
```text
|
||||||
|
SetPanelVisibility(panelId, show):
|
||||||
|
requested = find m_childrenInfoArray entry by panelId
|
||||||
|
if requested is missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
if show:
|
||||||
|
if requested.child == m_currentlyShownPanel:
|
||||||
|
requested.child.SetVisible(true)
|
||||||
|
parent.SetVisible(true)
|
||||||
|
return
|
||||||
|
|
||||||
|
previous = m_currentlyShownPanel
|
||||||
|
m_currentlyShownPanel = requested.child
|
||||||
|
|
||||||
|
if previous is visible:
|
||||||
|
if requested restores previous and previous does not:
|
||||||
|
m_previouslyShownPanel = previous
|
||||||
|
else:
|
||||||
|
m_previouslyShownPanel = null
|
||||||
|
send SetPanelVisibility(previous.panelID, false)
|
||||||
|
|
||||||
|
requested.child.SetVisible(true)
|
||||||
|
parent.SetVisible(true)
|
||||||
|
return
|
||||||
|
|
||||||
|
if requested.child == m_currentlyShownPanel:
|
||||||
|
requested.child.SetVisible(false)
|
||||||
|
if m_previouslyShownPanel exists:
|
||||||
|
send SetPanelVisibility(previous.panelID, true)
|
||||||
|
m_previouslyShownPanel = null
|
||||||
|
else:
|
||||||
|
m_currentlyShownPanel = null
|
||||||
|
parent.SetVisible(false)
|
||||||
|
return
|
||||||
|
|
||||||
|
requested.child.SetVisible(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
The decisive placement fact is structural: Inventory, Character/Skills, and
|
||||||
|
Magic are children of one `gmPanelUI`. The switch changes child visibility;
|
||||||
|
there is no child move and no per-panel top-level position. Dragging the parent
|
||||||
|
therefore moves the one position subsequently used by every child.
|
||||||
|
|
||||||
|
## acdream port shape
|
||||||
|
|
||||||
|
acdream imports these child LayoutDesc roots independently, so each currently
|
||||||
|
has its own retained wrapper. `RetailPanelUiController` already ports retail's
|
||||||
|
one-active-child and restore-previous lifecycle. Complete that logical-parent
|
||||||
|
ownership by adding one canonical placement for the primary toolbar children
|
||||||
|
(Inventory `7`, Character `11`, Magic `13`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
on primary child frame moved:
|
||||||
|
canonicalPosition = moved frame position
|
||||||
|
move every other primary child frame to canonicalPosition
|
||||||
|
|
||||||
|
before showing a primary child:
|
||||||
|
if canonicalPosition exists:
|
||||||
|
move requested child frame to canonicalPosition
|
||||||
|
else:
|
||||||
|
canonicalPosition = requested child frame position
|
||||||
|
|
||||||
|
before hiding the active primary child:
|
||||||
|
capture its current position as canonicalPosition
|
||||||
|
```
|
||||||
|
|
||||||
|
The controller owns this synchronization and subscribes to typed
|
||||||
|
`RetailWindowHandle.Moved` events. Toolbar commands, keyboard commands,
|
||||||
|
restore-previous effects, persistence restore, and direct window dragging all
|
||||||
|
therefore pass through the same owner. Panel-authored size/resize policy stays
|
||||||
|
with the active child; only the retail parent placement is shared.
|
||||||
|
|
||||||
|
ACE contains no client UI implementation, and the in-tree WorldBuilder
|
||||||
|
reference contains no `gmPanelUI` port, so neither supplies a competing
|
||||||
|
behavioral interpretation. The named retail client and verbatim header are the
|
||||||
|
oracle for this UI ownership rule.
|
||||||
|
|
@ -9,7 +9,7 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// deferred child; this owner applies the same lifecycle to registered retained
|
/// deferred child; this owner applies the same lifecycle to registered retained
|
||||||
/// windows without making the toolbar authoritative for non-toolbar panels.
|
/// windows without making the toolbar authoritative for non-toolbar panels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RetailPanelUiController
|
public sealed class RetailPanelUiController : IDisposable
|
||||||
{
|
{
|
||||||
public const uint RestorePreviousPropertyId = 0x10000049u;
|
public const uint RestorePreviousPropertyId = 0x10000049u;
|
||||||
|
|
||||||
|
|
@ -21,6 +21,9 @@ public sealed class RetailPanelUiController
|
||||||
private uint? _activePanel;
|
private uint? _activePanel;
|
||||||
private uint? _deferredPanel;
|
private uint? _deferredPanel;
|
||||||
private bool _applying;
|
private bool _applying;
|
||||||
|
private bool _synchronizingPlacement;
|
||||||
|
private PanelPlacement? _mainPanelPlacement;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
public RetailPanelUiController(
|
public RetailPanelUiController(
|
||||||
Func<string, bool> isVisible,
|
Func<string, bool> isVisible,
|
||||||
|
|
@ -34,21 +37,72 @@ public sealed class RetailPanelUiController
|
||||||
|
|
||||||
public uint? ActivePanelId => _activePanel;
|
public uint? ActivePanelId => _activePanel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers one of retail <c>gmPanelUI</c>'s primary toolbar children.
|
||||||
|
/// Inventory, Character, and Magic retain panel-specific content/resize
|
||||||
|
/// policy but share the one parent placement. A drag of any registered
|
||||||
|
/// child updates every hidden sibling through typed window handles, so
|
||||||
|
/// persistence observes the same canonical position too.
|
||||||
|
/// </summary>
|
||||||
|
public void RegisterMainPanel(
|
||||||
|
uint panelId,
|
||||||
|
string windowName,
|
||||||
|
RetailWindowHandle window,
|
||||||
|
bool restorePrevious = false)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(window);
|
||||||
|
if (!string.Equals(windowName, window.Name, StringComparison.Ordinal))
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Panel window name '{windowName}' does not match handle '{window.Name}'.",
|
||||||
|
nameof(windowName));
|
||||||
|
|
||||||
|
RegisterCore(
|
||||||
|
panelId,
|
||||||
|
windowName,
|
||||||
|
restorePrevious,
|
||||||
|
window,
|
||||||
|
sharesMainPanelPlacement: true);
|
||||||
|
}
|
||||||
|
|
||||||
/// <param name="restorePrevious">
|
/// <param name="restorePrevious">
|
||||||
/// Effective DAT bool property <c>0x10000049</c>. Retail retains the previous
|
/// Effective DAT bool property <c>0x10000049</c>. Retail retains the previous
|
||||||
/// panel only when the newly shown panel has this property and the previous
|
/// panel only when the newly shown panel has this property and the previous
|
||||||
/// panel does not.
|
/// panel does not.
|
||||||
/// </param>
|
/// </param>
|
||||||
public void Register(uint panelId, string windowName, bool restorePrevious = false)
|
public void Register(uint panelId, string windowName, bool restorePrevious = false)
|
||||||
|
=> RegisterCore(
|
||||||
|
panelId,
|
||||||
|
windowName,
|
||||||
|
restorePrevious,
|
||||||
|
window: null,
|
||||||
|
sharesMainPanelPlacement: false);
|
||||||
|
|
||||||
|
private void RegisterCore(
|
||||||
|
uint panelId,
|
||||||
|
string windowName,
|
||||||
|
bool restorePrevious,
|
||||||
|
RetailWindowHandle? window,
|
||||||
|
bool sharesMainPanelPlacement)
|
||||||
{
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
|
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||||
if (_byPanel.ContainsKey(panelId) || _byWindow.ContainsKey(windowName))
|
if (_byPanel.ContainsKey(panelId) || _byWindow.ContainsKey(windowName))
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Panel {panelId} or retained window '{windowName}' is already registered.");
|
$"Panel {panelId} or retained window '{windowName}' is already registered.");
|
||||||
|
|
||||||
_byPanel.Add(panelId, new PanelEntry(windowName, restorePrevious));
|
var entry = new PanelEntry(
|
||||||
|
windowName,
|
||||||
|
restorePrevious,
|
||||||
|
window,
|
||||||
|
sharesMainPanelPlacement);
|
||||||
|
_byPanel.Add(panelId, entry);
|
||||||
_byWindow.Add(windowName, panelId);
|
_byWindow.Add(windowName, panelId);
|
||||||
|
if (window is not null)
|
||||||
|
window.Moved += OnWindowMoved;
|
||||||
|
|
||||||
|
if (sharesMainPanelPlacement && _mainPanelPlacement is { } placement)
|
||||||
|
MoveWindowTo(entry, placement);
|
||||||
if (_isVisible(windowName))
|
if (_isVisible(windowName))
|
||||||
ObserveWindowVisibility(windowName, visible: true);
|
ObserveWindowVisibility(windowName, visible: true);
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +143,10 @@ public sealed class RetailPanelUiController
|
||||||
if (visible)
|
if (visible)
|
||||||
{
|
{
|
||||||
if (_activePanel == panelId)
|
if (_activePanel == panelId)
|
||||||
|
{
|
||||||
|
PrepareMainPanelPlacement(requested);
|
||||||
return _show(requested.WindowName);
|
return _show(requested.WindowName);
|
||||||
|
}
|
||||||
|
|
||||||
uint? previousId = _activePanel;
|
uint? previousId = _activePanel;
|
||||||
PanelEntry? previous = previousId is uint id
|
PanelEntry? previous = previousId is uint id
|
||||||
|
|
@ -98,6 +155,9 @@ public sealed class RetailPanelUiController
|
||||||
? found
|
? found
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (previous is { } previousEntry)
|
||||||
|
CaptureMainPanelPlacement(previousEntry, synchronizeSiblings: true);
|
||||||
|
|
||||||
_deferredPanel = requested.RestorePrevious
|
_deferredPanel = requested.RestorePrevious
|
||||||
&& previous is { RestorePrevious: false }
|
&& previous is { RestorePrevious: false }
|
||||||
? previousId
|
? previousId
|
||||||
|
|
@ -105,6 +165,7 @@ public sealed class RetailPanelUiController
|
||||||
_activePanel = panelId;
|
_activePanel = panelId;
|
||||||
if (previous is not null)
|
if (previous is not null)
|
||||||
_hide(previous.Value.WindowName);
|
_hide(previous.Value.WindowName);
|
||||||
|
PrepareMainPanelPlacement(requested);
|
||||||
return _show(requested.WindowName);
|
return _show(requested.WindowName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,6 +175,7 @@ public sealed class RetailPanelUiController
|
||||||
return _hide(requested.WindowName);
|
return _hide(requested.WindowName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CaptureMainPanelPlacement(requested, synchronizeSiblings: true);
|
||||||
bool hidden = _hide(requested.WindowName);
|
bool hidden = _hide(requested.WindowName);
|
||||||
_activePanel = null;
|
_activePanel = null;
|
||||||
if (_deferredPanel is not uint deferredId
|
if (_deferredPanel is not uint deferredId
|
||||||
|
|
@ -122,6 +184,7 @@ public sealed class RetailPanelUiController
|
||||||
|
|
||||||
_deferredPanel = null;
|
_deferredPanel = null;
|
||||||
_activePanel = deferredId;
|
_activePanel = deferredId;
|
||||||
|
PrepareMainPanelPlacement(deferred);
|
||||||
return _show(deferred.WindowName) || hidden;
|
return _show(deferred.WindowName) || hidden;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -140,5 +203,94 @@ public sealed class RetailPanelUiController
|
||||||
SetPanelVisibility(panelId, visible);
|
SetPanelVisibility(panelId, visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct PanelEntry(string WindowName, bool RestorePrevious);
|
private void OnWindowMoved(RetailWindowHandle window)
|
||||||
|
{
|
||||||
|
if (_disposed || _synchronizingPlacement) return;
|
||||||
|
|
||||||
|
foreach (PanelEntry entry in _byPanel.Values)
|
||||||
|
{
|
||||||
|
if (!entry.SharesMainPanelPlacement
|
||||||
|
|| !ReferenceEquals(entry.Window, window))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
|
||||||
|
SynchronizeMainPanelSiblings(window);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CaptureMainPanelPlacement(
|
||||||
|
PanelEntry entry,
|
||||||
|
bool synchronizeSiblings)
|
||||||
|
{
|
||||||
|
if (!entry.SharesMainPanelPlacement || entry.Window is not { } window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
|
||||||
|
if (synchronizeSiblings)
|
||||||
|
SynchronizeMainPanelSiblings(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrepareMainPanelPlacement(PanelEntry entry)
|
||||||
|
{
|
||||||
|
if (!entry.SharesMainPanelPlacement || entry.Window is not { } window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_mainPanelPlacement is not { } placement)
|
||||||
|
{
|
||||||
|
_mainPanelPlacement = new PanelPlacement(window.Left, window.Top);
|
||||||
|
SynchronizeMainPanelSiblings(window);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MoveWindowTo(entry, placement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SynchronizeMainPanelSiblings(RetailWindowHandle source)
|
||||||
|
{
|
||||||
|
if (_mainPanelPlacement is not { } placement) return;
|
||||||
|
|
||||||
|
_synchronizingPlacement = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (PanelEntry sibling in _byPanel.Values)
|
||||||
|
{
|
||||||
|
if (!sibling.SharesMainPanelPlacement
|
||||||
|
|| sibling.Window is not { } window
|
||||||
|
|| ReferenceEquals(window, source))
|
||||||
|
continue;
|
||||||
|
MoveWindowTo(sibling, placement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_synchronizingPlacement = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveWindowTo(PanelEntry entry, PanelPlacement placement)
|
||||||
|
{
|
||||||
|
if (entry.Window is not { } window
|
||||||
|
|| (window.Left == placement.Left && window.Top == placement.Top))
|
||||||
|
return;
|
||||||
|
|
||||||
|
window.MoveTo(placement.Left, placement.Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
foreach (PanelEntry entry in _byPanel.Values)
|
||||||
|
if (entry.Window is { } window)
|
||||||
|
window.Moved -= OnWindowMoved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct PanelPlacement(float Left, float Top);
|
||||||
|
|
||||||
|
private readonly record struct PanelEntry(
|
||||||
|
string WindowName,
|
||||||
|
bool RestorePrevious,
|
||||||
|
RetailWindowHandle? Window,
|
||||||
|
bool SharesMainPanelPlacement);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -784,7 +784,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
|
|
||||||
SpellbookWindowController = controller;
|
SpellbookWindowController = controller;
|
||||||
UiElement root = layout.Root;
|
UiElement root = layout.Root;
|
||||||
RetailWindowFrame.Mount(
|
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||||
Host.Root,
|
Host.Root,
|
||||||
root,
|
root,
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
|
|
@ -805,7 +805,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
Controller = controller,
|
Controller = controller,
|
||||||
});
|
});
|
||||||
_panelUi.Register(RetailPanelCatalog.Magic, WindowNames.Spellbook);
|
_panelUi.RegisterMainPanel(
|
||||||
|
RetailPanelCatalog.Magic,
|
||||||
|
WindowNames.Spellbook,
|
||||||
|
handle);
|
||||||
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
|
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1134,7 +1137,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
(request, completed) => HandleCharacterRaise(provider, request, completed),
|
(request, completed) => HandleCharacterRaise(provider, request, completed),
|
||||||
() => CloseWindow(WindowNames.Character));
|
() => CloseWindow(WindowNames.Character));
|
||||||
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
|
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||||
|
Host.Root,
|
||||||
|
layout.Root,
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
new RetailWindowFrame.Options
|
new RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
WindowName = WindowNames.Character,
|
WindowName = WindowNames.Character,
|
||||||
|
|
@ -1149,7 +1155,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
});
|
});
|
||||||
_panelUi.Register(RetailPanelCatalog.Character, WindowNames.Character);
|
_panelUi.RegisterMainPanel(
|
||||||
|
RetailPanelCatalog.Character,
|
||||||
|
WindowNames.Character,
|
||||||
|
handle);
|
||||||
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
|
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1231,7 +1240,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
Host.Root.Height - root.Top),
|
Host.Root.Height - root.Top),
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||||
});
|
});
|
||||||
_panelUi.Register(RetailPanelCatalog.Inventory, WindowNames.Inventory);
|
_panelUi.RegisterMainPanel(
|
||||||
|
RetailPanelCatalog.Inventory,
|
||||||
|
WindowNames.Inventory,
|
||||||
|
handle);
|
||||||
|
|
||||||
uint contents, sideBag, mainPack;
|
uint contents, sideBag, mainPack;
|
||||||
IReadOnlyDictionary<uint, uint> paperdollEmptySprites;
|
IReadOnlyDictionary<uint, uint> paperdollEmptySprites;
|
||||||
|
|
@ -1280,6 +1292,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_itemConfirmationController?.Dispose();
|
_itemConfirmationController?.Dispose();
|
||||||
_gameplayConfirmationController?.Dispose();
|
_gameplayConfirmationController?.Dispose();
|
||||||
DialogFactory?.Dispose();
|
DialogFactory?.Dispose();
|
||||||
|
_panelUi.Dispose();
|
||||||
Host.Dispose();
|
Host.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
using AcDream.App.UI.Layout;
|
using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
@ -74,6 +75,71 @@ public sealed class RetailPanelUiControllerTests
|
||||||
Assert.Equal(2u, controller.ActivePanelId);
|
Assert.Equal(2u, controller.ActivePanelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainPanels_ShareOneCanonicalPlacementAcrossMoveCloseAndSwitch()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
||||||
|
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f);
|
||||||
|
RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f);
|
||||||
|
RetailWindowHandle spellbook = Mount(root, WindowNames.Spellbook, 18f, 18f);
|
||||||
|
using var controller = Create(root);
|
||||||
|
controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory);
|
||||||
|
controller.RegisterMainPanel(11u, WindowNames.Character, character);
|
||||||
|
controller.RegisterMainPanel(13u, WindowNames.Spellbook, spellbook);
|
||||||
|
|
||||||
|
int characterMoves = 0;
|
||||||
|
int spellbookMoves = 0;
|
||||||
|
character.Moved += _ => characterMoves++;
|
||||||
|
spellbook.Moved += _ => spellbookMoves++;
|
||||||
|
|
||||||
|
controller.SetPanelVisibility(7u, visible: true);
|
||||||
|
inventory.MoveTo(312f, 147f);
|
||||||
|
|
||||||
|
Assert.Equal((312f, 147f), (character.Left, character.Top));
|
||||||
|
Assert.Equal((312f, 147f), (spellbook.Left, spellbook.Top));
|
||||||
|
Assert.True(characterMoves > 0);
|
||||||
|
Assert.True(spellbookMoves > 0);
|
||||||
|
|
||||||
|
controller.SetPanelVisibility(11u, visible: true);
|
||||||
|
Assert.False(inventory.IsVisible);
|
||||||
|
Assert.True(character.IsVisible);
|
||||||
|
Assert.Equal((312f, 147f), (character.Left, character.Top));
|
||||||
|
|
||||||
|
controller.SetPanelVisibility(11u, visible: false);
|
||||||
|
controller.SetPanelVisibility(13u, visible: true);
|
||||||
|
Assert.False(character.IsVisible);
|
||||||
|
Assert.True(spellbook.IsVisible);
|
||||||
|
Assert.Equal((312f, 147f), (spellbook.Left, spellbook.Top));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RestorePreviousEffect_DoesNotReplaceMainPanelPlacement()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
||||||
|
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f);
|
||||||
|
RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f);
|
||||||
|
RetailWindowHandle effect = Mount(root, WindowNames.PositiveEffects, 900f, 30f);
|
||||||
|
using var controller = Create(root);
|
||||||
|
controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory);
|
||||||
|
controller.RegisterMainPanel(11u, WindowNames.Character, character);
|
||||||
|
controller.Register(4u, WindowNames.PositiveEffects, restorePrevious: true);
|
||||||
|
|
||||||
|
controller.SetPanelVisibility(7u, visible: true);
|
||||||
|
inventory.MoveTo(280f, 120f);
|
||||||
|
controller.SetPanelVisibility(4u, visible: true);
|
||||||
|
|
||||||
|
Assert.Equal((900f, 30f), (effect.Left, effect.Top));
|
||||||
|
Assert.False(inventory.IsVisible);
|
||||||
|
Assert.True(effect.IsVisible);
|
||||||
|
|
||||||
|
controller.SetPanelVisibility(4u, visible: false);
|
||||||
|
Assert.True(inventory.IsVisible);
|
||||||
|
Assert.Equal((280f, 120f), (inventory.Left, inventory.Top));
|
||||||
|
|
||||||
|
controller.SetPanelVisibility(11u, visible: true);
|
||||||
|
Assert.Equal((280f, 120f), (character.Left, character.Top));
|
||||||
|
}
|
||||||
|
|
||||||
private static RetailPanelUiController Create(Dictionary<string, bool> visible)
|
private static RetailPanelUiController Create(Dictionary<string, bool> visible)
|
||||||
=> new(
|
=> new(
|
||||||
name => visible[name],
|
name => visible[name],
|
||||||
|
|
@ -87,4 +153,28 @@ public sealed class RetailPanelUiControllerTests
|
||||||
visible[name] = false;
|
visible[name] = false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private static RetailPanelUiController Create(UiRoot root)
|
||||||
|
=> new(root.IsWindowVisible, root.ShowWindow, root.HideWindow);
|
||||||
|
|
||||||
|
private static RetailWindowHandle Mount(
|
||||||
|
UiRoot root,
|
||||||
|
string name,
|
||||||
|
float left,
|
||||||
|
float top)
|
||||||
|
{
|
||||||
|
var content = new UiPanel { Width = 300f, Height = 500f };
|
||||||
|
return RetailWindowFrame.Mount(
|
||||||
|
root,
|
||||||
|
content,
|
||||||
|
_ => (0u, 0, 0),
|
||||||
|
new RetailWindowFrame.Options
|
||||||
|
{
|
||||||
|
WindowName = name,
|
||||||
|
Left = left,
|
||||||
|
Top = top,
|
||||||
|
Visible = false,
|
||||||
|
ConstrainDragToParent = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
using AcDream.UI.Abstractions.Panels.Settings;
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI;
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
@ -28,6 +29,47 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
|
||||||
Assert.False(saved.Visible);
|
Assert.False(saved.Visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MainPanelMove_PersistsCanonicalPositionForEveryPrimaryChild()
|
||||||
|
{
|
||||||
|
var store = new SettingsStore(PathName);
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory);
|
||||||
|
RetailWindowHandle character = Mount(root, WindowNames.Character);
|
||||||
|
inventory.Hide();
|
||||||
|
character.Hide();
|
||||||
|
|
||||||
|
using var panels = new RetailPanelUiController(
|
||||||
|
root.IsWindowVisible,
|
||||||
|
root.ShowWindow,
|
||||||
|
root.HideWindow);
|
||||||
|
panels.RegisterMainPanel(
|
||||||
|
RetailPanelCatalog.Inventory,
|
||||||
|
WindowNames.Inventory,
|
||||||
|
inventory);
|
||||||
|
panels.RegisterMainPanel(
|
||||||
|
RetailPanelCatalog.Character,
|
||||||
|
WindowNames.Character,
|
||||||
|
character);
|
||||||
|
using var persistence = new RetailWindowLayoutPersistence(
|
||||||
|
root.WindowManager,
|
||||||
|
store,
|
||||||
|
() => "Alice",
|
||||||
|
() => (800, 600));
|
||||||
|
|
||||||
|
panels.SetPanelVisibility(RetailPanelCatalog.Inventory, visible: true);
|
||||||
|
inventory.MoveTo(244f, 133f);
|
||||||
|
|
||||||
|
UiWindowLayout savedInventory = Assert.IsType<UiWindowLayout>(
|
||||||
|
store.LoadWindowLayout(
|
||||||
|
"Alice", "800x600", WindowNames.Inventory, default));
|
||||||
|
UiWindowLayout savedCharacter = Assert.IsType<UiWindowLayout>(
|
||||||
|
store.LoadWindowLayout(
|
||||||
|
"Alice", "800x600", WindowNames.Character, default));
|
||||||
|
Assert.Equal((244f, 133f), (savedInventory.X, savedInventory.Y));
|
||||||
|
Assert.Equal((244f, 133f), (savedCharacter.X, savedCharacter.Y));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Restore_ClampsBoundsAndReappliesPanelStateAndVisibility()
|
public void Restore_ClampsBoundsAndReappliesPanelStateAndVisibility()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue