feat(items): port retail external-container looting

Add the ClientUISystem ground-object lifecycle, authoritative root and nested ViewContents projections, replacement and close semantics, and the DAT-authored gmExternalContainerUI strip for chests and corpses.

Route double-click loot and full or partial drag transfers through the shared retail item policy without optimistic external ownership. Remove the incorrect NoLongerViewingContents behavior from owned side packs and retire AP-106/#196.

Release build succeeds and all 5,875 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 16:18:10 +02:00
parent 48a118db91
commit 20ce67b625
27 changed files with 1750 additions and 45 deletions

View file

@ -2279,22 +2279,22 @@ R5/R6 touches the action list. **Where:**
--- ---
## #196 — External-container lifecycle and retained window events are missing ## #196 — External-container lifecycle and retained window events are missing — DONE 2026-07-17
**Status:** OPEN **Status:** CLOSED
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-02 **Filed:** 2026-07-02
**Component:** ui / net **Component:** ui / net
**Description:** `UiRoot`'s window registry flips `Visible` with no lifecycle event, toolbar-button sync is manually pushed at each mutation site, and acdream has no retained external/ground-container window. The original issue incorrectly assigned `NoLongerViewingContents (0x0195)` to closing or navigating the owned inventory's side bags. Named-retail evidence instead ties it to `ClientUISystem.groundObject`: replacing or closing an external container sends `0x0195` exactly once. The owned `gmInventoryUI` side-bag path has no corresponding static send. **Description:** `UiRoot`'s window registry originally flipped `Visible` with no lifecycle event, toolbar-button sync was manually pushed at each mutation site, and acdream had no retained external/ground-container window. The original issue incorrectly assigned `NoLongerViewingContents (0x0195)` to closing or navigating the owned inventory's side bags. Named-retail evidence instead ties it to replacing `ClientUISystem.groundObject`; the authored close/range path sends `Use(root)` and waits for server event `CloseGroundContainer (0x0052)`. Owned `gmInventoryUI` packs never send `0x0195`.
**Root cause / status:** PARTIAL 2026-07-11. Typed retained-window visibility events and subscriber-driven toolbar state shipped in Wave 4.4d, replacing manual sync at mutation sites. The issue remains open for the separate external-container owner whose ground-object replacement/close path must emit one `0x0195`. Do not add that packet to owned side-bag navigation without a live packet trace. **Resolution:** Typed retained-window visibility and subscriber-driven toolbar state shipped in Wave 4.4d. The remaining external lifetime shipped 2026-07-17: Core `ExternalContainerState` owns expected/current root identity; App `ExternalContainerLifecycleController` emits exactly one `0x0195` only on root replacement; `GameEventWiring` accepts the expected root `ViewContents`, preserves nested snapshots, and retires the complete temporary projection tree on authoritative `0x0052`. `ExternalContainerController` mounts retail LayoutDesc `0x21000008`, including its horizontal lists/scrollbar, nested-container selection, exact close/range behavior, loot-to-pack, return-to-container, and partial-stack requests. Owned inventory no longer exposes any `0x0195` capability.
**Files:** `src/AcDream.App/UI/RetailWindowManager.cs`, `src/AcDream.App/UI/RetailUiRuntime.cs`; future external-container controller. **Files:** `src/AcDream.Core/Items/ExternalContainerState.cs`, `src/AcDream.App/World/ExternalContainerLifecycleController.cs`, `src/AcDream.App/UI/Layout/ExternalContainerController.cs`, `src/AcDream.Core.Net/GameEventWiring.cs`, `src/AcDream.Core/Items/ClientObjectTable.cs`.
**Research:** `docs/research/2026-07-02-ui-architecture-review.md`; correction oracle `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md`. **Research:** `docs/research/2026-07-17-retail-external-container-looting-pseudocode.md`.
**Acceptance:** replacing/closing an external container sends `NoLongerViewingContents` exactly once; owned main/side-pack navigation and closing send nothing absent contrary trace evidence; lifecycle subscribers replace manual toolbar sync calls; repeat hiding is idempotent. **Acceptance:** replacement sends `NoLongerViewingContents` once; authored close/range sends `Use` once and waits for `0x0052`; owned main/side-pack navigation sends neither; root/nested projections retire without deleting objects; looting and partial/full transfers remain server-authoritative. Focused Core, Core.Net, and App tests pin each path; visual acceptance remains the normal user gate.
--- ---

View file

@ -114,6 +114,14 @@ radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
Temporary pointer modes are separate App orchestration in `InteractionState` and Temporary pointer modes are separate App orchestration in `InteractionState` and
must never become a competing selection owner. must never become a competing selection owner.
External-container state follows the same ownership rule. Core
`ExternalContainerState` owns the expected/current `ClientUISystem::groundObject`
identity, Core.Net applies authoritative `ViewContents` and
`CloseGroundContainer` events, and App `ExternalContainerLifecycleController`
owns the replacement-only `NoLongerViewingContents` side effect. The retained
`ExternalContainerController` is a projection over that state; mounting or
disabling the gameplay UI cannot change the session lifetime.
Every retained gameplay window has a typed `RetailWindowHandle` and at most one Every retained gameplay window has a typed `RetailWindowHandle` and at most one
`IRetainedPanelController` lifecycle owner. Multi-controller windows use `IRetainedPanelController` lifecycle owner. Multi-controller windows use
`RetainedPanelControllerGroup`; the manager disposes controllers exactly once in `RetainedPanelControllerGroup`; the manager disposes controllers exactly once in

View file

@ -203,7 +203,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-101~~ | **RETIRED 2026-07-11 (Wave 4.4e)** — toolbar control coverage is complete: DAT panel launchers, combat, Use/Examine, selected-object strip, shortcuts, and exact thrown-weapon/separate-ammo count resolution on authored element `0x10000194`. | `src/AcDream.Core/Items/ToolbarAmmoPolicy.cs`; `src/AcDream.App/UI/Layout/ToolbarController.cs` | — | — | `gmToolbarUI::UpdateAmmoID @ 0x004BF210`; `UpdateAmmoNumber @ 0x004BE9E0` | | ~~AP-101~~ | **RETIRED 2026-07-11 (Wave 4.4e)** — toolbar control coverage is complete: DAT panel launchers, combat, Use/Examine, selected-object strip, shortcuts, and exact thrown-weapon/separate-ammo count resolution on authored element `0x10000194`. | `src/AcDream.Core/Items/ToolbarAmmoPolicy.cs`; `src/AcDream.App/UI/Layout/ToolbarController.cs` | — | — | `gmToolbarUI::UpdateAmmoID @ 0x004BF210`; `UpdateAmmoNumber @ 0x004BE9E0` |
| 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 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, 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-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
| AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **Primary replacement retired from this row 2026-07-14:** inventory activation and paperdoll drops share the confirmed blocker transaction, preserve explicit slot intent, relocate an already-worn item to an explicitly selected compatible slot, and emit retail's successful move-to-backpack status. **Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; primary weapon, incompatible shield, and mismatched ammo blockers sequence through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-108 | Paperdoll/AutoWield still omit full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **Primary replacement retired from this row 2026-07-14:** inventory activation and paperdoll drops share the confirmed blocker transaction, preserve explicit slot intent, relocate an already-worn item to an explicitly selected compatible slot, and emit retail's successful move-to-backpack status. **Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, and live doll work; primary weapon, incompatible shield, and mismatched ammo blockers sequence through server-confirmed dequip→wield in both peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::BlocksUseOfShield @ 0x0055D3E0`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |

View file

@ -133,6 +133,7 @@ W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../s
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ | | B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ | | B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ |
| B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ | | B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ |
| B.5+ external-container looting | 2026-07-17. Retail `ClientUISystem::groundObject` lifetime plus `gmExternalContainerUI` LayoutDesc `0x21000008`: authoritative root/nested `ViewContents`, root-replacement `NoLongerViewingContents`, authored close/range `Use``CloseGroundContainer`, temporary projection-tree teardown, horizontal item lists/scrollbar, nested-container browsing, double-click loot, drag return, and partial-stack requests. Owned side packs no longer send the external-only packet. Research: [`docs/research/2026-07-17-retail-external-container-looting-pseudocode.md`](../research/2026-07-17-retail-external-container-looting-pseudocode.md). Closes #196 and retires AP-106. | Tests ✓; live visual gate pending |
| Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ | | Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ |
| Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task<ObjectMeshData?>` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger<ObjectMeshManager>` with a Console-backed `ConsoleErrorLogger<T>` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet<Setup>(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ | | Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task<ObjectMeshData?>` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger<ObjectMeshManager>` with a Console-backed `ConsoleErrorLogger<T>` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet<Setup>(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ | | C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |

View file

@ -0,0 +1,168 @@
# Retail external-container and looting pseudocode
Date: 2026-07-17
Scope: chests, corpses, and other world containers opened through `Use`; the
bottom-of-screen external-container strip; looting into the player's carried
inventory; returning carried items to the open external container; and the
external-view close/replacement lifetime. Owned backpack and side-pack browsing
is deliberately not part of this lifetime.
## Retail oracle
Named Sept-2013 retail symbols:
- `gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0`
- `gmExternalContainerUI::SetGroundObject @ 0x004CBBD0`
- `gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0`
- `gmExternalContainerUI::PostInit @ 0x004CBDE0`
- `gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50`
- `gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0`
- `gmExternalContainerUI::OnItemListDragOver @ 0x004CC0C0`
- `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50`
- `ClientUISystem::SetGroundObject @ 0x00564510`
- `ClientUISystem::OnViewContents @ 0x00565870`
- `ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680`
- `ACCWeenieObject::UIAttemptSplitToContainer @ 0x0058D7D0`
- `ItemHolder::AttemptToPlaceInContainer @ 0x00588140`
- `ItemHolder::UseObject @ 0x00588A80`
The exact client dispatch for server event `CloseGroundContainer (0x0052)` is
at `0x0055B187` in `acclient_2013_pseudo_c.txt`.
Cross-references:
- ACE `GameEventViewContents`, `GameEventCloseGroundContainer`,
`GameActionNoLongerViewingContents`, `Container.SendInventory`, and
`Container.FinishClose` confirm the authoritative packet order and that ACE
can send `ViewContents` for the root followed by its nested containers.
- holtburger protocol `inventory/events.rs` and `inventory/actions.rs` confirm
the exact wire shapes. Its world inventory handler independently models open
container contents as temporary preview retention and retires that preview on
`CloseGroundContainer`.
## Authored UI
The strip is LayoutDesc `0x21000008`, selected root `0x10000063`
(`0,400`, `800 x 110`) rather than a synthetic inventory window.
| Element | Purpose | Authored bounds |
|---|---|---|
| `0x10000064` | current root container item list | `5,5,36,36` |
| `0x10000067` | nested-container selector list | `67,5,680,36` |
| `0x10000068` | close button | `770,1,25,23` |
| `0x1000006A` | current contents item list | `8,47,784,32` |
| `0x1000006B` | horizontal contents scrollbar | `8,81,784,16` |
The close button uses sprites `0x060011BD` (normal) and `0x060011BC`
(pressed). The horizontal scrollbar inherits the retail track, three-part
thumb, and arrow sprites from LayoutDesc `0x2100003E`.
## Pseudocode
```text
UseWorldObject(objectId):
decision = ItemHolder policy
if decision is SetGroundObject:
expectedExternalRoot = objectId
send Use(objectId)
OnViewContents(containerId, orderedEntries):
// Always update the projection: ACE sends nested-container snapshots too.
objectTable.ReplaceContents(containerId, orderedEntries)
if containerId != expectedExternalRoot:
return
old = currentExternalRoot
if old != 0 and old != containerId:
objectTable.StopViewingContentsTree(old)
send NoLongerViewingContents(old) exactly once
currentExternalRoot = containerId
expectedExternalRoot = containerId
externalPanel.SetGroundObject(containerId)
ExternalPanel.SetGroundObject(containerId):
unregister old range watch
clear root, nested-container, and contents lists
add root object to top list
set current local container to root
populate nested-container selector and current contents
if containerId == 0:
hide panel
else:
register range watch using root UseRadius
show panel
ExternalPanel.CloseCurrentContainer():
if currentExternalRoot == 0:
return
send Use(currentExternalRoot)
// Do not send NoLongerViewingContents here. ACE responds with 0x0052.
detach local lists and wait for authoritative close
OnCloseGroundContainer(containerId):
if containerId != currentExternalRoot:
return
objectTable.StopViewingContentsTree(containerId)
currentExternalRoot = 0
expectedExternalRoot = 0
externalPanel.SetGroundObject(0)
OnUseDone(error):
if error != 0 and expectedExternalRoot != currentExternalRoot:
expectedExternalRoot = currentExternalRoot
OnExternalRangeExit():
externalPanel.SetVisible(false)
ExternalPanel.CloseCurrentContainer()
OnExternalItemSingleClick(itemId):
first offer it to target mode
otherwise select the item
OnExternalItemDoubleClick(itemId):
ItemHolder.UseObject(itemId)
// Because item.ContainerId is currentExternalRoot, retail policy emits
// PlaceInBackpack; the server remains authoritative for the move.
DragExternalItemToOwnedInventory(itemId, placement, splitAmount):
if splitAmount < full stack:
send StackableSplitToContainer(itemId, player/openOwnedBag,
placement, splitAmount)
else:
send PutItemInContainer(itemId, player/openOwnedBag, placement)
do not mutate canonical ownership before the server response
DragOwnedItemToExternalContainer(itemId, placement, splitAmount):
if splitAmount < full stack:
send StackableSplitToContainer(itemId, currentExternalSubcontainer,
placement, splitAmount)
else:
send PutItemInContainer(itemId, currentExternalSubcontainer, placement)
do not mutate canonical ownership before the server response
```
## Architectural mapping
- `ExternalContainerState` in Core owns only expected/current external-root
identity and transition ordering. It filters ACE's nested `ViewContents`
packets so they cannot replace the visible root.
- `ExternalContainerLifecycleController` in App observes those transitions,
retires the replaced root's temporary preview tree, then owns the
replacement-only `NoLongerViewingContents` network side effect. The wire
lifetime therefore does not depend on whether retained UI is mounted.
- `ClientObjectTable` remains the canonical object and ordered-content
projection owner.
- `ExternalContainerController` owns the authored strip, range/close behavior,
nested-list selection, selection, and drag/drop presentation.
- `ItemInteractionController` remains the only interpreter of retail item-use
and placement policy. It gains explicit adapters for `SetGroundObject` and
`PlaceInContainer`; panels do not reimplement those decisions.
- `GameWindow` only creates the lifecycle/UI bindings and supplies network
delegates. No external-container feature body lives there.
There is no new intended retail divergence. Completing this mechanism retires
AP-106/#196: `NoLongerViewingContents` belongs to replacement of an external
root, not switching between owned side packs.

View file

@ -840,6 +840,8 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Combat.CombatAttackController? _combatAttackController; private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.Combat.CombatTargetController? _combatTargetController; private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController; private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private readonly AcDream.Core.Items.ExternalContainerState _externalContainers = new();
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
private AcDream.App.Spells.MagicRuntime? _magicRuntime; private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private AcDream.App.Spells.MagicCatalog? _magicCatalog; private AcDream.App.Spells.MagicCatalog? _magicCatalog;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new(); private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
@ -2110,6 +2112,10 @@ public sealed class GameWindow : IDisposable
_selection, _selection,
autoTarget: () => _persistedGameplay.AutoTarget, autoTarget: () => _persistedGameplay.AutoTarget,
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false)); selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
_externalContainers,
Objects,
guid => _liveSession?.SendNoLongerViewingContents(guid));
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1). // Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
if (_options.RetailUi) if (_options.RetailUi)
@ -2146,7 +2152,11 @@ public sealed class GameWindow : IDisposable
stackSplitQuantity: _stackSplitQuantity, stackSplitQuantity: _stackSplitQuantity,
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au), systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) => sendPutItemInContainer: (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement)); _liveSession?.SendPutItemInContainer(item, container, placement),
sendSplitToContainer: (item, container, placement, amount) =>
_liveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController, _itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode // Retail UpdateCursorState (0x00564630) keys target-mode
@ -2358,7 +2368,6 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null, is { } strength ? (int?)strength.Current : null,
guid => _liveSession?.SendUse(guid), guid => _liveSession?.SendUse(guid),
guid => _liveSession?.SendNoLongerViewingContents(guid),
(item, container, placement) => (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement), _liveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) => (item, container, placement, amount) =>
@ -2368,6 +2377,22 @@ public sealed class GameWindow : IDisposable
_liveSession?.SendStackableMerge(source, target, amount), _liveSession?.SendStackableMerge(source, target, amount),
_itemInteractionController, _itemInteractionController,
_selection), _selection),
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
_externalContainers,
Objects,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
_itemInteractionController,
_selection,
guid => _liveSession?.SendUse(guid),
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
_liveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
IsWithinExternalContainerUseRange),
Cursor: new AcDream.App.UI.RetailUiCursorBindings( Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController, cursorFeedbackController,
retailCursorManager), retailCursorManager),
@ -2863,6 +2888,7 @@ public sealed class GameWindow : IDisposable
// Attachment projections own GL-backed world registrations, so tear // Attachment projections own GL-backed world registrations, so tear
// them down before dropping the canonical GUID/timestamp snapshots. // them down before dropping the canonical GUID/timestamp snapshots.
_equippedChildRenderer?.Clear(); _equippedChildRenderer?.Clear();
_externalContainers.Reset();
Objects.Clear(); Objects.Clear();
SpellBook.Clear(); SpellBook.Clear();
_magicRuntime?.Reset(); _magicRuntime?.Reset();
@ -3025,7 +3051,11 @@ public sealed class GameWindow : IDisposable
}, },
onShortcuts: list => Shortcuts = list, onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid, playerGuid: () => _playerServerGuid,
onUseDone: error => _itemInteractionController?.CompleteUse(error), onUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
itemMana: ItemMana, itemMana: ItemMana,
onConfirmationRequest: request => onConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request), _retailUiRuntime?.HandleConfirmationRequest(request),
@ -3038,7 +3068,8 @@ public sealed class GameWindow : IDisposable
_characterOptions1 = _characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1) (AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1, options1,
clientTime: ClientTimerNow); clientTime: ClientTimerNow,
externalContainers: _externalContainers);
// Phase I.7: subscribe to CombatState events and emit // Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into // retail-faithful "You hit X for Y damage" chat lines into
@ -13266,6 +13297,9 @@ public sealed class GameWindow : IDisposable
return; return;
} }
if (IsExternalContainerTarget(guid))
_externalContainers.RequestOpen(guid);
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject // B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
// through the player's MoveToManager so close-range Use rotates the // through the player's MoveToManager so close-range Use rotates the
// body to face before the action fires. For FAR targets, ACE's // body to face before the action fires. For FAR targets, ACE's
@ -13482,6 +13516,40 @@ public sealed class GameWindow : IDisposable
return distSq <= useRadius * useRadius; return distSq <= useRadius * useRadius;
} }
private bool IsWithinExternalContainerUseRange(uint targetGuid)
{
if (_playerController is null
|| !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
{
// The server remains authoritative for forced close. An entity can be
// temporarily absent from the visible-cell projection while its external
// view is still valid; do not manufacture a range exit from missing render state.
return true;
}
if (!LastSpawns.TryGetValue(targetGuid, out var spawn)
|| spawn.UseRadius is not > 0f)
return true;
float useRadius = spawn.UseRadius.Value;
float dx = entity.Position.X - _playerController.Position.X;
float dy = entity.Position.Y - _playerController.Position.Y;
return dx * dx + dy * dy <= useRadius * useRadius;
}
private bool IsExternalContainerTarget(uint guid)
{
if (Objects.Get(guid) is { } item
&& (item.ContainerTypeHint != 0u
|| item.Type.HasFlag(AcDream.Core.Items.ItemType.Container)
|| item.ItemsCapacity > 0))
{
return true;
}
return LastSpawns.TryGetValue(guid, out var spawn)
&& (spawn.ObjectDescriptionFlags.GetValueOrDefault() & 0x2000u) != 0u;
}
private void InstallSpeculativeTurnToTarget(uint targetGuid) private void InstallSpeculativeTurnToTarget(uint targetGuid)
{ {
if (_playerController is not { } pc || pc.MoveTo is null) return; if (_playerController is not { } pc || pc.MoveTo is null) return;
@ -14784,6 +14852,8 @@ public sealed class GameWindow : IDisposable
_uiHost = null; _uiHost = null;
_itemInteractionController?.Dispose(); _itemInteractionController?.Dispose();
_itemInteractionController = null; _itemInteractionController = null;
_externalContainerLifecycle?.Dispose();
_externalContainerLifecycle = null;
_magicRuntime = null; _magicRuntime = null;
_magicCatalog = null; _magicCatalog = null;

View file

@ -30,6 +30,8 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendWield; private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop; private readonly Action<uint>? _sendDrop;
private readonly Action<uint, uint>? _sendSplitToWorld; private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
private readonly Action<uint, uint, uint>? _sendGive; private readonly Action<uint, uint, uint>? _sendGive;
private readonly Action<string>? _toast; private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest; private readonly Func<bool> _readyForInventoryRequest;
@ -39,6 +41,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Func<bool> _inNonCombatMode; private readonly Func<bool> _inNonCombatMode;
private readonly Func<uint, bool> _isComponentPack; private readonly Func<uint, bool> _isComponentPack;
private readonly Action<uint>? _placeInBackpack; private readonly Action<uint>? _placeInBackpack;
private readonly Action<uint>? _requestExternalContainer;
private readonly Action<ItemPolicyAction>? _auxiliaryAction; private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState; private readonly InteractionState _interactionState;
private readonly Func<uint> _selectedObjectId; private readonly Func<uint> _selectedObjectId;
@ -78,7 +81,9 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint, int>? sendPutItemInContainer = null, Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendGive = null, Action<uint, uint, uint>? sendGive = null,
Func<bool>? dragOnPlayerOpensSecureTrade = null, Func<bool>? dragOnPlayerOpensSecureTrade = null,
Action<string>? systemMessage = null) Action<string>? systemMessage = null,
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
Action<uint>? requestExternalContainer = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -88,6 +93,8 @@ public sealed class ItemInteractionController : IDisposable
_sendWield = sendWield; _sendWield = sendWield;
_sendDrop = sendDrop; _sendDrop = sendDrop;
_sendSplitToWorld = sendSplitToWorld; _sendSplitToWorld = sendSplitToWorld;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_sendGive = sendGive; _sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64); _nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast; _toast = toast;
@ -98,6 +105,7 @@ public sealed class ItemInteractionController : IDisposable
_inNonCombatMode = inNonCombatMode ?? (() => false); _inNonCombatMode = inNonCombatMode ?? (() => false);
_isComponentPack = isComponentPack ?? (_ => false); _isComponentPack = isComponentPack ?? (_ => false);
_placeInBackpack = placeInBackpack; _placeInBackpack = placeInBackpack;
_requestExternalContainer = requestExternalContainer;
_auxiliaryAction = auxiliaryAction; _auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u); _selectedObjectId = selectedObjectId ?? (() => 0u);
_stackSplitQuantity = stackSplitQuantity; _stackSplitQuantity = stackSplitQuantity;
@ -416,6 +424,10 @@ public sealed class ItemInteractionController : IDisposable
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId); _sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId);
acted |= _sendUseWithTarget is not null; acted |= _sendUseWithTarget is not null;
break; break;
case ItemPolicyActionKind.SetGroundObject:
_requestExternalContainer?.Invoke(action.ObjectId);
acted |= _requestExternalContainer is not null;
break;
case ItemPolicyActionKind.EnterTargetMode: case ItemPolicyActionKind.EnterTargetMode:
EnterTargetMode(action.ObjectId); EnterTargetMode(action.ObjectId);
acted = true; acted = true;
@ -464,6 +476,18 @@ public sealed class ItemInteractionController : IDisposable
action.ObjectId, action.ObjectId,
(uint)Math.Max(1, action.Amount)); (uint)Math.Max(1, action.Amount));
break; break;
case ItemPolicyActionKind.PlaceInContainer:
{
uint fullStack = (uint)Math.Max(
1,
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
uint amount = (uint)Math.Max(1, action.Amount);
if (amount < fullStack)
_sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount);
else
_sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0);
break;
}
case ItemPolicyActionKind.Reject: case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message)) if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message); _toast?.Invoke(action.Message);

View file

@ -0,0 +1,514 @@
using System;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail <c>gmExternalContainerUI</c> controller for LayoutDesc 0x21000008.
/// Chests, corpses, and other world containers share this bottom-screen strip;
/// it is intentionally independent from the owned backpack window.
/// </summary>
public sealed class ExternalContainerController : IItemListDragHandler, IRetainedPanelController
{
public const uint LayoutId = 0x21000008u;
public const uint RootId = 0x10000063u;
public const uint TopContainerId = 0x10000064u;
public const uint ContainerListId = 0x10000067u;
public const uint CloseButtonId = 0x10000068u;
public const uint ContentsListId = 0x1000006Au;
public const uint ContentsScrollbarId = 0x1000006Bu;
private const float ItemCellSize = 32f;
private const float ContainerCellSize = 36f;
private readonly ExternalContainerState _state;
private readonly ClientObjectTable _objects;
private readonly SelectionState _selection;
private readonly ItemInteractionController _itemInteraction;
private readonly StackSplitQuantityState _stackSplitQuantity;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveDragIcon;
private readonly Action<uint> _sendUse;
private readonly Action<uint, uint, int> _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint> _sendSplitToContainer;
private readonly Func<uint, bool> _isWithinUseRange;
private readonly RetailWindowHandle _window;
private readonly UiItemList _topContainer;
private readonly UiItemList _containerList;
private readonly UiItemList _contentsList;
private uint _openContainer;
private bool _closeRequested;
private bool _disposed;
private ExternalContainerController(
ImportedLayout layout,
ExternalContainerState state,
ClientObjectTable objects,
SelectionState selection,
ItemInteractionController itemInteraction,
StackSplitQuantityState stackSplitQuantity,
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> resolveDragIcon,
Action<uint> sendUse,
Action<uint, uint, int> sendPutItemInContainer,
Action<uint, uint, uint, uint> sendSplitToContainer,
Func<uint, bool> isWithinUseRange,
RetailWindowHandle window,
uint contentsEmptySprite,
uint containerEmptySprite)
{
_state = state;
_objects = objects;
_selection = selection;
_itemInteraction = itemInteraction;
_stackSplitQuantity = stackSplitQuantity;
_resolveIcon = resolveIcon;
_resolveDragIcon = resolveDragIcon;
_sendUse = sendUse;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_isWithinUseRange = isWithinUseRange;
_window = window;
_topContainer = RequiredList(layout, TopContainerId);
_containerList = RequiredList(layout, ContainerListId);
_contentsList = RequiredList(layout, ContentsListId);
ConfigureList(_topContainer, ContainerCellSize, horizontalScroll: false, containerEmptySprite);
ConfigureList(_containerList, ContainerCellSize, horizontalScroll: true, containerEmptySprite);
ConfigureList(_contentsList, ItemCellSize, horizontalScroll: true, contentsEmptySprite);
_contentsList.RegisterDragHandler(this);
if (layout.FindElement(ContentsScrollbarId) is UiScrollbar scrollbar)
{
scrollbar.Model = _contentsList.Scroll;
scrollbar.Horizontal = true;
scrollbar.SpriteResolve ??= _contentsList.SpriteResolve;
// Horizontal base LayoutDesc 0x2100003E media. The compatibility
// factory treats all horizontal bars as scalar controls; this panel
// binds the authored model sprites explicitly.
scrollbar.TrackSprite = 0x06004C7Fu;
scrollbar.ThumbTopSprite = 0x06004C80u;
scrollbar.ThumbSprite = 0x06004C83u;
scrollbar.ThumbBotSprite = 0x06004C86u;
scrollbar.DownSprite = 0x06004C89u;
scrollbar.UpSprite = 0x06004C8Cu;
}
BindClose(layout, RequestClose);
_state.Changed += OnExternalContainerChanged;
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ContainerContentsReplaced += OnContentsReplaced;
_objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged;
_itemInteraction.StateChanged += OnInteractionStateChanged;
ClearLists();
}
public static ExternalContainerController Bind(
ImportedLayout layout,
ExternalContainerState state,
ClientObjectTable objects,
SelectionState selection,
ItemInteractionController itemInteraction,
StackSplitQuantityState stackSplitQuantity,
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> resolveDragIcon,
Action<uint> sendUse,
Action<uint, uint, int> sendPutItemInContainer,
Action<uint, uint, uint, uint> sendSplitToContainer,
Func<uint, bool> isWithinUseRange,
RetailWindowHandle window,
uint contentsEmptySprite = 0u,
uint containerEmptySprite = 0u)
=> new(
layout,
state,
objects,
selection,
itemInteraction,
stackSplitQuantity,
resolveIcon,
resolveDragIcon,
sendUse,
sendPutItemInContainer,
sendSplitToContainer,
isWithinUseRange,
window,
contentsEmptySprite,
containerEmptySprite);
public void Tick()
{
uint root = _state.CurrentContainerId;
if (root != 0u && _window.IsVisible && !_closeRequested && !_isWithinUseRange(root))
RequestClose();
}
public void RequestClose()
{
uint root = _state.CurrentContainerId;
if (root == 0u || _closeRequested)
return;
// gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0 sends
// Use(root), detaches the lists, and waits for event 0x0052. It does not
// send NoLongerViewingContents on this path.
_closeRequested = true;
_window.Hide();
ClearLists();
_sendUse(root);
}
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
{
if (payload.ObjId != 0u)
_selection.Select(payload.ObjId, SelectionChangeSource.ExternalContainer);
}
public ItemDragAcceptance OnDragOver(
UiItemList targetList,
UiItemSlot targetCell,
ItemDragPayload payload)
{
if (!ReferenceEquals(targetList, _contentsList)
|| payload.SourceKind == ItemDragSource.ShortcutBar
|| payload.ObjId == 0u
|| _openContainer == 0u
|| payload.ObjId == _openContainer)
return ItemDragAcceptance.Reject;
return ItemDragAcceptance.Accept;
}
public void HandleDropRelease(
UiItemList targetList,
UiItemSlot targetCell,
ItemDragPayload payload)
{
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
return;
if (_objects.Get(payload.ObjId) is not { } item)
return;
uint fullStack = (uint)Math.Max(1, item.StackSize);
uint amount = _stackSplitQuantity.GetObjectSplitSize(
item.ObjectId,
_selection.SelectedObjectId ?? 0u,
fullStack);
int placement = targetCell.ItemId != 0u
? Math.Max(0, targetCell.SlotIndex)
: _objects.GetContents(_openContainer).Count;
// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and
// UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not
// optimistically rewrite external ownership.
if (amount < fullStack)
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
else
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
}
private void OnExternalContainerChanged(ExternalContainerTransition transition)
{
if (transition.ContainerId == 0u)
{
_openContainer = 0u;
_closeRequested = false;
_window.Hide();
ClearLists();
return;
}
_openContainer = transition.ContainerId;
_closeRequested = false;
ScrollToHome();
Populate();
_window.Show();
}
private void Populate()
{
uint root = _state.CurrentContainerId;
if (root == 0u)
{
ClearLists();
return;
}
if (_openContainer == 0u)
_openContainer = root;
using IDisposable topLayout = _topContainer.DeferLayout();
using IDisposable containerLayout = _containerList.DeferLayout();
using IDisposable contentsLayout = _contentsList.DeferLayout();
_topContainer.Flush();
_containerList.Flush();
_contentsList.Flush();
AddRootCell(root);
foreach (uint guid in _objects.GetContents(root))
{
if (IsContainer(_objects.Get(guid)))
AddContainerCell(guid);
}
foreach (uint guid in _objects.GetContents(_openContainer))
{
if (!IsContainer(_objects.Get(guid)))
AddContentsCell(guid);
}
ApplyIndicators();
}
private void AddRootCell(uint guid)
{
UiItemSlot cell = CreateCell(_topContainer, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
cell.DoubleClicked = RequestClose;
cell.IsOpenContainer = _openContainer == guid;
SetCapacity(cell, guid);
_topContainer.AddItem(cell);
}
private void AddContainerCell(uint guid)
{
UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => OpenNestedContainer(guid));
SetCapacity(cell, guid);
_containerList.AddItem(cell);
}
private void AddContentsCell(uint guid)
{
UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground);
cell.Clicked = () => HandlePrimaryClick(guid, () => Select(guid));
cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid);
cell.DragAcceptSprite = 0x060011F9u;
cell.DragRejectSprite = 0x060011F8u;
_contentsList.AddItem(cell);
}
private UiItemSlot CreateCell(UiItemList owner, uint guid, ItemDragSource source)
{
ClientObject? item = _objects.Get(guid);
uint icon = item is null ? 0u : _resolveIcon(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
uint dragIcon = item is null ? 0u : _resolveDragIcon(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
var cell = new UiItemSlot
{
SpriteResolve = owner.SpriteResolve,
SlotIndex = owner.GetNumUIItems(),
SourceKind = source,
};
cell.SetItem(guid, icon, dragIconTexture: dragIcon);
return cell;
}
private void OpenNestedContainer(uint guid)
{
Select(guid);
_openContainer = guid;
_contentsList.Scroll.SetScrollY(0);
Populate();
}
private void Select(uint guid)
=> _selection.Select(guid, SelectionChangeSource.ExternalContainer);
private void HandlePrimaryClick(uint guid, Action fallback)
{
if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive)
return;
fallback();
}
private void ApplyIndicators()
{
ApplyIndicators(_topContainer);
ApplyIndicators(_containerList);
ApplyIndicators(_contentsList);
}
private void ApplyIndicators(UiItemList list)
{
for (int i = 0; i < list.GetNumUIItems(); i++)
{
if (list.GetItem(i) is not { } cell) continue;
bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId);
cell.Selected = cell.ItemId != 0u
&& cell.ItemId == _selection.SelectedObjectId
&& !pendingSource;
cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer;
}
}
private void SetCapacity(UiItemSlot cell, uint containerId)
{
int capacity = _objects.Get(containerId)?.ItemsCapacity ?? 0;
cell.CapacityFill = capacity <= 0
? -1f
: Math.Clamp(_objects.GetContents(containerId).Count / (float)capacity, 0f, 1f);
}
private void ClearLists()
{
_topContainer.Flush();
_containerList.Flush();
_contentsList.Flush();
ScrollToHome();
}
private void ScrollToHome()
{
_topContainer.Scroll.SetScrollY(0);
_containerList.Scroll.SetScrollY(0);
_contentsList.Scroll.SetScrollY(0);
}
private void OnObjectChanged(ClientObject item)
{
if (_window.IsVisible && Concerns(item))
Populate();
}
private void OnObjectMoved(ClientObjectMove move)
{
if (!_window.IsVisible) return;
uint root = _state.CurrentContainerId;
if ((move.Item is { } item && Concerns(item))
|| move.Previous.ContainerId == root
|| move.Current.ContainerId == root
|| move.Previous.ContainerId == _openContainer
|| move.Current.ContainerId == _openContainer)
{
Populate();
}
}
private void OnContentsReplaced(uint containerId)
{
if (_window.IsVisible
&& (containerId == _state.CurrentContainerId
|| containerId == _openContainer
|| ProjectionContains(_state.CurrentContainerId, containerId)))
Populate();
}
private void OnObjectRemoved(ClientObject item)
{
if (_selection.SelectedObjectId == item.ObjectId)
{
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved);
}
if (item.ObjectId == _state.CurrentContainerId)
_state.ApplyClose(item.ObjectId);
else if (_window.IsVisible && Concerns(item))
Populate();
}
private void OnObjectsCleared()
{
_openContainer = 0u;
_closeRequested = false;
_window.Hide();
ClearLists();
}
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnInteractionStateChanged() => ApplyIndicators();
private static bool IsContainer(ClientObject? item)
=> item is not null
&& (item.ContainerTypeHint != 0u
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0);
private bool Concerns(ClientObject item)
{
uint root = _state.CurrentContainerId;
return item.ObjectId == root
|| item.ObjectId == _openContainer
|| item.ContainerId == root
|| item.ContainerId == _openContainer
|| ProjectionContains(root, item.ObjectId)
|| ProjectionContains(_openContainer, item.ObjectId);
}
private bool ProjectionContains(uint containerId, uint itemId)
{
if (containerId == 0u || itemId == 0u) return false;
foreach (uint candidate in _objects.GetContents(containerId))
{
if (candidate == itemId) return true;
}
return false;
}
private static void ConfigureList(
UiItemList list,
float cellSize,
bool horizontalScroll,
uint emptySprite)
{
list.Columns = 1;
list.CellWidth = cellSize;
list.CellHeight = cellSize;
list.SingleRow = true;
list.HorizontalScroll = horizontalScroll;
list.FillVisibleEmptySlots = true;
if (emptySprite != 0u)
list.CellEmptySprite = emptySprite;
list.EmptySlotFactory = () => new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
SourceKind = ItemDragSource.Ground,
DragAcceptSprite = 0x060011F9u,
DragRejectSprite = 0x060011F8u,
};
}
private static UiItemList RequiredList(ImportedLayout layout, uint id)
=> layout.FindElement(id) as UiItemList
?? throw new InvalidOperationException(
$"External-container LayoutDesc is missing ItemList 0x{id:X8}.");
private static void BindClose(ImportedLayout layout, Action close)
{
switch (layout.FindElement(CloseButtonId))
{
case UiButton button:
button.OnClick = close;
break;
case UiDatElement element:
element.ClickThrough = false;
element.OnClick = close;
break;
default:
throw new InvalidOperationException(
"External-container LayoutDesc is missing its close button.");
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_state.Changed -= OnExternalContainerChanged;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ContainerContentsReplaced -= OnContentsReplaced;
_objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
}
}

View file

@ -57,7 +57,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
private readonly SelectionState _selection; private readonly SelectionState _selection;
private readonly Action<uint>? _sendUse; private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement) private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
private readonly Action<uint, uint, uint, uint>? _sendStackableSplitToContainer; private readonly Action<uint, uint, uint, uint>? _sendStackableSplitToContainer;
private readonly Action<uint, uint, uint>? _sendStackableMerge; private readonly Action<uint, uint, uint>? _sendStackableMerge;
@ -84,7 +83,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint sideBagEmptySprite, uint sideBagEmptySprite,
uint mainPackEmptySprite, uint mainPackEmptySprite,
Action<uint>? sendUse, Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer, Action<uint, uint, int>? sendPutItemInContainer,
Action<uint, uint, uint, uint>? sendStackableSplitToContainer, Action<uint, uint, uint, uint>? sendStackableSplitToContainer,
Action<uint, uint, uint>? sendStackableMerge, Action<uint, uint, uint>? sendStackableMerge,
@ -100,7 +98,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_strength = strength; _strength = strength;
_ownerName = ownerName; _ownerName = ownerName;
_sendUse = sendUse; _sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer; _sendPutItemInContainer = sendPutItemInContainer;
_sendStackableSplitToContainer = sendStackableSplitToContainer; _sendStackableSplitToContainer = sendStackableSplitToContainer;
_sendStackableMerge = sendStackableMerge; _sendStackableMerge = sendStackableMerge;
@ -212,7 +209,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint sideBagEmptySprite = 0u, uint sideBagEmptySprite = 0u,
uint mainPackEmptySprite = 0u, uint mainPackEmptySprite = 0u,
Action<uint>? sendUse = null, Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null, Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint, uint>? sendStackableSplitToContainer = null, Action<uint, uint, uint, uint>? sendStackableSplitToContainer = null,
Action<uint, uint, uint>? sendStackableMerge = null, Action<uint, uint, uint>? sendStackableMerge = null,
@ -224,7 +220,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
=> new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection, => new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection,
ownerName, datFont, ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer, sendUse, sendPutItemInContainer,
sendStackableSplitToContainer, sendStackableMerge, sendStackableSplitToContainer, sendStackableMerge,
notifyMergeAttempt, itemInteraction, notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity); onClose, stackSplitQuantity);
@ -529,7 +525,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
} }
} }
_objects.MoveItemOptimistic(item, container, placement); // instant local move // External-container contents are a temporary ViewContents projection.
// Retail UIAttemptPutInContainer leaves them untouched until the server's
// authoritative move response; only rearrangements within owned inventory
// use the existing optimistic projection.
if (payload.SourceKind != ItemDragSource.Ground)
_objects.MoveItemOptimistic(item, container, placement);
_sendPutItemInContainer?.Invoke(item, container, placement); _sendPutItemInContainer?.Invoke(item, container, placement);
} }
@ -588,8 +589,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
} }
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the /// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and /// open container. Sends Use to open a side bag server-side (ViewContents arrives async).
/// NoLongerViewingContents when switching away from a previously-open side bag. Retail: /// Owned packs are not ClientUISystem::groundObject and never send
/// NoLongerViewingContents. Retail:
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary> /// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
private void OpenContainer(uint guid) private void OpenContainer(uint guid)
{ {
@ -599,8 +601,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
uint p = _playerGuid(); uint p = _playerGuid();
if (open != p && open != 0)
_sendNoLongerViewing?.Invoke(open); // close the previously-open side bag
_openContainer = guid; _openContainer = guid;
if (guid != p) if (guid != p)
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land) _sendUse?.Invoke(guid); // open the side bag (ViewContents will land)

View file

@ -118,13 +118,24 @@ public sealed record InventoryRuntimeBindings(
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon, Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<int?> Strength, Func<int?> Strength,
Action<uint>? SendUse, Action<uint>? SendUse,
Action<uint>? SendNoLongerViewing,
Action<uint, uint, int>? SendPutItemInContainer, Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer, Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
Action<uint, uint, uint>? SendStackableMerge, Action<uint, uint, uint>? SendStackableMerge,
ItemInteractionController ItemInteraction, ItemInteractionController ItemInteraction,
SelectionState Selection); SelectionState Selection);
public sealed record ExternalContainerRuntimeBindings(
ExternalContainerState State,
ClientObjectTable Objects,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
ItemInteractionController ItemInteraction,
SelectionState Selection,
Action<uint> SendUse,
Action<uint, uint, int> SendPutItemInContainer,
Action<uint, uint, uint, uint> SendStackableSplitToContainer,
Func<uint, bool> IsWithinUseRange);
public sealed record RetailUiPersistenceBindings( public sealed record RetailUiPersistenceBindings(
SettingsStore Store, SettingsStore Store,
Func<string> CharacterKey, Func<string> CharacterKey,
@ -157,6 +168,7 @@ public sealed record RetailUiRuntimeBindings(
ToolbarRuntimeBindings Toolbar, ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character, CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory, InventoryRuntimeBindings Inventory,
ExternalContainerRuntimeBindings ExternalContainer,
RetailUiCursorBindings Cursor, RetailUiCursorBindings Cursor,
ConfirmationRuntimeBindings Confirmations, ConfirmationRuntimeBindings Confirmations,
StackSplitQuantityState StackSplitQuantity, StackSplitQuantityState StackSplitQuantity,
@ -204,6 +216,7 @@ public sealed class RetailUiRuntime : IDisposable
MountCharacter(); MountCharacter();
MountPlugins(); MountPlugins();
MountInventory(); MountInventory();
MountExternalContainer();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons(); BindToolbarPanelButtons();
SyncToolbarWindowButtons(); SyncToolbarWindowButtons();
@ -215,7 +228,12 @@ public sealed class RetailUiRuntime : IDisposable
persistence.Store, persistence.Store,
persistence.CharacterKey, persistence.CharacterKey,
persistence.ScreenSize, persistence.ScreenSize,
stateManagedVisibilityWindows: [WindowNames.Combat, WindowNames.JumpPowerbar]); stateManagedVisibilityWindows:
[
WindowNames.Combat,
WindowNames.JumpPowerbar,
WindowNames.ExternalContainer,
]);
} }
if (bindings.Probe.Enabled) if (bindings.Probe.Enabled)
@ -257,6 +275,7 @@ public sealed class RetailUiRuntime : IDisposable
public UiViewport? PaperdollViewportWidget { get; private set; } public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; } public UiNineSlicePanel? InventoryFrame { get; private set; }
public RetailDialogFactory? DialogFactory { get; private set; } public RetailDialogFactory? DialogFactory { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{ {
@ -283,6 +302,7 @@ public sealed class RetailUiRuntime : IDisposable
IndicatorBarController?.Tick(); IndicatorBarController?.Tick();
JumpPowerbarController?.Tick(); JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds); SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
DialogFactory?.Tick(); DialogFactory?.Tick();
Host.Tick(deltaSeconds); Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds); _automation?.Tick(deltaSeconds);
@ -1577,7 +1597,7 @@ public sealed class RetailUiRuntime : IDisposable
InventoryController inventory = InventoryController.Bind( InventoryController inventory = InventoryController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection, layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName, _bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing, contents, sideBag, mainPack, b.SendUse,
b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge, b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction, notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory), () => CloseWindow(WindowNames.Inventory),
@ -1596,6 +1616,83 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
} }
private void MountExternalContainer()
{
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.RootId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine("[D.2b] external container: LayoutDesc 0x21000008 not found.");
return;
}
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.ExternalContainer,
Chrome = RetailWindowChrome.Imported,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
Visible = false,
Draggable = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
});
uint contentsEmpty;
uint containerEmpty;
lock (_bindings.Assets.DatLock)
{
contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.ContentsListId);
containerEmpty = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
ExternalContainerController.LayoutId,
ExternalContainerController.ContainerListId);
}
ExternalContainerRuntimeBindings b = _bindings.ExternalContainer;
ExternalContainerController = ExternalContainerController.Bind(
layout,
b.State,
b.Objects,
b.Selection,
b.ItemInteraction,
StackSplitQuantity,
b.ResolveIcon,
b.ResolveDragIcon,
b.SendUse,
b.SendPutItemInContainer,
b.SendStackableSplitToContainer,
b.IsWithinUseRange,
handle,
contentsEmpty,
containerEmpty);
Host.WindowManager.AttachController(
WindowNames.ExternalContainer,
ExternalContainerController);
Console.WriteLine(
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
}
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;

View file

@ -140,6 +140,13 @@ public sealed class UiItemList : UiElement
/// </summary> /// </summary>
public bool SingleRow { get; set; } public bool SingleRow { get; set; }
/// <summary>
/// The single row uses the shared pixel scroll model as a horizontal offset.
/// Retail's external-container list (LayoutDesc 0x21000008) is the authored
/// horizontal ItemList + Scrollbar consumer.
/// </summary>
public bool HorizontalScroll { get; set; }
/// <summary> /// <summary>
/// Maintain a tail of empty UIItems that fills the visible horizontal extent. /// Maintain a tail of empty UIItems that fills the visible horizontal extent.
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700. /// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
@ -206,6 +213,28 @@ public sealed class UiItemList : UiElement
: Columns < 1 ? 1 : Columns; : Columns < 1 ? 1 : Columns;
int cellH = (int)MathF.Round(CellHeight); int cellH = (int)MathF.Round(CellHeight);
if (SingleRow && HorizontalScroll)
{
int cellW = Math.Max(1, (int)MathF.Round(CellWidth));
Scroll.LineHeight = cellW;
Scroll.ContentHeight = _cells.Count * cellW;
Scroll.ViewHeight = (int)MathF.Floor(Width);
Scroll.SetScrollY(Scroll.ScrollY);
float scrollX = Scroll.ScrollY;
for (int i = 0; i < _cells.Count; i++)
{
float left = i * CellWidth - scrollX;
UiItemSlot cell = _cells[i];
cell.Left = left;
cell.Top = 0f;
cell.Width = CellWidth;
cell.Height = CellHeight;
cell.Visible = left < Width && left + CellWidth > 0f;
}
return;
}
// Drive the shared scroll model from the current geometry, then re-clamp the offset to // Drive the shared scroll model from the current geometry, then re-clamp the offset to
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY // the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
// so the clamp uses the right max. // so the clamp uses the right max.
@ -292,6 +321,16 @@ public sealed class UiItemList : UiElement
public void ScrollItemIntoView(int index) public void ScrollItemIntoView(int index)
{ {
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return; if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
if (SingleRow && HorizontalScroll)
{
float left = index * CellWidth;
float right = left + CellWidth;
if (left < Scroll.ScrollY)
Scroll.SetScrollY((int)MathF.Floor(left));
else if (right > Scroll.ScrollY + Width)
Scroll.SetScrollY((int)MathF.Ceiling(right - Width));
return;
}
int columns = Math.Max(1, Columns); int columns = Math.Max(1, Columns);
int row = Flow == UiItemListFlow.ColumnMajor int row = Flow == UiItemListFlow.ColumnMajor
? index % Math.Max(1, RowCount(_cells.Count, columns)) ? index % Math.Max(1, RowCount(_cells.Count, columns))

View file

@ -141,6 +141,11 @@ public sealed class UiScrollbar : UiElement
if (SpriteResolve is not { } resolve) return; if (SpriteResolve is not { } resolve) return;
if (Horizontal) if (Horizontal)
{ {
if (ScalarChanged is null && Model is { } horizontalModel)
{
DrawHorizontalModel(ctx, resolve, horizontalModel);
return;
}
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
(float rangeLeft, float rangeWidth) = ScalarRangeRect(); (float rangeLeft, float rangeWidth) = ScalarRangeRect();
if (ScalarRangeSprite != 0) if (ScalarRangeSprite != 0)
@ -196,6 +201,31 @@ public sealed class UiScrollbar : UiElement
} }
} }
private void DrawHorizontalModel(
UiRenderContext ctx,
Func<uint, (uint tex, int w, int h)> resolve,
UiScrollable model)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, ButtonH, Height);
DrawSprite(ctx, resolve, DownSprite, Width - ButtonH, 0f, ButtonH, Height);
if (!model.HasOverflow) return;
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
{
DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height);
DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
}
else
{
DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height);
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary> /// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve, private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h) uint id, float x, float y, float w, float h)
@ -263,6 +293,9 @@ public sealed class UiScrollbar : UiElement
if (Horizontal && ScalarChanged is not null) if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e); return OnScalarEvent(e);
if (Horizontal && Model is not null)
return OnHorizontalModelEvent(e);
if (Model is not { } m) return false; if (Model is not { } m) return false;
switch (e.Type) switch (e.Type)
@ -318,6 +351,50 @@ public sealed class UiScrollbar : UiElement
return false; return false;
} }
private bool OnHorizontalModelEvent(in UiEvent e)
{
UiScrollable m = Model!;
switch (e.Type)
{
case UiEventType.MouseDown:
{
float x = e.Data1;
if (x <= ButtonH) { m.ScrollByLines(-1); return true; }
if (x >= Width - ButtonH) { m.ScrollByLines(1); return true; }
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
if (x >= tx && x <= tx + tw)
{
_draggingThumb = true;
_dragOffsetX = x - tx;
}
else
{
m.ScrollByPage(x < tx ? -1 : 1);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
float thumbWidth = MathF.Max(MinThumb, trackLength * m.ThumbRatio);
float travel = MathF.Max(1f, trackLength - thumbWidth);
float ratio = ((float)e.Data1 - _dragOffsetX - trackLeft) / travel;
m.SetPositionRatio(ratio);
return true;
}
case UiEventType.MouseUp:
_draggingThumb = false;
return true;
}
return false;
}
private bool OnScalarEvent(in UiEvent e) private bool OnScalarEvent(in UiEvent e)
{ {
switch (e.Type) switch (e.Type)

View file

@ -9,6 +9,7 @@ public static class WindowNames
public const string Character = "character"; public const string Character = "character";
public const string CharacterInformation = "character-information"; public const string CharacterInformation = "character-information";
public const string Inventory = "inventory"; public const string Inventory = "inventory";
public const string ExternalContainer = "external-container";
public const string Chat = "chat"; public const string Chat = "chat";
public const string Radar = "radar"; public const string Radar = "radar";
public const string Combat = "combat"; public const string Combat = "combat";

View file

@ -0,0 +1,51 @@
using AcDream.Core.Items;
namespace AcDream.App.World;
/// <summary>
/// Owns the network side effect of replacing retail's current ground object.
/// Presentation controllers observe the same state but do not own session
/// lifecycle messages.
/// </summary>
public sealed class ExternalContainerLifecycleController : IDisposable
{
private readonly ExternalContainerState _state;
private readonly ClientObjectTable _objects;
private readonly Action<uint> _sendNoLongerViewingContents;
private bool _disposed;
public ExternalContainerLifecycleController(
ExternalContainerState state,
ClientObjectTable objects,
Action<uint> sendNoLongerViewingContents)
{
_state = state ?? throw new ArgumentNullException(nameof(state));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_sendNoLongerViewingContents = sendNoLongerViewingContents
?? throw new ArgumentNullException(nameof(sendNoLongerViewingContents));
_state.Changed += OnChanged;
}
private void OnChanged(ExternalContainerTransition transition)
{
// ClientUISystem::SetGroundObject @ 0x00564510 always queues the old
// root's contents tree for destruction when the ground object changes.
if (transition.PreviousContainerId != 0u)
_objects.StopViewingContentsTree(transition.PreviousContainerId);
// Only replacement emits Event_NoLongerViewingContents. The authored
// close/range path instead sends Use(root) and waits for event 0x0052.
if (transition.Kind == ExternalContainerTransitionKind.Replaced
&& transition.PreviousContainerId != 0u)
{
_sendNoLongerViewingContents(transition.PreviousContainerId);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_state.Changed -= OnChanged;
}
}

View file

@ -79,7 +79,8 @@ public static class GameEventWiring
SquelchState? squelch = null, SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null, Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null, Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
Func<double>? clientTime = null) Func<double>? clientTime = null,
ExternalContainerState? externalContainers = null)
{ {
ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(items);
@ -364,6 +365,7 @@ public static class GameEventWiring
p.Value.Items[i].Guid, p.Value.Items[i].Guid,
p.Value.Items[i].ContainerType); p.Value.Items[i].ContainerType);
items.ReplaceContents(p.Value.ContainerGuid, entries); items.ReplaceContents(p.Value.ContainerGuid, entries);
externalContainers?.ApplyViewContents(p.Value.ContainerGuid);
}); });
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
@ -412,13 +414,15 @@ public static class GameEventWiring
chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0); chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0);
}); });
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container // CloseGroundContainer (0x0052): clear ClientUISystem::groundObject and
// view. No table change (the view is UI-only, wired in container-open); log. // retire the root plus nested temporary ViewContents projections. Child
// objects remain until authoritative move/delete wire says otherwise.
dispatcher.Register(GameEventType.CloseGroundContainer, e => dispatcher.Register(GameEventType.CloseGroundContainer, e =>
{ {
var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span); var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span);
if (guid is not null) if (guid is null) return;
Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}"); externalContainers?.ApplyClose(guid.Value);
items.StopViewingContentsTree(guid.Value);
}); });
dispatcher.Register(GameEventType.IdentifyObjectResponse, e => dispatcher.Register(GameEventType.IdentifyObjectResponse, e =>

View file

@ -1026,6 +1026,56 @@ public sealed class ClientObjectTable
ContainerContentsReplaced?.Invoke(containerId); ContainerContentsReplaced?.Invoke(containerId);
} }
/// <summary>
/// Retires a viewed container's temporary ordered projection without inventing
/// item movement or deleting its child objects. Retail
/// <c>ACCObjectMaint::StopViewingObjectContents</c> is driven by server event
/// CloseGroundContainer; canonical ownership remains owned by move/delete wire.
/// </summary>
public bool StopViewingContents(uint containerId)
{
if (containerId == 0u || !_containerIndex.Remove(containerId))
return false;
ContainerContentsReplaced?.Invoke(containerId);
return true;
}
/// <summary>
/// Retires the root and every nested container projection reachable through
/// the last ViewContents snapshots. ACE sends a snapshot for each nested
/// container; retail queues the external root's contents tree for destruction
/// when the view closes.
/// </summary>
public int StopViewingContentsTree(uint rootContainerId)
{
if (rootContainerId == 0u)
return 0;
var pending = new Stack<uint>();
var visited = new HashSet<uint>();
var removed = new List<uint>();
pending.Push(rootContainerId);
while (pending.Count != 0)
{
uint containerId = pending.Pop();
if (!visited.Add(containerId)
|| !_containerIndex.TryGetValue(containerId, out List<uint>? contents))
continue;
foreach (uint childId in contents)
{
if (_containerIndex.ContainsKey(childId))
pending.Push(childId);
}
_containerIndex.Remove(containerId);
removed.Add(containerId);
}
foreach (uint containerId in removed)
ContainerContentsReplaced?.Invoke(containerId);
return removed.Count;
}
/// <summary> /// <summary>
/// Initialize canonical inventory ownership from PlayerDescription. This /// Initialize canonical inventory ownership from PlayerDescription. This
/// login manifest is object-state initialization, not a move transition. /// login manifest is object-state initialization, not a move transition.

View file

@ -0,0 +1,98 @@
using System;
namespace AcDream.Core.Items;
public enum ExternalContainerTransitionKind
{
Opened,
Replaced,
Closed,
Reset,
}
public readonly record struct ExternalContainerTransition(
ExternalContainerTransitionKind Kind,
uint PreviousContainerId,
uint ContainerId);
/// <summary>
/// Owns retail <c>ClientUISystem::groundObject</c> and the outstanding external
/// container request. ACE sends ViewContents for the requested root and then for
/// nested containers; only the expected root may become the visible ground object.
/// Retail: <c>ClientUISystem::OnViewContents @ 0x00565870</c> and
/// <c>SetGroundObject @ 0x00564510</c>.
/// </summary>
public sealed class ExternalContainerState
{
public uint RequestedContainerId { get; private set; }
public uint CurrentContainerId { get; private set; }
public event Action<ExternalContainerTransition>? Changed;
public bool RequestOpen(uint containerId)
{
if (containerId == 0u || RequestedContainerId == containerId)
return false;
RequestedContainerId = containerId;
return true;
}
public bool ApplyViewContents(uint containerId)
{
if (containerId == 0u || containerId != RequestedContainerId)
return false;
uint previous = CurrentContainerId;
CurrentContainerId = containerId;
RequestedContainerId = containerId;
if (previous == containerId)
return false;
Changed?.Invoke(new ExternalContainerTransition(
previous == 0u
? ExternalContainerTransitionKind.Opened
: ExternalContainerTransitionKind.Replaced,
previous,
containerId));
return true;
}
public bool ApplyClose(uint containerId)
{
if (containerId == 0u || containerId != CurrentContainerId)
return false;
uint previous = CurrentContainerId;
CurrentContainerId = 0u;
RequestedContainerId = 0u;
Changed?.Invoke(new ExternalContainerTransition(
ExternalContainerTransitionKind.Closed,
previous,
0u));
return true;
}
public bool ApplyUseDone(uint weenieError)
{
if (weenieError == 0u || RequestedContainerId == CurrentContainerId)
return false;
RequestedContainerId = CurrentContainerId;
return true;
}
public bool Reset()
{
uint previous = CurrentContainerId;
bool changed = previous != 0u || RequestedContainerId != 0u;
CurrentContainerId = 0u;
RequestedContainerId = 0u;
if (changed)
{
Changed?.Invoke(new ExternalContainerTransition(
ExternalContainerTransitionKind.Reset,
previous,
0u));
}
return changed;
}
}

View file

@ -8,6 +8,7 @@ public enum SelectionChangeSource
World, World,
Radar, Radar,
Inventory, Inventory,
ExternalContainer,
Paperdoll, Paperdoll,
Toolbar, Toolbar,
Keyboard, Keyboard,

View file

@ -19,6 +19,8 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Source, uint Target)> UseWithTarget = new(); public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new(); public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<(uint Item, uint Container, int Placement)> Puts = new(); public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
public readonly List<uint> ExternalRequests = new();
public readonly List<uint> Drops = new(); public readonly List<uint> Drops = new();
public readonly List<(uint Item, uint Amount)> SplitDrops = new(); public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new(); public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
@ -28,6 +30,7 @@ public sealed class ItemInteractionControllerTests
public uint SelectedObject; public uint SelectedObject;
public bool NonCombatMode; public bool NonCombatMode;
public bool DragOnPlayerOpensSecureTrade = true; public bool DragOnPlayerOpensSecureTrade = true;
public uint GroundObject;
public long Now = 1_000; public long Now = 1_000;
public Harness() public Harness()
@ -61,11 +64,19 @@ public sealed class ItemInteractionControllerTests
selectedObjectId: () => SelectedObject, selectedObjectId: () => SelectedObject,
stackSplitQuantity: SplitQuantity, stackSplitQuantity: SplitQuantity,
inNonCombatMode: () => NonCombatMode, inNonCombatMode: () => NonCombatMode,
groundObjectId: () => GroundObject,
sendPutItemInContainer: (item, container, placement) => sendPutItemInContainer: (item, container, placement) =>
Puts.Add((item, container, placement)), Puts.Add((item, container, placement)),
sendGive: (target, item, amount) => Gives.Add((target, item, amount)), sendGive: (target, item, amount) => Gives.Add((target, item, amount)),
dragOnPlayerOpensSecureTrade: () => DragOnPlayerOpensSecureTrade, dragOnPlayerOpensSecureTrade: () => DragOnPlayerOpensSecureTrade,
systemMessage: SystemMessages.Add); systemMessage: SystemMessages.Add,
sendSplitToContainer: (item, container, placement, amount) =>
SplitPuts.Add((item, container, placement, amount)),
requestExternalContainer: id =>
{
GroundObject = id;
ExternalRequests.Add(id);
});
} }
public ItemInteractionController Controller { get; } public ItemInteractionController Controller { get; }
@ -103,6 +114,51 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.UseWithTarget); Assert.Empty(h.UseWithTarget);
} }
[Fact]
public void ExternalContainerUse_RequestsGroundObjectAndSendsUse()
{
var h = new Harness();
const uint chest = 0x70000001u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = chest,
Type = ItemType.Container,
ItemsCapacity = 24,
Useability = ItemUseability.Remote,
});
Assert.True(h.Controller.ActivateItem(chest));
Assert.Equal(new uint[] { chest }, h.Uses);
Assert.Equal(new uint[] { chest }, h.ExternalRequests);
Assert.Equal(chest, h.GroundObject);
}
[Fact]
public void DropOwnedItemOnOpenExternalContainer_SendsPutWithoutOptimisticMove()
{
var h = new Harness();
const uint itemId = 0x50000A33u;
const uint chest = 0x70000001u;
ClientObject item = h.AddContained(itemId);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = chest,
Type = ItemType.Container,
ItemsCapacity = 24,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Openable,
});
h.GroundObject = chest;
var cell = new UiItemSlot();
cell.SetItem(itemId, 0u);
var payload = new ItemDragPayload(itemId, ItemDragSource.Inventory, 0, cell);
Assert.True(h.Controller.PlaceIn3D(payload, chest));
Assert.Equal(new[] { (itemId, chest, 0) }, h.Puts);
Assert.Equal(Pack, item.ContainerId);
}
[Fact] [Fact]
public void PrimaryClickRouter_distinguishesInactiveSuccessAndConsumedRejection() public void PrimaryClickRouter_distinguishesInactiveSuccessAndConsumedRejection()
{ {

View file

@ -0,0 +1,203 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.Tests.UI.Layout;
public sealed class ExternalContainerControllerTests
{
private const uint Player = 0x50000001u;
private const uint Chest = 0x70000001u;
private const uint Item = 0x60000001u;
private sealed class TestElement : UiElement { }
private sealed class Harness : IDisposable
{
public readonly ExternalContainerState State = new();
public readonly ClientObjectTable Objects = new();
public readonly SelectionState Selection = new();
public readonly StackSplitQuantityState Split = new();
public readonly List<uint> Uses = new();
public readonly List<uint> NoLonger = new();
public readonly List<uint> Pickups = new();
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> Splits = new();
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
public readonly UiItemList Top = new() { Width = 36f, Height = 36f };
public readonly UiItemList Containers = new() { Width = 680f, Height = 36f };
public readonly UiItemList Contents = new() { Width = 784f, Height = 32f };
public readonly RetailWindowHandle Window;
public readonly ItemInteractionController Interaction;
public readonly ExternalContainerLifecycleController Lifecycle;
public readonly ExternalContainerController Controller;
public bool InRange = true;
public Harness()
{
var root = new TestElement { Width = 800f, Height = 110f };
var close = new UiButton(
new ElementInfo { Id = ExternalContainerController.CloseButtonId, Type = 1 },
static _ => (0u, 0, 0));
var scrollbar = new UiScrollbar { Width = 784f, Height = 16f };
root.AddChild(Top);
root.AddChild(Containers);
root.AddChild(Contents);
root.AddChild(close);
root.AddChild(scrollbar);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[ExternalContainerController.TopContainerId] = Top,
[ExternalContainerController.ContainerListId] = Containers,
[ExternalContainerController.ContentsListId] = Contents,
[ExternalContainerController.ContentsScrollbarId] = scrollbar,
[ExternalContainerController.CloseButtonId] = close,
});
Window = RetailWindowFrame.Mount(
Screen,
root,
static _ => (0u, 0, 0),
new RetailWindowFrame.Options
{
WindowName = WindowNames.ExternalContainer,
Chrome = RetailWindowChrome.Imported,
Visible = false,
Draggable = false,
Resizable = false,
});
Interaction = new ItemInteractionController(
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => State.CurrentContainerId,
placeInBackpack: Pickups.Add,
sendPutItemInContainer: (item, container, placement) =>
Puts.Add((item, container, placement)),
sendSplitToContainer: (item, container, placement, amount) =>
Splits.Add((item, container, placement, amount)),
requestExternalContainer: id => State.RequestOpen(id));
Lifecycle = new ExternalContainerLifecycleController(State, Objects, NoLonger.Add);
Controller = ExternalContainerController.Bind(
layout,
State,
Objects,
Selection,
Interaction,
Split,
static (_, _, _, _, _) => 0u,
static (_, _, _, _, _) => 0u,
Uses.Add,
(item, container, placement) => Puts.Add((item, container, placement)),
(item, container, placement, amount) =>
Splits.Add((item, container, placement, amount)),
_ => InRange,
Window);
Screen.WindowManager.AttachController(WindowNames.ExternalContainer, Controller);
}
public void Open(uint id = Chest, params ContainerContentEntry[] contents)
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = id,
Type = ItemType.Container,
ItemsCapacity = 24,
Useability = ItemUseability.Remote,
});
State.RequestOpen(id);
Objects.ReplaceContents(id, contents);
State.ApplyViewContents(id);
}
public void Dispose()
{
Lifecycle.Dispose();
Interaction.Dispose();
Screen.WindowManager.Dispose();
}
}
[Fact]
public void AuthoritativeView_ShowsAuthoredStripAndPopulatesLists()
{
using var h = new Harness();
h.Open(Chest, new ContainerContentEntry(Item, 0u));
Assert.True(h.Window.IsVisible);
Assert.Equal(Chest, h.Top.GetItem(0)!.ItemId);
Assert.Equal(Item, h.Contents.GetItem(0)!.ItemId);
Assert.True(h.Contents.HorizontalScroll);
Assert.Same(h.Contents.Scroll,
Assert.IsType<UiScrollbar>(h.Window.ContentRoot.Children.Last()).Model);
}
[Fact]
public void DoubleClickLoot_UsesSharedItemPolicyAndPicksUpItem()
{
using var h = new Harness();
h.Open(Chest, new ContainerContentEntry(Item, 0u));
h.Contents.GetItem(0)!.DoubleClicked!();
Assert.Equal(new uint[] { Item }, h.Pickups);
}
[Fact]
public void CloseAndRangeExit_SendUseOnceWithoutNoLongerViewing()
{
using var h = new Harness();
h.Open();
h.InRange = false;
h.Controller.Tick();
h.Controller.Tick();
Assert.Equal(new uint[] { Chest }, h.Uses);
Assert.Empty(h.NoLonger);
Assert.False(h.Window.IsVisible);
h.State.ApplyClose(Chest);
Assert.Empty(h.NoLonger);
}
[Fact]
public void Replacement_SendsNoLongerViewingForPreviousRootExactlyOnce()
{
using var h = new Harness();
h.Open(Chest);
h.Open(0x70000002u);
Assert.Equal(new uint[] { Chest }, h.NoLonger);
Assert.Equal(0x70000002u, h.State.CurrentContainerId);
Assert.True(h.Window.IsVisible);
}
[Fact]
public void DropOwnedPartialStackIntoChest_UsesSplitRequestWithoutLocalMove()
{
using var h = new Harness();
h.Open();
var item = new ClientObject { ObjectId = Item, StackSize = 5, Type = ItemType.Misc };
h.Objects.AddOrUpdate(item);
h.Objects.MoveItem(Item, Player, 0);
h.Selection.Select(Item, SelectionChangeSource.Inventory);
h.Split.Reset(5u, 2u);
var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory };
source.SetItem(Item, 0u);
var payload = new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source);
UiItemSlot target = h.Contents.GetItem(0)!;
h.Controller.HandleDropRelease(h.Contents, target, payload);
Assert.Equal(new[] { (Item, Chest, 0u, 2u) }, h.Splits);
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
Assert.Empty(h.Puts);
}
}

View file

@ -54,7 +54,7 @@ public class InventoryControllerTests
} }
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null, int? strength = 100, List<uint>? uses = null,
List<(uint item, uint container, int placement)>? puts = null, List<(uint item, uint container, int placement)>? puts = null,
List<(uint item, uint container, uint placement, uint amount)>? splits = null, List<(uint item, uint container, uint placement, uint amount)>? splits = null,
List<(uint source, uint target, uint amount)>? merges = null, List<(uint source, uint target, uint amount)>? merges = null,
@ -68,7 +68,6 @@ public class InventoryControllerTests
strength: () => strength, datFont: null, strength: () => strength, datFont: null,
ownerName: ownerName is null ? null : () => ownerName, ownerName: ownerName is null ? null : () => ownerName,
sendUse: uses is null ? null : g => uses.Add(g), sendUse: uses is null ? null : g => uses.Add(g),
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)), sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
sendStackableSplitToContainer: splits is null sendStackableSplitToContainer: splits is null
? null ? null
@ -410,7 +409,7 @@ public class InventoryControllerTests
} }
[Fact] [Fact]
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack() public void ClickMainPackCell_afterBag_returnsToMainPack_withoutExternalCloseWire()
{ {
var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();
@ -418,34 +417,30 @@ public class InventoryControllerTests
SeedBag(objects, 0xC, slot: 1); SeedBag(objects, 0xC, slot: 1);
SeedContained(objects, 0xB1, 0xC, slot: 0); SeedContained(objects, 0xB1, 0xC, slot: 0);
var uses = new List<uint>(); var uses = new List<uint>();
var closes = new List<uint>(); var ctrl = Bind(layout, objects, uses: uses);
var ctrl = Bind(layout, objects, uses: uses, closes: closes);
containers.GetItem(0)!.Clicked!(); // open the bag containers.GetItem(0)!.Clicked!(); // open the bag
top.GetItem(0)!.Clicked!(); // click the main-pack cell top.GetItem(0)!.Clicked!(); // click the main-pack cell
Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent
Assert.DoesNotContain(Player, uses); // no Use for the main pack Assert.DoesNotContain(Player, uses); // no Use for the main pack
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack
Assert.Equal(102, grid.GetNumUIItems()); Assert.Equal(102, grid.GetNumUIItems());
} }
[Fact] [Fact]
public void SwitchBetweenTwoBags_closesPrevious_opensNext() public void SwitchBetweenTwoBags_opensEach_withoutExternalCloseWire()
{ {
var (layout, _, containers, _, _, _, _, _) = BuildLayout(); var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();
SeedBag(objects, 0xC1, slot: 0); SeedBag(objects, 0xC1, slot: 0);
SeedBag(objects, 0xC2, slot: 1); SeedBag(objects, 0xC2, slot: 1);
var uses = new List<uint>(); var uses = new List<uint>();
var closes = new List<uint>(); Bind(layout, objects, uses: uses);
Bind(layout, objects, uses: uses, closes: closes);
containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1) containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1)
containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2) containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2)
Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B) Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B)
Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack)
} }
[Fact] [Fact]
@ -469,15 +464,13 @@ public class InventoryControllerTests
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0); SeedContained(objects, 0xA, Player, slot: 0);
var uses = new List<uint>(); var uses = new List<uint>();
var closes = new List<uint>(); Bind(layout, objects, uses: uses);
Bind(layout, objects, uses: uses, closes: closes);
grid.GetItem(0)!.Clicked!(); // select the loose item grid.GetItem(0)!.Clicked!(); // select the loose item
Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item
Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged) Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged)
Assert.Empty(uses); // selection sends no wire Assert.Empty(uses); // selection sends no wire
Assert.Empty(closes);
} }
[Fact] [Fact]

View file

@ -114,4 +114,36 @@ public class UiItemListTests
list.ScrollItemIntoView(0); list.ScrollItemIntoView(0);
Assert.Equal(0, list.Scroll.ScrollY); Assert.Equal(0, list.Scroll.ScrollY);
} }
[Fact]
public void HorizontalScroll_UsesPixelOffsetAndKeepsPartialEdgeCells()
{
var list = new UiItemList
{
Width = 80f,
Height = 32f,
CellWidth = 32f,
CellHeight = 32f,
SingleRow = true,
HorizontalScroll = true,
};
list.Flush();
for (int i = 0; i < 4; i++) list.AddItem(new UiItemSlot());
list.Scroll.SetScrollY(17);
list.LayoutCells();
Assert.Equal(128, list.Scroll.ContentHeight);
Assert.Equal(80, list.Scroll.ViewHeight);
Assert.Equal(-17f, list.GetItem(0)!.Left);
Assert.True(list.GetItem(0)!.Visible);
Assert.True(list.GetItem(3)!.Visible);
Assert.False(list.GetItem(2) is null);
list.ScrollItemIntoView(3);
Assert.Equal(48, list.Scroll.ScrollY);
list.LayoutCells();
Assert.Equal(48f, list.GetItem(3)!.Left);
Assert.Equal(80f, list.GetItem(3)!.Left + list.GetItem(3)!.Width);
}
} }

View file

@ -103,6 +103,31 @@ public class UiScrollbarTests
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45))); Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
} }
[Fact]
public void HorizontalModel_ButtonsTrackAndThumbDriveSharedScroll()
{
var model = new UiScrollable { ContentHeight = 320, ViewHeight = 80, LineHeight = 32 };
var bar = new UiScrollbar
{
Width = 160f,
Height = 16f,
Horizontal = true,
Model = model,
};
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 159)));
Assert.Equal(32, model.ScrollY);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 100)));
Assert.True(model.ScrollY >= 80);
model.SetScrollY(0);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 20)));
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 144)));
Assert.Equal(model.MaxScroll, model.ScrollY);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 144)));
}
[Theory] [Theory]
[InlineData(0f, 0f, 0f)] [InlineData(0f, 0f, 0f)]
[InlineData(0.5f, 0f, 50f)] [InlineData(0.5f, 0f, 50f)]

View file

@ -0,0 +1,50 @@
using AcDream.App.World;
using AcDream.Core.Items;
namespace AcDream.App.Tests.World;
public sealed class ExternalContainerLifecycleControllerTests
{
[Fact]
public void Replacement_RetiresPreviousRootExactlyOnce()
{
var state = new ExternalContainerState();
var objects = new ClientObjectTable();
var retired = new List<uint>();
using var controller = new ExternalContainerLifecycleController(state, objects, retired.Add);
objects.AddOrUpdate(new ClientObject { ObjectId = 100u });
objects.ReplaceContents(10u, new[] { new ContainerContentEntry(100u, 0u) });
state.RequestOpen(10u);
state.ApplyViewContents(10u);
state.RequestOpen(20u);
state.ApplyViewContents(20u);
state.ApplyViewContents(20u);
Assert.Equal(new uint[] { 10u }, retired);
Assert.Empty(objects.GetContents(10u));
Assert.NotNull(objects.Get(100u));
}
[Fact]
public void AuthoredCloseAndReset_DoNotSendNoLongerViewing()
{
var state = new ExternalContainerState();
var objects = new ClientObjectTable();
var retired = new List<uint>();
using var controller = new ExternalContainerLifecycleController(state, objects, retired.Add);
state.RequestOpen(10u);
state.ApplyViewContents(10u);
objects.ReplaceContents(10u, new[] { new ContainerContentEntry(100u, 0u) });
state.ApplyClose(10u);
Assert.Empty(objects.GetContents(10u));
state.RequestOpen(20u);
state.ApplyViewContents(20u);
objects.ReplaceContents(20u, new[] { new ContainerContentEntry(200u, 0u) });
state.Reset();
Assert.Empty(retired);
Assert.Empty(objects.GetContents(20u));
}
}

View file

@ -950,6 +950,45 @@ public sealed class GameEventWiringTests
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
} }
[Fact]
public void WireAll_ExternalContainer_FiltersNestedViewsAndAppliesAuthoritativeClose()
{
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
var external = new ExternalContainerState();
GameEventWiring.WireAll(
dispatcher,
items,
new CombatState(),
new Spellbook(),
new ChatLog(),
externalContainers: external);
external.RequestOpen(0x70000001u);
DispatchViewContents(
dispatcher,
0x70000001u,
new uint[] { 0x60000001u, 0x70000002u });
DispatchViewContents(dispatcher, 0x70000002u, new uint[] { 0x60000002u });
Assert.Equal(0x70000001u, external.CurrentContainerId);
Assert.Equal(
new uint[] { 0x60000001u, 0x70000002u },
items.GetContents(0x70000001u));
Assert.Equal(new uint[] { 0x60000002u }, items.GetContents(0x70000002u));
byte[] closePayload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(closePayload, 0x70000001u);
var close = GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.CloseGroundContainer, closePayload));
dispatcher.Dispatch(close!.Value);
Assert.Equal(0u, external.CurrentContainerId);
Assert.Empty(items.GetContents(0x70000001u));
Assert.NotNull(items.Get(0x60000001u));
Assert.Empty(items.GetContents(0x70000002u));
}
private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids) private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids)
{ {
byte[] payload = new byte[8 + itemGuids.Length * 8]; byte[] payload = new byte[8 + itemGuids.Length * 8];

View file

@ -46,6 +46,41 @@ public sealed class ClientObjectTableTests
Assert.Equal(1, propUpdateCount); Assert.Equal(1, propUpdateCount);
} }
[Fact]
public void StopViewingContents_RemovesOnlyTemporaryProjection()
{
var repo = new ClientObjectTable();
ClientObject item = MakeItem(100u);
item.ContainerId = 77u;
repo.AddOrUpdate(item);
repo.ReplaceContents(77u, new uint[] { 100u });
uint? changed = null;
repo.ContainerContentsReplaced += id => changed = id;
Assert.True(repo.StopViewingContents(77u));
Assert.Empty(repo.GetContents(77u));
Assert.Same(item, repo.Get(100u));
Assert.Equal(77u, item.ContainerId);
Assert.Equal(77u, changed);
Assert.False(repo.StopViewingContents(77u));
}
[Fact]
public void StopViewingContentsTree_RemovesNestedSnapshotsButPreservesObjects()
{
var repo = new ClientObjectTable();
repo.ReplaceContents(77u, new[] { new ContainerContentEntry(88u, 1u) });
repo.ReplaceContents(88u, new[] { new ContainerContentEntry(100u, 0u) });
Assert.Equal(2, repo.StopViewingContentsTree(77u));
Assert.Empty(repo.GetContents(77u));
Assert.Empty(repo.GetContents(88u));
Assert.NotNull(repo.Get(88u));
Assert.NotNull(repo.Get(100u));
}
[Fact] [Fact]
public void MoveItem_UpdatesContainerAndFiresEvent() public void MoveItem_UpdatesContainerAndFiresEvent()
{ {

View file

@ -0,0 +1,70 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ExternalContainerStateTests
{
[Fact]
public void ViewContents_onlyCommitsTheExpectedRoot()
{
var state = new ExternalContainerState();
state.RequestOpen(0x70000001u);
Assert.False(state.ApplyViewContents(0x70000002u));
Assert.Equal(0u, state.CurrentContainerId);
Assert.True(state.ApplyViewContents(0x70000001u));
Assert.Equal(0x70000001u, state.CurrentContainerId);
}
[Fact]
public void ReplacementPublishesOldAndNewExactlyOnce()
{
var state = new ExternalContainerState();
var changes = new List<ExternalContainerTransition>();
state.Changed += changes.Add;
state.RequestOpen(1u);
state.ApplyViewContents(1u);
state.RequestOpen(2u);
state.ApplyViewContents(2u);
state.ApplyViewContents(2u);
Assert.Equal(2, changes.Count);
Assert.Equal(ExternalContainerTransitionKind.Opened, changes[0].Kind);
Assert.Equal(
new ExternalContainerTransition(
ExternalContainerTransitionKind.Replaced, 1u, 2u),
changes[1]);
}
[Fact]
public void CloseIgnoresStaleContainerAndClosesCurrent()
{
var state = Open(2u);
Assert.False(state.ApplyClose(1u));
Assert.Equal(2u, state.CurrentContainerId);
Assert.True(state.ApplyClose(2u));
Assert.Equal(0u, state.CurrentContainerId);
Assert.Equal(0u, state.RequestedContainerId);
}
[Fact]
public void RefusedReplacementRestoresCurrentAsExpectedRoot()
{
var state = Open(1u);
state.RequestOpen(2u);
Assert.True(state.ApplyUseDone(0x550u));
Assert.Equal(1u, state.CurrentContainerId);
Assert.Equal(1u, state.RequestedContainerId);
Assert.False(state.ApplyViewContents(2u));
}
private static ExternalContainerState Open(uint id)
{
var state = new ExternalContainerState();
state.RequestOpen(id);
state.ApplyViewContents(id);
return state;
}
}