fix(interaction): close selection lifecycle review gaps

Bind queued actions and pending inventory requests to exact live incarnations, separate optimistic placement from authoritative responses, and serialize retail-style inventory ownership across UI surfaces.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 09:01:02 +02:00
parent d2bb5af453
commit 5acc3f01cf
23 changed files with 2635 additions and 168 deletions

View file

@ -56,7 +56,7 @@ accepted-divergence entries (#96, #49, #50).
| 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-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-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-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` | | 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`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`) | 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` |
| IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` | | IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` |
--- ---
@ -73,7 +73,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 | | AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 | | AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow null for the established creature/large-object fallback while preserving explicit zero/one rejection | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsUseable`) | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally leaves without useability can become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923; `ItemUses::IsUseable @ 0x004FCCC0` |
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
| AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB | | AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB |
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 | | AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
@ -87,7 +87,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | | AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) | | AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it). The companion `MoveToCancelled` seam only withdraws the waiting client presentation/action. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Rendering/GameWindow.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract; cancellation/error only clears retained intent. | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | | AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | | AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | | AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
@ -132,7 +132,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 12 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive D3DBLEND_ONE | | AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive D3DBLEND_ONE |
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 | | AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | | AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` | | ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) | | AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |

View file

@ -16,6 +16,7 @@ namespace AcDream.App.Input;
public sealed class OutboundInteractionQueue public sealed class OutboundInteractionQueue
{ {
private readonly Queue<Action> _pending = new(); private readonly Queue<Action> _pending = new();
private uint _clearEpoch;
public int Count => _pending.Count; public int Count => _pending.Count;
@ -33,9 +34,14 @@ public sealed class OutboundInteractionQueue
public void Drain() public void Drain()
{ {
int count = _pending.Count; int count = _pending.Count;
while (count-- > 0) uint epoch = _clearEpoch;
while (count-- > 0 && epoch == _clearEpoch && _pending.Count > 0)
_pending.Dequeue().Invoke(); _pending.Dequeue().Invoke();
} }
public void Clear() => _pending.Clear(); public void Clear()
{
_pending.Clear();
_clearEpoch++;
}
} }

View file

@ -6,7 +6,13 @@ namespace AcDream.App.Interaction;
internal interface IPlayerInteractionMovementSink internal interface IPlayerInteractionMovementSink
{ {
bool BeginApproach(InteractionApproach approach); /// <summary>
/// Cancels any preceding MoveTo, invokes <paramref name="armAfterCancel"/>,
/// then installs the new movement. The callback boundary lets the intent
/// owner arm completion state before an already-facing TurnTo can finish
/// synchronously, without letting the preceding cancellation clear it.
/// </summary>
bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null);
} }
/// <summary> /// <summary>
@ -20,7 +26,7 @@ internal sealed class PlayerInteractionMovementSink(
private readonly Func<PlayerMovementController?> _player = player private readonly Func<PlayerMovementController?> _player = player
?? throw new ArgumentNullException(nameof(player)); ?? throw new ArgumentNullException(nameof(player));
public bool BeginApproach(InteractionApproach approach) public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
{ {
PlayerMovementController? controller = _player(); PlayerMovementController? controller = _player();
if (controller?.MoveTo is null) if (controller?.MoveTo is null)
@ -47,11 +53,16 @@ internal sealed class PlayerInteractionMovementSink(
Height = approach.TargetHeight, Height = approach.TargetHeight,
}; };
// PerformMovement cancels at its head. Do it explicitly before the
// intent is armed so cancellation of the preceding move cannot clear
// the new request; the internal second call is then a retail no-op.
controller.Movement.CancelMoveTo(WeenieError.ActionCancelled);
armAfterCancel?.Invoke();
// P1's wire MoveToObject store marks the command non-autonomous. // P1's wire MoveToObject store marks the command non-autonomous.
// The speculative local install must do the same or the next raw-input // The speculative local install must do the same or the next raw-input
// pump overwrites it before ACE's authoritative movement arrives. // pump overwrites it before ACE's authoritative movement arrives.
controller.SetLastMoveWasAutonomous(false); controller.SetLastMoveWasAutonomous(false);
controller.Movement.PerformMovement(movement); return controller.Movement.PerformMovement(movement) == WeenieError.None;
return true;
} }
} }

View file

@ -1,6 +1,7 @@
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.Ui; using AcDream.Core.Ui;
@ -24,12 +25,18 @@ internal sealed class SelectionInteractionController
private readonly Action<string>? _toast; private readonly Action<string>? _toast;
private PendingPostArrivalAction? _pendingPostArrival; private PendingPostArrivalAction? _pendingPostArrival;
private readonly record struct QueuedInteractionIdentity(
uint ServerGuid,
uint? LocalEntityId,
ClientObject? ClientObject);
private readonly record struct PendingPostArrivalAction( private readonly record struct PendingPostArrivalAction(
uint ServerGuid, uint ServerGuid,
uint LocalEntityId, uint LocalEntityId,
bool IsPickup, bool IsPickup,
uint DestinationContainerId, uint DestinationContainerId,
int Placement); int Placement,
ulong PendingPlacementToken);
public SelectionInteractionController( public SelectionInteractionController(
SelectionState selection, SelectionState selection,
@ -69,8 +76,14 @@ internal sealed class SelectionInteractionController
case InputAction.SelectionPickUp: case InputAction.SelectionPickUp:
if (_selection.SelectedObjectId is uint pickupTarget) if (_selection.SelectedObjectId is uint pickupTarget)
{ {
_outbound.Enqueue(() => EnqueueIdentityBound(
_items.PlaceWorldItemInBackpack(pickupTarget)); pickupTarget,
requireLiveEntity: true,
guid =>
{
if (ValidatePickupTarget(guid, showToast: true))
_items.PlaceWorldItemInBackpack(guid);
});
} }
else else
{ {
@ -85,12 +98,18 @@ internal sealed class SelectionInteractionController
} }
} }
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
=> _query.PickAt(mouseX, mouseY, includeSelf);
public uint? PickAtCursor(bool includeSelf) public uint? PickAtCursor(bool includeSelf)
=> _query.PickAtCursor(includeSelf); => _query.PickAtCursor(includeSelf);
public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY)
{
ArgumentNullException.ThrowIfNull(payload);
uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u;
if (target != 0u)
_query.BeginLightingPulse(target);
_items.PlaceIn3D(payload, target);
}
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{ {
if (_selection.SelectedObjectId is { } selected if (_selection.SelectedObjectId is { } selected
@ -147,7 +166,13 @@ internal sealed class SelectionInteractionController
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
_toast?.Invoke($"Selected: {label}"); _toast?.Invoke($"Selected: {label}");
if (useImmediately) if (useImmediately)
_outbound.Enqueue(() => _items.ActivateItem(guid)); EnqueueIdentityBound(
guid,
requireLiveEntity: true,
selected =>
{
_items.ActivateItem(selected);
});
} }
public void UseCurrentSelection() public void UseCurrentSelection()
@ -157,11 +182,18 @@ internal sealed class SelectionInteractionController
_toast?.Invoke("Nothing selected"); _toast?.Invoke("Nothing selected");
return; return;
} }
_outbound.Enqueue(() => _items.UseSelectedOrEnterMode(selected)); EnqueueIdentityBound(
selected,
requireLiveEntity: false,
guid =>
{
_items.UseSelectedOrEnterMode(guid);
});
} }
public void SendUse(uint serverGuid) public void SendUse(uint serverGuid)
{ {
CancelPendingApproach();
if (!_transport.IsInWorld) if (!_transport.IsInWorld)
{ {
_toast?.Invoke("Not in world"); _toast?.Invoke("Not in world");
@ -173,63 +205,104 @@ internal sealed class SelectionInteractionController
return; return;
} }
_movement.BeginApproach(approach);
if (approach.IsCloseRange) if (approach.IsCloseRange)
{ {
_pendingPostArrival = new PendingPostArrivalAction( var pending = new PendingPostArrivalAction(
serverGuid, serverGuid,
approach.Target.LocalEntityId, approach.Target.LocalEntityId,
IsPickup: false, IsPickup: false,
DestinationContainerId: 0u, DestinationContainerId: 0u,
Placement: 0); Placement: 0,
PendingPlacementToken: 0u);
bool started = _movement.BeginApproach(
approach,
() => _pendingPostArrival = pending);
if (!started && _pendingPostArrival == pending)
CancelPendingApproach();
return; return;
} }
_movement.BeginApproach(approach);
if (_transport.TrySendUse(serverGuid, out uint sequence)) if (_transport.TrySendUse(serverGuid, out uint sequence))
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}"); Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
} }
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement) public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
{ {
CancelPendingApproach();
if (!_transport.IsInWorld) if (!_transport.IsInWorld)
{ {
_toast?.Invoke("Not in world"); _toast?.Invoke("Not in world");
CancelPickupPresentation(itemGuid);
return; return;
} }
if (_query.IsCreature(itemGuid)) if (!ValidatePickupTarget(itemGuid, showToast: true)
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
{
CancelPickupPresentation(itemGuid);
return;
}
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
itemGuid,
out PendingBackpackPlacement pendingPlacement)
? pendingPlacement.Token
: 0u;
if (!IsCurrentPickupPresentation(
itemGuid,
destinationContainerId,
placement,
pendingPlacementToken))
{ {
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
return; return;
} }
if (!_query.IsPickupable(itemGuid))
{
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(itemGuid)));
return;
}
if (!_query.TryGetApproach(itemGuid, out InteractionApproach approach))
return;
_movement.BeginApproach(approach);
if (approach.IsCloseRange) if (approach.IsCloseRange)
{ {
_pendingPostArrival = new PendingPostArrivalAction( var pending = new PendingPostArrivalAction(
itemGuid, itemGuid,
approach.Target.LocalEntityId, approach.Target.LocalEntityId,
IsPickup: true, IsPickup: true,
destinationContainerId, destinationContainerId,
placement); placement,
pendingPlacementToken);
bool started = _movement.BeginApproach(
approach,
() => _pendingPostArrival = pending);
if (!started && _pendingPostArrival == pending)
CancelPendingApproach();
else if (!started)
CancelPickupPresentation(itemGuid, pendingPlacementToken);
return; return;
} }
if (_transport.TrySendPickup( _movement.BeginApproach(approach);
if (!IsCurrentPickupPresentation(
itemGuid, itemGuid,
destinationContainerId, destinationContainerId,
placement, placement,
out uint sequence)) pendingPlacementToken))
{
return;
}
uint sequence = 0u;
if (_items.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
itemGuid,
() => _transport.TrySendPickup(
itemGuid,
destinationContainerId,
placement,
out sequence),
pendingPlacementToken))
{ {
Console.WriteLine( Console.WriteLine(
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}"); $"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
} }
else
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
}
} }
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary> /// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
@ -239,15 +312,38 @@ internal sealed class SelectionInteractionController
return; return;
_pendingPostArrival = null; _pendingPostArrival = null;
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
{
if (pending.IsPickup)
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
return; return;
}
if (pending.IsPickup) if (pending.IsPickup)
{ {
_transport.TrySendPickup( if (!IsCurrentPickupPresentation(
pending.ServerGuid, pending.ServerGuid,
pending.DestinationContainerId, pending.DestinationContainerId,
pending.Placement, pending.Placement,
out _); pending.PendingPlacementToken))
{
return;
}
if (!_items.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
pending.ServerGuid,
() => _transport.TrySendPickup(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
out _),
pending.PendingPlacementToken))
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
} }
else else
{ {
@ -257,10 +353,12 @@ internal sealed class SelectionInteractionController
public void DrainOutbound() => _outbound.Drain(); public void DrainOutbound() => _outbound.Drain();
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
public void OnEntityHidden(uint serverGuid) public void OnEntityHidden(uint serverGuid)
{ {
if (_pendingPostArrival?.ServerGuid == serverGuid) if (_pendingPostArrival?.ServerGuid == serverGuid)
_pendingPostArrival = null; CancelPendingApproach();
if (_selection.SelectedObjectId == serverGuid) if (_selection.SelectedObjectId == serverGuid)
{ {
_selection.Clear( _selection.Clear(
@ -276,7 +374,7 @@ internal sealed class SelectionInteractionController
&& pending.ServerGuid == record.ServerGuid && pending.ServerGuid == record.ServerGuid
&& pending.LocalEntityId == record.LocalEntityId) && pending.LocalEntityId == record.LocalEntityId)
{ {
_pendingPostArrival = null; CancelPendingApproach();
} }
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
{ {
@ -288,9 +386,85 @@ internal sealed class SelectionInteractionController
public void ResetSession() public void ResetSession()
{ {
CancelPendingApproach();
_items.ResetSession(); _items.ResetSession();
_selection.Reset(); _selection.Reset();
_outbound.Clear(); _outbound.Clear();
_pendingPostArrival = null; _pendingPostArrival = null;
} }
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
{
if (_query.IsCreature(serverGuid))
{
if (showToast)
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
return false;
}
if (_query.IsPickupable(serverGuid))
return true;
if (showToast)
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid)));
return false;
}
private bool EnqueueIdentityBound(
uint serverGuid,
bool requireLiveEntity,
Action<uint> action)
{
ArgumentNullException.ThrowIfNull(action);
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
? localId
: null;
ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured)
? captured
: null;
if ((requireLiveEntity && localEntityId is null)
|| (localEntityId is null && item is null))
{
return false;
}
var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item);
_outbound.Enqueue(() =>
{
if (IsCurrent(identity))
action(identity.ServerGuid);
});
return true;
}
private bool IsCurrent(QueuedInteractionIdentity identity)
=> (identity.LocalEntityId is not uint localId
|| _query.IsCurrent(identity.ServerGuid, localId))
&& (identity.ClientObject is not { } item
|| _items.IsCurrentObjectIdentity(identity.ServerGuid, item));
private void CancelPendingApproach()
{
if (_pendingPostArrival is not { } pending)
return;
_pendingPostArrival = null;
if (pending.IsPickup)
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
=> _items.CancelPendingBackpackPlacement(itemGuid, token);
private bool IsCurrentPickupPresentation(
uint itemGuid,
uint destinationContainerId,
int placement,
ulong token)
=> token != 0u
&& _items.TryGetPendingBackpackPlacement(
itemGuid,
out PendingBackpackPlacement pending)
&& pending.Token == token
&& pending.ContainerId == destinationContainerId
&& pending.Placement == placement;
} }

View file

@ -39,6 +39,7 @@ internal interface IWorldSelectionQuery
uint? PickAtCursor(bool includeSelf); uint? PickAtCursor(bool includeSelf);
uint? PickAt(float mouseX, float mouseY, bool includeSelf); uint? PickAt(float mouseX, float mouseY, bool includeSelf);
void BeginLightingPulse(uint serverGuid); void BeginLightingPulse(uint serverGuid);
bool TryCaptureIdentity(uint serverGuid, out uint localEntityId);
bool IsCurrent(uint serverGuid, uint localEntityId); bool IsCurrent(uint serverGuid, uint localEntityId);
string Describe(uint serverGuid); string Describe(uint serverGuid);
bool IsCreature(uint serverGuid); bool IsCreature(uint serverGuid);
@ -142,6 +143,18 @@ internal sealed class WorldSelectionQuery : IWorldSelectionQuery
_selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId); _selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId);
} }
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
{
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
{
localEntityId = target.LocalEntityId;
return true;
}
localEntityId = 0u;
return false;
}
public bool TryGetInteractionTarget( public bool TryGetInteractionTarget(
uint serverGuid, uint serverGuid,
out WorldInteractionTarget target) out WorldInteractionTarget target)
@ -274,7 +287,7 @@ internal sealed class WorldSelectionQuery : IWorldSelectionQuery
return true; return true;
} }
/// <summary>ItemUses::IsUseable @ retail 0x00566A20 call family.</summary> /// <summary>ItemUses::IsUseable @ retail 0x004FCCC0 call family.</summary>
public bool IsUseable(uint serverGuid) public bool IsUseable(uint serverGuid)
{ {
if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn)) if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))

View file

@ -13081,12 +13081,7 @@ public sealed class GameWindow : IDisposable
private void OnUiDragReleasedOutside(object payload, int x, int y) private void OnUiDragReleasedOutside(object payload, int x, int y)
{ {
if (payload is AcDream.App.UI.ItemDragPayload itemPayload) if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
{ _selectionInteractions?.PlaceDraggedItem(itemPayload, x, y);
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
if (target != 0u)
BeginSelectionLightingPulse(target);
_itemInteractionController?.PlaceIn3D(itemPayload, target);
}
} }
// L.0 follow-up: persisted-settings cache populated by // L.0 follow-up: persisted-settings cache populated by
@ -13746,15 +13741,8 @@ public sealed class GameWindow : IDisposable
return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected); return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected);
} }
// ============================================================
// Phase B.4b — outbound Use handler. Wires three input actions
// (LMB click select, LMB-double-click select+use, R hotkey
// use-selected) through WorldPicker into InteractRequests.BuildUse.
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
// ============================================================
/// <summary> /// <summary>
/// Shared world pick at the current cursor. The renderer supplies the /// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
/// retail's drawing-sphere broadphase followed by flat visual-polygon /// retail's drawing-sphere broadphase followed by flat visual-polygon
/// intersection. <paramref name="includeSelf"/> allows item target-use to /// intersection. <paramref name="includeSelf"/> allows item target-use to
@ -13764,13 +13752,6 @@ public sealed class GameWindow : IDisposable
=> _selectionInteractions?.PickAtCursor(includeSelf) => _selectionInteractions?.PickAtCursor(includeSelf)
?? _worldSelectionQuery?.PickAtCursor(includeSelf); ?? _worldSelectionQuery?.PickAtCursor(includeSelf);
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
=> _selectionInteractions?.PickAt(mouseX, mouseY, includeSelf)
?? _worldSelectionQuery?.PickAt(mouseX, mouseY, includeSelf);
private void BeginSelectionLightingPulse(uint serverGuid)
=> _worldSelectionQuery?.BeginLightingPulse(serverGuid);
// Contained/toolbar activation is not a world-selection interaction: it // Contained/toolbar activation is not a world-selection interaction: it
// sends directly without speculative TurnToObject/MoveToObject. // sends directly without speculative TurnToObject/MoveToObject.
private void UseItemByGuid(uint guid) private void UseItemByGuid(uint guid)
@ -13818,33 +13799,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid) private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
=> _worldSelectionQuery?.ResolveVividTargetInfo(guid); => _worldSelectionQuery?.ResolveVividTargetInfo(guid);
/// <summary>
/// 2026-05-16 — retail-faithful port of
/// <c>SmartBox::GetObjectBoundingBox</c> (decomp <c>0x00452e20</c>)
/// using <c>CPhysicsObj::GetSelectionSphere</c> (<c>0x0050ea40</c>)
/// → <c>CPartArray::GetSelectionSphere</c> (<c>0x00518b80</c>).
///
/// <para>
/// Retail's VividTargetIndicator does NOT use a per-mesh AABB —
/// it uses the Setup's <c>selection_sphere</c> field (a single
/// sphere encompassing the entire entity, baked at Setup-creation
/// time). The sphere is scaled by the part-array scale
/// (component-wise on center, Z-scale on radius — retail's exact
/// formula at <c>0x00518ba60x00518be3</c>) and rotated by entity
/// orientation. The screen indicator rect is the projection of
/// the camera-aligned BBox of this sphere — i.e. a screen circle
/// of radius <c>worldRadius * focalLength / depth</c>.
/// </para>
///
/// <para>
/// Result: the indicator rect MATCHES the Setup's intended
/// "selectable extent" — which is typically larger than the mesh
/// AABB by design (Setups bake a slightly oversized selection
/// sphere so far targets still get pickable indicators). That's
/// why our previous mesh-AABB indicator was smaller than retail's.
/// </para>
/// </summary>
private void TogglePlayerMode() private void TogglePlayerMode()
{ {
// Phase B.2 guard: only active when a live session is in-world. // Phase B.2 guard: only active when a live session is in-world.
@ -14084,7 +14038,11 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"[autowalk-end] reason=complete err={err}"); Console.WriteLine($"[autowalk-end] reason=complete err={err}");
if (err == AcDream.Core.Physics.WeenieError.None) if (err == AcDream.Core.Physics.WeenieError.None)
OnAutoWalkArrivedSendDeferredAction(); OnAutoWalkArrivedSendDeferredAction();
else
_selectionInteractions?.OnMoveToCancelled(err);
}; };
playerMoveTo.MoveToCancelled = err =>
_selectionInteractions?.OnMoveToCancelled(err);
// R5-V3 (#171, retires TS-39 — player side): bind the sticky // R5-V3 (#171, retires TS-39 — player side): bind the sticky
// seams to the player host's PositionManager (same trio as the // seams to the player host's PositionManager (same trio as the

View file

@ -12,6 +12,31 @@ public enum ItemPrimaryClickResult
ConsumedRejected, ConsumedRejected,
} }
public readonly record struct PendingBackpackPlacement(
ulong Token,
uint ItemId,
uint ContainerId,
int Placement,
ClientObject? ItemIdentity);
public enum InventoryRequestKind
{
Pickup,
PutInContainer,
SplitToContainer,
Merge,
DropToWorld,
SplitToWorld,
Give,
}
public readonly record struct PendingInventoryRequest(
ulong Token,
InventoryRequestKind Kind,
uint ItemId,
ClientObject? ItemIdentity,
bool Dispatched);
/// <summary> /// <summary>
/// Shared retail item interaction orchestrator. UI widgets route item clicks, /// Shared retail item interaction orchestrator. UI widgets route item clicks,
/// target acquisition, and drag-out drops here instead of duplicating /// target acquisition, and drag-out drops here instead of duplicating
@ -20,6 +45,8 @@ public enum ItemPrimaryClickResult
public sealed class ItemInteractionController : IDisposable public sealed class ItemInteractionController : IDisposable
{ {
private const long RetailUseThrottleMs = 200; private const long RetailUseThrottleMs = 200;
internal const string InventoryRequestBusyMessage =
"You can only move or use one item at a time";
private const long RetailDoubleClickMs = 500; private const long RetailDoubleClickMs = 500;
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
@ -56,6 +83,9 @@ public sealed class ItemInteractionController : IDisposable
private uint _consumedPrimaryClickTarget; private uint _consumedPrimaryClickTarget;
private long _consumedPrimaryClickMs = long.MinValue / 2; private long _consumedPrimaryClickMs = long.MinValue / 2;
private int _busyCount; private int _busyCount;
private ulong _nextInventoryRequestToken;
private PendingBackpackPlacement? _pendingBackpackPlacement;
private PendingInventoryRequest? _pendingInventoryRequest;
private bool _disposed; private bool _disposed;
public ItemInteractionController( public ItemInteractionController(
@ -119,6 +149,11 @@ public sealed class ItemInteractionController : IDisposable
_systemMessage = systemMessage; _systemMessage = systemMessage;
_interactionState = interactionState ?? new InteractionState(); _interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged; _interactionState.Changed += OnInteractionModeChanged;
_objects.ObjectMoved += OnInventoryObjectMoved;
_objects.MoveRequestFailed += OnInventoryMoveFailed;
_objects.ObjectRemoved += OnInventoryObjectRemoved;
_objects.StackSizeUpdated += OnInventoryStackSizeUpdated;
_objects.Cleared += OnInventoryObjectsCleared;
_autoWield = new AutoWieldController( _autoWield = new AutoWieldController(
_objects, _objects,
_playerGuid, _playerGuid,
@ -137,7 +172,20 @@ public sealed class ItemInteractionController : IDisposable
/// panel inserts a waiting projection before the pickup request is sent. /// panel inserts a waiting projection before the pickup request is sent.
/// The destination and placement are the same values sent on the wire. /// The destination and placement are the same values sent on the wire.
/// </summary> /// </summary>
public event Action<uint, uint, int>? PendingBackpackPlacementRequested; public event Action<PendingBackpackPlacement>? PendingBackpackPlacementRequested;
/// <summary>
/// Withdraws a waiting pickup projection when the approach is rejected or
/// cancelled before a request reaches the server.
/// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementCancelled;
/// <summary>
/// Resolves the exact waiting projection after an authoritative response.
/// The token prevents a late response for a recycled GUID from erasing a
/// newer incarnation's projection in the inventory panel.
/// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved;
public uint PlayerGuid => _playerGuid(); public uint PlayerGuid => _playerGuid();
@ -146,8 +194,151 @@ public sealed class ItemInteractionController : IDisposable
public int BusyCount => _busyCount; public int BusyCount => _busyCount;
public bool CanMakeInventoryRequest => public bool CanMakeInventoryRequest =>
BaseCanMakeInventoryRequest && _pendingInventoryRequest is null;
private bool BaseCanMakeInventoryRequest =>
_readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy; _readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy;
/// <summary>
/// Retail <c>ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest</c>.
/// The destination ItemList's separate <c>m_pendingItem</c> rejection is
/// reported by <see cref="ReportPendingBackpackPlacementConflict"/>.
/// </summary>
public bool EnsureInventoryRequestReady()
{
if (CanMakeInventoryRequest)
return true;
if (_pendingInventoryRequest is not null
|| _busyCount != 0
|| _autoWield.IsBusy)
_systemMessage?.Invoke(InventoryRequestBusyMessage);
return false;
}
/// <summary>
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination
/// list which already displays the waiting projection.
/// </summary>
public void ReportPendingBackpackPlacementConflict()
{
if (_pendingBackpackPlacement is not { } pending)
return;
string? itemName = _objects.Get(pending.ItemId)?.Name;
string name = string.IsNullOrWhiteSpace(itemName) ? "that item" : itemName;
_systemMessage?.Invoke($"Already attempting to place {name} here");
}
/// <summary>
/// Dispatches one retail inventory request. A provisional global
/// <c>ACCWeenieObject::prevRequest</c> owner closes callback reentrancy,
/// then becomes dispatched immediately after a successful wire send.
/// The callback returns false when it could not dispatch anything.
/// Retail: <c>RecordRequest @ 0x0058C220</c>, UIAttempt family
/// <c>0x0058D590..0x0058D850</c>.
/// </summary>
public bool TryDispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func<bool> dispatch,
ulong reservationToken = 0u)
{
ArgumentNullException.ThrowIfNull(dispatch);
if (itemId == 0u)
return false;
if (reservationToken != 0u)
{
if (_pendingInventoryRequest is not { } reserved
|| reserved.Token != reservationToken
|| reserved.ItemId != itemId
|| reserved.Kind != kind
|| reserved.Dispatched)
{
return false;
}
bool reservedDispatch;
try
{
reservedDispatch = dispatch();
}
catch
{
CancelPendingBackpackPlacement(itemId, reservationToken);
throw;
}
if (!reservedDispatch)
{
CancelPendingBackpackPlacement(itemId, reservationToken);
return false;
}
// A synchronous test transport or reentrant table listener may
// already have completed this exact request. Never resurrect it or
// overwrite a newer transaction acquired from that response.
if (_pendingInventoryRequest is { } reservationCurrent
&& reservationCurrent.Token == reserved.Token)
{
_pendingInventoryRequest = reserved with { Dispatched = true };
StateChanged?.Invoke();
}
return true;
}
if (!EnsureInventoryRequestReady())
return false;
ulong token = ++_nextInventoryRequestToken;
if (token == 0u)
token = ++_nextInventoryRequestToken;
var provisional = new PendingInventoryRequest(
token,
kind,
itemId,
_objects.Get(itemId),
Dispatched: false);
// Provisional ownership closes the reentrancy window around optimistic
// table mutation and arbitrary send adapters. Retail writes prevRequest
// after its synchronous event call; our callback seam can publish local
// notices reentrantly, so the owner is reserved before entering it.
_pendingInventoryRequest = provisional;
StateChanged?.Invoke();
bool dispatched;
try
{
dispatched = dispatch();
}
catch
{
ClearPendingInventoryRequest(token);
throw;
}
if (!dispatched)
{
ClearPendingInventoryRequest(token);
return false;
}
if (_pendingInventoryRequest is { } current
&& current.Token == token)
{
_pendingInventoryRequest = provisional with { Dispatched = true };
StateChanged?.Invoke();
}
return true;
}
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
{
if (_pendingInventoryRequest is { } current)
{
pending = current;
return true;
}
pending = default;
return false;
}
/// <summary> /// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a /// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The /// request issued by another retained controller has been sent. The
@ -287,6 +478,8 @@ public sealed class ItemInteractionController : IDisposable
if (!ConsumeUseThrottle()) if (!ConsumeUseThrottle())
return true; return true;
if (!EnsureInventoryRequestReady())
return false;
var input = new ItemUsePolicyInput( var input = new ItemUsePolicyInput(
Snapshot(item), Snapshot(item),
@ -317,11 +510,195 @@ public sealed class ItemInteractionController : IDisposable
if (containerId == 0u) if (containerId == 0u)
containerId = _playerGuid(); containerId = _playerGuid();
const int placement = 0; const int placement = 0;
PendingBackpackPlacementRequested?.Invoke(itemGuid, containerId, placement); if (!TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
out _))
{
// Retail permits only one inventory request at a time. Consume the
// input without replacing a pending projection or issuing a wire
// request while another inventory transaction owns the gate.
return true;
}
// This callback may install a retail MoveToObject approach instead of
// sending immediately. SelectionInteractionController promotes the
// reservation to Dispatched only when it actually emits the wire move.
_placeInBackpack(itemGuid, containerId, placement); _placeInBackpack(itemGuid, containerId, placement);
return true; return true;
} }
public bool TryBeginPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
out PendingBackpackPlacement pending)
=> TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
InventoryRequestKind.Pickup,
out pending);
private bool TryBeginPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
InventoryRequestKind kind,
out PendingBackpackPlacement pending)
{
if (itemGuid == 0u || containerId == 0u)
{
pending = default;
return false;
}
if (_pendingBackpackPlacement is { } existing)
{
ReportPendingBackpackPlacementConflict();
pending = existing;
return false;
}
if (!EnsureInventoryRequestReady())
{
pending = default;
return false;
}
ulong token = ++_nextInventoryRequestToken;
if (token == 0u)
token = ++_nextInventoryRequestToken;
ClientObject? identity = _objects.Get(itemGuid);
pending = new PendingBackpackPlacement(
token,
itemGuid,
containerId,
placement,
identity);
_pendingBackpackPlacement = pending;
_pendingInventoryRequest = new PendingInventoryRequest(
token,
kind,
itemGuid,
identity,
Dispatched: false);
PendingBackpackPlacementRequested?.Invoke(pending);
if (_pendingBackpackPlacement != pending
|| _pendingInventoryRequest is not { } published
|| published.Token != token
|| published.ItemId != itemGuid
|| published.Kind != kind
|| published.Dispatched)
{
return false;
}
StateChanged?.Invoke();
return true;
}
/// <summary>
/// Publishes retail's destination waiting projection, dispatches the move,
/// then records the global request. Failed dispatch withdraws only the
/// projection created by this call.
/// </summary>
public bool TryDispatchPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
InventoryRequestKind kind,
Func<bool> dispatch)
{
// Callers such as gmToolbarUI check the global prevRequest before they
// ask CPlayerSystem to publish ShowPendingInPlayer. This preserves the
// generic busy wording when both states happen to be present.
if (!EnsureInventoryRequestReady())
return false;
if (!TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
kind,
out PendingBackpackPlacement pending))
{
return false;
}
if (_pendingBackpackPlacement != pending
|| _pendingInventoryRequest is not { } reserved
|| reserved.Token != pending.Token
|| reserved.ItemId != pending.ItemId
|| reserved.Kind != kind
|| reserved.Dispatched)
{
return false;
}
if (TryDispatchInventoryRequest(
kind,
itemGuid,
dispatch,
pending.Token))
return true;
CancelPendingBackpackPlacement(itemGuid, pending.Token);
return false;
}
public bool TryGetPendingBackpackPlacement(
uint itemGuid,
out PendingBackpackPlacement pending)
{
if (_pendingBackpackPlacement is { } current
&& current.ItemId == itemGuid)
{
pending = current;
return true;
}
pending = default;
return false;
}
public void CancelPendingBackpackPlacement(uint itemGuid = 0u, ulong token = 0u)
{
if (_pendingBackpackPlacement is not { } pending
|| (itemGuid != 0u && pending.ItemId != itemGuid)
|| (token != 0u && pending.Token != token))
{
return;
}
_pendingBackpackPlacement = null;
if (_pendingInventoryRequest is { } request
&& request.Token == pending.Token
&& !request.Dispatched)
{
_pendingInventoryRequest = null;
StateChanged?.Invoke();
}
PendingBackpackPlacementCancelled?.Invoke(pending);
}
private void ClearPendingInventoryRequest(ulong token)
{
if (_pendingInventoryRequest is not { } pending
|| pending.Token != token)
{
return;
}
_pendingInventoryRequest = null;
StateChanged?.Invoke();
}
internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item)
{
item = _objects.Get(objectId)!;
return item is not null;
}
internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item)
=> ReferenceEquals(_objects.Get(objectId), item);
/// <summary> /// <summary>
/// Retail paperdoll drop entry point. The paperdoll resolves the exact /// Retail paperdoll drop entry point. The paperdoll resolves the exact
/// target side/location; this shared interaction owner performs AutoWield's /// target side/location; this shared interaction owner performs AutoWield's
@ -333,6 +710,8 @@ public sealed class ItemInteractionController : IDisposable
return false; return false;
if (_objects.Get(itemGuid) is not { } item) if (_objects.Get(itemGuid) is not { } item)
return false; return false;
if (!EnsureInventoryRequestReady())
return false;
return _autoWield.TryWield(item, targetMask); return _autoWield.TryWield(item, targetMask);
} }
@ -360,7 +739,7 @@ public sealed class ItemInteractionController : IDisposable
if (!compatible) if (!compatible)
return false; return false;
if (!CanMakeInventoryRequest) if (!EnsureInventoryRequestReady())
return false; return false;
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid); _sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
_busyCount++; _busyCount++;
@ -380,6 +759,8 @@ public sealed class ItemInteractionController : IDisposable
{ {
if (objectId == 0u || _sendUse is null) if (objectId == 0u || _sendUse is null)
return false; return false;
if (!EnsureInventoryRequestReady())
return false;
_sendUse(objectId); _sendUse(objectId);
_busyCount++; _busyCount++;
return true; return true;
@ -407,6 +788,8 @@ public sealed class ItemInteractionController : IDisposable
return false; return false;
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item) if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
return false; return false;
if (!EnsureInventoryRequestReady())
return false;
uint fullStack = (uint)Math.Max(1, item.StackSize); uint fullStack = (uint)Math.Max(1, item.StackSize);
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize( int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
@ -496,22 +879,54 @@ public sealed class ItemInteractionController : IDisposable
switch (action.Kind) switch (action.Kind)
{ {
case ItemPolicyActionKind.DropToWorld: case ItemPolicyActionKind.DropToWorld:
_objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1); TryDispatchInventoryRequest(
_sendDrop?.Invoke(action.ObjectId); InventoryRequestKind.DropToWorld,
action.ObjectId,
() =>
{
if (_sendDrop is null
|| !_objects.MoveItemOptimistic(
action.ObjectId,
newContainerId: 0u,
newSlot: -1))
{
return false;
}
_sendDrop(action.ObjectId);
return true;
});
break; break;
case ItemPolicyActionKind.SplitToWorld: case ItemPolicyActionKind.SplitToWorld:
// UIAttemptSplitTo3D leaves the original object in its container; // UIAttemptSplitTo3D leaves the original object in its container;
// the server assigns the split-off ground stack a new guid. // the server assigns the split-off ground stack a new guid.
_sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount); TryDispatchInventoryRequest(
InventoryRequestKind.SplitToWorld,
action.ObjectId,
() =>
{
if (_sendSplitToWorld is null)
return false;
_sendSplitToWorld(action.ObjectId, (uint)action.Amount);
return true;
});
break; break;
case ItemPolicyActionKind.GiveToTarget: case ItemPolicyActionKind.GiveToTarget:
// UIAttemptGive @ 0x0058D620 sends the request and leaves // UIAttemptGive @ 0x0058D620 sends the request and leaves
// the source untouched until the authoritative server // the source untouched until the authoritative server
// InventoryRemoveObject / SetStackSize response arrives. // InventoryRemoveObject / SetStackSize response arrives.
_sendGive?.Invoke( TryDispatchInventoryRequest(
InventoryRequestKind.Give,
action.ObjectId,
() =>
{
if (_sendGive is null)
return false;
_sendGive(
action.TargetId, action.TargetId,
action.ObjectId, action.ObjectId,
(uint)Math.Max(1, action.Amount)); (uint)Math.Max(1, action.Amount));
return true;
});
break; break;
case ItemPolicyActionKind.PlaceInContainer: case ItemPolicyActionKind.PlaceInContainer:
{ {
@ -519,10 +934,35 @@ public sealed class ItemInteractionController : IDisposable
1, 1,
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount); _objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
uint amount = (uint)Math.Max(1, action.Amount); uint amount = (uint)Math.Max(1, action.Amount);
InventoryRequestKind kind = amount < fullStack
? InventoryRequestKind.SplitToContainer
: InventoryRequestKind.PutInContainer;
TryDispatchInventoryRequest(
kind,
action.ObjectId,
() =>
{
if (amount < fullStack) if (amount < fullStack)
_sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount); {
if (_sendSplitToContainer is null)
return false;
_sendSplitToContainer(
action.ObjectId,
action.TargetId,
0u,
amount);
}
else else
_sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0); {
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(
action.ObjectId,
action.TargetId,
0);
}
return true;
});
break; break;
} }
case ItemPolicyActionKind.Reject: case ItemPolicyActionKind.Reject:
@ -571,11 +1011,93 @@ public sealed class ItemInteractionController : IDisposable
private void OnInteractionModeChanged(InteractionModeTransition _) private void OnInteractionModeChanged(InteractionModeTransition _)
=> StateChanged?.Invoke(); => StateChanged?.Invoke();
private void OnInventoryObjectMoved(ClientObjectMove move)
{
if (move.Origin != ClientObjectMoveOrigin.AuthoritativeResponse)
return;
CompleteInventoryResponse(move.ItemId, move.Item);
}
private void OnInventoryMoveFailed(MoveRequestFailure failure)
{
ClientObject? identity = _objects.Get(failure.ItemId);
CompleteInventoryResponse(failure.ItemId, identity);
}
private void OnInventoryObjectRemoved(ClientObject item)
{
CompleteInventoryResponse(item.ObjectId, item);
}
private void OnInventoryStackSizeUpdated(ClientObject item)
{
CompleteInventoryResponse(item.ObjectId, item);
}
private void CompleteInventoryResponse(uint itemId, ClientObject? identity)
{
PendingInventoryRequest? completedRequest = null;
PendingBackpackPlacement? completedPlacement = null;
if (_pendingInventoryRequest is { } request
&& request.ItemId == itemId
&& MatchesIdentity(request.ItemIdentity, itemId, identity))
{
completedRequest = request;
_pendingInventoryRequest = null;
}
if (_pendingBackpackPlacement is { } placement
&& placement.ItemId == itemId
&& MatchesIdentity(placement.ItemIdentity, itemId, identity))
{
completedPlacement = placement;
_pendingBackpackPlacement = null;
}
// ACCWeenieObject::RecordResponse @ 0x0058CAB0 clears prevRequest
// before ServerSaysMoveItem publishes the item-list notice. Clear the
// coupled global owner and local waiting projection atomically before
// either callback so observers never see a half-completed transaction.
if (completedPlacement is { } resolved)
PendingBackpackPlacementResolved?.Invoke(resolved);
if (completedRequest is not null)
StateChanged?.Invoke();
}
private bool MatchesIdentity(
ClientObject? expected,
uint itemId,
ClientObject? actual)
{
if (expected is null)
return true;
if (actual is not null)
return ReferenceEquals(expected, actual);
return _objects.Get(itemId) is not { } current
|| ReferenceEquals(expected, current);
}
private void OnInventoryObjectsCleared()
{
bool changed = _pendingInventoryRequest is not null;
_pendingInventoryRequest = null;
_pendingBackpackPlacement = null;
if (changed)
StateChanged?.Invoke();
}
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
_interactionState.Changed -= OnInteractionModeChanged; _interactionState.Changed -= OnInteractionModeChanged;
_objects.ObjectMoved -= OnInventoryObjectMoved;
_objects.MoveRequestFailed -= OnInventoryMoveFailed;
_objects.ObjectRemoved -= OnInventoryObjectRemoved;
_objects.StackSizeUpdated -= OnInventoryStackSizeUpdated;
_objects.Cleared -= OnInventoryObjectsCleared;
_autoWield.Dispose(); _autoWield.Dispose();
} }
@ -602,9 +1124,11 @@ public sealed class ItemInteractionController : IDisposable
/// </summary> /// </summary>
public void ResetSession() public void ResetSession()
{ {
bool busyChanged = _busyCount != 0; CancelPendingBackpackPlacement();
bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null;
bool modeChanged = IsAnyTargetModeActive; bool modeChanged = IsAnyTargetModeActive;
_busyCount = 0; _busyCount = 0;
_pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2; _lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u; _consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2; _consumedPrimaryClickMs = long.MinValue / 2;

View file

@ -223,6 +223,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
{ {
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
return; return;
if (!_itemInteraction.EnsureInventoryRequestReady())
return;
if (_objects.Get(payload.ObjId) is not { } item) if (_objects.Get(payload.ObjId) is not { } item)
return; return;
@ -238,10 +240,20 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and // ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and
// UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not // UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not
// optimistically rewrite external ownership. // optimistically rewrite external ownership.
InventoryRequestKind kind = amount < fullStack
? InventoryRequestKind.SplitToContainer
: InventoryRequestKind.PutInContainer;
_itemInteraction.TryDispatchInventoryRequest(
kind,
item.ObjectId,
() =>
{
if (amount < fullStack) if (amount < fullStack)
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
else else
_sendPutItemInContainer(item.ObjectId, _openContainer, placement); _sendPutItemInContainer(item.ObjectId, _openContainer, placement);
return true;
});
} }
private void OnExternalContainerChanged(ExternalContainerTransition transition) private void OnExternalContainerChanged(ExternalContainerTransition transition)

View file

@ -67,6 +67,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private bool _disposed; private bool _disposed;
private readonly record struct PendingListPlacement( private readonly record struct PendingListPlacement(
ulong Token,
uint ItemId, uint ItemId,
uint ContainerId, uint ContainerId,
int Placement); int Placement);
@ -183,6 +184,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{ {
_itemInteraction.StateChanged += OnInteractionStateChanged; _itemInteraction.StateChanged += OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested += OnPendingBackpackPlacementRequested; _itemInteraction.PendingBackpackPlacementRequested += OnPendingBackpackPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled += OnPendingBackpackPlacementCancelled;
_itemInteraction.PendingBackpackPlacementResolved += OnPendingBackpackPlacementResolved;
} }
Populate(); Populate();
@ -242,7 +245,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
} }
private void OnObjectRemoved(ClientObject o) private void OnObjectRemoved(ClientObject o)
{ {
if (_pendingListPlacement?.ItemId == o.ObjectId) bool fallbackResolved = _itemInteraction is null
&& _pendingListPlacement?.ItemId == o.ObjectId;
if (fallbackResolved)
_pendingListPlacement = null; _pendingListPlacement = null;
if (_selection.SelectedObjectId == o.ObjectId) if (_selection.SelectedObjectId == o.ObjectId)
{ {
@ -250,15 +255,16 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
SelectionChangeSource.System, SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved); SelectionChangeReason.SelectedObjectRemoved);
} }
if (Concerns(o)) Populate(); if (fallbackResolved || Concerns(o)) Populate();
} }
private void OnObjectMoved(ClientObjectMove move) private void OnObjectMoved(ClientObjectMove move)
{ {
bool resolvedPending = _pendingListPlacement?.ItemId == move.ItemId; bool fallbackResolved = _itemInteraction is null
if (resolvedPending) && _pendingListPlacement?.ItemId == move.ItemId;
if (fallbackResolved)
_pendingListPlacement = null; _pendingListPlacement = null;
uint player = _playerGuid(); uint player = _playerGuid();
if (resolvedPending if (fallbackResolved
|| (move.Item is { } item && Concerns(item)) || (move.Item is { } item && Concerns(item))
|| move.Previous.ContainerId == player || move.Previous.ContainerId == player
|| move.Current.ContainerId == player || move.Current.ContainerId == player
@ -272,30 +278,45 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Populate(); Populate();
} }
private void OnInteractionStateChanged() => ApplyIndicators(); private void OnInteractionStateChanged() => ApplyIndicators();
private void OnPendingBackpackPlacementRequested( private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending)
uint itemId,
uint containerId,
int placement)
{ {
if (_pendingListPlacement is not null if (_pendingListPlacement is not null
|| itemId == 0u || pending.ItemId == 0u
|| containerId != EffectiveOpen() || pending.ContainerId != EffectiveOpen()
|| _objects.Get(itemId) is not { } item || _objects.Get(pending.ItemId) is not { } item
|| IsBag(item)) || IsBag(item))
{ {
return; return;
} }
_pendingListPlacement = new PendingListPlacement( _pendingListPlacement = new PendingListPlacement(
itemId, pending.Token,
containerId, pending.ItemId,
placement); pending.ContainerId,
pending.Placement);
Populate();
}
private void OnPendingBackpackPlacementCancelled(PendingBackpackPlacement pending)
{
if (_pendingListPlacement is not { } projection
|| projection.Token != pending.Token)
return;
_pendingListPlacement = null;
Populate();
}
private void OnPendingBackpackPlacementResolved(PendingBackpackPlacement pending)
{
if (_pendingListPlacement is not { } projection
|| projection.Token != pending.Token)
return;
_pendingListPlacement = null;
Populate(); Populate();
} }
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnMoveRequestFailed(MoveRequestFailure failure) private void OnMoveRequestFailed(MoveRequestFailure failure)
{ {
if (_pendingListPlacement?.ItemId != failure.ItemId) if (_itemInteraction is not null
|| _pendingListPlacement?.ItemId != failure.ItemId)
return; return;
_pendingListPlacement = null; _pendingListPlacement = null;
Populate(); Populate();
@ -531,9 +552,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
return ItemDragAcceptance.None; return ItemDragAcceptance.None;
if (payload.ObjId == 0) if (payload.ObjId == 0)
return ItemDragAcceptance.Reject; return ItemDragAcceptance.Reject;
if (payload.SourceKind == ItemDragSource.Ground
&& _pendingListPlacement is not null)
return ItemDragAcceptance.Reject;
if (targetList == _contentsGrid) if (targetList == _contentsGrid)
return ItemDragAcceptance.Accept; return ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer) if (targetList == _containerList || targetList == _topContainer)
@ -561,6 +579,23 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint item = payload.ObjId; uint item = payload.ObjId;
if (item == 0) return; if (item == 0) return;
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
// release while m_pendingItem exists, before merge, split, or ordinary
// placement. ItemList_DragOver has no equivalent gate, so hover may
// still show the authored accept cursor immediately before this no-op.
if (targetList == _contentsGrid
&& _pendingListPlacement is { } localPending
&& localPending.ContainerId == EffectiveOpen())
{
_itemInteraction?.ReportPendingBackpackPlacementConflict();
return;
}
if (_itemInteraction is not null
&& !_itemInteraction.EnsureInventoryRequestReady())
{
return;
}
// UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first // UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first
// when a physical item is released on an occupied inventory cell. A legal // when a physical item is released on an occupied inventory cell. A legal
// merge consumes the drop; an illegal merge falls through to insert/move. // merge consumes the drop; an illegal merge falls through to insert/move.
@ -597,8 +632,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{ {
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will // UIAttemptSplitToContainer leaves the source stack where it is. ACE will
// publish the reduced source plus a newly-guided destination stack. // publish the reduced source plus a newly-guided destination stack.
_sendStackableSplitToContainer?.Invoke( DispatchInventoryRequest(
item, container, (uint)placement, splitSize); InventoryRequestKind.SplitToContainer,
item,
() =>
{
if (_sendStackableSplitToContainer is null)
return false;
_sendStackableSplitToContainer(
item,
container,
(uint)placement,
splitSize);
return true;
});
return; return;
} }
} }
@ -610,13 +657,52 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680. // @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
if (payload.SourceKind == ItemDragSource.Ground) if (payload.SourceKind == ItemDragSource.Ground)
{ {
if (_pendingListPlacement is not null) if (_itemInteraction is not null)
{
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
item,
container,
placement,
InventoryRequestKind.Pickup,
() =>
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(item, container, placement);
return true;
}))
{
return;
}
return; return;
_pendingListPlacement = new PendingListPlacement(item, container, placement);
Populate();
} }
else else
{ {
if (_pendingListPlacement is not null)
return;
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
Populate();
}
}
else
{
if (_itemInteraction is not null)
{
DispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
item,
() =>
{
if (_sendPutItemInContainer is null
|| !_objects.MoveItemOptimistic(item, container, placement))
{
return false;
}
_sendPutItemInContainer(item, container, placement);
return true;
});
return;
}
_objects.MoveItemOptimistic(item, container, placement); _objects.MoveItemOptimistic(item, container, placement);
} }
_sendPutItemInContainer?.Invoke(item, container, placement); _sendPutItemInContainer?.Invoke(item, container, placement);
@ -651,14 +737,30 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (plan is not { } merge) if (plan is not { } merge)
return false; return false;
_sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount); return DispatchInventoryRequest(
InventoryRequestKind.Merge,
merge.SourceObjectId,
() =>
{
_sendStackableMerge(
merge.SourceObjectId,
merge.TargetObjectId,
merge.Amount);
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge; // ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
// it is an attempt notice, not a later server-confirm event. // it is an attempt notice, not a later server-confirm event.
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId); _notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory); _selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
return true; return true;
});
} }
private bool DispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func<bool> dispatch)
=> _itemInteraction?.TryDispatchInventoryRequest(kind, itemId, dispatch)
?? dispatch();
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A /// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary> /// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
private bool IsContainerFull(uint container) private bool IsContainerFull(uint container)
@ -691,7 +793,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint p = _playerGuid(); uint p = _playerGuid();
_openContainer = guid; _openContainer = guid;
if (guid != p) if (guid != p)
{
if (_itemInteraction is not null)
_itemInteraction.ActivateItem(guid);
else
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land) _sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
}
Populate(); // repaint the grid for the new open container Populate(); // repaint the grid for the new open container
} }
@ -814,6 +921,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{ {
_itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested; _itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled;
_itemInteraction.PendingBackpackPlacementResolved -= OnPendingBackpackPlacementResolved;
} }
} }
} }

View file

@ -519,6 +519,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
uint player = _playerGuid?.Invoke() ?? 0u; uint player = _playerGuid?.Invoke() ?? 0u;
if (player == 0 || _repo.Get(payload.ObjId) is null) if (player == 0 || _repo.Get(payload.ObjId) is null)
return; return;
if (_itemInteraction is not null)
{
Func<bool> dispatch = () =>
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(payload.ObjId, player, 0);
return true;
};
if (_itemInteraction.IsOwnedByPlayer(payload.ObjId))
_itemInteraction.TryDispatchPendingBackpackPlacement(
payload.ObjId,
player,
0,
InventoryRequestKind.PutInContainer,
dispatch);
else
_itemInteraction.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
payload.ObjId,
dispatch);
return;
}
_sendPutItemInContainer?.Invoke(payload.ObjId, player, 0); _sendPutItemInContainer?.Invoke(payload.ObjId, player, 0);
} }

View file

@ -50,11 +50,19 @@ public readonly record struct ClientObjectPlacement(
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and /// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and
/// publishes both complete placements after applying the new state. /// publishes both complete placements after applying the new state.
/// </summary> /// </summary>
public enum ClientObjectMoveOrigin
{
LocalProjection,
ServerRollbackProjection,
AuthoritativeResponse,
}
public readonly record struct ClientObjectMove( public readonly record struct ClientObjectMove(
uint ItemId, uint ItemId,
ClientObject? Item, ClientObject? Item,
ClientObjectPlacement Previous, ClientObjectPlacement Previous,
ClientObjectPlacement Current); ClientObjectPlacement Current,
ClientObjectMoveOrigin Origin);
public enum ClientObjectRemovalReason public enum ClientObjectRemovalReason
{ {
@ -119,7 +127,10 @@ public sealed class ClientObjectTable
/// <summary> /// <summary>
/// Fires after a complete placement notice (moved between packs, /// Fires after a complete placement notice (moved between packs,
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Item"/> /// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Origin"/>
/// distinguishes an immediate local projection from ACE's authoritative
/// response; transaction owners must complete only on the latter.
/// <see cref="ClientObjectMove.Item"/>
/// is null when retail publishes the notice before CreateObject resolves /// is null when retail publishes the notice before CreateObject resolves
/// the GUID; the old/new placement remains authoritative notice data. /// the GUID; the old/new placement remains authoritative notice data.
/// </summary> /// </summary>
@ -168,6 +179,13 @@ public sealed class ClientObjectTable
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary> /// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
public event Action<ClientObject>? ObjectUpdated; public event Action<ClientObject>? ObjectUpdated;
/// <summary>
/// Fires only for an authoritative SetStackSize response. Inventory request
/// owners use this narrower seam instead of treating an unrelated appraisal
/// or property update as the response to a merge, split, give, or drop.
/// </summary>
public event Action<ClientObject>? StackSizeUpdated;
/// <summary>Fires after all session object and pending-move state is flushed.</summary> /// <summary>Fires after all session object and pending-move state is flushed.</summary>
public event Action? Cleared; public event Action? Cleared;
@ -242,7 +260,8 @@ public sealed class ClientObjectTable
newSlot, newSlot,
item.WielderId, item.WielderId,
newEquipLocation), newEquipLocation),
containerTypeHint); containerTypeHint,
ClientObjectMoveOrigin.LocalProjection);
} }
/// <summary> /// <summary>
@ -270,13 +289,15 @@ public sealed class ClientObjectTable
newSlot, newSlot,
newWielderId, newWielderId,
newEquipLocation), newEquipLocation),
containerTypeHint); containerTypeHint,
ClientObjectMoveOrigin.AuthoritativeResponse);
} }
private bool ApplyPlacement( private bool ApplyPlacement(
ClientObject item, ClientObject item,
ClientObjectPlacement current, ClientObjectPlacement current,
uint? containerTypeHint = null) uint? containerTypeHint,
ClientObjectMoveOrigin origin)
{ {
ClientObjectPlacement previous = ClientObjectPlacement.From(item); ClientObjectPlacement previous = ClientObjectPlacement.From(item);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes( List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
@ -289,18 +310,19 @@ public sealed class ClientObjectTable
if (containerTypeHint is { } hint) if (containerTypeHint is { } hint)
item.ContainerTypeHint = hint; item.ContainerTypeHint = hint;
Reindex(item, previous.ContainerId); Reindex(item, previous.ContainerId);
PublishPlacementChange(item, previous); PublishPlacementChange(item, previous, origin);
PublishContainerContentsChanges(changedContainers); PublishContainerContentsChanges(changedContainers);
return true; return true;
} }
private void PublishPlacementChange( private void PublishPlacementChange(
ClientObject item, ClientObject item,
ClientObjectPlacement previous) ClientObjectPlacement previous,
ClientObjectMoveOrigin origin)
{ {
ClientObjectPlacement current = ClientObjectPlacement.From(item); ClientObjectPlacement current = ClientObjectPlacement.From(item);
UpdateEquipmentIndex(item.ObjectId, previous, current); UpdateEquipmentIndex(item.ObjectId, previous, current);
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current)); ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current, origin));
} }
/// <summary> /// <summary>
@ -337,7 +359,8 @@ public sealed class ClientObjectTable
newContainerId, newContainerId,
newSlot, newSlot,
newWielderId, newWielderId,
newEquipLocation))); newEquipLocation),
ClientObjectMoveOrigin.AuthoritativeResponse));
return false; return false;
} }
@ -364,14 +387,17 @@ public sealed class ClientObjectTable
itemId, itemId,
Item: null, Item: null,
Previous: default, Previous: default,
Current: placement)); Current: placement,
ClientObjectMoveOrigin.AuthoritativeResponse));
WieldConfirmed?.Invoke(itemId); WieldConfirmed?.Invoke(itemId);
return false; return false;
} }
if (!ApplyPlacement( if (!ApplyPlacement(
item, item,
placement)) placement,
containerTypeHint: null,
ClientObjectMoveOrigin.AuthoritativeResponse))
{ {
return false; return false;
} }
@ -434,7 +460,7 @@ public sealed class ClientObjectTable
} }
else item.ContainerSlot = newSlot; else item.ContainerSlot = newSlot;
PublishPlacementChange(item, previous); PublishPlacementChange(item, previous, ClientObjectMoveOrigin.LocalProjection);
PublishContainerContentsChanges(changedContainers); PublishContainerContentsChanges(changedContainers);
return true; return true;
} }
@ -485,12 +511,12 @@ public sealed class ClientObjectTable
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
_pendingMoves.Remove(itemId); _pendingMoves.Remove(itemId);
ClientObjectPlacement placement = pre.placement; ClientObjectPlacement placement = pre.placement;
if (!ApplyServerMove( if (!_objects.TryGetValue(itemId, out ClientObject? item)
itemId, || !ApplyPlacement(
placement.ContainerId, item,
placement.WielderId, placement,
placement.ContainerSlot, containerTypeHint: null,
placement.EquipLocation)) ClientObjectMoveOrigin.ServerRollbackProjection))
return false; return false;
MoveRolledBack?.Invoke(_objects[itemId]); MoveRolledBack?.Invoke(_objects[itemId]);
return true; return true;
@ -654,6 +680,7 @@ public sealed class ClientObjectTable
item.StackSize = stackSize; item.StackSize = stackSize;
item.Value = value; item.Value = value;
ObjectUpdated?.Invoke(item); ObjectUpdated?.Invoke(item);
StackSizeUpdated?.Invoke(item);
return true; return true;
} }

View file

@ -167,6 +167,14 @@ public sealed class MoveToManager
/// </summary> /// </summary>
public Action<WeenieError>? MoveToComplete { get; set; } public Action<WeenieError>? MoveToComplete { get; set; }
/// <summary>
/// CLIENT ADDITION — cancellation companion to <see cref="MoveToComplete"/>.
/// Fires only when <see cref="CancelMoveTo"/> actually tears down an
/// active move. It never changes retail movement behavior; App owners use
/// it to withdraw presentation state which was waiting for natural arrival.
/// </summary>
public Action<WeenieError>? MoveToCancelled { get; set; }
/// <summary>Retail <c>Timer::cur_time</c> — injectable clock (tests drive /// <summary>Retail <c>Timer::cur_time</c> — injectable clock (tests drive
/// this explicitly; production binds to the real wall/game clock). /// this explicitly; production binds to the real wall/game clock).
/// Defaults to a monotonically-increasing stub if not overridden via the /// Defaults to a monotonically-increasing stub if not overridden via the
@ -1455,18 +1463,18 @@ public sealed class MoveToManager
/// <see cref="_pendingActions"/>, <see cref="CleanUp"/>, then /// <see cref="_pendingActions"/>, <see cref="CleanUp"/>, then
/// StopCompletely. The <paramref name="error"/> argument is NEVER READ /// StopCompletely. The <paramref name="error"/> argument is NEVER READ
/// in retail's body (every call site's error code is dropped in this /// in retail's body (every call site's error code is dropped in this
/// build, decomp §7c) — kept for parity/logging/tests only; NO behavior /// build, decomp §7c). The retail body remains independent of it; only the
/// depends on its value. /// documented client-addition <see cref="MoveToCancelled"/> observes it
/// after teardown so App presentation can classify the cancellation.
/// </summary> /// </summary>
public void CancelMoveTo(WeenieError error) public void CancelMoveTo(WeenieError error)
{ {
_ = error; // retail: never read in the body (decomp §7c) — kept for parity/logging.
if (MovementTypeState == MovementType.Invalid) return; if (MovementTypeState == MovementType.Invalid) return;
_pendingActions.Clear(); _pendingActions.Clear();
CleanUp(); CleanUp();
if (HasPhysicsObj) _stopCompletely(); if (HasPhysicsObj) _stopCompletely();
MoveToCancelled?.Invoke(error);
} }
/// <summary> /// <summary>

View file

@ -53,4 +53,43 @@ public sealed class OutboundInteractionQueueTests
Assert.False(invoked); Assert.False(invoked);
} }
[Fact]
public void Drain_ReentrantClearStopsTheCapturedBatch()
{
var queue = new OutboundInteractionQueue();
var actual = new List<string>();
queue.Enqueue(() =>
{
actual.Add("first");
queue.Clear();
});
queue.Enqueue(() => actual.Add("stale"));
queue.Drain();
Assert.Equal(new[] { "first" }, actual);
Assert.Equal(0, queue.Count);
}
[Fact]
public void Drain_ReentrantClearAndEnqueueDefersTheNewEpoch()
{
var queue = new OutboundInteractionQueue();
var actual = new List<string>();
queue.Enqueue(() =>
{
actual.Add("first");
queue.Clear();
queue.Enqueue(() => actual.Add("new-session"));
});
queue.Enqueue(() => actual.Add("stale"));
queue.Drain();
Assert.Equal(new[] { "first" }, actual);
Assert.Equal(1, queue.Count);
queue.Drain();
Assert.Equal(new[] { "first", "new-session" }, actual);
}
} }

View file

@ -0,0 +1,106 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Interaction;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
namespace AcDream.App.Tests.Interaction;
public sealed class PlayerInteractionMovementSinkTests
{
private const uint Player = 0x5000_0001u;
private const uint Target = 0x7000_0001u;
private const uint Cell = 0x0101_0001u;
[Fact]
public void MissingPlayerDoesNotArmTheIntent()
{
var sink = new PlayerInteractionMovementSink(() => null);
bool armed = false;
Assert.False(sink.BeginApproach(Approach(closeRange: true), () => armed = true));
Assert.False(armed);
}
[Theory]
[InlineData(true, MovementType.TurnToObject)]
[InlineData(false, MovementType.MoveToObject)]
public void ProductionSinkCancelsThenArmsAndInstallsExactMovement(
bool closeRange,
MovementType expectedType)
{
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SetPosition(Vector3.Zero, Cell);
bool nonAutonomousAtTargetInstall = false;
int cancellations = 0;
double targetQuantum = 0d;
var moveTo = new MoveToManager(
controller.Motion,
stopCompletely: () => { },
getPosition: () => controller.CellPosition,
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
setHeading: (heading, _) => controller.Yaw = MoveToMath.YawFromHeading(heading),
getOwnRadius: () => 0.4f,
getOwnHeight: () => 1.8f,
contact: () => true,
isInterpolating: () => false,
getVelocity: () => Vector3.Zero,
getSelfId: () => Player,
setTarget: (_, topLevelId, radius, quantum) =>
{
Assert.Equal(Target, topLevelId);
Assert.Equal(0.5f, radius);
targetQuantum = quantum;
nonAutonomousAtTargetInstall =
controller.Motion.PhysicsObj?.LastMoveWasAutonomous == false;
},
clearTarget: () => { },
getTargetQuantum: () => targetQuantum,
setTargetQuantum: quantum => targetQuantum = quantum);
moveTo.MoveToCancelled = _ => cancellations++;
controller.MoveTo = moveTo;
// Prove the sink's explicit cancel occurs before its arm callback.
moveTo.MoveToPosition(
new Position(Cell, new Vector3(2f, 0f, 0f), Quaternion.Identity),
new MovementParameters { UseSpheres = false });
bool armedAfterCancellation = false;
var sink = new PlayerInteractionMovementSink(() => controller);
Assert.True(sink.BeginApproach(
Approach(closeRange),
() => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo()));
Assert.True(armedAfterCancellation);
Assert.True(nonAutonomousAtTargetInstall);
Assert.Equal(expectedType, moveTo.MovementTypeState);
Assert.Equal(Target, moveTo.SoughtObjectId);
Assert.Equal(Target, moveTo.TopLevelObjectId);
Assert.Equal(closeRange ? 0f : 0.75f, moveTo.SoughtObjectRadius);
Assert.Equal(closeRange ? 0f : 2.25f, moveTo.SoughtObjectHeight);
Assert.Equal(0.6f, moveTo.Params.DistanceToObject);
Assert.Equal(!closeRange, moveTo.Params.CanCharge);
}
private static InteractionApproach Approach(bool closeRange)
{
var entity = new WorldEntity
{
Id = 101u,
ServerGuid = Target,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = new Vector3(5f, 0f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = [],
};
return new InteractionApproach(
new WorldInteractionTarget(Target, entity.Id, entity),
new PlayerInteractionPose(Cell, Vector3.Zero),
UseRadius: 0.6f,
IsCloseRange: closeRange,
CanCharge: !closeRange,
TargetRadius: 0.75f,
TargetHeight: 2.25f);
}
}

View file

@ -26,6 +26,8 @@ public sealed class SelectionInteractionControllerTests
public bool Hostile { get; set; } public bool Hostile { get; set; }
public bool Useable { get; set; } = true; public bool Useable { get; set; } = true;
public bool Pickupable { get; set; } = true; public bool Pickupable { get; set; } = true;
public bool CaptureIdentity { get; set; } = true;
public uint LocalEntityId { get; set; } = 101u;
public ClosestCombatTarget? Closest { get; set; } public ClosestCombatTarget? Closest { get; set; }
public InteractionApproach? Approach { get; set; } public InteractionApproach? Approach { get; set; }
public List<string> Events { get; } = new(); public List<string> Events { get; } = new();
@ -41,6 +43,11 @@ public sealed class SelectionInteractionControllerTests
=> PickAtCursor(includeSelf); => PickAtCursor(includeSelf);
public void BeginLightingPulse(uint serverGuid) => Events.Add("pulse"); public void BeginLightingPulse(uint serverGuid) => Events.Add("pulse");
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
{
localEntityId = LocalEntityId;
return CaptureIdentity;
}
public bool IsCurrent(uint serverGuid, uint localEntityId) => Current; public bool IsCurrent(uint serverGuid, uint localEntityId) => Current;
public string Describe(uint serverGuid) => $"Target {serverGuid:X8}"; public string Describe(uint serverGuid) => $"Target {serverGuid:X8}";
public bool IsCreature(uint serverGuid) => Creature; public bool IsCreature(uint serverGuid) => Creature;
@ -100,9 +107,15 @@ public sealed class SelectionInteractionControllerTests
private sealed class Movement : IPlayerInteractionMovementSink private sealed class Movement : IPlayerInteractionMovementSink
{ {
public List<InteractionApproach> Approaches { get; } = new(); public List<InteractionApproach> Approaches { get; } = new();
public bool BeginApproach(InteractionApproach approach) public bool Starts { get; set; } = true;
public Action? AfterArm { get; set; }
public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
{ {
Approaches.Add(approach); Approaches.Add(approach);
if (!Starts)
return false;
armAfterCancel?.Invoke();
AfterArm?.Invoke();
return true; return true;
} }
} }
@ -116,6 +129,8 @@ public sealed class SelectionInteractionControllerTests
public readonly ClientObjectTable Objects = new(); public readonly ClientObjectTable Objects = new();
public readonly List<string> Toasts = new(); public readonly List<string> Toasts = new();
public readonly List<uint> Examines = new(); public readonly List<uint> Examines = new();
public readonly List<PendingBackpackPlacement> PendingPlacements = new();
public readonly List<PendingBackpackPlacement> CancelledPlacements = new();
public readonly ItemInteractionController Items; public readonly ItemInteractionController Items;
public readonly SelectionInteractionController Controller; public readonly SelectionInteractionController Controller;
@ -127,6 +142,12 @@ public sealed class SelectionInteractionControllerTests
ObjectId = Player, ObjectId = Player,
Type = ItemType.Creature, Type = ItemType.Creature,
}); });
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Target,
Name = "Target",
Type = ItemType.Misc,
});
Items = new ItemInteractionController( Items = new ItemInteractionController(
Objects, Objects,
() => Player, () => Player,
@ -148,6 +169,8 @@ public sealed class SelectionInteractionControllerTests
Transport, Transport,
Movement, Movement,
Toasts.Add); Toasts.Add);
Items.PendingBackpackPlacementRequested += PendingPlacements.Add;
Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add;
} }
public void SetApproach(bool closeRange, uint localEntityId = 101u) public void SetApproach(bool closeRange, uint localEntityId = 101u)
@ -270,12 +293,14 @@ public sealed class SelectionInteractionControllerTests
h.Selection.Select(Target, SelectionChangeSource.World); h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.SelectionPickUp); h.Controller.HandleInputAction(InputAction.SelectionPickUp);
h.Controller.SendUse(Target); h.Controller.SendUse(Target);
h.Items.InteractionState.EnterExamine();
h.Controller.ResetSession(); h.Controller.ResetSession();
h.Controller.DrainOutbound(); h.Controller.DrainOutbound();
h.Controller.OnNaturalMoveToComplete(); h.Controller.OnNaturalMoveToComplete();
Assert.Null(h.Selection.SelectedObjectId); Assert.Null(h.Selection.SelectedObjectId);
Assert.False(h.Items.IsAnyTargetModeActive);
Assert.Empty(h.Transport.Pickups); Assert.Empty(h.Transport.Pickups);
Assert.Empty(h.Transport.Uses); Assert.Empty(h.Transport.Uses);
} }
@ -305,6 +330,216 @@ public sealed class SelectionInteractionControllerTests
Assert.Empty(h.Transport.Uses); Assert.Empty(h.Transport.Uses);
} }
[Fact]
public void SynchronousNaturalCompletionObservesTheArmedCloseAction()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete;
h.Controller.SendUse(Target);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[Fact]
public void SynchronousResetCannotResurrectTheCloseAction()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.AfterArm = h.Controller.ResetSession;
h.Controller.SendUse(Target);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void CancelledClosePickupWithdrawsPresentationAndNeverFiresLater()
{
var h = new Harness();
h.SetApproach(closeRange: true);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
Assert.Single(h.PendingPlacements);
Assert.Empty(h.Transport.Pickups);
h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId));
Assert.Empty(h.Transport.Pickups);
}
[Fact]
public void ErrorCompletionClearsCloseActionBeforeALaterSuccess()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Controller.SendUse(Target);
h.Controller.OnMoveToCancelled(WeenieError.NoObject);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void FailedClosePickupStartWithdrawsPresentation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
h.Movement.Starts = false;
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
Assert.Single(h.PendingPlacements);
Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId));
Assert.Empty(h.Transport.Pickups);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void InvalidKeyboardPickupNeverPublishesAWaitingSlot(bool creature)
{
var h = new Harness();
h.Query.Creature = creature;
h.Query.Pickupable = false;
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
h.Controller.DrainOutbound();
Assert.Empty(h.PendingPlacements);
Assert.Empty(h.Transport.Pickups);
}
[Theory]
[InlineData(InputAction.SelectDblLeft)]
[InlineData(InputAction.UseSelected)]
[InlineData(InputAction.SelectionPickUp)]
public void QueuedWorldActionCannotCrossAnIncarnationBoundary(InputAction action)
{
var h = new Harness();
h.Query.Picked = Target;
h.SetApproach(closeRange: false);
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(action);
h.Query.Current = false;
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
Assert.Empty(h.PendingPlacements);
}
[Fact]
public void DragReleasePulsesTheDropTargetWithoutChangingSelection()
{
var h = new Harness();
h.Query.Picked = Target;
const uint item = 0x7000_0002u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "Dragged",
Type = ItemType.Misc,
});
var payload = new ItemDragPayload(
item,
ItemDragSource.Inventory,
SourceSlot: 0,
new UiItemSlot());
h.Controller.PlaceDraggedItem(payload, 10f, 20f);
Assert.Contains("pulse", h.Query.Events);
Assert.Null(h.Selection.SelectedObjectId);
}
[Fact]
public void RejectedTargetModeClickConsumesTheFollowingDoubleClick()
{
var h = new Harness();
h.Query.Picked = Target;
h.Objects.Remove(Target);
h.Items.InteractionState.EnterUse();
h.Controller.HandleInputAction(InputAction.SelectLeft);
h.Controller.HandleInputAction(InputAction.SelectDblLeft);
h.Controller.DrainOutbound();
Assert.Null(h.Selection.SelectedObjectId);
Assert.Empty(h.Transport.Uses);
Assert.Empty(h.Transport.Pickups);
}
[Fact]
public void TransportLossAtClosePickupCompletionWithdrawsPresentation()
{
var h = new Harness();
h.SetApproach(closeRange: true);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
h.Transport.IsInWorld = false;
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId));
Assert.Empty(h.Transport.Pickups);
}
[Fact]
public void RepeatedPickupKeepsTheOriginalReservationAndDeferredAction()
{
var h = new Harness();
h.SetApproach(closeRange: true);
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
h.Selection.Select(Target, SelectionChangeSource.World);
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
h.Controller.DrainOutbound();
Assert.Single(h.PendingPlacements);
Assert.Empty(h.CancelledPlacements);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups);
}
[Fact]
public void WithdrawnPickupReservationInvalidatesDeferredWireAction()
{
const uint replacement = 0x7000_0002u;
var h = new Harness();
h.SetApproach(closeRange: true);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = replacement,
Name = "Replacement loot",
Type = ItemType.Misc,
});
Assert.True(h.Items.PlaceWorldItemInBackpack(Target));
Assert.Empty(h.Transport.Pickups);
PendingBackpackPlacement original = Assert.Single(h.PendingPlacements);
h.Items.CancelPendingBackpackPlacement(Target, original.Token);
Assert.True(h.Items.TryBeginPendingBackpackPlacement(
replacement,
Player,
placement: 0,
out _));
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Transport.Pickups);
Assert.True(h.Items.TryGetPendingBackpackPlacement(replacement, out _));
Assert.False(h.Items.TryGetPendingBackpackPlacement(Target, out _));
}
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
=> new( => new(
guid, guid,

View file

@ -32,6 +32,9 @@ public sealed class WorldSelectionQueryTests
public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh; public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh;
} }
private sealed record Animation(WorldEntity Entity, uint CurrentMotion)
: ILiveEntityAnimationRuntime;
private sealed class Harness private sealed class Harness
{ {
public readonly ClientObjectTable Objects = new(); public readonly ClientObjectTable Objects = new();
@ -131,6 +134,41 @@ public sealed class WorldSelectionQueryTests
Assert.Null(h.Query.PickAtCursor(includeSelf: false)); Assert.Null(h.Query.PickAtCursor(includeSelf: false));
} }
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void PickRejectsPublishedPartAfterVisibilityLifetimeEnds(int transition)
{
var h = new Harness();
WorldEntity target = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc);
h.Publish(target);
Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false));
switch (transition)
{
case 0:
Assert.True(h.Runtime.TryApplyState(
new SetState.Parsed(
Target,
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
InstanceSequence: 1,
StateSequence: 2),
out _));
break;
case 1:
Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target));
break;
default:
Assert.True(h.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Target, 1),
isLocalPlayer: false));
break;
}
Assert.Null(h.Query.PickAtCursor(includeSelf: false));
}
[Fact] [Fact]
public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters() public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters()
{ {
@ -150,6 +188,43 @@ public sealed class WorldSelectionQueryTests
new Vector3(1f, 0f, 0f), new Vector3(1f, 0f, 0f),
ItemType.Misc, ItemType.Misc,
SelectedObjectHealthPolicy.BfAttackable); SelectedObjectHealthPolicy.BfAttackable);
h.Add(
0x7000_0013u,
new Vector3(3f, 0f, 0f),
ItemType.Creature,
SelectedObjectHealthPolicy.BfAttackable | SelectedObjectHealthPolicy.BfPlayer);
WorldEntity pet = h.Add(
0x7000_0014u,
new Vector3(4f, 0f, 0f),
ItemType.Creature,
SelectedObjectHealthPolicy.BfAttackable);
h.Objects.Get(pet.ServerGuid)!.PetOwnerId = Player;
WorldEntity dead = h.Add(
0x7000_0015u,
new Vector3(5f, 0f, 0f),
ItemType.Creature,
SelectedObjectHealthPolicy.BfAttackable);
h.Runtime.SetAnimationRuntime(
dead.ServerGuid,
new Animation(dead, MotionCommand.Dead));
WorldEntity hidden = h.Add(
0x7000_0016u,
new Vector3(6f, 0f, 0f),
ItemType.Creature,
SelectedObjectHealthPolicy.BfAttackable);
Assert.True(h.Runtime.TryApplyState(
new SetState.Parsed(
hidden.ServerGuid,
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
InstanceSequence: 1,
StateSequence: 2),
out _));
WorldEntity pending = h.Add(
0x7000_0017u,
new Vector3(7f, 0f, 0f),
ItemType.Creature,
SelectedObjectHealthPolicy.BfAttackable);
Assert.True(h.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid));
ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster(); ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster();

View file

@ -1424,14 +1424,445 @@ public sealed class ItemInteractionControllerTests
{ {
var h = new Harness(); var h = new Harness();
const uint item = 0x70000A0Bu; const uint item = 0x70000A0Bu;
var pending = new List<(uint Item, uint Container, int Placement)>(); var pending = new List<PendingBackpackPlacement>();
h.Controller.PendingBackpackPlacementRequested += h.Controller.PendingBackpackPlacementRequested += pending.Add;
(objectId, container, placement) => pending.Add((objectId, container, placement));
Assert.True(h.Controller.PlaceWorldItemInBackpack(item)); Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
Assert.Equal(new[] { (item, Player, 0) }, pending); Assert.Collection(
pending,
placement =>
{
Assert.NotEqual(0u, placement.Token);
Assert.Equal(item, placement.ItemId);
Assert.Equal(Player, placement.ContainerId);
Assert.Equal(0, placement.Placement);
});
Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements); Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var request));
Assert.Equal(InventoryRequestKind.Pickup, request.Kind);
Assert.False(request.Dispatched);
Assert.True(h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
item,
static () => true,
pending[0].Token));
Assert.True(h.Controller.TryGetPendingInventoryRequest(out request));
Assert.True(request.Dispatched);
}
[Fact]
public void PendingRequestSerializesEveryLaterItemWithoutSecondWire()
{
var h = new Harness();
const uint item = 0x70000A0Du;
const uint laterItem = 0x70000A0Eu;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "Loot",
Type = ItemType.Misc,
});
var requested = new List<PendingBackpackPlacement>();
var cancelled = new List<PendingBackpackPlacement>();
h.Controller.PendingBackpackPlacementRequested += requested.Add;
h.Controller.PendingBackpackPlacementCancelled += cancelled.Add;
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
PendingBackpackPlacement first = Assert.Single(requested);
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
Assert.True(h.Controller.PlaceWorldItemInBackpack(laterItem));
Assert.Single(requested);
Assert.Empty(cancelled);
Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements);
Assert.Equal(
new[]
{
"Already attempting to place Loot here",
"Already attempting to place Loot here",
},
h.SystemMessages);
Assert.True(h.Controller.TryGetPendingBackpackPlacement(item, out var current));
Assert.Equal(first, current);
h.Objects.RejectMove(item, weenieError: 0x29u);
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
Assert.Empty(cancelled);
}
[Fact]
public void BusyInventoryTransactionPreventsPickupReservationAndWireRequest()
{
var h = new Harness();
const uint item = 0x70000A0Fu;
h.Controller.IncrementBusyCount();
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
Assert.Empty(h.BackpackPlacements);
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
Assert.Equal(
new[] { ItemInteractionController.InventoryRequestBusyMessage },
h.SystemMessages);
}
[Fact]
public void PendingPickupPreventsContainedUseRequest()
{
var h = new Harness();
const uint pickup = 0x70000A10u;
const uint contained = 0x50000A11u;
h.AddContained(contained, item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
h.Now += 200;
h.Controller.ActivateItem(contained);
Assert.Empty(h.Uses);
Assert.True(h.Controller.TryGetPendingBackpackPlacement(pickup, out _));
Assert.Equal(
new[] { ItemInteractionController.InventoryRequestBusyMessage },
h.SystemMessages);
}
[Fact]
public void PendingPickupPreventsPaperdollWieldRequest()
{
var h = new Harness();
const uint pickup = 0x70000A12u;
const uint helm = 0x50000A13u;
h.AddContained(helm, item => item.ValidLocations = EquipMask.HeadWear);
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
Assert.False(h.Controller.WieldFromPaperdoll(helm, EquipMask.HeadWear));
Assert.Empty(h.Wields);
Assert.Equal(EquipMask.None, h.Objects.Get(helm)!.CurrentlyEquippedLocation);
Assert.Equal(
new[] { ItemInteractionController.InventoryRequestBusyMessage },
h.SystemMessages);
}
[Fact]
public void PendingPickupPreventsConfirmedUseRequest()
{
var h = new Harness();
const uint pickup = 0x70000A14u;
const uint item = 0x50000A15u;
h.AddContained(item, contained => contained.Useability = ItemUseability.Contained);
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
Assert.False(h.Controller.ExecuteConfirmedUse(item));
Assert.Empty(h.Uses);
Assert.Equal(0, h.Controller.BusyCount);
Assert.Equal(
new[] { ItemInteractionController.InventoryRequestBusyMessage },
h.SystemMessages);
}
[Fact]
public void GlobalInventoryRequestRejectsPickupUntilMatchingMoveResponse()
{
var h = new Harness();
const uint moving = 0x50000A16u;
const uint unrelated = 0x50000A17u;
const uint pickup = 0x70000A18u;
h.AddContained(moving);
h.AddContained(unrelated);
Assert.True(h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
moving,
static () => true));
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
Assert.Empty(h.BackpackPlacements);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(moving, pending.ItemId);
Assert.Equal(
new[] { ItemInteractionController.InventoryRequestBusyMessage },
h.SystemMessages);
h.Objects.ApplyConfirmedServerMove(unrelated, Pack, 0u, 0);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out _));
h.Objects.ApplyConfirmedServerMove(moving, Pack, 0u, 0);
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
Assert.Equal(new[] { (pickup, Player, 0) }, h.BackpackPlacements);
}
[Fact]
public void SplitRequestReleasesOnlyOnSourceStackResponse()
{
var h = new Harness();
const uint source = 0x50000A19u;
const uint unrelated = 0x50000A1Au;
h.AddContained(source, item => item.StackSize = 10);
h.AddContained(unrelated, item => item.StackSize = 10);
Assert.True(h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.SplitToContainer,
source,
static () => true));
h.Objects.UpdateStackSize(unrelated, 9, 0);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out _));
h.Objects.UpdateStackSize(source, 9, 0);
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void MatchingInventoryFailureReleasesGlobalRequest()
{
var h = new Harness();
const uint source = 0x50000A1Bu;
h.AddContained(source);
Assert.True(h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
source,
static () => true));
h.Objects.RejectMove(source, weenieError: 0x29u);
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
Assert.True(h.Controller.CanMakeInventoryRequest);
}
[Fact]
public void ProvisionalInventoryOwnerRejectsReentrantSecondDispatch()
{
var h = new Harness();
const uint first = 0x50000A1Cu;
const uint second = 0x50000A1Du;
h.AddContained(first);
h.AddContained(second);
bool secondDispatched = true;
Assert.True(h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
first,
() =>
{
secondDispatched = h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
second,
static () => true);
return true;
}));
Assert.False(secondDispatched);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(first, pending.ItemId);
Assert.True(pending.Dispatched);
Assert.Equal(
new[] { ItemInteractionController.InventoryRequestBusyMessage },
h.SystemMessages);
}
[Fact]
public void SynchronousMatchingResponseDoesNotResurrectProvisionalOwner()
{
var h = new Harness();
const uint item = 0x50000A1Eu;
h.AddContained(item);
Assert.True(h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
item,
() => h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0)));
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
Assert.True(h.Controller.CanMakeInventoryRequest);
}
[Fact]
public void OptimisticMoveKeepsGlobalOwnerUntilAuthoritativeResponse()
{
var h = new Harness();
const uint item = 0x50000A20u;
const uint second = 0x50000A21u;
h.AddContained(item);
h.AddContained(second);
Assert.True(h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
item,
() => h.Objects.MoveItemOptimistic(item, Player, 0)));
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(item, pending.ItemId);
Assert.True(pending.Dispatched);
Assert.False(h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
second,
static () => true));
Assert.True(h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0));
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void AuthoritativeResponseReleasesGlobalOwnerBeforePublishingPlacementResolution()
{
var h = new Harness();
const uint first = 0x70000A22u;
const uint second = 0x50000A23u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = first,
Name = "Loot",
Type = ItemType.Misc,
});
h.AddContained(second);
bool secondDispatched = false;
h.Controller.PendingBackpackPlacementResolved += _ =>
secondDispatched = h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
second,
static () => true);
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
first,
Player,
0,
InventoryRequestKind.Pickup,
static () => true));
Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0));
Assert.True(secondDispatched);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(second, pending.ItemId);
Assert.True(pending.Dispatched);
}
[Fact]
public void FailedMoveReleasesOldOwnerOnceAndPreservesReentrantSameItemRequest()
{
var h = new Harness();
const uint item = 0x50000A24u;
h.AddContained(item);
bool replacementDispatched = false;
h.Controller.PendingBackpackPlacementResolved += _ =>
replacementDispatched = h.Controller.TryDispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
item,
static () => true);
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
item,
Player,
0,
InventoryRequestKind.PutInContainer,
() => h.Objects.MoveItemOptimistic(item, Player, 0)));
Assert.True(h.Objects.RejectMove(item, weenieError: 0x29u));
Assert.True(replacementDispatched);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(item, pending.ItemId);
Assert.True(pending.Dispatched);
}
[Fact]
public void ResponseClearsGlobalAndLocalStateBeforeReadinessNotification()
{
var h = new Harness();
const uint first = 0x70000A25u;
const uint second = 0x70000A26u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = first,
Name = "First loot",
Type = ItemType.Misc,
});
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = second,
Name = "Second loot",
Type = ItemType.Misc,
});
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
first,
Player,
0,
InventoryRequestKind.Pickup,
static () => true));
var eventOrder = new List<string>();
h.Controller.PendingBackpackPlacementResolved += placement =>
{
if (placement.ItemId == first)
eventOrder.Add("resolved-first");
};
h.Controller.PendingBackpackPlacementRequested += placement =>
{
if (placement.ItemId == second)
eventOrder.Add("requested-second");
};
bool attempted = false;
bool secondDispatched = false;
h.Controller.StateChanged += () =>
{
if (attempted || !h.Controller.CanMakeInventoryRequest)
return;
attempted = true;
eventOrder.Add("ready");
secondDispatched = h.Controller.TryDispatchPendingBackpackPlacement(
second,
Player,
0,
InventoryRequestKind.Pickup,
static () => true);
};
Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0));
Assert.True(attempted);
Assert.True(secondDispatched);
Assert.True(h.Controller.TryGetPendingBackpackPlacement(second, out _));
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(second, pending.ItemId);
Assert.True(pending.Dispatched);
Assert.Equal(
new[] { "resolved-first", "ready", "requested-second" },
eventOrder);
}
[Fact]
public void ReentrantPendingPublicationCancellationPreventsWireDispatch()
{
var h = new Harness();
const uint item = 0x70000A1Fu;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = item,
Name = "Loot",
Type = ItemType.Misc,
});
bool wireDispatched = false;
h.Controller.PendingBackpackPlacementRequested += pending =>
h.Controller.CancelPendingBackpackPlacement(pending.ItemId, pending.Token);
Assert.False(h.Controller.TryDispatchPendingBackpackPlacement(
item,
Player,
0,
InventoryRequestKind.Pickup,
() =>
{
wireDispatched = true;
return true;
}));
Assert.False(wireDispatched);
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
} }
[Fact] [Fact]

View file

@ -263,4 +263,81 @@ public sealed class ExternalContainerControllerTests
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId); Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
Assert.Empty(h.Puts); Assert.Empty(h.Puts);
} }
[Fact]
public void PendingPickupRejectsOwnedDropIntoChest()
{
using var h = new Harness();
h.Open();
h.Objects.AddOrUpdate(new ClientObject { ObjectId = Item, Type = ItemType.Misc });
h.Objects.MoveItem(Item, Player, 0);
var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory };
source.SetItem(Item, 0u);
Assert.True(h.Interaction.PlaceWorldItemInBackpack(0x70000A01u));
h.Controller.HandleDropRelease(
h.Contents,
h.Contents.GetItem(0)!,
new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source));
Assert.Empty(h.Puts);
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
Assert.True(h.Interaction.TryGetPendingBackpackPlacement(0x70000A01u, out _));
}
[Fact]
public void PendingPickupRejectsPartialStackSplitIntoChest()
{
using var h = new Harness();
h.Open();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = Item,
StackSize = 5,
StackSizeMax = 100,
Type = ItemType.Misc,
});
h.Objects.MoveItem(Item, Player, 0);
h.Selection.Select(Item, SelectionChangeSource.Inventory);
h.Split.Reset(5u, 2u);
var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory };
source.SetItem(Item, 0u);
Assert.True(h.Interaction.PlaceWorldItemInBackpack(0x70000A02u));
h.Controller.HandleDropRelease(
h.Contents,
h.Contents.GetItem(0)!,
new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source));
Assert.Empty(h.Splits);
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
Assert.True(h.Interaction.TryGetPendingBackpackPlacement(0x70000A02u, out _));
}
[Fact]
public void ExternalPutOwnsGlobalGateUntilMatchingServerMove()
{
using var h = new Harness();
const uint pickup = 0x70000A03u;
h.Open();
h.Objects.AddOrUpdate(new ClientObject { ObjectId = Item, Type = ItemType.Misc });
h.Objects.MoveItem(Item, Player, 0);
var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory };
source.SetItem(Item, 0u);
h.Controller.HandleDropRelease(
h.Contents,
h.Contents.GetItem(0)!,
new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source));
Assert.True(h.Interaction.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(Item, pending.ItemId);
Assert.True(h.Interaction.PlaceWorldItemInBackpack(pickup));
Assert.Empty(h.Pickups);
h.Objects.ApplyConfirmedServerMove(Item, Chest, 0u, 0);
Assert.False(h.Interaction.TryGetPendingInventoryRequest(out _));
Assert.True(h.Interaction.PlaceWorldItemInBackpack(pickup));
Assert.Equal(new uint[] { pickup }, h.Pickups);
}
} }

View file

@ -706,6 +706,467 @@ public class InventoryControllerTests
Assert.Equal(0xBu, grid.GetItem(2)!.ItemId); Assert.Equal(0xBu, grid.GetItem(2)!.ItemId);
} }
[Fact]
public void DirectLootWhilePendingKeepsTheOriginalProjectionAndRequest()
{
const uint chest = 0x70000021u;
const uint firstLoot = 0x70000022u;
const uint secondLoot = 0x70000023u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, firstLoot, chest, slot: 0, type: ItemType.Misc);
SeedContained(objects, secondLoot, chest, slot: 1, type: ItemType.Misc);
long now = 1_000;
var eventOrder = new List<(string Kind, uint Item, ulong Token)>();
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => now,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: static (_, _, _) => { });
interaction.PendingBackpackPlacementRequested += pending =>
eventOrder.Add(("request", pending.ItemId, pending.Token));
interaction.PendingBackpackPlacementCancelled += pending =>
eventOrder.Add(("cancel", pending.ItemId, pending.Token));
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
itemInteraction: interaction);
Assert.True(interaction.ActivateItem(firstLoot));
now += 200;
Assert.False(interaction.ActivateItem(secondLoot));
Assert.Collection(
eventOrder,
first => Assert.Equal(("request", firstLoot), (first.Kind, first.Item)));
Assert.Equal(firstLoot, grid.GetItem(0)!.ItemId);
Assert.True(grid.GetItem(0)!.WaitingVisual);
Assert.DoesNotContain(
Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId),
id => id == secondLoot);
}
[Fact]
public void GroundDragThenDirectPickupKeepsTheFirstPendingProjection()
{
const uint chest = 0x70000031u;
const uint draggedLoot = 0x70000032u;
const uint directLoot = 0x70000033u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, draggedLoot, chest, slot: 0, type: ItemType.Misc);
SeedContained(objects, directLoot, chest, slot: 1, type: ItemType.Misc);
var puts = new List<(uint Item, uint Container, int Placement)>();
var eventOrder = new List<(string Kind, uint Item)>();
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: (item, container, placement) =>
puts.Add((item, container, placement)));
interaction.PendingBackpackPlacementRequested += pending =>
eventOrder.Add(("request", pending.ItemId));
interaction.PendingBackpackPlacementCancelled += pending =>
eventOrder.Add(("cancel", pending.ItemId));
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
sendPutItemInContainer: (item, container, placement) =>
puts.Add((item, container, placement)),
itemInteraction: interaction);
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
source.SetItem(draggedLoot, 0u);
inventory.HandleDropRelease(
grid,
grid.GetItem(4)!,
new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source));
Assert.True(interaction.PlaceWorldItemInBackpack(directLoot));
Assert.Equal(new[] { ("request", draggedLoot) }, eventOrder);
Assert.Equal(new[] { (draggedLoot, Player, 0) }, puts);
Assert.Equal(draggedLoot, grid.GetItem(0)!.ItemId);
Assert.True(grid.GetItem(0)!.WaitingVisual);
Assert.DoesNotContain(
Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId),
id => id == directLoot);
}
[Fact]
public void DirectPickupThenGroundDragAcceptsHoverButRejectsTheSecondRequest()
{
const uint chest = 0x70000041u;
const uint directLoot = 0x70000042u;
const uint draggedLoot = 0x70000043u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, directLoot, chest, slot: 0, type: ItemType.Misc);
SeedContained(objects, draggedLoot, chest, slot: 1, type: ItemType.Misc);
var eventOrder = new List<(string Kind, uint Item)>();
var puts = new List<(uint Item, uint Container, int Placement)>();
var messages = new List<string>();
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: (item, container, placement) =>
puts.Add((item, container, placement)),
systemMessage: messages.Add);
interaction.PendingBackpackPlacementRequested += pending =>
eventOrder.Add(("request", pending.ItemId));
interaction.PendingBackpackPlacementCancelled += pending =>
eventOrder.Add(("cancel", pending.ItemId));
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
sendPutItemInContainer: (item, container, placement) =>
puts.Add((item, container, placement)),
itemInteraction: interaction);
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
source.SetItem(draggedLoot, 0u);
Assert.True(interaction.PlaceWorldItemInBackpack(directLoot));
Assert.Equal(
ItemDragAcceptance.Accept,
inventory.OnDragOver(
grid,
grid.GetItem(5)!,
new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source)));
inventory.HandleDropRelease(
grid,
grid.GetItem(5)!,
new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source));
Assert.Equal(new[] { ("request", directLoot) }, eventOrder);
Assert.Equal(new[] { (directLoot, Player, 0) }, puts);
Assert.Equal(directLoot, grid.GetItem(0)!.ItemId);
Assert.True(grid.GetItem(0)!.WaitingVisual);
Assert.Equal(
new[] { "Already attempting to place that item here" },
messages);
Assert.DoesNotContain(
Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId),
id => id == draggedLoot);
}
[Fact]
public void PendingContentsProjectionUsesGenericGlobalMessageOnBagColumnDrop()
{
const uint chest = 0x70000044u;
const uint pendingLoot = 0x70000045u;
const uint ownedItem = 0x50000046u;
const uint bag = 0x50000047u;
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, pendingLoot, chest, 0, type: ItemType.Misc);
SeedContained(objects, ownedItem, Player, 0, type: ItemType.Misc);
SeedBag(objects, bag, 1);
var puts = new List<(uint Item, uint Container, int Placement)>();
var messages = new List<string>();
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: static (_, _, _) => { },
systemMessage: messages.Add);
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
sendPutItemInContainer: (item, container, placement) =>
puts.Add((item, container, placement)),
itemInteraction: interaction);
Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot));
inventory.HandleDropRelease(
containers,
containers.GetItem(0)!,
Payload(ownedItem));
Assert.Empty(puts);
Assert.Equal(
new[] { ItemInteractionController.InventoryRequestBusyMessage },
messages);
}
[Fact]
public void PendingPickupRejectsCompatibleGroundMergeBeforeWireDispatch()
{
const uint chest = 0x70000071u;
const uint pendingLoot = 0x70000072u;
const uint sourceStack = 0x70000073u;
const uint targetStack = 0x70000074u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc);
objects.AddOrUpdate(new ClientObject
{
ObjectId = sourceStack,
WeenieClassId = 0x1234u,
StackSize = 10,
StackSizeMax = 100,
});
objects.MoveItem(sourceStack, chest, 1);
objects.AddOrUpdate(new ClientObject
{
ObjectId = targetStack,
WeenieClassId = 0x1234u,
StackSize = 50,
StackSizeMax = 100,
});
objects.MoveItem(targetStack, Player, 0);
var merges = new List<(uint Source, uint Target, uint Amount)>();
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: static (_, _, _) => { });
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
sendStackableMerge: (source, target, amount) =>
merges.Add((source, target, amount)),
itemInteraction: interaction);
var sourceCell = new UiItemSlot { SourceKind = ItemDragSource.Ground };
sourceCell.SetItem(sourceStack, 0u);
Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot));
UiItemSlot targetCell = Enumerable.Range(0, grid.GetNumUIItems())
.Select(i => grid.GetItem(i)!)
.Single(cell => cell.ItemId == targetStack);
inventory.HandleDropRelease(
grid,
targetCell,
new ItemDragPayload(sourceStack, ItemDragSource.Ground, 0, sourceCell));
Assert.Empty(merges);
Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _));
}
[Fact]
public void PendingPickupRejectsPartialGroundSplitBeforeWireDispatch()
{
const uint chest = 0x70000081u;
const uint pendingLoot = 0x70000082u;
const uint sourceStack = 0x70000083u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc);
objects.AddOrUpdate(new ClientObject
{
ObjectId = sourceStack,
StackSize = 10,
StackSizeMax = 100,
});
objects.MoveItem(sourceStack, chest, 1);
var selection = new SelectionState();
selection.Select(sourceStack, SelectionChangeSource.Inventory);
var splitQuantity = new StackSplitQuantityState();
splitQuantity.Reset(10u);
splitQuantity.SetValue(1u);
var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>();
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: static (_, _, _) => { });
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: selection,
datFont: null,
sendStackableSplitToContainer: (item, container, placement, amount) =>
splits.Add((item, container, placement, amount)),
itemInteraction: interaction,
stackSplitQuantity: splitQuantity);
var sourceCell = new UiItemSlot { SourceKind = ItemDragSource.Ground };
sourceCell.SetItem(sourceStack, 0u);
Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot));
inventory.HandleDropRelease(
grid,
grid.GetItem(5)!,
new ItemDragPayload(sourceStack, ItemDragSource.Ground, 0, sourceCell));
Assert.Empty(splits);
Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _));
}
[Fact]
public void PendingPickupRejectsOwnedInventoryMoveBeforeOptimisticMutation()
{
const uint chest = 0x70000091u;
const uint pendingLoot = 0x70000092u;
const uint ownedItem = 0x70000093u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc);
SeedContained(objects, ownedItem, Player, slot: 0, type: ItemType.Misc);
var puts = new List<(uint Item, uint Container, int Placement)>();
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: static (_, _, _) => { });
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
sendPutItemInContainer: (item, container, placement) =>
puts.Add((item, container, placement)),
itemInteraction: interaction);
Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot));
inventory.HandleDropRelease(grid, grid.GetItem(5)!, Payload(ownedItem));
Assert.Empty(puts);
Assert.Equal(Player, objects.Get(ownedItem)!.ContainerId);
Assert.Equal(0, objects.Get(ownedItem)!.ContainerSlot);
Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _));
}
[Fact]
public void PendingLootRemovalImmediatelyWithdrawsTheProjection()
{
const uint chest = 0x70000051u;
const uint loot = 0x70000052u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: static (_, _, _) => { });
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
itemInteraction: interaction);
Assert.True(interaction.PlaceWorldItemInBackpack(loot));
Assert.Equal(loot, grid.GetItem(0)!.ItemId);
Assert.True(grid.GetItem(0)!.WaitingVisual);
Assert.True(objects.Remove(loot));
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
Assert.False(interaction.TryGetPendingBackpackPlacement(loot, out _));
}
[Fact]
public void PendingLootGenerationReplacementDoesNotLeaveTheOldProjection()
{
const uint chest = 0x70000061u;
const uint loot = 0x70000062u;
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
using var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => chest,
backpackContainerId: () => Player,
placeInBackpack: static (_, _, _) => { });
using var inventory = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
itemInteraction: interaction);
Assert.True(interaction.PlaceWorldItemInBackpack(loot));
Assert.Equal(loot, grid.GetItem(0)!.ItemId);
objects.ReplaceGeneration(WorldReplacement(loot), generation: 2);
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
Assert.False(interaction.TryGetPendingBackpackPlacement(loot, out _));
}
[Fact] [Fact]
public void LootDrop_ServerFailureRemovesOnlyThePendingProjection() public void LootDrop_ServerFailureRemovesOnlyThePendingProjection()
{ {

View file

@ -46,7 +46,8 @@ public class PaperdollControllerTests
SelectionState? selection = null, SelectionState? selection = null,
IReadOnlyDictionary<uint, uint>? emptySlotSprites = null, IReadOnlyDictionary<uint, uint>? emptySlotSprites = null,
List<(uint item, uint container, int placement)>? puts = null, List<(uint item, uint container, int placement)>? puts = null,
List<string>? systemMessages = null) List<string>? systemMessages = null,
Action<ItemInteractionController>? configureInteraction = null)
{ {
var itemInteraction = new ItemInteractionController( var itemInteraction = new ItemInteractionController(
objects, objects,
@ -59,6 +60,7 @@ public class PaperdollControllerTests
? null ? null
: (item, container, placement) => puts.Add((item, container, placement)), : (item, container, placement) => puts.Add((item, container, placement)),
systemMessage: systemMessages is null ? null : systemMessages.Add); systemMessage: systemMessages is null ? null : systemMessages.Add);
configureInteraction?.Invoke(itemInteraction);
return PaperdollController.Bind(layout, objects, () => Player, return PaperdollController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0x1234u, iconIds: (_, _, _, _, _) => 0x1234u,
itemInteraction: itemInteraction, itemInteraction: itemInteraction,
@ -240,6 +242,38 @@ public class PaperdollControllerTests
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
} }
[Fact]
public void HandleDropRelease_pendingPickupRejectsWieldWithoutOptimisticMutation()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
const uint pickup = 0x70000D02u;
const uint helm = 0x50000D03u;
SeedPackItem(objects, helm, EquipMask.HeadWear);
var wields = new List<(uint item, uint mask)>();
var messages = new List<string>();
ItemInteractionController? interaction = null;
var ctrl = Bind(
layout,
objects,
wields,
systemMessages: messages,
configureInteraction: value => interaction = value);
Assert.True(interaction!.TryBeginPendingBackpackPlacement(
pickup,
Player,
0,
out _));
ctrl.HandleDropRelease(
lists[HeadSlot],
lists[HeadSlot].Cell,
new ItemDragPayload(helm, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell));
Assert.Empty(wields);
Assert.Equal(EquipMask.None, objects.Get(helm)!.CurrentlyEquippedLocation);
Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages);
}
[Fact] [Fact]
public void HandleDropRelease_weaponSlot_delegatesToConfirmedAutoWieldTransaction() public void HandleDropRelease_weaponSlot_delegatesToConfirmedAutoWieldTransaction()
{ {

View file

@ -301,6 +301,97 @@ public class ToolbarControllerTests
Assert.Equal(new[] { (item, player, 0) }, puts); Assert.Equal(new[] { (item, player, 0) }, puts);
} }
[Fact]
public void InventoryButton_unownedGroundDropUsesDirectAttemptWithoutDestinationProjection()
{
const uint player = 0x5000u;
const uint item = 0x70005001u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = item, Name = "Loot", Type = ItemType.Misc });
var placements = new List<(uint Item, uint Container, int Placement)>();
var directPuts = new List<(uint Item, uint Container, int Placement)>();
using var interaction = new ItemInteractionController(
repo,
playerGuid: () => player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
placeInBackpack: (i, c, p) => placements.Add((i, c, p)));
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: static (_, _, _, _, _) => 0u,
useItem: static _ => { },
itemInteraction: interaction,
playerGuid: () => player,
sendPutItemInContainer: (i, c, p) => directPuts.Add((i, c, p)));
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
var payload = new ItemDragPayload(item, ItemDragSource.Ground, 0, new UiItemSlot());
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)));
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)));
Assert.Empty(placements);
Assert.Equal(new[] { (item, player, 0) }, directPuts);
Assert.False(interaction.TryGetPendingBackpackPlacement(item, out _));
Assert.True(interaction.TryGetPendingInventoryRequest(out var request));
Assert.Equal(item, request.ItemId);
}
[Fact]
public void InventoryButton_ownedDropPublishesDestinationAndGloballyRejectsSecondRequest()
{
const uint player = 0x5000u;
const uint first = 0x70005002u;
const uint second = 0x70005003u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
const uint pack = 0x5004u;
repo.AddOrUpdate(new ClientObject { ObjectId = player, Name = "Player", Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Name = "Pack", Type = ItemType.Container });
repo.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject { ObjectId = first, Name = "First Loot", Type = ItemType.Misc });
repo.AddOrUpdate(new ClientObject { ObjectId = second, Name = "Second Loot", Type = ItemType.Misc });
repo.MoveItem(first, pack, 0);
repo.MoveItem(second, pack, 1);
var puts = new List<(uint Item, uint Container, int Placement)>();
var messages = new List<string>();
using var interaction = new ItemInteractionController(
repo,
playerGuid: () => player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
systemMessage: messages.Add);
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: static (_, _, _, _, _) => 0u,
useItem: static _ => { },
itemInteraction: interaction,
playerGuid: () => player,
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
foreach (uint item in new[] { first, second })
{
var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 0, new UiItemSlot());
button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload));
button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload));
}
Assert.Equal(new[] { (first, player, 0) }, puts);
Assert.True(interaction.TryGetPendingBackpackPlacement(first, out _));
Assert.True(interaction.TryGetPendingInventoryRequest(out var request));
Assert.Equal(first, request.ItemId);
Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages);
}
[Fact] [Fact]
public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem() public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem()
{ {

View file

@ -72,6 +72,38 @@ public sealed class RetailItemConfirmationControllerTests
Assert.Equal(0, items.BusyCount); Assert.Equal(0, items.BusyCount);
} }
[Fact]
public void PositiveConfirmationRechecksGlobalInventoryGateBeforeUse()
{
var objects = BuildObjects(PublicWeenieFlags.VolatileRare);
var uses = new List<uint>();
var messages = new List<string>();
var items = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: uses.Add,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
placeInBackpack: static (_, _, _) => { },
systemMessage: messages.Add,
nowMs: () => 1000L);
var root = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout? shown = null;
var factory = new RetailDialogFactory(root, _ =>
shown = FixtureLoader.LoadConfirmationDialog());
using var confirmations = new RetailItemConfirmationController(factory, items);
Assert.True(items.ActivateItem(Rare));
Assert.True(items.PlaceWorldItemInBackpack(0x70000001u));
Assert.IsType<UiButton>(shown!.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.Empty(uses);
Assert.Equal(0, items.BusyCount);
Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages);
}
private static ClientObjectTable BuildObjects(PublicWeenieFlags flags) private static ClientObjectTable BuildObjects(PublicWeenieFlags flags)
{ {
var objects = new ClientObjectTable(); var objects = new ClientObjectTable();

View file

@ -85,6 +85,27 @@ public sealed class MoveToManagerCompletionSeamTests
Assert.Empty(h.MoveToCompleteCalls); Assert.Empty(h.MoveToCompleteCalls);
} }
[Fact]
public void CancelMoveTo_FiresCancellationCompanionOnlyForAnActiveMove()
{
var h = new MoveToManagerHarness();
var cancellations = new List<WeenieError>();
h.Manager.MoveToCancelled = cancellations.Add;
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Empty(cancellations);
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToPosition(
new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
new MovementParameters { UseSpheres = false });
h.Manager.CancelMoveTo(WeenieError.ObjectGone);
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(new[] { WeenieError.ObjectGone }, cancellations);
Assert.Empty(h.MoveToCompleteCalls);
}
[Fact] [Fact]
public void StickyCompletion_FiresMoveToComplete_AfterStickToHandoff() public void StickyCompletion_FiresMoveToComplete_AfterStickToHandoff()
{ {