From 20ce67b625d94c14fb07607fa0ba2bd721477e2c Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Jul 2026 16:18:10 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 14 +- docs/architecture/acdream-architecture.md | 8 + .../retail-divergence-register.md | 1 - docs/plans/2026-04-11-roadmap.md | 1 + ...l-external-container-looting-pseudocode.md | 168 ++++++ src/AcDream.App/Rendering/GameWindow.cs | 78 ++- .../UI/ItemInteractionController.cs | 26 +- .../UI/Layout/ExternalContainerController.cs | 514 ++++++++++++++++++ .../UI/Layout/InventoryController.cs | 20 +- src/AcDream.App/UI/RetailUiRuntime.cs | 103 +++- src/AcDream.App/UI/UiItemList.cs | 39 ++ src/AcDream.App/UI/UiScrollbar.cs | 77 +++ src/AcDream.App/UI/WindowNames.cs | 1 + .../ExternalContainerLifecycleController.cs | 51 ++ src/AcDream.Core.Net/GameEventWiring.cs | 14 +- src/AcDream.Core/Items/ClientObjectTable.cs | 50 ++ .../Items/ExternalContainerState.cs | 98 ++++ src/AcDream.Core/Selection/SelectionState.cs | 1 + .../UI/ItemInteractionControllerTests.cs | 58 +- .../ExternalContainerControllerTests.cs | 203 +++++++ .../UI/Layout/InventoryControllerTests.cs | 19 +- tests/AcDream.App.Tests/UI/UiItemListTests.cs | 32 ++ .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 25 + ...ternalContainerLifecycleControllerTests.cs | 50 ++ .../GameEventWiringTests.cs | 39 ++ .../Items/ClientObjectTableTests.cs | 35 ++ .../Items/ExternalContainerStateTests.cs | 70 +++ 27 files changed, 1750 insertions(+), 45 deletions(-) create mode 100644 docs/research/2026-07-17-retail-external-container-looting-pseudocode.md create mode 100644 src/AcDream.App/UI/Layout/ExternalContainerController.cs create mode 100644 src/AcDream.App/World/ExternalContainerLifecycleController.cs create mode 100644 src/AcDream.Core/Items/ExternalContainerState.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs create mode 100644 tests/AcDream.App.Tests/World/ExternalContainerLifecycleControllerTests.cs create mode 100644 tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 94cc5bf3..6936b421 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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. --- diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 62f57d6d..bd802be8 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 53907bfd..beff1c98 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index a9fe8424..93911858 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger` with a Console-backed `ConsoleErrorLogger` 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(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(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 ✓ | diff --git a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md new file mode 100644 index 00000000..922c1c0f --- /dev/null +++ b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3c98c528..9f527902 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 135ad8b1..11a6b93d 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -30,6 +30,8 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _sendWield; private readonly Action? _sendDrop; private readonly Action? _sendSplitToWorld; + private readonly Action? _sendPutItemInContainer; + private readonly Action? _sendSplitToContainer; private readonly Action? _sendGive; private readonly Action? _toast; private readonly Func _readyForInventoryRequest; @@ -39,6 +41,7 @@ public sealed class ItemInteractionController : IDisposable private readonly Func _inNonCombatMode; private readonly Func _isComponentPack; private readonly Action? _placeInBackpack; + private readonly Action? _requestExternalContainer; private readonly Action? _auxiliaryAction; private readonly InteractionState _interactionState; private readonly Func _selectedObjectId; @@ -78,7 +81,9 @@ public sealed class ItemInteractionController : IDisposable Action? sendPutItemInContainer = null, Action? sendGive = null, Func? dragOnPlayerOpensSecureTrade = null, - Action? systemMessage = null) + Action? systemMessage = null, + Action? sendSplitToContainer = null, + Action? 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); diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs new file mode 100644 index 00000000..5036241c --- /dev/null +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -0,0 +1,514 @@ +using System; +using AcDream.Core.Items; +using AcDream.Core.Selection; + +namespace AcDream.App.UI.Layout; + +/// +/// Retail gmExternalContainerUI controller for LayoutDesc 0x21000008. +/// Chests, corpses, and other world containers share this bottom-screen strip; +/// it is intentionally independent from the owned backpack window. +/// +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 _resolveIcon; + private readonly Func _resolveDragIcon; + private readonly Action _sendUse; + private readonly Action _sendPutItemInContainer; + private readonly Action _sendSplitToContainer; + private readonly Func _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 resolveIcon, + Func resolveDragIcon, + Action sendUse, + Action sendPutItemInContainer, + Action sendSplitToContainer, + Func 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 resolveIcon, + Func resolveDragIcon, + Action sendUse, + Action sendPutItemInContainer, + Action sendSplitToContainer, + Func 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; + } +} diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 20cc80f3..2fb177d0 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -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? _sendUse; - private readonly Action? _sendNoLongerViewing; private readonly Action? _sendPutItemInContainer; // (item, container, placement) private readonly Action? _sendStackableSplitToContainer; private readonly Action? _sendStackableMerge; @@ -84,7 +83,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo uint sideBagEmptySprite, uint mainPackEmptySprite, Action? sendUse, - Action? sendNoLongerViewing, Action? sendPutItemInContainer, Action? sendStackableSplitToContainer, Action? 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? sendUse = null, - Action? sendNoLongerViewing = null, Action? sendPutItemInContainer = null, Action? sendStackableSplitToContainer = null, Action? 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 } /// 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. 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) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 5eede127..91a7c688 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -118,13 +118,24 @@ public sealed record InventoryRuntimeBindings( Func ResolveDragIcon, Func Strength, Action? SendUse, - Action? SendNoLongerViewing, Action? SendPutItemInContainer, Action? SendStackableSplitToContainer, Action? SendStackableMerge, ItemInteractionController ItemInteraction, SelectionState Selection); +public sealed record ExternalContainerRuntimeBindings( + ExternalContainerState State, + ClientObjectTable Objects, + Func ResolveIcon, + Func ResolveDragIcon, + ItemInteractionController ItemInteraction, + SelectionState Selection, + Action SendUse, + Action SendPutItemInContainer, + Action SendStackableSplitToContainer, + Func IsWithinUseRange); + public sealed record RetailUiPersistenceBindings( SettingsStore Store, Func 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; diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index b4b7ab7a..a2d0fa0d 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -140,6 +140,13 @@ public sealed class UiItemList : UiElement /// public bool SingleRow { get; set; } + /// + /// 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. + /// + public bool HorizontalScroll { get; set; } + /// /// 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)) diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index e1cef44b..8f65ba5d 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -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 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); + } + } + /// Draw a sprite stretched 1:1 to the dest rect. private void DrawSprite(UiRenderContext ctx, Func 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) diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index b5acacef..6d3d024e 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -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"; diff --git a/src/AcDream.App/World/ExternalContainerLifecycleController.cs b/src/AcDream.App/World/ExternalContainerLifecycleController.cs new file mode 100644 index 00000000..bd526db2 --- /dev/null +++ b/src/AcDream.App/World/ExternalContainerLifecycleController.cs @@ -0,0 +1,51 @@ +using AcDream.Core.Items; + +namespace AcDream.App.World; + +/// +/// 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. +/// +public sealed class ExternalContainerLifecycleController : IDisposable +{ + private readonly ExternalContainerState _state; + private readonly ClientObjectTable _objects; + private readonly Action _sendNoLongerViewingContents; + private bool _disposed; + + public ExternalContainerLifecycleController( + ExternalContainerState state, + ClientObjectTable objects, + Action 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; + } +} diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index dbc2e59a..fba3279a 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -79,7 +79,8 @@ public static class GameEventWiring SquelchState? squelch = null, Action>? onDesiredComponents = null, Action? onCharacterOptions = null, - Func? clientTime = null) + Func? 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 => diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index a103fcc5..86526f9e 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -1026,6 +1026,56 @@ public sealed class ClientObjectTable ContainerContentsReplaced?.Invoke(containerId); } + /// + /// Retires a viewed container's temporary ordered projection without inventing + /// item movement or deleting its child objects. Retail + /// ACCObjectMaint::StopViewingObjectContents is driven by server event + /// CloseGroundContainer; canonical ownership remains owned by move/delete wire. + /// + public bool StopViewingContents(uint containerId) + { + if (containerId == 0u || !_containerIndex.Remove(containerId)) + return false; + ContainerContentsReplaced?.Invoke(containerId); + return true; + } + + /// + /// 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. + /// + public int StopViewingContentsTree(uint rootContainerId) + { + if (rootContainerId == 0u) + return 0; + + var pending = new Stack(); + var visited = new HashSet(); + var removed = new List(); + pending.Push(rootContainerId); + while (pending.Count != 0) + { + uint containerId = pending.Pop(); + if (!visited.Add(containerId) + || !_containerIndex.TryGetValue(containerId, out List? 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; + } + /// /// Initialize canonical inventory ownership from PlayerDescription. This /// login manifest is object-state initialization, not a move transition. diff --git a/src/AcDream.Core/Items/ExternalContainerState.cs b/src/AcDream.Core/Items/ExternalContainerState.cs new file mode 100644 index 00000000..eb6ec4e7 --- /dev/null +++ b/src/AcDream.Core/Items/ExternalContainerState.cs @@ -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); + +/// +/// Owns retail ClientUISystem::groundObject 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: ClientUISystem::OnViewContents @ 0x00565870 and +/// SetGroundObject @ 0x00564510. +/// +public sealed class ExternalContainerState +{ + public uint RequestedContainerId { get; private set; } + public uint CurrentContainerId { get; private set; } + + public event Action? 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; + } +} diff --git a/src/AcDream.Core/Selection/SelectionState.cs b/src/AcDream.Core/Selection/SelectionState.cs index bfc28f43..1bef0d44 100644 --- a/src/AcDream.Core/Selection/SelectionState.cs +++ b/src/AcDream.Core/Selection/SelectionState.cs @@ -8,6 +8,7 @@ public enum SelectionChangeSource World, Radar, Inventory, + ExternalContainer, Paperdoll, Toolbar, Keyboard, diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index b212e789..64222de8 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -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 ExternalRequests = new(); public readonly List 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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs new file mode 100644 index 00000000..e10b1eb4 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs @@ -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 Uses = new(); + public readonly List NoLonger = new(); + public readonly List 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 + { + [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(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); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 729b6a78..b887ed4b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -54,7 +54,7 @@ public class InventoryControllerTests } private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100, List? uses = null, List? closes = null, + int? strength = 100, List? 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(); - var closes = new List(); - 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(); - var closes = new List(); - 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(); - var closes = new List(); - 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] diff --git a/tests/AcDream.App.Tests/UI/UiItemListTests.cs b/tests/AcDream.App.Tests/UI/UiItemListTests.cs index a03925d9..344c2605 100644 --- a/tests/AcDream.App.Tests/UI/UiItemListTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemListTests.cs @@ -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); + } } diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index b77c44ef..1e9ae1a6 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -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)] diff --git a/tests/AcDream.App.Tests/World/ExternalContainerLifecycleControllerTests.cs b/tests/AcDream.App.Tests/World/ExternalContainerLifecycleControllerTests.cs new file mode 100644 index 00000000..b7b66bd7 --- /dev/null +++ b/tests/AcDream.App.Tests/World/ExternalContainerLifecycleControllerTests.cs @@ -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(); + 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(); + 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)); + } +} diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 6e93239d..46b44cce 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -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]; diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 2d167fbf..d4517cf1 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs new file mode 100644 index 00000000..9af8d2fb --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs @@ -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(); + 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; + } +}