diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6936b421..dd9f1a3b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index beff1c98..b0c52719 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 93911858..8e29b9f2 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids β€” surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger` with a Console-backed `ConsoleErrorLogger` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 β€” `TryGet(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` β€” needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) β€” all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live βœ“ | | C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** β€” new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]` β†’ `[Default]` β†’ first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** β€” activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Farβ†’Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Nearβ†’Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec Β§3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) β€” no synthetic-ID scheme or separate walker class needed (handoff Β§4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live βœ“ | diff --git a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md index 922c1c0f..2d164c0f 100644 --- a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md +++ b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9f527902..df99fd4d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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; } - /// - /// 2026-05-16. Retail-faithful gate for F-key PickUp / right-click - /// "Pick Up." Distinct from 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. - /// - /// - /// 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. - /// - /// - /// - /// Null-useability fallback: same as - /// β€” 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. - /// - /// /// /// 2026-05-16 β€” pickup gate is ItemType-based, NOT useability-based. /// @@ -13935,7 +13873,7 @@ public sealed class GameWindow : IDisposable /// /// /// - /// 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). /// @@ -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; } diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index 5036241c..a6927778 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -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); + /// + /// 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. + /// + 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; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 2fb177d0..626e0ecc 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -63,8 +63,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private readonly Action? _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(); + } /// True if the object is in (or wielded by) the player β€” i.e. a rebuild is warranted. 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(); 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 /// Add a populated cell wired to its click role: container cell β†’ open+select, /// item cell β†’ select-only. - 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; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 91a7c688..02755703 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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; diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 0a9a7d58..2d929f83 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -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); } + /// + /// Apply retail's m_elem_Icon_Ghosted waiting presentation independently + /// of pointer ownership. Destination ItemLists use this for their temporary + /// m_pendingItem inserted by HandleDropRelease @ 0x004E4790. + /// + internal void SetWaitingState(bool waiting) + => _waiting = waiting && ItemId != 0; + /// 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 diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs index 0486d5fc..0a52b51d 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -21,6 +21,7 @@ public enum PublicWeenieFlags : uint PlayerKillerSwitch = 0x00000400, NonPlayerKillerSwitch = 0x00000800, Door = 0x00001000, + Corpse = 0x00002000, RequiresPackSlot = 0x00800000, VolatileRare = 0x10000000, WieldOnUse = 0x20000000, diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 64222de8..e3ddf361 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs index e10b1eb4..b1f7cea2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ExternalContainerControllerTests.cs @@ -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(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(); diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index b887ed4b..cedcedd2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -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() {