fix(items): complete retail corpse looting feedback

Route corpse Use through the shared ItemHolder policy so Stuck corpses open instead of being sent as pickup requests. Restore the framed, horizontally resizable external-container strip and use a compact initial width. Port retail's target-list pending item projection so loot is marked in the chosen inventory slot without changing canonical ownership before the server confirms.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 16:53:49 +02:00
parent 20ce67b625
commit d51a0fc825
13 changed files with 275 additions and 102 deletions

View file

@ -2288,13 +2288,13 @@ R5/R6 touches the action list. **Where:**
**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`.
**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.
**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. The live-gate correction adds the shared outer bevel, X-only resizing, compact opening width, shared `ItemHolder::UseObject` routing for corpses, and retail's destination-list `m_pendingItem` waiting projection at the chosen loot slot. Owned inventory no longer exposes any `0x0195` capability.
**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-17-retail-external-container-looting-pseudocode.md`.
**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.
**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; corpse Use never attempts to pick up the corpse; a full-stack loot drop immediately shows a ghosted pending copy at the chosen destination slot and confirmation/failure clears it; canonical transfers remain server-authoritative. Focused Core, Core.Net, and App tests pin each path; visual acceptance remains the normal user gate.
---

View file

@ -53,7 +53,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). Synthetic wrapper borders/whole-window drag regions use the exact DAT Type-2/Type-9 control cursors. `gmPanelUI` children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical geometry and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from retail UI deep-dives, named client methods, and production LayoutDesc media. Separate child wrappers preserve each LayoutDesc's content tree while typed move/resize synchronization gives all registered toolbar/detail children the same persistent parent rectangle | Edge-case low-level input semantics can differ silently even though outer geometry, visibility, and restore-previous ownership match | `UIElementManager::CheckCursor` 0x0045ABF0; `UIElement_Resizebar::StartMouseResizing` 0x0046B7E0; `UIElement_Dragbar::StartMouseMoving` 0x0046C760; `gmPanelUI::SetupChildren` 0x004BC9E0; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |

View file

@ -133,7 +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 |
| 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, shared outer bevel with horizontal-only resizing, nested-container browsing, double-click loot, drag return, partial-stack requests, and `UIElement_ItemList::m_pendingItem` destination-slot feedback. Corpse keyboard Use now shares `ItemHolder::UseObject` policy and cannot issue a pickup for the BF_STUCK corpse itself. 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 ✓; corrective live visual gate pending |
| Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ |
| Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task<ObjectMeshData?>` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger<ObjectMeshManager>` with a Console-backed `ConsoleErrorLogger<T>` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet<Setup>(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |

View file

@ -45,6 +45,12 @@ Cross-references:
The strip is LayoutDesc `0x21000008`, selected root `0x10000063`
(`0,400`, `800 x 110`) rather than a synthetic inventory window.
The root's own DirectState is only the tiled center surface `0x06004CC2`; the
common floating-window nine-slice supplies the missing outer bevel. The authored
Left/Right edge rules stretch the two horizontal lists and scrollbar while
pinning the close button, so the mounted strip resizes horizontally only. The
modern retained mount starts at a compact 700-pixel content width and may be
resized from either side up to the viewport edge.
| Element | Purpose | Authored bounds |
|---|---|---|
@ -67,6 +73,12 @@ UseWorldObject(objectId):
expectedExternalRoot = objectId
send Use(objectId)
UseCorpse(corpseId):
// A corpse is a TYPE_CONTAINER carrying BF_STUCK | BF_CORPSE.
// BF_STUCK excludes DetermineUseResult's PlaceInBackpack branch.
classify through ItemHolder::UseObject
send Use(corpseId), never PutItemInContainer(corpseId)
OnViewContents(containerId, orderedEntries):
// Always update the projection: ACE sends nested-container snapshots too.
objectTable.ReplaceContents(containerId, orderedEntries)
@ -132,9 +144,21 @@ DragExternalItemToOwnedInventory(itemId, placement, splitAmount):
send StackableSplitToContainer(itemId, player/openOwnedBag,
placement, splitAmount)
else:
destinationList.InsertItem(itemId, placement)
destinationList.pendingItem.SetWaitingState(true)
send PutItemInContainer(itemId, player/openOwnedBag, placement)
do not mutate canonical ownership before the server response
OnServerMoveItem(itemId):
if destinationList.pendingItem.itemId == itemId:
clear pendingItem
rebuild the list from authoritative placement
OnInventoryServerSaveFailed(itemId):
if destinationList.pendingItem.itemId == itemId:
remove the pending copy
leave the source ownership unchanged
DragOwnedItemToExternalContainer(itemId, placement, splitAmount):
if splitAmount < full stack:
send StackableSplitToContainer(itemId, currentExternalSubcontainer,
@ -157,6 +181,10 @@ DragOwnedItemToExternalContainer(itemId, placement, splitAmount):
projection owner.
- `ExternalContainerController` owns the authored strip, range/close behavior,
nested-list selection, selection, and drag/drop presentation.
- `InventoryController` owns retail `UIElement_ItemList::m_pendingItem`: a
destination-only waiting projection at the exact chosen slot. It subscribes
to authoritative move and failure notices and never rewrites external-item
ownership while the request is in flight.
- `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.

View file

@ -13232,50 +13232,11 @@ public sealed class GameWindow : IDisposable
return;
}
// 2026-05-16 (Phase B.6 follow-up) — R is the universal "interact"
// key. Retail dispatches by TARGET TYPE first; the useability gate
// is enforced by each individual action handler (SendUse checks
// IsUseableTarget; SendPickUp checks IsPickupableTarget), not as
// a top-level block. Previously the IsUseableTarget gate at the
// entry point rejected USEABLE_NO=1 items (spell components,
// gems) which retail accepts as pickupable — they just aren't
// "useable" in the activate-from-world sense.
//
// Dispatch order:
// 1. Creature → SendUse (talk to NPC, attack monster)
// 2. Pickupable → SendPickUp (small items, corpses)
// 3. Useable → SendUse (doors, portals, lifestones,
// potions / scrolls activated from world)
// 4. Else → toast "X cannot be used" (signs, banners,
// decorative scenery)
//
// Retail string at acclient_2013_pseudo_c.txt:1033115
// (data_7e2a70): "The %s cannot be used".
bool isCreature = (LiveItemType(sel) & AcDream.Core.Items.ItemType.Creature) != 0;
if (isCreature)
{
SendUse(sel);
return;
}
if (IsPickupableTarget(sel))
{
SendPickUp(sel);
return;
}
if (IsUseableTarget(sel))
{
SendUse(sel);
return;
}
string label = DescribeLiveEntity(sel);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] R-key ignored — neither pickupable nor useable guid=0x{sel:X8}");
// Retail sends keyboard Use through ItemHolder::UseObject @ 0x00588A80,
// the same policy owner used by the toolbar. Keeping a second classifier here
// made BF_CORPSE containers look pickupable and sent PutItemInContainer,
// which ACE correctly rejected as Stuck (0x0029).
_itemInteractionController?.UseSelectedOrEnterMode(sel);
}
private void SendUse(uint guid)
@ -13896,29 +13857,6 @@ public sealed class GameWindow : IDisposable
return false;
}
/// <summary>
/// 2026-05-16. Retail-faithful gate for F-key PickUp / right-click
/// "Pick Up." Distinct from <see cref="IsUseableTarget"/> because
/// pickup is more restrictive than Use: the entity must be useable
/// FROM THE WORLD (USEABLE_REMOTE bit, 0x20). Signs / banners with
/// USEABLE_NO (0x1) lack the REMOTE bit so pickup is blocked
/// client-side without a wire packet — matches retail's "The X can't
/// be picked up!" client-side toast.
///
/// <para>
/// Useable values that include USEABLE_REMOTE (0x20):
/// USEABLE_REMOTE (0x20), USEABLE_REMOTE_NEVER_WALK (0x60),
/// USEABLE_VIEWED_REMOTE (0x30), and the SOURCE_*_TARGET_REMOTE
/// composites in the 0x200000+ range.
/// </para>
///
/// <para>
/// Null-useability fallback: same as <see cref="IsUseableTarget"/>
/// — permit pickup for entities with BF_CORPSE bit set, and for
/// items with small-item ItemType. This preserves M1 ground-item
/// pickup flow for entities where ACE didn't publish useability.
/// </para>
/// </summary>
/// <summary>
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
///
@ -13935,7 +13873,7 @@ public sealed class GameWindow : IDisposable
/// </para>
///
/// <para>
/// Now matches retail: small-item ItemType class OR BF_CORPSE bit
/// Now matches retail: a non-Stuck small-item ItemType class
/// → pickupable. Server validates the request server-side
/// (in-range, target-still-exists, container-has-room).
/// </para>
@ -13961,11 +13899,9 @@ public sealed class GameWindow : IDisposable
if (spawn.ObjectDescriptionFlags is { } odf)
{
const uint BF_STUCK = 0x0004u;
const uint BF_CORPSE = 0x2000u;
// Corpses are pickupable (loot) — BF_CORPSE wins over
// any BF_STUCK that might be coincidentally set.
if ((odf & BF_CORPSE) != 0u) return true;
// Anything else with BF_STUCK is immovable scenery.
// Corpses are BF_STUCK containers: Use opens their contents; the
// corpse object itself is never picked up. ItemHolder::DetermineUseResult
// @ 0x00588460 excludes Stuck objects from PlaceInBackpack.
if ((odf & BF_STUCK) != 0u) return false;
}

View file

@ -21,6 +21,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private const float ItemCellSize = 32f;
private const float ContainerCellSize = 36f;
// User-directed retained default: the raw 800-pixel LayoutDesc remains
// horizontally reachable by resize/persistence but opens less wide.
internal const float DefaultContentWidth = 700f;
internal const float MinimumContentWidth = 160f;
private readonly ExternalContainerState _state;
private readonly ClientObjectTable _objects;
@ -144,6 +148,32 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
contentsEmptySprite,
containerEmptySprite);
/// <summary>
/// Retail's root authors only the tiled center surface; the common floating-window
/// bevel surrounds it. Its Left/Right edge rules stretch every horizontal list and
/// pin the close button, so this strip resizes on X only.
/// </summary>
internal static RetailWindowFrame.Options CreateWindowOptions(UiElement root)
=> new()
{
WindowName = WindowNames.ExternalContainer,
Chrome = RetailWindowChrome.NineSlice,
Left = root.Left,
Top = root.Top,
ContentWidth = Math.Min(root.Width, DefaultContentWidth),
ContentHeight = root.Height,
MinWidth = MinimumContentWidth + 2f * RetailChromeSprites.Border,
Visible = false,
Draggable = true,
Resizable = true,
ResizeX = true,
ResizeY = false,
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
DrawChromeCenter = false,
};
public void Tick()
{
uint root = _state.CurrentContainerId;

View file

@ -63,8 +63,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly Action<uint, uint>? _notifyMergeAttempt;
private readonly ItemInteractionController? _itemInteraction;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private PendingListPlacement? _pendingListPlacement;
private bool _disposed;
private readonly record struct PendingListPlacement(
uint ItemId,
uint ContainerId,
int Placement);
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
private const uint EncumbranceValProperty = 5u; // total carried burden
private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation
@ -167,6 +173,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Rebuild on any change to the player's possessions.
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.MoveRequestFailed += OnMoveRequestFailed;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectUpdated += OnObjectChanged;
@ -225,9 +232,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectChanged(ClientObject o)
{
if (Concerns(o) || _pendingListPlacement?.ItemId == o.ObjectId)
Populate();
}
private void OnObjectRemoved(ClientObject o)
{
if (_pendingListPlacement?.ItemId == o.ObjectId)
_pendingListPlacement = null;
if (_selection.SelectedObjectId == o.ObjectId)
{
_selection.Clear(
@ -238,8 +251,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
private void OnObjectMoved(ClientObjectMove move)
{
bool resolvedPending = _pendingListPlacement?.ItemId == move.ItemId;
if (resolvedPending)
_pendingListPlacement = null;
uint player = _playerGuid();
if ((move.Item is { } item && Concerns(item))
if (resolvedPending
|| (move.Item is { } item && Concerns(item))
|| move.Previous.ContainerId == player
|| move.Current.ContainerId == player
|| move.Previous.WielderId == player
@ -253,7 +270,18 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
private void OnInteractionStateChanged() => ApplyIndicators();
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnObjectsCleared() => Populate();
private void OnMoveRequestFailed(MoveRequestFailure failure)
{
if (_pendingListPlacement?.ItemId != failure.ItemId)
return;
_pendingListPlacement = null;
Populate();
}
private void OnObjectsCleared()
{
_pendingListPlacement = null;
Populate();
}
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
private bool Concerns(ClientObject o)
@ -291,12 +319,32 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
// no sub-bags, so the isBag skip is a no-op when a bag is open.)
var visibleContents = new List<uint>();
foreach (var guid in _objects.GetContents(open))
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = IsBag(item);
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
if (!isBag) visibleContents.Add(guid);
}
PendingListPlacement? pending = _pendingListPlacement;
if (pending is { } projection
&& projection.ContainerId == open
&& !visibleContents.Contains(projection.ItemId)
&& _objects.Get(projection.ItemId) is { } pendingItem
&& !IsBag(pendingItem))
{
int index = Math.Clamp(projection.Placement, 0, visibleContents.Count);
visibleContents.Insert(index, projection.ItemId);
}
foreach (uint guid in visibleContents)
{
bool waiting = pending is { } waitingProjection
&& waitingProjection.ContainerId == open
&& waitingProjection.ItemId == guid;
AddCell(_contentsGrid, guid, isContainer: false, waiting);
}
// Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24).
@ -365,7 +413,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
/// item cell → select-only.</summary>
private void AddCell(UiItemList? list, uint guid, bool isContainer)
private void AddCell(UiItemList? list, uint guid, bool isContainer, bool waiting = false)
{
if (list is null) return;
var item = _objects.Get(guid);
@ -376,6 +424,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u;
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
cell.SetItem(guid, tex, dragIconTexture: dragTex);
cell.SetWaitingState(waiting);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
ConfigureDropFeedback(list, cell);
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
@ -456,6 +505,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
return ItemDragAcceptance.None;
if (payload.ObjId == 0)
return ItemDragAcceptance.Reject;
if (payload.SourceKind == ItemDragSource.Ground
&& _pendingListPlacement is not null)
return ItemDragAcceptance.Reject;
if (targetList == _contentsGrid)
return ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer)
@ -525,12 +577,22 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
}
// 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)
// External-container contents retain canonical ownership while the request
// is in flight, but retail immediately inserts an m_pendingItem copy into
// the chosen destination slot and ghosts it. The server move/failure notice
// resolves that visual projection. UIElement_ItemList::HandleDropRelease
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
if (payload.SourceKind == ItemDragSource.Ground)
{
if (_pendingListPlacement is not null)
return;
_pendingListPlacement = new PendingListPlacement(item, container, placement);
Populate();
}
else
{
_objects.MoveItemOptimistic(item, container, placement);
}
_sendPutItemInContainer?.Invoke(item, container, placement);
}
@ -716,6 +778,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_disposed = true;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.MoveRequestFailed -= OnMoveRequestFailed;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectChanged;

View file

@ -1640,20 +1640,7 @@ public sealed class RetailUiRuntime : IDisposable
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,
});
ExternalContainerController.CreateWindowOptions(root));
uint contentsEmpty;
uint containerEmpty;

View file

@ -144,9 +144,17 @@ public class UiItemSlot : UiElement
// ItemList_BeginDrag ghosts physical lists, but explicitly excludes shortcut lists
// (along with vendor/salvage lists, which acdream does not model as ItemDragSource).
// Keep the source's full cell icon in place and reveal the authored grey mesh over it.
_waiting = active && ItemId != 0 && SourceKind != ItemDragSource.ShortcutBar;
SetWaitingState(active && SourceKind != ItemDragSource.ShortcutBar);
}
/// <summary>
/// Apply retail's <c>m_elem_Icon_Ghosted</c> waiting presentation independently
/// of pointer ownership. Destination ItemLists use this for their temporary
/// <c>m_pendingItem</c> inserted by <c>HandleDropRelease @ 0x004E4790</c>.
/// </summary>
internal void SetWaitingState(bool waiting)
=> _waiting = waiting && ItemId != 0;
/// <summary>An OCCUPIED slot is a drag source — a press-and-move picks up the item
/// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a
/// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar

View file

@ -21,6 +21,7 @@ public enum PublicWeenieFlags : uint
PlayerKillerSwitch = 0x00000400,
NonPlayerKillerSwitch = 0x00000800,
Door = 0x00001000,
Corpse = 0x00002000,
RequiresPackSlot = 0x00800000,
VolatileRare = 0x10000000,
WieldOnUse = 0x20000000,

View file

@ -134,6 +134,30 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(chest, h.GroundObject);
}
[Fact]
public void CorpseUse_UsesStuckContainerInsteadOfTryingToPickItUp()
{
var h = new Harness();
const uint corpse = 0x70000002u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = corpse,
Type = ItemType.Container,
ItemsCapacity = 24,
Useability = ItemUseability.Remote,
PublicWeenieBitfield = (uint)(
PublicWeenieFlags.Openable
| PublicWeenieFlags.Stuck
| PublicWeenieFlags.Corpse),
});
Assert.True(h.Controller.UseSelectedOrEnterMode(corpse));
Assert.Equal(new uint[] { corpse }, h.Uses);
Assert.Equal(new uint[] { corpse }, h.ExternalRequests);
Assert.Empty(h.Puts);
}
[Fact]
public void DropOwnedItemOnOpenExternalContainer_SendsPutWithoutOptimisticMove()
{

View file

@ -14,6 +14,40 @@ public sealed class ExternalContainerControllerTests
private sealed class TestElement : UiElement { }
[Fact]
public void WindowPolicy_AddsOuterFrameAndAllowsHorizontalResize()
{
var screen = new UiRoot { Width = 1280f, Height = 720f };
var root = new TestElement
{
Left = 0f,
Top = 400f,
Width = 800f,
Height = 110f,
};
RetailWindowFrame.Options options =
ExternalContainerController.CreateWindowOptions(root);
RetailWindowHandle handle = RetailWindowFrame.Mount(
screen,
root,
static _ => (0u, 0, 0),
options);
var frame = Assert.IsType<UiNineSlicePanel>(handle.OuterFrame);
Assert.Equal(710f, frame.Width);
Assert.Equal(120f, frame.Height);
Assert.Equal(170f, frame.MinWidth);
Assert.True(frame.Draggable);
Assert.True(frame.Resizable);
Assert.True(frame.ResizeX);
Assert.False(frame.ResizeY);
Assert.Equal(ResizeEdges.Left | ResizeEdges.Right, frame.ResizableEdges);
Assert.True(frame.ConstrainResizeToParent);
Assert.False(frame.DrawCenterFill);
Assert.Equal(700f, root.Width);
}
private sealed class Harness : IDisposable
{
public readonly ExternalContainerState State = new();

View file

@ -627,6 +627,68 @@ public class InventoryControllerTests
Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant)
}
[Fact]
public void LootDrop_InsertsWaitingProjectionAtChosenSlotUntilServerConfirms()
{
const uint chest = 0x70000001u;
const uint loot = 0x70000002u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xAu, Player, slot: 0);
SeedContained(objects, 0xBu, Player, slot: 1);
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
var selection = new SelectionState();
selection.Select(loot, SelectionChangeSource.ExternalContainer);
var puts = new List<(uint item, uint container, int placement)>();
using var ctrl = Bind(layout, objects, puts: puts, selection: selection);
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
source.SetItem(loot, 0u);
var payload = new ItemDragPayload(loot, ItemDragSource.Ground, 0, source);
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, payload);
Assert.Equal(new[] { (loot, Player, 1) }, puts);
Assert.Equal(chest, objects.Get(loot)!.ContainerId);
Assert.Equal(loot, grid.GetItem(1)!.ItemId);
Assert.True(grid.GetItem(1)!.WaitingVisual);
Assert.True(grid.GetItem(1)!.Selected);
Assert.Equal(0xBu, grid.GetItem(2)!.ItemId);
objects.ApplyConfirmedServerMove(loot, Player, 0u, newSlot: 1);
UiItemSlot confirmed = Enumerable.Range(0, grid.GetNumUIItems())
.Select(i => grid.GetItem(i)!)
.Single(cell => cell.ItemId == loot);
Assert.False(confirmed.WaitingVisual);
Assert.Equal(Player, objects.Get(loot)!.ContainerId);
}
[Fact]
public void LootDrop_ServerFailureRemovesOnlyThePendingProjection()
{
const uint chest = 0x70000001u;
const uint loot = 0x70000002u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xAu, Player, slot: 0);
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
var puts = new List<(uint item, uint container, int placement)>();
using var ctrl = Bind(layout, objects, puts: puts);
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
source.SetItem(loot, 0u);
var payload = new ItemDragPayload(loot, ItemDragSource.Ground, 0, source);
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, payload);
Assert.Equal(loot, grid.GetItem(1)!.ItemId);
objects.RejectMove(loot, weenieError: 0x29u);
Assert.DoesNotContain(
Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId),
id => id == loot);
Assert.Equal(chest, objects.Get(loot)!.ContainerId);
}
[Fact]
public void Drop_onEmptyGridCell_appendsToFirstEmpty()
{