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
**Filed:** 2026-07-02
**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
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
`IRetainedPanelController` lifecycle owner. Multi-controller windows use
`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-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-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-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` |

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.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+ 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 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 ✓ |

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.CombatTargetController? _combatTargetController;
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.MagicCatalog? _magicCatalog;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
@ -2110,6 +2112,10 @@ public sealed class GameWindow : IDisposable
_selection,
autoTarget: () => _persistedGameplay.AutoTarget,
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).
if (_options.RetailUi)
@ -2146,7 +2152,11 @@ public sealed class GameWindow : IDisposable
stackSplitQuantity: _stackSplitQuantity,
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
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(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
@ -2358,7 +2368,6 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
guid => _liveSession?.SendUse(guid),
guid => _liveSession?.SendNoLongerViewingContents(guid),
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
@ -2368,6 +2377,22 @@ public sealed class GameWindow : IDisposable
_liveSession?.SendStackableMerge(source, target, amount),
_itemInteractionController,
_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(
cursorFeedbackController,
retailCursorManager),
@ -2863,6 +2888,7 @@ public sealed class GameWindow : IDisposable
// Attachment projections own GL-backed world registrations, so tear
// them down before dropping the canonical GUID/timestamp snapshots.
_equippedChildRenderer?.Clear();
_externalContainers.Reset();
Objects.Clear();
SpellBook.Clear();
_magicRuntime?.Reset();
@ -3025,7 +3051,11 @@ public sealed class GameWindow : IDisposable
},
onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid,
onUseDone: error => _itemInteractionController?.CompleteUse(error),
onUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
itemMana: ItemMana,
onConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
@ -3038,7 +3068,8 @@ public sealed class GameWindow : IDisposable
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
clientTime: ClientTimerNow);
clientTime: ClientTimerNow,
externalContainers: _externalContainers);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
@ -13266,6 +13297,9 @@ public sealed class GameWindow : IDisposable
return;
}
if (IsExternalContainerTarget(guid))
_externalContainers.RequestOpen(guid);
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
// through the player's MoveToManager so close-range Use rotates the
// 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;
}
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)
{
if (_playerController is not { } pc || pc.MoveTo is null) return;
@ -14784,6 +14852,8 @@ public sealed class GameWindow : IDisposable
_uiHost = null;
_itemInteractionController?.Dispose();
_itemInteractionController = null;
_externalContainerLifecycle?.Dispose();
_externalContainerLifecycle = null;
_magicRuntime = null;
_magicCatalog = null;

View file

@ -30,6 +30,8 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
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<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
@ -39,6 +41,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Func<bool> _inNonCombatMode;
private readonly Func<uint, bool> _isComponentPack;
private readonly Action<uint>? _placeInBackpack;
private readonly Action<uint>? _requestExternalContainer;
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState;
private readonly Func<uint> _selectedObjectId;
@ -78,7 +81,9 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendGive = 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));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -88,6 +93,8 @@ public sealed class ItemInteractionController : IDisposable
_sendWield = sendWield;
_sendDrop = sendDrop;
_sendSplitToWorld = sendSplitToWorld;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
@ -98,6 +105,7 @@ public sealed class ItemInteractionController : IDisposable
_inNonCombatMode = inNonCombatMode ?? (() => false);
_isComponentPack = isComponentPack ?? (_ => false);
_placeInBackpack = placeInBackpack;
_requestExternalContainer = requestExternalContainer;
_auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_stackSplitQuantity = stackSplitQuantity;
@ -416,6 +424,10 @@ public sealed class ItemInteractionController : IDisposable
_sendUseWithTarget?.Invoke(action.ObjectId, action.TargetId);
acted |= _sendUseWithTarget is not null;
break;
case ItemPolicyActionKind.SetGroundObject:
_requestExternalContainer?.Invoke(action.ObjectId);
acted |= _requestExternalContainer is not null;
break;
case ItemPolicyActionKind.EnterTargetMode:
EnterTargetMode(action.ObjectId);
acted = true;
@ -464,6 +476,18 @@ public sealed class ItemInteractionController : IDisposable
action.ObjectId,
(uint)Math.Max(1, action.Amount));
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:
if (!string.IsNullOrWhiteSpace(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 readonly SelectionState _selection;
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, uint, uint>? _sendStackableSplitToContainer;
private readonly Action<uint, uint, uint>? _sendStackableMerge;
@ -84,7 +83,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint sideBagEmptySprite,
uint mainPackEmptySprite,
Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer,
Action<uint, uint, uint, uint>? sendStackableSplitToContainer,
Action<uint, uint, uint>? sendStackableMerge,
@ -100,7 +98,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_strength = strength;
_ownerName = ownerName;
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_sendStackableSplitToContainer = sendStackableSplitToContainer;
_sendStackableMerge = sendStackableMerge;
@ -212,7 +209,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint sideBagEmptySprite = 0u,
uint mainPackEmptySprite = 0u,
Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint, uint>? sendStackableSplitToContainer = 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,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer,
sendUse, sendPutItemInContainer,
sendStackableSplitToContainer, sendStackableMerge,
notifyMergeAttempt, itemInteraction,
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);
}
@ -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
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and
/// NoLongerViewingContents when switching away from a previously-open side bag. Retail:
/// open container. Sends Use to open a side bag server-side (ViewContents arrives async).
/// Owned packs are not ClientUISystem::groundObject and never send
/// NoLongerViewingContents. Retail:
/// gmBackpackUI container-selection + CM_Physics::Event_Use.</summary>
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
uint p = _playerGuid();
if (open != p && open != 0)
_sendNoLongerViewing?.Invoke(open); // close the previously-open side bag
_openContainer = guid;
if (guid != p)
_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<int?> Strength,
Action<uint>? SendUse,
Action<uint>? SendNoLongerViewing,
Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
Action<uint, uint, uint>? SendStackableMerge,
ItemInteractionController ItemInteraction,
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(
SettingsStore Store,
Func<string> CharacterKey,
@ -157,6 +168,7 @@ public sealed record RetailUiRuntimeBindings(
ToolbarRuntimeBindings Toolbar,
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
ExternalContainerRuntimeBindings ExternalContainer,
RetailUiCursorBindings Cursor,
ConfirmationRuntimeBindings Confirmations,
StackSplitQuantityState StackSplitQuantity,
@ -204,6 +216,7 @@ public sealed class RetailUiRuntime : IDisposable
MountCharacter();
MountPlugins();
MountInventory();
MountExternalContainer();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
@ -215,7 +228,12 @@ public sealed class RetailUiRuntime : IDisposable
persistence.Store,
persistence.CharacterKey,
persistence.ScreenSize,
stateManagedVisibilityWindows: [WindowNames.Combat, WindowNames.JumpPowerbar]);
stateManagedVisibilityWindows:
[
WindowNames.Combat,
WindowNames.JumpPowerbar,
WindowNames.ExternalContainer,
]);
}
if (bindings.Probe.Enabled)
@ -257,6 +275,7 @@ public sealed class RetailUiRuntime : IDisposable
public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; }
public RetailDialogFactory? DialogFactory { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -283,6 +302,7 @@ public sealed class RetailUiRuntime : IDisposable
IndicatorBarController?.Tick();
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
@ -1577,7 +1597,7 @@ public sealed class RetailUiRuntime : IDisposable
InventoryController inventory = InventoryController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
_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,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory),
@ -1596,6 +1616,83 @@ public sealed class RetailUiRuntime : IDisposable
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()
{
if (_disposed) return;

View file

@ -140,6 +140,13 @@ public sealed class UiItemList : UiElement
/// </summary>
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>
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
@ -206,6 +213,28 @@ public sealed class UiItemList : UiElement
: Columns < 1 ? 1 : Columns;
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
// the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY
// so the clamp uses the right max.
@ -292,6 +321,16 @@ public sealed class UiItemList : UiElement
public void ScrollItemIntoView(int index)
{
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 row = Flow == UiItemListFlow.ColumnMajor
? 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 (Horizontal)
{
if (ScalarChanged is null && Model is { } horizontalModel)
{
DrawHorizontalModel(ctx, resolve, horizontalModel);
return;
}
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
(float rangeLeft, float rangeWidth) = ScalarRangeRect();
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>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
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)
return OnScalarEvent(e);
if (Horizontal && Model is not null)
return OnHorizontalModelEvent(e);
if (Model is not { } m) return false;
switch (e.Type)
@ -318,6 +351,50 @@ public sealed class UiScrollbar : UiElement
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)
{
switch (e.Type)

View file

@ -9,6 +9,7 @@ public static class WindowNames
public const string Character = "character";
public const string CharacterInformation = "character-information";
public const string Inventory = "inventory";
public const string ExternalContainer = "external-container";
public const string Chat = "chat";
public const string Radar = "radar";
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,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
Func<double>? clientTime = null)
Func<double>? clientTime = null,
ExternalContainerState? externalContainers = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -364,6 +365,7 @@ public static class GameEventWiring
p.Value.Items[i].Guid,
p.Value.Items[i].ContainerType);
items.ReplaceContents(p.Value.ContainerGuid, entries);
externalContainers?.ApplyViewContents(p.Value.ContainerGuid);
});
// 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);
});
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
// view. No table change (the view is UI-only, wired in container-open); log.
// CloseGroundContainer (0x0052): clear ClientUISystem::groundObject and
// retire the root plus nested temporary ViewContents projections. Child
// objects remain until authoritative move/delete wire says otherwise.
dispatcher.Register(GameEventType.CloseGroundContainer, e =>
{
var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span);
if (guid is not null)
Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}");
if (guid is null) return;
externalContainers?.ApplyClose(guid.Value);
items.StopViewingContentsTree(guid.Value);
});
dispatcher.Register(GameEventType.IdentifyObjectResponse, e =>

View file

@ -1026,6 +1026,56 @@ public sealed class ClientObjectTable
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>
/// Initialize canonical inventory ownership from PlayerDescription. This
/// 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,
Radar,
Inventory,
ExternalContainer,
Paperdoll,
Toolbar,
Keyboard,

View file

@ -19,6 +19,8 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Source, uint Target)> UseWithTarget = 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, uint Placement, uint Amount)> SplitPuts = new();
public readonly List<uint> ExternalRequests = new();
public readonly List<uint> Drops = new();
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
@ -28,6 +30,7 @@ public sealed class ItemInteractionControllerTests
public uint SelectedObject;
public bool NonCombatMode;
public bool DragOnPlayerOpensSecureTrade = true;
public uint GroundObject;
public long Now = 1_000;
public Harness()
@ -61,11 +64,19 @@ public sealed class ItemInteractionControllerTests
selectedObjectId: () => SelectedObject,
stackSplitQuantity: SplitQuantity,
inNonCombatMode: () => NonCombatMode,
groundObjectId: () => GroundObject,
sendPutItemInContainer: (item, container, placement) =>
Puts.Add((item, container, placement)),
sendGive: (target, item, amount) => Gives.Add((target, item, amount)),
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; }
@ -103,6 +114,51 @@ public sealed class ItemInteractionControllerTests
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]
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,
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, uint placement, uint amount)>? splits = null,
List<(uint source, uint target, uint amount)>? merges = null,
@ -68,7 +68,6 @@ public class InventoryControllerTests
strength: () => strength, datFont: null,
ownerName: ownerName is null ? null : () => ownerName,
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)),
sendStackableSplitToContainer: splits is null
? null
@ -410,7 +409,7 @@ public class InventoryControllerTests
}
[Fact]
public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack()
public void ClickMainPackCell_afterBag_returnsToMainPack_withoutExternalCloseWire()
{
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
@ -418,34 +417,30 @@ public class InventoryControllerTests
SeedBag(objects, 0xC, slot: 1);
SeedContained(objects, 0xB1, 0xC, slot: 0);
var uses = new List<uint>();
var closes = new List<uint>();
var ctrl = Bind(layout, objects, uses: uses, closes: closes);
var ctrl = Bind(layout, objects, uses: uses);
containers.GetItem(0)!.Clicked!(); // open the bag
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.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack
Assert.Equal(102, grid.GetNumUIItems());
}
[Fact]
public void SwitchBetweenTwoBags_closesPrevious_opensNext()
public void SwitchBetweenTwoBags_opensEach_withoutExternalCloseWire()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xC1, slot: 0);
SeedBag(objects, 0xC2, slot: 1);
var uses = new List<uint>();
var closes = new List<uint>();
Bind(layout, objects, uses: uses, closes: closes);
Bind(layout, objects, uses: uses);
containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1)
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 }, closes.ToArray()); // close A when switching to B (not the main pack)
}
[Fact]
@ -469,15 +464,13 @@ public class InventoryControllerTests
var objects = new ClientObjectTable();
SeedContained(objects, 0xA, Player, slot: 0);
var uses = new List<uint>();
var closes = new List<uint>();
Bind(layout, objects, uses: uses, closes: closes);
Bind(layout, objects, uses: uses);
grid.GetItem(0)!.Clicked!(); // select the loose 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.Empty(uses); // selection sends no wire
Assert.Empty(closes);
}
[Fact]

View file

@ -114,4 +114,36 @@ public class UiItemListTests
list.ScrollItemIntoView(0);
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)));
}
[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]
[InlineData(0f, 0f, 0f)]
[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);
}
[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)
{
byte[] payload = new byte[8 + itemGuids.Length * 8];

View file

@ -46,6 +46,41 @@ public sealed class ClientObjectTableTests
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]
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;
}
}