feat(ui): unify retained window mounts

This commit is contained in:
Erik 2026-07-10 22:22:25 +02:00
parent 6e9e10367f
commit a8e9503d2e
20 changed files with 823 additions and 366 deletions

View file

@ -54,8 +54,8 @@ accepted-divergence entries (#96, #49, #50).
| 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) | `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-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. `RetailWindowManager`/typed handles now centralize registry, raise, focus/capture cleanup, lifecycle events, and controller teardown. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`; chrome/mount paths remain mixed pending Wave 2.2. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; production mounts 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. | Mixed mount/chrome paths or unported persistence semantics can make windows drift from one another at non-reference sizes or across restart; lifecycle edge cases remain constrained by conformance tests rather than a Keystone byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.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. `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, and controller teardown; `RetailWindowFrame` is now the single production/Studio mount contract for both 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`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; production mounts 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. | Unported persistence semantics can still make windows drift across restart/resolution changes; lifecycle edge cases remain constrained by conformance tests rather than a Keystone byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the dat stacks both rows always. | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
--- ---
@ -203,14 +203,14 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-95 | `UiButton` dispatches Click only; it lacks retail hover, pressed, released-outside, disabled, selected/toggle, and missing-state fallback transitions | `src/AcDream.App/UI/UiButton.cs` | Controllers manually set a few semantic states; generic pointer visuals remain incomplete | Buttons look inert or overwrite custom states, and disabled/toggle behavior can fire incorrectly | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` | | AP-95 | `UiButton` dispatches Click only; it lacks retail hover, pressed, released-outside, disabled, selected/toggle, and missing-state fallback transitions | `src/AcDream.App/UI/UiButton.cs` | Controllers manually set a few semantic states; generic pointer visuals remain incomplete | Buttons look inert or overwrite custom states, and disabled/toggle behavior can fire incorrectly | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` |
| AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance | | AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance |
| ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` | | ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` |
| AP-98 | **PARTIAL 2026-07-10 (Wave 2.1)** — typed handles and `RetailWindowManager` now own registry, raise, lifecycle, focus/capture cleanup, and controller disposal. Complete layout persistence is still absent, and chat still maximizes imported content to hardcoded 320 px instead of resizing the outer frame. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowHandle.cs`; `ChatWindowController.cs`; production mounts in `GameWindow.cs` | Window lifecycle now has one tested owner; Wave 2.2/2.3 migrate mounts and persistence | Layouts still reset/drift across restart, and chat chrome/content disagree after maximize | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail saveui/loadui behavior | | AP-98 | **PARTIAL 2026-07-10 (Wave 2.2)** — typed handles/manager own lifecycle and every production/Studio window uses `RetailWindowFrame`. Chat maximize now ports the half-parent growth/restore algorithm, resizes the outer frame, uses DAT 100360 height limits, and drives imported button states. Complete layout persistence is still absent. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs` | Lifecycle, mounts, and chat outer geometry now have one tested owner; Wave 2.3 adds persistence | Layouts still reset/drift across restart and resolution/character changes | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail saveui/loadui behavior |
| AP-99 | Shared item activation implements only a subset of `ItemHolder::UseObject`/placement legality and confirmation policy | `src/AcDream.App/UI/ItemInteractionController.cs` | Current PvE use/equip/container flows cover the M2 loop; Wave 3 ports the complete pure decision matrix | Vendor/trade/busy/confirmation/target edge cases send the wrong action or permit an illegal use | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` | | AP-99 | Shared item activation implements only a subset of `ItemHolder::UseObject`/placement legality and confirmation policy | `src/AcDream.App/UI/ItemInteractionController.cs` | Current PvE use/equip/container flows cover the M2 loop; Wave 3 ports the complete pure decision matrix | Vendor/trade/busy/confirmation/target edge cases send the wrong action or permit an illegal use | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
| AP-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` | | AP-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` |
| AP-101 | Toolbar quick-slot actions and most panel/Use/Examine/combat controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, stance indicators, health/name, and some toggles are present | Bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | | AP-101 | Toolbar quick-slot actions and most panel/Use/Examine/combat controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, stance indicators, health/name, and some toggles are present | Bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
| AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` | | AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` |
| AP-103 | Shortcut session storage keeps only object guid and discards raw non-object fields; parser/builder names the final uint as `u16 spellId/u16 layer` instead of preserving retail `spellID_` as raw data | `src/AcDream.Core/Items/ShortcutStore.cs`; `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`; `InventoryActions.cs` | `gmToolbarUI` itself displays only nonzero object ids, so current item shortcuts work | Round-trip mutation corrupts unknown/raw fields and encourages invented spell support in the item toolbar | `ShortCutData` in `acclient.h:36484`; `gmToolbarUI::UpdateFromPlayerDesc @ 0x004BF810` | | AP-103 | Shortcut session storage keeps only object guid and discards raw non-object fields; parser/builder names the final uint as `u16 spellId/u16 layer` instead of preserving retail `spellID_` as raw data | `src/AcDream.Core/Items/ShortcutStore.cs`; `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`; `InventoryActions.cs` | `gmToolbarUI` itself displays only nonzero object ids, so current item shortcuts work | Round-trip mutation corrupts unknown/raw fields and encourages invented spell support in the item toolbar | `ShortCutData` in `acclient.h:36484`; `gmToolbarUI::UpdateFromPlayerDesc @ 0x004BF810` |
| AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` | | AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` |
| AP-105 | Retained chat lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; scrollbar arrow roles are reversed and a second ChatVM loses FPS/position providers | `src/AcDream.App/UI/Layout/ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path and word wrapping work; Waves 2/5 consolidate the remaining state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `ChatInterface` methods | | AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
| AP-107 | Inventory grid/bag clicks bypass active target-mode acquisition while cursor resolution treats occupied cells as targets (#197) | `src/AcDream.App/UI/Layout/InventoryController.cs`; `UiItemSlot.cs` | Main pack/toolbar/paperdoll already opt in at their call sites | A valid-target cursor click selects/opens rather than consuming the target | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-107 | Inventory grid/bag clicks bypass active target-mode acquisition while cursor resolution treats occupied cells as targets (#197) | `src/AcDream.App/UI/Layout/InventoryController.cs`; `UiItemSlot.cs` | Main pack/toolbar/paperdoll already opt in at their call sites | A valid-target cursor click selects/opens rather than consuming the target | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
| AP-108 | Paperdoll omits Aetheria, authoritative upper-item priority, full `AutoWieldIsLegal`/dual-wield rules, and body-part interaction/highlighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, and optimistic wield work | Layered clothing, special slots, invalid drops, and doll-body clicks differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` | | AP-108 | Paperdoll omits Aetheria, authoritative upper-item priority, full `AutoWieldIsLegal`/dual-wield rules, and body-part interaction/highlighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, and optimistic wield work | Layered clothing, special slots, invalid drops, and doll-body clicks differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |

View file

@ -241,17 +241,21 @@ text; full suite green; user visual gate.
### 2.2 Use one mount path for every production window ### 2.2 Use one mount path for every production window
- [ ] Extend `RetailWindowFrame.Mount` to return a handle and accept exact resize, - [x] Extend `RetailWindowFrame.Mount` to return a handle and accept exact resize,
chrome, opacity, visibility and constraint policy. chrome, opacity, visibility and constraint policy.
- [ ] Support both imported roots that already own retail chrome and shared - [x] Support both imported roots that already own retail chrome and shared
nine-slice wrappers, with independent X/Y resize policy, so migrations cannot nine-slice wrappers, with independent X/Y resize policy, so migrations cannot
double-frame or double-inset content. double-frame or double-inset content.
- [ ] Migrate vitals, chat, toolbar, inventory, radar, character, and Studio one at - [x] Migrate vitals, chat, toolbar, inventory, radar, character, and Studio one at
a time. Delete all inline `UiNineSlicePanel` mount recipes from `GameWindow` and a time. Delete all inline `UiNineSlicePanel` mount recipes from `GameWindow` and
`MockupDesktop`. `MockupDesktop`.
- [ ] Make chat maximize operate on its handle's outer frame and import retail - [x] Make chat maximize operate on its handle's outer frame and import retail
min/max attributes and button states; remove hardcoded `320f`. min/max attributes and button states; remove hardcoded `320f`.
- [ ] Correct chat scrollbar art from the actual inherited DAT roles, not literals. - [x] Correct chat scrollbar art from the actual inherited DAT roles, not literals.
**Acceptance (2026-07-10):** full suite green (4,538 passed, 5 skipped); live
Debug gate passed for shared chrome, resize/collapse, lock/raise, chat maximize,
scrollbar buttons, track/thumb dragging, wheel scrolling, and bottom-pin behavior.
### 2.3 Persist the complete UI layout ### 2.3 Persist the complete UI layout

View file

@ -2099,22 +2099,20 @@ public sealed class GameWindow : IDisposable
healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "",
manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : "");
// Top-level retail window: user-positioned (Anchors.None so the per-frame
// anchor pass doesn't reset it), movable, and horizontally resizable like
// retail. On a width change the dat edge-anchors reflow the pieces
// (UIElement::UpdateForParentSizeChange @0x00462640): top/bottom edges +
// the three bars stretch, corners stay 5px, the right edge/corners track
// the right side. Vertical resize is off (the layout has no vertical stretch).
var vitalsRoot = imported.Root; var vitalsRoot = imported.Root;
vitalsRoot.Left = 10; vitalsRoot.Top = 30; AcDream.App.UI.Layout.RetailWindowFrame.Mount(
vitalsRoot.ClickThrough = false; _uiHost.Root, vitalsRoot, ResolveChrome,
vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None; new AcDream.App.UI.Layout.RetailWindowFrame.Options
vitalsRoot.Draggable = true; {
vitalsRoot.Resizable = true; WindowName = AcDream.App.UI.WindowNames.Vitals,
vitalsRoot.ResizeX = true; Chrome = AcDream.App.UI.Layout.RetailWindowChrome.Imported,
vitalsRoot.ResizeY = false; Left = 10f,
vitalsRoot.MinWidth = 40f; Top = 30f,
_uiHost.Root.AddChild(vitalsRoot); ResizeX = true,
ResizeY = false,
MinWidth = 40f,
ContentClickThrough = false,
});
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C)."); Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
} }
else else
@ -2150,16 +2148,20 @@ public sealed class GameWindow : IDisposable
setUiLocked: SetRetailUiLocked, setUiLocked: SetRetailUiLocked,
datFont: vitalsDatFont); datFont: vitalsDatFont);
radarRoot.Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f); AcDream.App.UI.Layout.RetailWindowFrame.Mount(
radarRoot.Top = 10f; _uiHost.Root, radarRoot, ResolveChrome,
radarRoot.ClickThrough = false; new AcDream.App.UI.Layout.RetailWindowFrame.Options
// User-positioned top-level window: Anchors.None prevents the {
// per-frame anchor pass from undoing drag-handle movement. WindowName = AcDream.App.UI.WindowNames.Radar,
radarRoot.Anchors = AcDream.App.UI.AnchorEdges.None; Chrome = AcDream.App.UI.Layout.RetailWindowChrome.Imported,
radarRoot.Resizable = false; Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f),
radarRoot.ConstrainDragToParent = true; Top = 10f,
_uiHost.Root.AddChild(radarRoot); Resizable = false,
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Radar, radarRoot); ResizeX = false,
ResizeY = false,
ConstrainDragToParent = true,
ContentClickThrough = false,
});
ApplySavedRadarPosition(); ApplySavedRadarPosition();
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074."); Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
} }
@ -2204,32 +2206,30 @@ public sealed class GameWindow : IDisposable
// HEIGHT so the content's child anchors (input-bar-at-bottom, transcript- // HEIGHT so the content's child anchors (input-bar-at-bottom, transcript-
// fills) capture correct margins on first layout; resizing the frame reflows // fills) capture correct margins on first layout; resizing the frame reflows
// them correctly from there. // them correctly from there.
const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border;
var chatRoot = chatController.Root; var chatRoot = chatController.Root;
float contentW = 490f, contentH = chatRoot.Height; // dat-authored height var chatHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) _uiHost.Root, chatRoot, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{ {
Left = 10, Top = 440, WindowName = AcDream.App.UI.WindowNames.Chat,
Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder, Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
MinWidth = 200f, MinHeight = 90f, Left = 10f,
// Retail chat is translucent — fade the window's backgrounds/chrome Top = 440f,
// (text stays opaque). Configurable opacity is a later step; 0.75 reads ContentWidth = 490f,
// as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0) ContentHeight = chatRoot.Height,
RebaseContentLayout = true,
DatConstraintSource = chatController.DatWindowInfo,
MinWidth = 200f,
ResizeX = true,
ResizeY = true,
Opacity = 0.75f, Opacity = 0.75f,
}; });
chatRoot.Left = chatBorder; chatRoot.Top = chatBorder; chatController.AttachWindow(chatHandle);
chatRoot.Width = contentW; chatRoot.Height = contentH;
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
// The LayoutDesc root is 800px wide because it shares the display // The LayoutDesc root is 800px wide because it shares the display
// coordinate space with auxiliary/floaty chat elements. The docked // coordinate space with auxiliary/floaty chat elements. The docked
// gmMainChatUI content is the left 490px. Rebase its immediate children // gmMainChatUI content is the left 490px. Rebase its immediate children
// after that intentional crop so subsequent frame resizing grows the // after that intentional crop so subsequent frame resizing grows the
// transcript/input across the whole mounted interior. // transcript/input across the whole mounted interior.
chatRoot.RebaseChildLayoutBaselines();
chatRoot.Draggable = false; chatRoot.Resizable = false;
chatFrame.AddChild(chatRoot);
_uiHost.Root.AddChild(chatFrame);
// Tab / Enter enters "write mode" by focusing this input (retail's chat // Tab / Enter enters "write mode" by focusing this input (retail's chat
// activation); a focused input suppresses character movement (see the // activation); a focused input suppresses character movement (see the
// WantsKeyboard gate in the movement poll). // WantsKeyboard gate in the movement poll).
@ -2365,34 +2365,26 @@ public sealed class GameWindow : IDisposable
// thickness on every side, giving an outer window of 310×132. // thickness on every side, giving an outer window of 310×132.
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border; const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height; float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome) var toolbarHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, toolbarRoot, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{ {
Left = 10, Top = 300, WindowName = AcDream.App.UI.WindowNames.Toolbar,
Width = toolbarContentW + 2 * toolbarBorder, Chrome = AcDream.App.UI.Layout.RetailWindowChrome.CollapsibleNineSlice,
Height = toolbarContentH + 2 * toolbarBorder, Left = 10f,
// The toolbar is fully opaque (not translucent like the chat window). Top = 300f,
Opacity = 1.0f, ContentWidth = toolbarContentW,
}; ContentHeight = toolbarContentH,
// Content is offset by the border so it sits inside the chrome. Resizable = false,
toolbarRoot.Left = toolbarBorder; ResizeX = false,
toolbarRoot.Top = toolbarBorder; ResizeY = true,
toolbarRoot.Width = toolbarContentW; ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
toolbarRoot.Height = toolbarContentH; ContentAnchors = AcDream.App.UI.AnchorEdges.Left
// Anchor content to Left|Top|Right only — drop Bottom so the dat content | AcDream.App.UI.AnchorEdges.Top
// keeps its full height when the frame collapses; row 2 hides via Visible, | AcDream.App.UI.AnchorEdges.Right,
// not reflow. (Width is fixed so the horizontal anchors are inert but harmless.) ContentClickThrough = true,
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top });
| AcDream.App.UI.AnchorEdges.Right; var toolbarFrame = (AcDream.App.UI.UiCollapsibleFrame)toolbarHandle.OuterFrame;
// The frame is the draggable window; the content itself is not. ClickThrough so the
// content panel never CLAIMS a hit — its behavioral children (slots, indicators) are
// hit children-first, and its empty areas fall through to the frame (move). Critically,
// when collapsed the row-2 band is hidden, so below the collapsed frame the content has
// no visible/hit children and ClickThrough lets clicks fall through (no phantom
// window-drag from where row 2 used to be). Same pattern as the chat content panel.
toolbarRoot.ClickThrough = true;
toolbarRoot.Draggable = false;
toolbarRoot.Resizable = false;
toolbarFrame.AddChild(toolbarRoot);
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden) // Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
// and two-row height. CollapsedHeight is computed from the layout (just above row 2), // and two-row height. CollapsedHeight is computed from the layout (just above row 2),
@ -2431,8 +2423,6 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0})."); Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0}).");
} }
_uiHost.Root.AddChild(toolbarFrame);
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016)."); Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
} }
else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found."); else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
@ -2462,16 +2452,18 @@ public sealed class GameWindow : IDisposable
_uiHost.Root, characterLayout.Root, ResolveChrome, _uiHost.Root, characterLayout.Root, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options new AcDream.App.UI.Layout.RetailWindowFrame.Options
{ {
WindowName = AcDream.App.UI.WindowNames.Character,
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
Left = 540f, Left = 540f,
Top = 18f, Top = 18f,
MaxHeight = 760f, MaxHeight = 760f,
ResizeX = false, ResizeX = false,
ResizeY = true,
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom, ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
Visible = false, Visible = false,
ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Bottom, | AcDream.App.UI.AnchorEdges.Bottom,
ContentClickThrough = false, ContentClickThrough = false,
WindowName = AcDream.App.UI.WindowNames.Character,
}); });
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E)."); Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
@ -2515,36 +2507,27 @@ public sealed class GameWindow : IDisposable
// SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI // SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI
// LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on // LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on
// every side. The frame is the draggable window; the dat content sits inside it. // every side. The frame is the draggable window; the dat content sits inside it.
const int invBorder = AcDream.App.UI.RetailChromeSprites.Border;
float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height; float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height;
var inventoryFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) var inventoryHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
_uiHost.Root, inventoryRoot, ResolveChrome,
new AcDream.App.UI.Layout.RetailWindowFrame.Options
{ {
Left = inventoryRoot.Left, Top = inventoryRoot.Top, // keep the dat window position WindowName = AcDream.App.UI.WindowNames.Inventory,
Width = invContentW + 2 * invBorder, Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
Height = invContentH + 2 * invBorder, Left = inventoryRoot.Left,
Opacity = 1.0f, Top = inventoryRoot.Top,
Visible = false, // F12 toggles it ContentWidth = invContentW,
Anchors = AcDream.App.UI.AnchorEdges.None, // user-positioned ContentHeight = invContentH,
Draggable = true, Visible = false,
}; ResizeX = false,
// Content offset by the border so it sits inside the chrome. ResizeY = true,
inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder; ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH; MaxHeight = 560f,
// Content fills the frame vertically (Left|Top|Bottom = fixed width + height tracks ContentAnchors = AcDream.App.UI.AnchorEdges.Left
// the frame) so a vertical resize grows the inner content. | AcDream.App.UI.AnchorEdges.Top
inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top | AcDream.App.UI.AnchorEdges.Bottom,
| AcDream.App.UI.AnchorEdges.Bottom; });
inventoryRoot.Draggable = false; var inventoryFrame = (AcDream.App.UI.UiNineSlicePanel)inventoryHandle.OuterFrame;
inventoryFrame.AddChild(inventoryRoot);
// Vertical-only resize: drag the BOTTOM edge to expand and see more of the pack.
// ResizeX=false + ResizableEdges=Bottom blocks horizontal resize (user request).
// MinHeight = the dat default (no shrink below it); MaxHeight = toward full screen.
inventoryFrame.Resizable = true;
inventoryFrame.ResizeX = false;
inventoryFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom;
inventoryFrame.MinHeight = invContentH + 2 * invBorder;
inventoryFrame.MaxHeight = 560f;
// Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid // Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid
// + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the // + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the
@ -2568,9 +2551,6 @@ public sealed class GameWindow : IDisposable
PinTopLeft(0x100001CDu); // paperdoll (fixed at top) PinTopLeft(0x100001CDu); // paperdoll (fixed at top)
PinTopLeft(0x100001CEu); // side-bag column (fixed at top) PinTopLeft(0x100001CEu); // side-bag column (fixed at top)
_uiHost.Root.AddChild(inventoryFrame);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryFrame);
// Phase D.2b-B — populate the inventory from ClientObjectTable: the // Phase D.2b-B — populate the inventory from ClientObjectTable: the
// "Contents of Backpack" grid + the pack-selector strip + the burden meter + // "Contents of Backpack" grid + the pack-selector strip + the burden meter +
// captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit. // captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.

View file

@ -23,15 +23,15 @@ internal static class MockupDesktop
var windows = new List<MockupWindow>(); var windows = new List<MockupWindow>();
if (MountVitals(dats, stack, objects) is { } vitals) if (MountVitals(dats, stack, objects) is { } vitals)
RegisterWindow(stack, windows, WindowNames.Vitals, "Vitals", vitals, zOrder: 10); RecordWindow(windows, "Vitals", vitals, zOrder: 10);
if (MountToolbar(dats, stack, objects) is { } toolbar) if (MountToolbar(dats, stack, objects) is { } toolbar)
RegisterWindow(stack, windows, WindowNames.Toolbar, "Toolbar", toolbar, zOrder: 20); RecordWindow(windows, "Toolbar", toolbar, zOrder: 20);
if (MountCharacter(dats, stack, objects) is { } character) if (MountCharacter(dats, stack, objects) is { } character)
RegisterWindow(stack, windows, WindowNames.Character, "Character", character, zOrder: 30); RecordWindow(windows, "Character", character, zOrder: 30);
if (MountInventory(dats, stack, objects) is { } inventory) if (MountInventory(dats, stack, objects) is { } inventory)
RegisterWindow(stack, windows, WindowNames.Inventory, "Inventory", inventory, zOrder: 40); RecordWindow(windows, "Inventory", inventory, zOrder: 40);
if (MountChat(dats, stack) is { } chat) if (MountChat(dats, stack) is { } chat)
RegisterWindow(stack, windows, WindowNames.Chat, "Chat", chat, zOrder: 50); RecordWindow(windows, "Chat", chat, zOrder: 50);
MountControls(stack, windows); MountControls(stack, windows);
} }
@ -40,27 +40,31 @@ internal static class MockupDesktop
=> LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont, => LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont,
fontResolve: stack.ResolveDatFont); fontResolve: stack.ResolveDatFont);
private static UiElement? MountVitals(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) private static RetailWindowHandle? MountVitals(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{ {
var layout = Import(dats, 0x2100006Cu, stack); var layout = Import(dats, 0x2100006Cu, stack);
if (layout is null) return null; if (layout is null) return null;
FixtureProvider.Populate(0x2100006Cu, layout, stack, objects, dats); FixtureProvider.Populate(0x2100006Cu, layout, stack, objects, dats);
var root = layout.Root; return RetailWindowFrame.Mount(
root.Left = 12f; stack.UiHost.Root,
root.Top = 18f; layout.Root,
root.ClickThrough = false; stack.ResolveChrome,
root.Anchors = AnchorEdges.None; new RetailWindowFrame.Options
root.Draggable = true; {
root.Resizable = true; WindowName = WindowNames.Vitals,
root.ResizeX = true; Chrome = RetailWindowChrome.Imported,
root.ResizeY = false; Left = 12f,
root.MinWidth = 40f; Top = 18f,
return root; ResizeX = true,
ResizeY = false,
MinWidth = 40f,
ContentClickThrough = false,
});
} }
private static UiElement? MountToolbar(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) private static RetailWindowHandle? MountToolbar(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{ {
var layout = Import(dats, 0x21000016u, stack); var layout = Import(dats, 0x21000016u, stack);
if (layout is null) return null; if (layout is null) return null;
@ -71,27 +75,29 @@ internal static class MockupDesktop
var toolbarRoot = layout.Root; var toolbarRoot = layout.Root;
float contentW = toolbarRoot.Width > 0f ? toolbarRoot.Width : 300f; float contentW = toolbarRoot.Width > 0f ? toolbarRoot.Width : 300f;
float contentH = toolbarRoot.Height; float contentH = toolbarRoot.Height;
var frame = new UiCollapsibleFrame(stack.ResolveChrome) RetailWindowHandle handle = RetailWindowFrame.Mount(
stack.UiHost.Root,
toolbarRoot,
stack.ResolveChrome,
new RetailWindowFrame.Options
{ {
WindowName = WindowNames.Toolbar,
Chrome = RetailWindowChrome.CollapsibleNineSlice,
Left = 12f, Left = 12f,
Top = 220f, Top = 220f,
Width = contentW + 2 * border, ContentWidth = contentW,
Height = contentH + 2 * border, ContentHeight = contentH,
Opacity = 1f, Resizable = false,
}; ResizeX = false,
ResizeY = true,
toolbarRoot.Left = border; ResizableEdges = ResizeEdges.Bottom,
toolbarRoot.Top = border; ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
toolbarRoot.Width = contentW; ContentClickThrough = true,
toolbarRoot.Height = contentH; });
toolbarRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right; var frame = (UiCollapsibleFrame)handle.OuterFrame;
toolbarRoot.ClickThrough = true;
toolbarRoot.Draggable = false;
toolbarRoot.Resizable = false;
frame.AddChild(toolbarRoot);
ConfigureToolbarCollapse(layout, frame, contentH, border); ConfigureToolbarCollapse(layout, frame, contentH, border);
return frame; return handle;
} }
private static void ConfigureToolbarCollapse(ImportedLayout layout, UiCollapsibleFrame frame, float contentH, int border) private static void ConfigureToolbarCollapse(ImportedLayout layout, UiCollapsibleFrame frame, float contentH, int border)
@ -126,60 +132,60 @@ internal static class MockupDesktop
frame.MaxHeight = expandedH; frame.MaxHeight = expandedH;
} }
private static UiElement? MountCharacter(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) private static RetailWindowHandle? MountCharacter(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{ {
var layout = Import(dats, 0x2100002Eu, stack); var layout = Import(dats, 0x2100002Eu, stack);
if (layout is null) return null; if (layout is null) return null;
FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats); FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats);
var root = layout.Root; return RetailWindowFrame.Mount(
root.Left = 540f; stack.UiHost.Root,
root.Top = 18f; layout.Root,
root.Anchors = AnchorEdges.None; stack.ResolveChrome,
root.ClickThrough = false; new RetailWindowFrame.Options
root.Draggable = true; {
root.Resizable = true; WindowName = WindowNames.Character,
root.MinWidth = root.Width; Chrome = RetailWindowChrome.NineSlice,
root.MinHeight = root.Height; Left = 540f,
return root; Top = 18f,
MaxHeight = 760f,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
ContentClickThrough = false,
});
} }
private static UiElement? MountInventory(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) private static RetailWindowHandle? MountInventory(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects)
{ {
var layout = Import(dats, 0x21000023u, stack); var layout = Import(dats, 0x21000023u, stack);
if (layout is null) return null; if (layout is null) return null;
FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats); FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats);
const int border = RetailChromeSprites.Border;
var inventoryRoot = layout.Root; var inventoryRoot = layout.Root;
float contentW = inventoryRoot.Width; float contentW = inventoryRoot.Width;
float contentH = inventoryRoot.Height; float contentH = inventoryRoot.Height;
var frame = new UiNineSlicePanel(stack.ResolveChrome) RetailWindowHandle handle = RetailWindowFrame.Mount(
stack.UiHost.Root,
inventoryRoot,
stack.ResolveChrome,
new RetailWindowFrame.Options
{ {
WindowName = WindowNames.Inventory,
Chrome = RetailWindowChrome.NineSlice,
Left = 900f, Left = 900f,
Top = 18f, Top = 18f,
Width = contentW + 2 * border, ContentWidth = contentW,
Height = contentH + 2 * border, ContentHeight = contentH,
Opacity = 1f,
Visible = true,
Anchors = AnchorEdges.None,
Draggable = true,
Resizable = true,
ResizeX = false, ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom, ResizableEdges = ResizeEdges.Bottom,
MinHeight = contentH + 2 * border,
MaxHeight = 560f, MaxHeight = 560f,
}; ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});
inventoryRoot.Left = border;
inventoryRoot.Top = border;
inventoryRoot.Width = contentW;
inventoryRoot.Height = contentH;
inventoryRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
inventoryRoot.Draggable = false;
frame.AddChild(inventoryRoot);
StretchV(layout, 0x100001D0u); StretchV(layout, 0x100001D0u);
StretchV(layout, 0x100001CFu); StretchV(layout, 0x100001CFu);
@ -188,7 +194,7 @@ internal static class MockupDesktop
PinTopLeft(layout, 0x100001CDu); PinTopLeft(layout, 0x100001CDu);
PinTopLeft(layout, 0x100001CEu); PinTopLeft(layout, 0x100001CEu);
return frame; return handle;
} }
private static void StretchV(ImportedLayout layout, uint id) private static void StretchV(ImportedLayout layout, uint id)
@ -203,7 +209,7 @@ internal static class MockupDesktop
element.Anchors = AnchorEdges.Left | AnchorEdges.Top; element.Anchors = AnchorEdges.Left | AnchorEdges.Top;
} }
private static UiElement? MountChat(DatCollection dats, RenderStack stack) private static RetailWindowHandle? MountChat(DatCollection dats, RenderStack stack)
{ {
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId); var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
if (rootInfo is null) return null; if (rootInfo is null) return null;
@ -229,42 +235,42 @@ internal static class MockupDesktop
stack.ResolveChrome); stack.ResolveChrome);
if (controller is null) return null; if (controller is null) return null;
const int border = RetailChromeSprites.Border;
var chatRoot = controller.Root; var chatRoot = controller.Root;
float contentW = 490f; RetailWindowHandle handle = RetailWindowFrame.Mount(
float contentH = chatRoot.Height; stack.UiHost.Root,
var frame = new UiNineSlicePanel(stack.ResolveChrome) chatRoot,
stack.ResolveChrome,
new RetailWindowFrame.Options
{ {
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Left = 12f, Left = 12f,
Top = 390f, Top = 390f,
Width = contentW + 2 * border, ContentWidth = 490f,
Height = contentH + 2 * border, ContentHeight = chatRoot.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f, MinWidth = 200f,
MinHeight = 90f, ResizeX = true,
ResizeY = true,
Opacity = 0.75f, Opacity = 0.75f,
}; });
controller.AttachWindow(handle);
chatRoot.Left = border;
chatRoot.Top = border;
chatRoot.Width = contentW;
chatRoot.Height = contentH;
chatRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
chatRoot.Draggable = false;
chatRoot.Resizable = false;
frame.AddChild(chatRoot);
stack.UiHost.Root.DefaultTextInput = controller.Input; stack.UiHost.Root.DefaultTextInput = controller.Input;
return frame; return handle;
} }
private static void RegisterWindow(RenderStack stack, List<MockupWindow> windows, private static void RecordWindow(
string name, string title, UiElement element, int zOrder) List<MockupWindow> windows,
string title,
RetailWindowHandle handle,
int zOrder)
{ {
UiElement element = handle.OuterFrame;
element.ZOrder = zOrder; element.ZOrder = zOrder;
stack.UiHost.Root.AddChild(element);
stack.UiHost.RegisterWindow(name, element);
windows.Add(new MockupWindow( windows.Add(new MockupWindow(
name, handle.Name,
title, title,
element, element,
new MockupRect(element.Left, element.Top, element.Width, element.Height), new MockupRect(element.Left, element.Top, element.Width, element.Height),

View file

@ -172,8 +172,8 @@ public static class CharacterStatController
private const uint ScrollThumbSprite = 0x06004C63u; private const uint ScrollThumbSprite = 0x06004C63u;
private const uint ScrollThumbTop = 0x06004C60u; private const uint ScrollThumbTop = 0x06004C60u;
private const uint ScrollThumbBot = 0x06004C66u; private const uint ScrollThumbBot = 0x06004C66u;
private const uint ScrollUpSprite = 0x06004C6Cu; private const uint ScrollUpSprite = 0x06004C69u;
private const uint ScrollDownSprite = 0x06004C69u; private const uint ScrollDownSprite = 0x06004C6Cu;
private enum CharacterStatTab private enum CharacterStatTab
{ {
@ -634,7 +634,11 @@ public static class CharacterStatController
if (source is UiScrollbar existingBar) if (source is UiScrollbar existingBar)
{ {
ConfigureSkillScrollbar(existingBar, spriteResolve); existingBar.SpriteResolve ??= id =>
{
var (handle, width, height) = spriteResolve(id);
return (handle, width, height);
};
existingBar.Visible = false; existingBar.Visible = false;
return existingBar; return existingBar;
} }

View file

@ -40,14 +40,6 @@ public sealed class ChatWindowController
private const uint SendId = 0x10000019u; private const uint SendId = 0x10000019u;
private const uint MaxMinId = 0x1000046Fu; private const uint MaxMinId = 0x1000046Fu;
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
private const uint TrackSprite = 0x06004C5Fu;
private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
// Channel menu sprite ids (confirmed in chat element dump). // Channel menu sprite ids (confirmed in chat element dump).
private const uint MenuNormal = 0x06004D65u; // button face private const uint MenuNormal = 0x06004D65u; // button face
private const uint MenuPressed = 0x06004D66u; // button pressed private const uint MenuPressed = 0x06004D66u; // button pressed
@ -72,6 +64,12 @@ public sealed class ChatWindowController
/// <summary>Channel-selector menu widget.</summary> /// <summary>Channel-selector menu widget.</summary>
public UiMenu Menu { get; private set; } = null!; public UiMenu Menu { get; private set; } = null!;
/// <summary>Resolved gmMainChatUI root metadata, including DAT size constraints.</summary>
public ElementInfo DatWindowInfo { get; private set; } = null!;
public RetailWindowHandle? WindowHandle { get; private set; }
public bool IsMaximized => _maximized;
// ── Private state ────────────────────────────────────────────────────── // ── Private state ──────────────────────────────────────────────────────
private ChatChannelKind _activeChannel = ChatChannelKind.Say; private ChatChannelKind _activeChannel = ChatChannelKind.Say;
@ -121,6 +119,7 @@ public sealed class ChatWindowController
/// <summary>Window top before maximize.</summary> /// <summary>Window top before maximize.</summary>
private float _normalTop; private float _normalTop;
private bool _maximized; private bool _maximized;
private UiButton? _maxMinButton;
// ── Factory ──────────────────────────────────────────────────────────── // ── Factory ────────────────────────────────────────────────────────────
@ -181,7 +180,11 @@ public sealed class ChatWindowController
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper, // GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
// orphaning the strays so they never draw. // orphaning the strays so they never draw.
var window = layout.FindElement(RootId) ?? layout.Root; var window = layout.FindElement(RootId) ?? layout.Root;
var c = new ChatWindowController { Root = window }; var c = new ChatWindowController
{
Root = window,
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
};
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and // Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
// juts out of the content-width window. The host wraps this content in the // juts out of the content-width window. The host wraps this content in the
@ -234,13 +237,7 @@ public sealed class ChatWindowController
bar.Height = bar.Height + oldTop; bar.Height = bar.Height + oldTop;
bar.ResetAnchorCapture(); bar.ResetAnchorCapture();
bar.Model = c.Transcript.Scroll; bar.Model = c.Transcript.Scroll;
bar.SpriteResolve = resolve; bar.SpriteResolve ??= resolve;
bar.TrackSprite = TrackSprite;
bar.ThumbSprite = ThumbSprite;
bar.ThumbTopSprite = ThumbTopSprite;
bar.ThumbBotSprite = ThumbBotSprite;
bar.UpSprite = UpSprite;
bar.DownSprite = DownSprite;
c.Scrollbar = bar; c.Scrollbar = bar;
} }
@ -301,7 +298,7 @@ public sealed class ChatWindowController
ReflowInputRow(); ReflowInputRow();
} }
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── // ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
if (layout.FindElement(MaxMinId) is UiButton maxMinEl) if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
{ {
// The dat puts max/min and the scrollbar up-button at the SAME X (both // The dat puts max/min and the scrollbar up-button at the SAME X (both
@ -310,6 +307,7 @@ public sealed class ChatWindowController
if (track is not null) if (track is not null)
maxMinEl.Left = track.Left - maxMinEl.Width; maxMinEl.Left = track.Left - maxMinEl.Width;
maxMinEl.ResetAnchorCapture(); maxMinEl.ResetAnchorCapture();
c._maxMinButton = maxMinEl;
maxMinEl.OnClick = c.ToggleMaximize; maxMinEl.OnClick = c.ToggleMaximize;
} }
@ -319,28 +317,76 @@ public sealed class ChatWindowController
// ── Max/min implementation ───────────────────────────────────────────── // ── Max/min implementation ─────────────────────────────────────────────
/// <summary> /// <summary>
/// Toggle between the normal chat window height and an expanded 320px height. /// Attach the typed outer-frame handle after the controller's imported content
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>: /// has been mounted. Maximize/restore must resize this frame, not the child root.
/// retail stores the pre-maximize height and restores it on a second click. /// </summary>
/// The 320px expanded size is the approximate retail maximized chat height. public void AttachWindow(RetailWindowHandle handle)
{
ArgumentNullException.ThrowIfNull(handle);
if (!ReferenceEquals(handle.ContentRoot, Root))
throw new ArgumentException("Chat handle content root does not match the bound layout.", nameof(handle));
if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle))
throw new InvalidOperationException("Chat controller is already attached to another window.");
WindowHandle = handle;
}
/// <summary>
/// Exact control flow from <c>gmMainChatUI::HandleMaximizeButton @0x004CCE50</c>:
/// save/restore Y+height, expand by half the parent, choose up/down growth from
/// available space, clamp through the mounted frame's DAT constraints, and set
/// the imported max/min button state.
/// </summary> /// </summary>
private void ToggleMaximize() private void ToggleMaximize()
{ {
if (!_maximized) if (WindowHandle is not { IsRegistered: true } handle)
return;
UiElement frame = handle.OuterFrame;
float parentHeight = frame.Parent?.Height ?? 0f;
if (parentHeight <= 0f)
return;
if (_maximized)
{ {
_normalHeight = Root.Height; float restoredHeight = Math.Clamp(_normalHeight, frame.MinHeight, frame.MaxHeight);
_normalTop = Root.Top; float restoredTop = Math.Clamp(
// Expand upward: move the top edge up so the bottom stays anchored. _normalTop,
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f); 0f,
Root.Height = 320f; MathF.Max(0f, parentHeight - frame.MinHeight));
_maximized = true;
}
else
{
Root.Top = _normalTop;
Root.Height = _normalHeight;
_maximized = false; _maximized = false;
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Minimized);
handle.ResizeTo(frame.Width, restoredHeight);
handle.MoveTo(frame.Left, restoredTop);
return;
} }
_normalTop = frame.Top;
_normalHeight = frame.Height;
float expansion = parentHeight / 2f;
float targetTop = frame.Top;
float targetHeight = frame.Height + expansion;
if (frame.Top + targetHeight > parentHeight || frame.Top >= parentHeight / 2f)
targetTop = MathF.Max(0f, frame.Top - expansion);
targetHeight = MathF.Min(targetHeight, parentHeight - targetTop);
targetHeight = Math.Clamp(targetHeight, frame.MinHeight, frame.MaxHeight);
_maximized = true;
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Maximized);
handle.ResizeTo(frame.Width, targetHeight);
handle.MoveTo(frame.Left, targetTop);
}
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (ElementInfo child in node.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
} }
// ── Helpers ──────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────

View file

@ -79,7 +79,7 @@ public static class DatWidgetFactory
6 => new UiMenu(), // UIElement_Menu (reg :120163) 6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter 7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf 0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655) 12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655)
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers) _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
@ -129,6 +129,68 @@ public static class DatWidgetFactory
return e; return e;
} }
/// <summary>
/// Bind inherited scrollbar media structurally. Property 0x77 names the
/// increment button and 0x78 the decrement button; the remaining Type-1
/// child is the thumb with ordered top/middle/bottom image slices.
/// </summary>
private static UiScrollbar BuildScrollbar(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve)
{
var bar = new UiScrollbar
{
SpriteResolve = resolve,
TrackSprite = DefaultImage(info),
};
uint incrementId = ReferencedElementId(info, 0x77u);
uint decrementId = ReferencedElementId(info, 0x78u);
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement);
bar.DownSprite = increment is null ? 0u : DefaultImage(increment);
ElementInfo? thumb = info.Children.FirstOrDefault(child =>
child.Type == 1u && child.Id != incrementId && child.Id != decrementId);
if (thumb is not null)
{
ElementInfo[] slices = thumb.Children
.Where(child => DefaultImage(child) != 0u)
.OrderBy(child => child.Y)
.ThenBy(child => child.ReadOrder)
.ToArray();
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]);
}
return bar;
}
private static uint ReferencedElementId(ElementInfo info, uint propertyId)
{
if (!info.TryGetEffectiveProperty(propertyId, out var property))
return 0u;
return property.Kind switch
{
UiPropertyKind.Enum or UiPropertyKind.DataId => (uint)property.UnsignedValue,
UiPropertyKind.Integer when property.IntegerValue >= 0 => (uint)property.IntegerValue,
_ => 0u,
};
}
private static uint DefaultImage(ElementInfo info)
{
uint stateId = info.EffectiveDefaultStateId();
if (info.States.TryGetValue(stateId, out var state) && state.Image is { } image)
return image.File;
if (info.States.TryGetValue(UiStateInfo.DirectStateId, out var direct)
&& direct.Image is { } directImage)
return directImage.File;
return 0u;
}
// ── Meter ──────────────────────────────────────────────────────────────── // ── Meter ────────────────────────────────────────────────────────────────
/// <summary> /// <summary>

View file

@ -25,15 +25,6 @@ public sealed class InventoryController : IItemListDragHandler
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>" public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid) public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
// ChatWindowController). Both inventory + chat scrollbars inherit this base.
private const uint ScrollTrackSprite = 0x06004C5Fu;
private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap
private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap
private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button)
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037). // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
private const int ContentsColumns = 6; private const int ContentsColumns = 6;
private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px) private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px)
@ -108,19 +99,13 @@ public sealed class InventoryController : IItemListDragHandler
_contentsGrid.CellHeight = ContentsCellPx; _contentsGrid.CellHeight = ContentsCellPx;
} }
// Bind the gutter scrollbar to the contents grid's scroll model (the factory built // Bind the gutter scrollbar to the contents grid's scroll model. The factory
// 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does). // already imported its inherited track/thumb/arrow media from LayoutDesc.
if (_contentsGrid is not null if (_contentsGrid is not null
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar) && layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
{ {
bar.Model = _contentsGrid.Scroll; bar.Model = _contentsGrid.Scroll;
bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor bar.SpriteResolve ??= _contentsGrid.SpriteResolve;
bar.TrackSprite = ScrollTrackSprite;
bar.ThumbSprite = ScrollThumbSprite;
bar.ThumbTopSprite = ScrollThumbTop;
bar.ThumbBotSprite = ScrollThumbBot;
bar.UpSprite = ScrollUpSprite;
bar.DownSprite = ScrollDownSprite;
} }
if (_containerList is not null) if (_containerList is not null)
{ {

View file

@ -2,65 +2,73 @@ using System;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
/// <summary> /// <summary>How a production retained window obtains its outer chrome.</summary>
/// Mounts an imported retail window's content into the shared 8-piece beveled public enum RetailWindowChrome
/// chrome (<see cref="UiNineSlicePanel"/>) and optionally registers it with
/// the <see cref="UiRoot"/> window manager. This is THE mount recipe every
/// retail window follows: frame sized content + 2×border, content inset by
/// the border with Draggable/Resizable off (the frame owns move/resize).
///
/// <para>The vitals/chat/toolbar/inventory windows still inline this recipe
/// in <c>GameWindow</c> (pre-extraction); new windows must mount through this
/// helper instead of copying the block again — see
/// docs/research/2026-07-02-ui-architecture-review.md (theme T1).</para>
/// </summary>
public static class RetailWindowFrame
{ {
/// <summary>Per-window placement + behavior knobs. Null-valued knobs keep /// <summary>The imported root already is the complete retail outer frame.</summary>
/// the widget defaults (<see cref="UiElement"/> / <see cref="UiNineSlicePanel"/>).</summary> Imported,
public sealed record Options
{
/// <summary>Initial window position (screen px).</summary>
public float Left { get; init; }
public float Top { get; init; }
/// <summary>Minimum frame size; null = lock to content + 2×border (non-shrinkable).</summary> /// <summary>Wrap imported content in the shared retail nine-slice frame.</summary>
public float? MinWidth { get; init; } NineSlice,
public float? MinHeight { get; init; }
/// <summary>Maximum frame height while resizing; null = unbounded.</summary> /// <summary>Use the shared frame variant with the toolbar's two height stops.</summary>
public float? MaxHeight { get; init; } CollapsibleNineSlice,
/// <summary>Allow horizontal resize (frame default: true).</summary>
public bool ResizeX { get; init; } = true;
/// <summary>Restrict which edges start a resize; null = all edges.</summary>
public ResizeEdges? ResizableEdges { get; init; }
/// <summary>Window translucency (retail SetDefaultOpacity analog).</summary>
public float Opacity { get; init; } = 1f;
/// <summary>Start shown or hidden (hidden windows toggle via the window manager).</summary>
public bool Visible { get; init; } = true;
/// <summary>How the content tracks the frame when it resizes.</summary>
public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
/// <summary>Force the content's ClickThrough; null = leave as imported.</summary>
public bool? ContentClickThrough { get; init; }
/// <summary>Register the frame under this name in the UiRoot window
/// manager (<see cref="WindowNames"/>); null = unmanaged window.</summary>
public string? WindowName { get; init; }
} }
/// <summary> /// <summary>
/// Wrap <paramref name="content"/> (sized by the importer) in the beveled /// The single mount path for production retained windows. It owns the distinction
/// chrome, add it to <paramref name="root"/>, and register it when /// between DAT roots that already contain chrome and content roots that require the
/// <see cref="Options.WindowName"/> is set. Returns the frame. /// shared nine-slice wrapper, applies exact resize/opacity/visibility policy, mounts
/// the outer frame, and returns its registered typed handle.
/// </summary> /// </summary>
public static UiNineSlicePanel Mount( public static class RetailWindowFrame
{
public sealed record Options
{
public required string WindowName { get; init; }
public RetailWindowChrome Chrome { get; init; } = RetailWindowChrome.NineSlice;
/// <summary>Initial outer-frame position in screen pixels.</summary>
public float Left { get; init; }
public float Top { get; init; }
/// <summary>
/// Optional mounted content extent. Used when a LayoutDesc root shares a
/// larger auxiliary coordinate space (the main chat root is cropped to 490px).
/// </summary>
public float? ContentWidth { get; init; }
public float? ContentHeight { get; init; }
public bool RebaseContentLayout { get; init; }
/// <summary>
/// Optional DAT element whose effective 0x3C..0x3F properties provide
/// max-height, max-width, min-height, and min-width respectively. Explicit
/// option values win; wrapper chrome is added to DAT content constraints.
/// </summary>
public ElementInfo? DatConstraintSource { get; init; }
public float? MinWidth { get; init; }
public float? MinHeight { get; init; }
public float? MaxWidth { get; init; }
public float? MaxHeight { get; init; }
public bool Draggable { get; init; } = true;
public bool Resizable { get; init; } = true;
public bool ResizeX { get; init; } = true;
public bool ResizeY { get; init; } = true;
public ResizeEdges ResizableEdges { get; init; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
public bool ConstrainDragToParent { get; init; }
public float Opacity { get; init; } = 1f;
public bool Visible { get; init; } = true;
public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
public bool? ContentClickThrough { get; init; }
public IRetainedPanelController? Controller { get; init; }
}
public static RetailWindowHandle Mount(
UiRoot root, UiRoot root,
UiElement content, UiElement content,
Func<uint, (uint handle, int w, int h)> resolveChrome, Func<uint, (uint handle, int w, int h)> resolveChrome,
@ -70,39 +78,103 @@ public static class RetailWindowFrame
ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(resolveChrome); ArgumentNullException.ThrowIfNull(resolveChrome);
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.WindowName);
float contentWidth = options.ContentWidth ?? content.Width;
float contentHeight = options.ContentHeight ?? content.Height;
bool wrapped = options.Chrome != RetailWindowChrome.Imported;
int inset = wrapped ? 2 * RetailChromeSprites.Border : 0;
float outerWidth = contentWidth + inset;
float outerHeight = contentHeight + inset;
UiElement outerFrame;
if (wrapped)
{
outerFrame = options.Chrome switch
{
RetailWindowChrome.NineSlice => new UiNineSlicePanel(resolveChrome),
RetailWindowChrome.CollapsibleNineSlice => new UiCollapsibleFrame(resolveChrome),
_ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)),
};
const int border = RetailChromeSprites.Border; const int border = RetailChromeSprites.Border;
float contentW = content.Width;
float contentH = content.Height;
var frame = new UiNineSlicePanel(resolveChrome)
{
Left = options.Left,
Top = options.Top,
Width = contentW + 2 * border,
Height = contentH + 2 * border,
MinWidth = options.MinWidth ?? contentW + 2 * border,
MinHeight = options.MinHeight ?? contentH + 2 * border,
ResizeX = options.ResizeX,
Opacity = options.Opacity,
Visible = options.Visible,
};
if (options.MaxHeight is { } maxHeight) frame.MaxHeight = maxHeight;
if (options.ResizableEdges is { } edges) frame.ResizableEdges = edges;
content.Left = border; content.Left = border;
content.Top = border; content.Top = border;
content.Width = contentW; content.Width = contentWidth;
content.Height = contentH; content.Height = contentHeight;
content.Anchors = options.ContentAnchors; content.Anchors = options.ContentAnchors;
if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough; content.Draggable = false;
content.Draggable = false; // the frame owns window move/resize
content.Resizable = false; content.Resizable = false;
if (options.ContentClickThrough is { } clickThrough)
content.ClickThrough = clickThrough;
if (options.RebaseContentLayout)
content.RebaseChildLayoutBaselines();
outerFrame.AddChild(content);
}
else
{
outerFrame = content;
content.Width = contentWidth;
content.Height = contentHeight;
content.Anchors = AnchorEdges.None;
if (options.ContentClickThrough is { } clickThrough)
content.ClickThrough = clickThrough;
if (options.RebaseContentLayout)
content.RebaseChildLayoutBaselines();
}
frame.AddChild(content); outerFrame.Left = options.Left;
root.AddChild(frame); outerFrame.Top = options.Top;
if (options.WindowName is { } name) outerFrame.Width = outerWidth;
root.RegisterWindow(name, frame); outerFrame.Height = outerHeight;
return frame; outerFrame.Anchors = AnchorEdges.None;
outerFrame.Draggable = options.Draggable;
outerFrame.Resizable = options.Resizable;
outerFrame.ResizeX = options.ResizeX;
outerFrame.ResizeY = options.ResizeY;
outerFrame.ResizableEdges = options.ResizableEdges;
outerFrame.ConstrainDragToParent = options.ConstrainDragToParent;
outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f);
outerFrame.Visible = options.Visible;
outerFrame.MinWidth = ResolveConstraint(
options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, inset);
outerFrame.MinHeight = ResolveConstraint(
options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, inset);
outerFrame.MaxWidth = Math.Max(
outerFrame.MinWidth,
ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, inset));
outerFrame.MaxHeight = Math.Max(
outerFrame.MinHeight,
ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, inset));
// Capture the wrapper/content baseline at the mounted design extent now,
// so a resize that occurs before the first draw uses the same margins as a
// resize after rendering one frame.
if (wrapped)
content.ApplyAnchor(outerWidth, outerHeight);
root.AddChild(outerFrame);
return root.RegisterWindow(
options.WindowName,
outerFrame,
content,
options.Controller);
}
private static float ResolveConstraint(
float? explicitValue,
ElementInfo? datSource,
uint propertyId,
float fallback,
int chromeInset)
{
if (explicitValue is { } value)
return value;
if (datSource is not null
&& datSource.TryGetEffectiveInteger(propertyId, out int datValue)
&& datValue >= 0)
return datValue + chromeInset;
return fallback;
} }
} }

View file

@ -38,6 +38,8 @@ synthesis + six deep-dive docs under
geometry events, lock propagation, and controller teardown. geometry events, lock propagation, and controller teardown.
- `IRetainedPanelController.cs` — disposable lifecycle contract for - `IRetainedPanelController.cs` — disposable lifecycle contract for
panel-specific show/hide and descendant-focus behavior. panel-specific show/hide and descendant-focus behavior.
- `Layout/RetailWindowFrame.cs` — the single production/Studio mount
contract for imported-chrome and shared-wrapper windows.
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`, - `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard` and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
helpers for Silk.NET plumbing. helpers for Silk.NET plumbing.

View file

@ -138,7 +138,7 @@ public sealed class RetailWindowManager : IDisposable
var frame = handle.OuterFrame; var frame = handle.OuterFrame;
if (!frame.ResizeX) width = frame.Width; if (!frame.ResizeX) width = frame.Width;
if (!frame.ResizeY) height = frame.Height; if (!frame.ResizeY) height = frame.Height;
width = Math.Clamp(width, frame.MinWidth, float.MaxValue); width = Math.Clamp(width, frame.MinWidth, frame.MaxWidth);
height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight); height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight);
if (frame.Width == width && frame.Height == height) return true; if (frame.Width == width && frame.Height == height) return true;
frame.Width = width; frame.Width = width;

View file

@ -219,7 +219,8 @@ public abstract class UiElement
public float MinWidth { get; set; } = 40f; public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f; public float MinHeight { get; set; } = 40f;
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary> /// <summary>Maximum size enforced while resizing (default unbounded).</summary>
public float MaxWidth { get; set; } = float.MaxValue;
public float MaxHeight { get; set; } = float.MaxValue; public float MaxHeight { get; set; } = float.MaxValue;
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary> /// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>

View file

@ -344,7 +344,7 @@ public sealed class UiRoot : UiElement
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY, _resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
_resizeTarget.MinWidth, _resizeTarget.MinHeight, _resizeTarget.MinWidth, _resizeTarget.MinHeight,
float.MaxValue, _resizeTarget.MaxHeight); _resizeTarget.MaxWidth, _resizeTarget.MaxHeight);
_resizeTarget.Left = nx; _resizeTarget.Top = ny; _resizeTarget.Left = nx; _resizeTarget.Top = ny;
_resizeTarget.Width = nw; _resizeTarget.Height = nh; _resizeTarget.Width = nw; _resizeTarget.Height = nh;
return; return;

View file

@ -33,6 +33,25 @@ public sealed class UiScrollable
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary> /// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
public bool AtEnd => _scrollY >= MaxScroll; public bool AtEnd => _scrollY >= MaxScroll;
/// <summary>
/// Update the content/view extents and clamp the current offset. When
/// <paramref name="preserveEnd"/> is true, a view that was at the end before
/// the geometry change remains at the end afterward. User-driven scrollbar
/// changes move <see cref="ScrollY"/> off the end, so later layout passes
/// preserve that selected position instead of snapping it back.
/// </summary>
public void SetExtents(int contentHeight, int viewHeight, bool preserveEnd = false)
{
bool wasAtEnd = AtEnd;
ContentHeight = Math.Max(0, contentHeight);
ViewHeight = Math.Max(0, viewHeight);
if (preserveEnd && wasAtEnd)
ScrollToEnd();
else
SetScrollY(_scrollY);
}
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary> /// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);

View file

@ -91,10 +91,10 @@ public sealed class UiScrollbar : UiElement
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting. // sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
// Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1. // Decrement/up button — top ButtonH rows (inherited element 0x10000071).
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
// Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art. // Increment/down button — bottom ButtonH rows (inherited element 0x10000072).
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
// Thumb — only when content overflows the view. Retail 3-slice: top cap + // Thumb — only when content overflows the view. Retail 3-slice: top cap +
@ -129,17 +129,6 @@ public sealed class UiScrollbar : UiElement
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
} }
/// <summary>Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
/// the top scroll button's (down-art) arrow upward.</summary>
private void DrawSpriteFlipV(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, _, _) = resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One);
}
/// <summary>Draw a sprite TILED to fill the dest rect (UV-repeat at native size on /// <summary>Draw a sprite TILED to fill the dest rect (UV-repeat at native size on
/// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.</summary> /// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.</summary>
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve, private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,

View file

@ -145,9 +145,6 @@ public sealed class UiText : UiElement
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary> /// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new(); public UiScrollable Scroll { get; } = new();
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
private bool _pinBottom = true;
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch) private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
// ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ──
@ -292,9 +289,10 @@ public sealed class UiText : UiElement
// Drive the shared scroll model with the current geometry. // Drive the shared scroll model with the current geometry.
Scroll.LineHeight = (int)MathF.Round(lh); Scroll.LineHeight = (int)MathF.Round(lh);
Scroll.ContentHeight = (int)MathF.Ceiling(contentH); Scroll.SetExtents(
Scroll.ViewHeight = (int)MathF.Floor(innerH); (int)MathF.Ceiling(contentH),
if (_pinBottom) Scroll.ScrollToEnd(); (int)MathF.Floor(innerH),
preserveEnd: true);
// UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest. // UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
// Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max). // Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max).
@ -358,7 +356,6 @@ public sealed class UiText : UiElement
// ScrollByLines sign: +down/newer, -up/older. // ScrollByLines sign: +down/newer, -up/older.
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines. // e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
_pinBottom = Scroll.AtEnd;
return true; return true;
} }

View file

@ -1511,8 +1511,8 @@ public class CharacterStatControllerTests
Assert.NotNull(scrollbar.Model); Assert.NotNull(scrollbar.Model);
Assert.NotNull(scrollbar.SpriteResolve); Assert.NotNull(scrollbar.SpriteResolve);
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite); Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
Assert.Equal(0x06004C6Cu, scrollbar.UpSprite); Assert.Equal(0x06004C69u, scrollbar.UpSprite);
Assert.Equal(0x06004C69u, scrollbar.DownSprite); Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
} }
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────

View file

@ -64,6 +64,32 @@ public class ChatLayoutConformanceTests
Assert.Equal(0x10000016u, input.DatElementId); Assert.Equal(0x10000016u, input.DatElementId);
} }
[Fact]
public void ChatFixture_ScrollbarImportsInheritedMediaRoles()
{
var layout = FixtureLoader.LoadChat();
var scrollbar = Assert.IsType<UiScrollbar>(layout.FindElement(0x10000012u));
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite);
Assert.Equal(0x06004C63u, scrollbar.ThumbSprite);
Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite);
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
}
[Fact]
public void ChatFixture_WindowRootCarriesRetailHeightConstraints()
{
var root = FixtureLoader.LoadChatInfos();
var window = Find(root, 0x1000000Eu)!;
Assert.True(window.TryGetEffectiveInteger(0x3Eu, out int minHeight));
Assert.True(window.TryGetEffectiveInteger(0x3Cu, out int maxHeight));
Assert.Equal(100, minHeight);
Assert.Equal(360, maxHeight);
}
[Fact] [Fact]
public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth() public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth()
{ {
@ -117,4 +143,121 @@ public class ChatLayoutConformanceTests
Assert.Equal(fittedWidth, controller.Menu.Width); Assert.Equal(fittedWidth, controller.Menu.Width);
Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left); Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left);
} }
[Fact]
public void ChatMaximize_ResizesOuterFrameByHalfParent_AndRestores()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
null,
null,
NoTex)!;
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Left = 12f,
Top = 390f,
ContentWidth = 490f,
ContentHeight = controller.Root.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f,
});
controller.AttachWindow(handle);
var maxMin = Assert.IsType<UiButton>(layout.FindElement(0x1000046Fu));
maxMin.OnClick!();
Assert.True(controller.IsMaximized);
Assert.Equal(90f, handle.Top);
Assert.Equal(370f, handle.Height); // DAT max 360 + 2*5px shared chrome
Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId);
controller.Root.ApplyAnchor(handle.Width, handle.Height);
Assert.Equal(360f, controller.Root.Height);
maxMin.OnClick!();
Assert.False(controller.IsMaximized);
Assert.Equal(390f, handle.Top);
Assert.Equal(110f, handle.Height);
Assert.Equal(RetailUiStateIds.Minimized, maxMin.ActiveRetailStateId);
}
[Fact]
public void MountedChatScrollbar_ButtonsAndThumbDragDriveTranscriptModel()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
null,
null,
NoTex)!;
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
controller.Root,
NoTex,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Chat,
Chrome = RetailWindowChrome.NineSlice,
Left = 10f,
Top = 440f,
ContentWidth = 490f,
ContentHeight = controller.Root.Height,
RebaseContentLayout = true,
DatConstraintSource = controller.DatWindowInfo,
MinWidth = 200f,
});
controller.AttachWindow(handle);
UiScrollbar bar = controller.Scrollbar;
UiScrollable scroll = controller.Transcript.Scroll;
scroll.LineHeight = 16;
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(350, scroll.ScrollY);
var screen = bar.ScreenPosition;
int centerX = (int)(screen.X + bar.Width / 2f);
// Top/decrement button.
int upY = (int)(screen.Y + 8f);
root.OnMouseDown(UiMouseButton.Left, centerX, upY);
root.OnMouseUp(UiMouseButton.Left, centerX, upY);
Assert.Equal(334, scroll.ScrollY);
// Re-layout must retain the position chosen through the scrollbar.
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(334, scroll.ScrollY);
// Drag the thumb toward the top of the track.
float trackTop = 16f;
float trackLength = bar.Height - 32f;
var (thumbY, thumbHeight) = UiScrollbar.ThumbRect(scroll, trackTop, trackLength);
int dragStartY = (int)(screen.Y + thumbY + thumbHeight / 2f);
int dragEndY = (int)(screen.Y + trackTop + thumbHeight / 2f);
root.OnMouseDown(UiMouseButton.Left, centerX, dragStartY);
root.OnMouseMove(centerX, dragEndY);
root.OnMouseUp(UiMouseButton.Left, centerX, dragEndY);
Assert.True(scroll.ScrollY < 334);
int draggedPosition = scroll.ScrollY;
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(draggedPosition, scroll.ScrollY);
}
} }

View file

@ -0,0 +1,126 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailWindowFrameTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void ImportedChrome_MountsRootWithoutASecondFrame()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 220, Height = 90 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "vitals",
Chrome = RetailWindowChrome.Imported,
Left = 12,
Top = 18,
ResizeX = true,
ResizeY = false,
MinWidth = 40,
ContentClickThrough = false,
});
Assert.Same(content, handle.OuterFrame);
Assert.Same(content, handle.ContentRoot);
Assert.Same(root, content.Parent);
Assert.Equal((12f, 18f, 220f, 90f),
(content.Left, content.Top, content.Width, content.Height));
Assert.True(content.Draggable);
Assert.True(content.Resizable);
Assert.True(content.ResizeX);
Assert.False(content.ResizeY);
Assert.Equal(40f, content.MinWidth);
}
[Fact]
public void NineSlice_UsesCroppedContentAndDatHeightConstraints()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 800, Height = 100 };
var constraints = Constraints(minHeight: 100, maxHeight: 360);
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "chat",
Chrome = RetailWindowChrome.NineSlice,
Left = 10,
Top = 440,
ContentWidth = 490,
DatConstraintSource = constraints,
MinWidth = 200,
Opacity = 0.75f,
});
var frame = Assert.IsType<UiNineSlicePanel>(handle.OuterFrame);
Assert.Same(content, handle.ContentRoot);
Assert.Same(frame, content.Parent);
Assert.Equal((500f, 110f), (frame.Width, frame.Height));
Assert.Equal((5f, 5f, 490f, 100f),
(content.Left, content.Top, content.Width, content.Height));
Assert.Equal(200f, frame.MinWidth);
Assert.Equal(110f, frame.MinHeight);
Assert.Equal(370f, frame.MaxHeight);
Assert.Equal(0.75f, frame.Opacity);
}
[Fact]
public void CollapsibleMount_ReturnsToolbarFrameAndIndependentAxes()
{
var root = new UiRoot { Width = 800, Height = 600 };
var content = new UiPanel { Width = 300, Height = 122 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "toolbar",
Chrome = RetailWindowChrome.CollapsibleNineSlice,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
ContentClickThrough = true,
});
var frame = Assert.IsType<UiCollapsibleFrame>(handle.OuterFrame);
Assert.False(frame.ResizeX);
Assert.True(frame.ResizeY);
Assert.Equal(ResizeEdges.Bottom, frame.ResizableEdges);
Assert.True(content.ClickThrough);
Assert.False(content.Draggable);
Assert.False(content.Resizable);
}
private static ElementInfo Constraints(int minHeight, int maxHeight)
{
var info = new ElementInfo();
var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId };
direct.Properties.Values[0x3Eu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = minHeight,
};
direct.Properties.Values[0x3Cu] = new UiPropertyValue
{
Kind = UiPropertyKind.Integer,
IntegerValue = maxHeight,
};
info.States[UiStateInfo.DirectStateId] = direct;
return info;
}
}

View file

@ -70,4 +70,25 @@ public class UiScrollableTests
s.ScrollByPage(1); s.ScrollByPage(1);
Assert.Equal(300, s.ScrollY); Assert.Equal(300, s.ScrollY);
} }
[Fact]
public void SetExtents_PreservesEndButRetainsUserScrollback()
{
var s = new UiScrollable();
s.SetExtents(contentHeight: 400, viewHeight: 100, preserveEnd: true);
Assert.Equal(300, s.ScrollY);
// A scrollbar/wheel interaction moves off the end. Repeating layout with
// the same extents must not overwrite that user-selected position.
s.ScrollByLines(-1);
Assert.Equal(284, s.ScrollY);
s.SetExtents(contentHeight: 400, viewHeight: 100, preserveEnd: true);
Assert.Equal(284, s.ScrollY);
// Returning to the end restores auto-pin for newly appended content.
s.ScrollToEnd();
s.SetExtents(contentHeight: 500, viewHeight: 100, preserveEnd: true);
Assert.Equal(400, s.ScrollY);
}
} }