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:
parent
20ce67b625
commit
d51a0fc825
13 changed files with 275 additions and 102 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 ✓ |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ public enum PublicWeenieFlags : uint
|
|||
PlayerKillerSwitch = 0x00000400,
|
||||
NonPlayerKillerSwitch = 0x00000800,
|
||||
Door = 0x00001000,
|
||||
Corpse = 0x00002000,
|
||||
RequiresPackSlot = 0x00800000,
|
||||
VolatileRare = 0x10000000,
|
||||
WieldOnUse = 0x20000000,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue