fix(ui): port retail partial stack transfers
Route the selected stack quantity through retail GetObjectSplitSize semantics, send exact split-to-container and split-to-ground actions, and keep the original object in place until the server publishes the newly guided stack. Cover selection scoping, wire bytes, container placement, and world drops with conformance tests. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
6005c51c4d
commit
ea72c395c9
14 changed files with 280 additions and 19 deletions
|
|
@ -2050,7 +2050,7 @@ See divergence register **AP-59**.
|
||||||
|
|
||||||
## #141 — Toolbar interactivity — selected-object display
|
## #141 — Toolbar interactivity — selected-object display
|
||||||
|
|
||||||
**Status:** IN PROGRESS (health/name/flash DONE and visually confirmed 2026-06-20; formatted stack count + entry/slider implemented 2026-07-11, visual gate pending; mana still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
|
**Status:** IN PROGRESS (health/name/flash DONE and visually confirmed 2026-06-20; formatted stack count + entry/slider visually confirmed 2026-07-11; partial stack-transfer live gate pending; mana still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
|
||||||
**Severity:** MEDIUM
|
**Severity:** MEDIUM
|
||||||
**Filed:** 2026-06-17
|
**Filed:** 2026-06-17
|
||||||
**Component:** ui — D.5 toolbar / selection
|
**Component:** ui — D.5 toolbar / selection
|
||||||
|
|
@ -2060,7 +2060,7 @@ See divergence register **AP-59**.
|
||||||
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
|
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
|
||||||
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet). Divergence row AP-46.
|
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet). Divergence row AP-46.
|
||||||
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2).
|
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2).
|
||||||
- **D.5.3b implementation (2026-07-11):** stacked selection formats the retail `"%d %hs"` count/name, now preserving the wire `PluralName` selected by `NAME_APPROPRIATE`; reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`; initializes to the full stack; clamps entry edits; honors the entry's DAT right alignment; and uses the exact 1000-step slider conversion. The thumb retains its raw pointer position so minimum/maximum reach both endpoints. One Core `StackSplitQuantityState` feeds both controls and the selected-source merge amount. Warning-free App Release + 4,691 tests; live visual gate pending.
|
- **D.5.3b implementation (2026-07-11):** stacked selection formats the retail `"%d %hs"` count/name, now preserving the wire `PluralName` selected by `NAME_APPROPRIATE`; reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`; initializes to the full stack; clamps entry edits; honors the entry's DAT right alignment; and uses the exact 1000-step slider conversion. The thumb retains its raw pointer position so minimum/maximum reach both endpoints. One Core `StackSplitQuantityState` feeds the controls plus selected-source merge, container split (`0x0055`), and ground split (`0x0056`). `GetObjectSplitSize @ 0x00586F00` ensures unselected objects still move their full stack. Partial transfers do not optimistically move the original because ACE creates the destination object with a new guid. Warning-free App Release + 4,697 tests; controls/polish visually confirmed, partial-transfer live action gate pending.
|
||||||
|
|
||||||
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
|
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -490,7 +490,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
||||||
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
|
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
|
||||||
- **✓ SHIPPED — Wave 4.3b source-aware toolbar transactions.** Pure Core `ShortcutDropPlanner` ports `gmToolbarUI::HandleDropRelease @ 0x004BE7C0`: fresh inventory drops displace cyclically right (with wrap), shortcut aliases restore to their vacated source, duplicate objects are removed before placement, full bars intentionally lose the displaced entry when retail finds no empty slot, and every transaction is planned before local mutation then emitted in exact Remove/Add order. Full-stack rekey planning preserves the raw spell word. AP-102 retired; 4,646 automated tests pass; live inventory-to-occupied displacement and toolbar reorder gate passed 2026-07-11.
|
- **✓ SHIPPED — Wave 4.3b source-aware toolbar transactions.** Pure Core `ShortcutDropPlanner` ports `gmToolbarUI::HandleDropRelease @ 0x004BE7C0`: fresh inventory drops displace cyclically right (with wrap), shortcut aliases restore to their vacated source, duplicate objects are removed before placement, full bars intentionally lose the displaced entry when retail finds no empty slot, and every transaction is planned before local mutation then emitted in exact Remove/Add order. Full-stack rekey planning preserves the raw spell word. AP-102 retired; 4,646 automated tests pass; live inventory-to-occupied displacement and toolbar reorder gate passed 2026-07-11.
|
||||||
- **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending.
|
- **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending.
|
||||||
- **✓ IMPLEMENTED — Wave 4.4a selected-stack controls.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with inventory merge. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, and pointer drag have conformance tests. Warning-free App Release build and 4,691 tests pass; live visual gate pending 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101.
|
- **✓ IMPLEMENTED — Wave 4.4a selected-stack controls and transfers.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with every item-holder transfer path. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact `ItemHolder::GetObjectSplitSize` selection scoping now drives merge, split-to-container `0x0055`, and split-to-ground `0x0056`; partial splits wait for the server-created object instead of moving the source optimistically. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, pointer drag, and wire bytes have conformance tests. Warning-free App Release build and 4,697 tests pass; controls/polish are live-confirmed and partial-transfer live gate is pending 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101.
|
||||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||||
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
||||||
|
|
|
||||||
|
|
@ -794,6 +794,44 @@ UseWithTarget (0x0035): u32 sourceObjectId, u32 targetObjectId
|
||||||
|
|
||||||
## 8. `AttemptPlaceIn3D` pseudocode
|
## 8. `AttemptPlaceIn3D` pseudocode
|
||||||
|
|
||||||
|
### 8.1 Shared selected-stack amount
|
||||||
|
|
||||||
|
```text
|
||||||
|
GetObjectSplitSize(item): // 0x00586F00
|
||||||
|
if item.id == selectedID:
|
||||||
|
return GenItemHolder.splitSize
|
||||||
|
if item.stackSize != 0:
|
||||||
|
return item.stackSize
|
||||||
|
return 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The quantity field is therefore global UI state, but it applies only to the
|
||||||
|
currently selected object. Dragging any other stack uses that object's full
|
||||||
|
quantity.
|
||||||
|
|
||||||
|
### 8.2 Container move versus container split
|
||||||
|
|
||||||
|
After the legality, capacity, and auto-merge branches choose a destination,
|
||||||
|
retail makes this exact decision:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AttemptToPlaceInContainer(itemId, containerId, ..., placement): // 0x00588140
|
||||||
|
...legality and destination selection...
|
||||||
|
if splitSize >= maxSplitSize:
|
||||||
|
item.UIAttemptPutInContainer(containerId, placement)
|
||||||
|
return true
|
||||||
|
item.UIAttemptSplitToContainer(containerId, placement, splitSize)
|
||||||
|
return true
|
||||||
|
|
||||||
|
UIAttemptSplitToContainer(item, containerId, placement, amount): // 0x0058D7D0
|
||||||
|
Event_StackableSplitToContainer(item.id, containerId, placement, amount)
|
||||||
|
```
|
||||||
|
|
||||||
|
The partial path sends GameAction `0x0055` with
|
||||||
|
`(stackGuid, containerGuid, placement, amount)`. It does not move the original
|
||||||
|
client object into the destination: the server reduces that source stack and
|
||||||
|
creates a distinct destination object with a new guid.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
AttemptPlaceIn3D(itemId, targetId, allowGroundFallback): // 0x00588600
|
AttemptPlaceIn3D(itemId, targetId, allowGroundFallback): // 0x00588600
|
||||||
if player not ready for inventory request: return false
|
if player not ready for inventory request: return false
|
||||||
|
|
@ -873,6 +911,11 @@ The vendor-drop test is now pinned. Matching-executable disassembly at
|
||||||
`0x00000200 = BF_VENDOR`. It is an object-trait test, distinct from
|
`0x00000200 = BF_VENDOR`. It is an object-trait test, distinct from
|
||||||
`ClientUISystem.vendorID`.
|
`ClientUISystem.vendorID`.
|
||||||
|
|
||||||
|
`UIAttemptSplitTo3D @ 0x0058D850` dispatches
|
||||||
|
`CM_Inventory::Event_StackableSplitTo3D @ 0x006ABFC0`, GameAction `0x0056`
|
||||||
|
with `(stackGuid, amount)`. Like the container split, the source object remains
|
||||||
|
in place until authoritative server updates arrive.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Target-mode click ordering
|
## 9. Target-mode click ordering
|
||||||
|
|
|
||||||
|
|
@ -509,8 +509,9 @@ server confirmation despite its name. Release build is warning-free and 4,660 te
|
||||||
pass. The compatible-stack live gate passed 2026-07-11 and exposed the expected
|
pass. The compatible-stack live gate passed 2026-07-11 and exposed the expected
|
||||||
Wave 4.4/AP-101 selected-stack gap. Wave 4.4a now implements that gap: exact count-first
|
Wave 4.4/AP-101 selected-stack gap. Wave 4.4a now implements that gap: exact count-first
|
||||||
name formatting, authored entry/slider media, one Core split-quantity owner, live
|
name formatting, authored entry/slider media, one Core split-quantity owner, live
|
||||||
stack-size refresh, and selected-source merge amount. The inventory-button and
|
stack-size refresh, and selected-source item-holder amounts. Controls/polish passed
|
||||||
selected-stack visual gates remain pending.
|
the live gate. Partial container/ground transfers now send retail `0x0055/0x0056`;
|
||||||
|
their live action gate remains pending. The inventory-button gate remains pending.
|
||||||
|
|
||||||
### 4.4 Finish toolbar controls
|
### 4.4 Finish toolbar controls
|
||||||
|
|
||||||
|
|
@ -526,7 +527,7 @@ selected-stack visual gates remain pending.
|
||||||
- [ ] Replace the PWD-only health gate with the exact attackable/player/pet policy.
|
- [ ] Replace the PWD-only health gate with the exact attackable/player/pet policy.
|
||||||
- [ ] Add item-mana query/cancel state and health query cancellation.
|
- [ ] Add item-mana query/cancel state and health query cancellation.
|
||||||
- [x] Add one shared split-quantity owner for the entry, slider, and selected-source
|
- [x] Add one shared split-quantity owner for the entry, slider, and selected-source
|
||||||
item-holder merge payload.
|
item-holder merge, container-split, and ground-split payloads.
|
||||||
|
|
||||||
**Wave 4.4a implementation (2026-07-11):** static cdb against the matching retail
|
**Wave 4.4a implementation (2026-07-11):** static cdb against the matching retail
|
||||||
binary pinned stack-name `"%d %hs"`, numeric entry `"%d"`, and the slider's exact
|
binary pinned stack-name `"%d %hs"`, numeric entry `"%d"`, and the slider's exact
|
||||||
|
|
@ -535,11 +536,16 @@ binary pinned stack-name `"%d %hs"`, numeric entry `"%d"`, and the slider's exac
|
||||||
`InventoryController` consumes the value only when the dragged source is selected.
|
`InventoryController` consumes the value only when the dragged source is selected.
|
||||||
The horizontal scrollbar remains the generic retained `UiScrollbar` in scalar mode,
|
The horizontal scrollbar remains the generic retained `UiScrollbar` in scalar mode,
|
||||||
using the DAT track/thumb rather than panel-local drawing. The warning-free App Release
|
using the DAT track/thumb rather than panel-local drawing. The warning-free App Release
|
||||||
build and 4,691-test suite are green. Gate polish preserves CreateObject
|
build and 4,697-test suite are green. Gate polish preserves CreateObject
|
||||||
`PluralName` through Core.Net into `ClientObject.GetAppropriateName`, honors the
|
`PluralName` through Core.Net into `ClientObject.GetAppropriateName`, honors the
|
||||||
entry's authored right alignment, and lets the pointer-owned thumb remain at exact
|
entry's authored right alignment, and lets the pointer-owned thumb remain at exact
|
||||||
minimum/maximum endpoints instead of snapping back from the quantized amount. The
|
minimum/maximum endpoints instead of snapping back from the quantized amount. The
|
||||||
live visual gate follows.
|
controls/polish live gate passed. `ItemHolder::GetObjectSplitSize @ 0x00586F00`
|
||||||
|
now scopes that quantity to the selected object for all transfers. Partial inventory
|
||||||
|
moves send `StackableSplitToContainer 0x0055`; partial drag-out drops send
|
||||||
|
`StackableSplitTo3D 0x0056`; neither path moves the original object optimistically
|
||||||
|
because the authoritative destination stack receives a server-assigned guid. The
|
||||||
|
partial-transfer live action gate follows.
|
||||||
|
|
||||||
**Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8,
|
**Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8,
|
||||||
Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a
|
Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a
|
||||||
|
|
|
||||||
|
|
@ -759,6 +759,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.UI.UiHost? _uiHost;
|
private AcDream.App.UI.UiHost? _uiHost;
|
||||||
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
|
||||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||||
|
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
|
||||||
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
||||||
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
|
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
|
||||||
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
|
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
|
||||||
|
|
@ -2003,7 +2004,11 @@ public sealed class GameWindow : IDisposable
|
||||||
inNonCombatMode: () => Combat.CurrentMode
|
inNonCombatMode: () => Combat.CurrentMode
|
||||||
== AcDream.Core.Combat.CombatMode.NonCombat,
|
== AcDream.Core.Combat.CombatMode.NonCombat,
|
||||||
isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true,
|
isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true,
|
||||||
placeInBackpack: SendPickUp);
|
placeInBackpack: SendPickUp,
|
||||||
|
sendSplitToWorld: (item, amount) =>
|
||||||
|
_liveSession?.SendStackableSplitTo3D(item, amount),
|
||||||
|
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
|
||||||
|
stackSplitQuantity: _stackSplitQuantity);
|
||||||
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
|
||||||
_itemInteractionController,
|
_itemInteractionController,
|
||||||
// Retail UpdateCursorState (0x00564630) keys target-mode
|
// Retail UpdateCursorState (0x00564630) keys target-mode
|
||||||
|
|
@ -2140,6 +2145,9 @@ public sealed class GameWindow : IDisposable
|
||||||
guid => _liveSession?.SendNoLongerViewingContents(guid),
|
guid => _liveSession?.SendNoLongerViewingContents(guid),
|
||||||
(item, container, placement) =>
|
(item, container, placement) =>
|
||||||
_liveSession?.SendPutItemInContainer(item, container, placement),
|
_liveSession?.SendPutItemInContainer(item, container, placement),
|
||||||
|
(item, container, placement, amount) =>
|
||||||
|
_liveSession?.SendStackableSplitToContainer(
|
||||||
|
item, container, placement, amount),
|
||||||
(source, target, amount) =>
|
(source, target, amount) =>
|
||||||
_liveSession?.SendStackableMerge(source, target, amount),
|
_liveSession?.SendStackableMerge(source, target, amount),
|
||||||
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||||||
|
|
@ -2148,6 +2156,7 @@ public sealed class GameWindow : IDisposable
|
||||||
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
|
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
|
||||||
cursorFeedbackController,
|
cursorFeedbackController,
|
||||||
retailCursorManager),
|
retailCursorManager),
|
||||||
|
StackSplitQuantity: _stackSplitQuantity,
|
||||||
Plugins: _uiRegistry,
|
Plugins: _uiRegistry,
|
||||||
Persistence: persistence,
|
Persistence: persistence,
|
||||||
Probe: new AcDream.App.UI.RetailUiProbeBindings(
|
Probe: new AcDream.App.UI.RetailUiProbeBindings(
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
private readonly Action<uint, uint>? _sendUseWithTarget;
|
private readonly Action<uint, uint>? _sendUseWithTarget;
|
||||||
private readonly Action<uint, uint>? _sendWield;
|
private readonly Action<uint, uint>? _sendWield;
|
||||||
private readonly Action<uint>? _sendDrop;
|
private readonly Action<uint>? _sendDrop;
|
||||||
|
private readonly Action<uint, uint>? _sendSplitToWorld;
|
||||||
private readonly Action<string>? _toast;
|
private readonly Action<string>? _toast;
|
||||||
private readonly Func<bool> _readyForInventoryRequest;
|
private readonly Func<bool> _readyForInventoryRequest;
|
||||||
private readonly Func<uint> _activeVendorId;
|
private readonly Func<uint> _activeVendorId;
|
||||||
|
|
@ -73,6 +74,8 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
private readonly Action<uint>? _placeInBackpack;
|
private readonly Action<uint>? _placeInBackpack;
|
||||||
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
|
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
|
||||||
private readonly InteractionState _interactionState;
|
private readonly InteractionState _interactionState;
|
||||||
|
private readonly Func<uint> _selectedObjectId;
|
||||||
|
private readonly StackSplitQuantityState? _stackSplitQuantity;
|
||||||
|
|
||||||
private long _lastUseMs = long.MinValue / 2;
|
private long _lastUseMs = long.MinValue / 2;
|
||||||
private uint _consumedPrimaryClickTarget;
|
private uint _consumedPrimaryClickTarget;
|
||||||
|
|
@ -97,7 +100,10 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
Func<bool>? inNonCombatMode = null,
|
Func<bool>? inNonCombatMode = null,
|
||||||
Func<uint, bool>? isComponentPack = null,
|
Func<uint, bool>? isComponentPack = null,
|
||||||
Action<uint>? placeInBackpack = null,
|
Action<uint>? placeInBackpack = null,
|
||||||
Action<ItemPolicyAction>? auxiliaryAction = null)
|
Action<ItemPolicyAction>? auxiliaryAction = null,
|
||||||
|
Action<uint, uint>? sendSplitToWorld = null,
|
||||||
|
Func<uint>? selectedObjectId = null,
|
||||||
|
StackSplitQuantityState? stackSplitQuantity = null)
|
||||||
{
|
{
|
||||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||||
|
|
@ -105,6 +111,7 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
_sendUseWithTarget = sendUseWithTarget;
|
_sendUseWithTarget = sendUseWithTarget;
|
||||||
_sendWield = sendWield;
|
_sendWield = sendWield;
|
||||||
_sendDrop = sendDrop;
|
_sendDrop = sendDrop;
|
||||||
|
_sendSplitToWorld = sendSplitToWorld;
|
||||||
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
_nowMs = nowMs ?? (() => Environment.TickCount64);
|
||||||
_toast = toast;
|
_toast = toast;
|
||||||
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
|
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
|
||||||
|
|
@ -115,6 +122,8 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
_isComponentPack = isComponentPack ?? (_ => false);
|
_isComponentPack = isComponentPack ?? (_ => false);
|
||||||
_placeInBackpack = placeInBackpack;
|
_placeInBackpack = placeInBackpack;
|
||||||
_auxiliaryAction = auxiliaryAction;
|
_auxiliaryAction = auxiliaryAction;
|
||||||
|
_selectedObjectId = selectedObjectId ?? (() => 0u);
|
||||||
|
_stackSplitQuantity = stackSplitQuantity;
|
||||||
_interactionState = interactionState ?? new InteractionState();
|
_interactionState = interactionState ?? new InteractionState();
|
||||||
_interactionState.Changed += OnInteractionModeChanged;
|
_interactionState.Changed += OnInteractionModeChanged;
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +277,9 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
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;
|
||||||
|
|
||||||
int splitSize = Math.Max(1, item.StackSize);
|
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||||
|
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
|
||||||
|
item.ObjectId, _selectedObjectId(), fullStack) ?? fullStack);
|
||||||
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
|
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
|
||||||
Snapshot(item),
|
Snapshot(item),
|
||||||
_playerGuid(),
|
_playerGuid(),
|
||||||
|
|
@ -353,6 +364,11 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
_objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1);
|
_objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1);
|
||||||
_sendDrop?.Invoke(action.ObjectId);
|
_sendDrop?.Invoke(action.ObjectId);
|
||||||
break;
|
break;
|
||||||
|
case ItemPolicyActionKind.SplitToWorld:
|
||||||
|
// UIAttemptSplitTo3D leaves the original object in its container;
|
||||||
|
// the server assigns the split-off ground stack a new guid.
|
||||||
|
_sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount);
|
||||||
|
break;
|
||||||
case ItemPolicyActionKind.Reject:
|
case ItemPolicyActionKind.Reject:
|
||||||
if (!string.IsNullOrWhiteSpace(action.Message))
|
if (!string.IsNullOrWhiteSpace(action.Message))
|
||||||
_toast?.Invoke(action.Message);
|
_toast?.Invoke(action.Message);
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
private readonly Action<uint>? _sendUse;
|
private readonly Action<uint>? _sendUse;
|
||||||
private readonly Action<uint>? _sendNoLongerViewing;
|
private readonly Action<uint>? _sendNoLongerViewing;
|
||||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||||
|
private readonly Action<uint, uint, uint, uint>? _sendStackableSplitToContainer;
|
||||||
private readonly Action<uint, uint, uint>? _sendStackableMerge;
|
private readonly Action<uint, uint, uint>? _sendStackableMerge;
|
||||||
private readonly Action<uint, uint>? _notifyMergeAttempt;
|
private readonly Action<uint, uint>? _notifyMergeAttempt;
|
||||||
private readonly ItemInteractionController? _itemInteraction;
|
private readonly ItemInteractionController? _itemInteraction;
|
||||||
|
|
@ -83,6 +84,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
Action<uint>? sendUse,
|
Action<uint>? sendUse,
|
||||||
Action<uint>? sendNoLongerViewing,
|
Action<uint>? sendNoLongerViewing,
|
||||||
Action<uint, uint, int>? sendPutItemInContainer,
|
Action<uint, uint, int>? sendPutItemInContainer,
|
||||||
|
Action<uint, uint, uint, uint>? sendStackableSplitToContainer,
|
||||||
Action<uint, uint, uint>? sendStackableMerge,
|
Action<uint, uint, uint>? sendStackableMerge,
|
||||||
Action<uint, uint>? notifyMergeAttempt,
|
Action<uint, uint>? notifyMergeAttempt,
|
||||||
ItemInteractionController? itemInteraction,
|
ItemInteractionController? itemInteraction,
|
||||||
|
|
@ -97,6 +99,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
_sendUse = sendUse;
|
_sendUse = sendUse;
|
||||||
_sendNoLongerViewing = sendNoLongerViewing;
|
_sendNoLongerViewing = sendNoLongerViewing;
|
||||||
_sendPutItemInContainer = sendPutItemInContainer;
|
_sendPutItemInContainer = sendPutItemInContainer;
|
||||||
|
_sendStackableSplitToContainer = sendStackableSplitToContainer;
|
||||||
_sendStackableMerge = sendStackableMerge;
|
_sendStackableMerge = sendStackableMerge;
|
||||||
_notifyMergeAttempt = notifyMergeAttempt;
|
_notifyMergeAttempt = notifyMergeAttempt;
|
||||||
_itemInteraction = itemInteraction;
|
_itemInteraction = itemInteraction;
|
||||||
|
|
@ -206,6 +209,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
Action<uint>? sendUse = null,
|
Action<uint>? sendUse = null,
|
||||||
Action<uint>? sendNoLongerViewing = null,
|
Action<uint>? sendNoLongerViewing = null,
|
||||||
Action<uint, uint, int>? sendPutItemInContainer = null,
|
Action<uint, uint, int>? sendPutItemInContainer = null,
|
||||||
|
Action<uint, uint, uint, uint>? sendStackableSplitToContainer = null,
|
||||||
Action<uint, uint, uint>? sendStackableMerge = null,
|
Action<uint, uint, uint>? sendStackableMerge = null,
|
||||||
Action<uint, uint>? notifyMergeAttempt = null,
|
Action<uint, uint>? notifyMergeAttempt = null,
|
||||||
ItemInteractionController? itemInteraction = null,
|
ItemInteractionController? itemInteraction = null,
|
||||||
|
|
@ -215,7 +219,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
ownerName, datFont,
|
ownerName, datFont,
|
||||||
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
|
||||||
sendUse, sendNoLongerViewing, sendPutItemInContainer,
|
sendUse, sendNoLongerViewing, sendPutItemInContainer,
|
||||||
sendStackableMerge, notifyMergeAttempt, itemInteraction,
|
sendStackableSplitToContainer, sendStackableMerge,
|
||||||
|
notifyMergeAttempt, itemInteraction,
|
||||||
onClose, stackSplitQuantity);
|
onClose, stackSplitQuantity);
|
||||||
|
|
||||||
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
|
||||||
|
|
@ -417,8 +422,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
return ItemDragAcceptance.Reject;
|
return ItemDragAcceptance.Reject;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
|
/// <summary>Resolve the destination and either split or move the stack. A partial split waits
|
||||||
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
|
/// for the server-created object's guid; a whole move remains optimistic. Retail:
|
||||||
|
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c>.</summary>
|
||||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||||
{
|
{
|
||||||
// UIElement_ItemList::HandleDropRelease @ 0x004E4790 applies the same
|
// UIElement_ItemList::HandleDropRelease @ 0x004E4790 applies the same
|
||||||
|
|
@ -456,6 +462,22 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
||||||
else return;
|
else return;
|
||||||
|
|
||||||
if (container == item) return; // never into itself
|
if (container == item) return; // never into itself
|
||||||
|
|
||||||
|
if (_objects.Get(item) is { } source)
|
||||||
|
{
|
||||||
|
uint fullStack = (uint)Math.Max(source.StackSize, 1);
|
||||||
|
uint splitSize = _stackSplitQuantity?.GetObjectSplitSize(
|
||||||
|
item, _selection.SelectedObjectId ?? 0u, fullStack) ?? fullStack;
|
||||||
|
if (splitSize < fullStack)
|
||||||
|
{
|
||||||
|
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will
|
||||||
|
// publish the reduced source plus a newly-guided destination stack.
|
||||||
|
_sendStackableSplitToContainer?.Invoke(
|
||||||
|
item, container, (uint)placement, splitSize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_objects.MoveItemOptimistic(item, container, placement); // instant local move
|
_objects.MoveItemOptimistic(item, container, placement); // instant local move
|
||||||
_sendPutItemInContainer?.Invoke(item, container, placement);
|
_sendPutItemInContainer?.Invoke(item, container, placement);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ public sealed record InventoryRuntimeBindings(
|
||||||
Action<uint>? SendUse,
|
Action<uint>? SendUse,
|
||||||
Action<uint>? SendNoLongerViewing,
|
Action<uint>? SendNoLongerViewing,
|
||||||
Action<uint, uint, int>? SendPutItemInContainer,
|
Action<uint, uint, int>? SendPutItemInContainer,
|
||||||
|
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
|
||||||
Action<uint, uint, uint>? SendStackableMerge,
|
Action<uint, uint, uint>? SendStackableMerge,
|
||||||
Action<uint, uint>? SendWield,
|
Action<uint, uint>? SendWield,
|
||||||
ItemInteractionController ItemInteraction,
|
ItemInteractionController ItemInteraction,
|
||||||
|
|
@ -97,6 +98,7 @@ public sealed record RetailUiRuntimeBindings(
|
||||||
CharacterRuntimeBindings Character,
|
CharacterRuntimeBindings Character,
|
||||||
InventoryRuntimeBindings Inventory,
|
InventoryRuntimeBindings Inventory,
|
||||||
RetailUiCursorBindings Cursor,
|
RetailUiCursorBindings Cursor,
|
||||||
|
StackSplitQuantityState StackSplitQuantity,
|
||||||
BufferedUiRegistry? Plugins,
|
BufferedUiRegistry? Plugins,
|
||||||
RetailUiPersistenceBindings? Persistence,
|
RetailUiPersistenceBindings? Persistence,
|
||||||
RetailUiProbeBindings Probe);
|
RetailUiProbeBindings Probe);
|
||||||
|
|
@ -109,7 +111,7 @@ public sealed record RetailUiRuntimeBindings(
|
||||||
public sealed class RetailUiRuntime : IDisposable
|
public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
private readonly RetailUiRuntimeBindings _bindings;
|
private readonly RetailUiRuntimeBindings _bindings;
|
||||||
private readonly StackSplitQuantityState _stackSplitQuantity = new();
|
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
|
||||||
private readonly RetailWindowLayoutPersistence? _persistence;
|
private readonly RetailWindowLayoutPersistence? _persistence;
|
||||||
private readonly RetailUiAutomationScriptRunner? _automation;
|
private readonly RetailUiAutomationScriptRunner? _automation;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
@ -385,7 +387,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
b.StackSize,
|
b.StackSize,
|
||||||
b.SendQueryHealth,
|
b.SendQueryHealth,
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
_stackSplitQuantity,
|
StackSplitQuantity,
|
||||||
handler => b.Objects.ObjectUpdated += handler,
|
handler => b.Objects.ObjectUpdated += handler,
|
||||||
handler => b.Objects.ObjectUpdated -= handler);
|
handler => b.Objects.ObjectUpdated -= handler);
|
||||||
|
|
||||||
|
|
@ -599,10 +601,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
|
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
|
||||||
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
|
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
|
||||||
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
|
||||||
b.SendPutItemInContainer, b.SendStackableMerge,
|
b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
|
||||||
notifyMergeAttempt, b.ItemInteraction,
|
notifyMergeAttempt, b.ItemInteraction,
|
||||||
() => CloseWindow(WindowNames.Inventory),
|
() => CloseWindow(WindowNames.Inventory),
|
||||||
_stackSplitQuantity);
|
StackSplitQuantity);
|
||||||
PaperdollController paperdoll = PaperdollController.Bind(
|
PaperdollController paperdoll = PaperdollController.Bind(
|
||||||
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
|
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
|
||||||
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);
|
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);
|
||||||
|
|
|
||||||
|
|
@ -1345,6 +1345,28 @@ public sealed class WorldSession : IDisposable
|
||||||
SendGameAction(InventoryActions.BuildStackableMerge(seq, sourceGuid, targetGuid, amount));
|
SendGameAction(InventoryActions.BuildStackableMerge(seq, sourceGuid, targetGuid, amount));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send retail StackableSplitToContainer (0x0055).
|
||||||
|
/// <c>CM_Inventory::Event_StackableSplitToContainer @ 0x006AC0D0</c>.
|
||||||
|
/// </summary>
|
||||||
|
public void SendStackableSplitToContainer(
|
||||||
|
uint stackGuid, uint containerGuid, uint placement, uint amount)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(InventoryActions.BuildStackableSplitToContainer(
|
||||||
|
seq, stackGuid, containerGuid, placement, amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send retail StackableSplitTo3D (0x0056).
|
||||||
|
/// <c>CM_Inventory::Event_StackableSplitTo3D @ 0x006ABFC0</c>.
|
||||||
|
/// </summary>
|
||||||
|
public void SendStackableSplitTo3D(uint stackGuid, uint amount)
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,18 @@ public sealed class StackSplitQuantityState
|
||||||
|
|
||||||
public void SetValue(uint value) => Set(value, Maximum);
|
public void SetValue(uint value) => Set(value, Maximum);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ports <c>ItemHolder::GetObjectSplitSize @ 0x00586F00</c>: only the
|
||||||
|
/// currently selected object consumes <c>GenItemHolder::splitSize</c>.
|
||||||
|
/// Every other object operation uses that object's full stack size.
|
||||||
|
/// </summary>
|
||||||
|
public uint GetObjectSplitSize(uint objectId, uint selectedObjectId, uint objectStackSize)
|
||||||
|
{
|
||||||
|
if (objectId == selectedObjectId)
|
||||||
|
return Value;
|
||||||
|
return Math.Max(objectStackSize, 1u);
|
||||||
|
}
|
||||||
|
|
||||||
private void Set(uint value, uint maximum)
|
private void Set(uint value, uint maximum)
|
||||||
{
|
{
|
||||||
maximum = Math.Max(maximum, 1u);
|
maximum = Math.Max(maximum, 1u);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ public sealed class ItemInteractionControllerTests
|
||||||
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
|
||||||
public readonly List<(uint Item, uint Mask)> Wields = new();
|
public readonly List<(uint Item, uint Mask)> Wields = new();
|
||||||
public readonly List<uint> Drops = new();
|
public readonly List<uint> Drops = new();
|
||||||
|
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
|
||||||
public readonly List<string> Toasts = new();
|
public readonly List<string> Toasts = new();
|
||||||
|
public readonly StackSplitQuantityState SplitQuantity = new();
|
||||||
|
public uint SelectedObject;
|
||||||
public long Now = 1_000;
|
public long Now = 1_000;
|
||||||
|
|
||||||
public Harness()
|
public Harness()
|
||||||
|
|
@ -46,7 +49,10 @@ public sealed class ItemInteractionControllerTests
|
||||||
sendWield: (item, mask) => Wields.Add((item, mask)),
|
sendWield: (item, mask) => Wields.Add((item, mask)),
|
||||||
sendDrop: Drops.Add,
|
sendDrop: Drops.Add,
|
||||||
nowMs: () => Now,
|
nowMs: () => Now,
|
||||||
toast: Toasts.Add);
|
toast: Toasts.Add,
|
||||||
|
sendSplitToWorld: (item, amount) => SplitDrops.Add((item, amount)),
|
||||||
|
selectedObjectId: () => SelectedObject,
|
||||||
|
stackSplitQuantity: SplitQuantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ItemInteractionController Controller { get; }
|
public ItemInteractionController Controller { get; }
|
||||||
|
|
@ -389,6 +395,28 @@ public sealed class ItemInteractionControllerTests
|
||||||
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
|
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedPartialStackDragOutsideUi_splitsWithoutMovingOriginal()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.AddContained(0x50000A70u, item => item.StackSize = 10);
|
||||||
|
h.SelectedObject = 0x50000A70u;
|
||||||
|
h.SplitQuantity.Reset(10u);
|
||||||
|
h.SplitQuantity.SetValue(1u);
|
||||||
|
var payload = new ItemDragPayload(
|
||||||
|
0x50000A70u,
|
||||||
|
ItemDragSource.Inventory,
|
||||||
|
SourceSlot: 0,
|
||||||
|
SourceCell: new UiItemSlot());
|
||||||
|
|
||||||
|
Assert.True(h.Controller.DropToWorld(payload));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (0x50000A70u, 1u) }, h.SplitDrops);
|
||||||
|
Assert.Empty(h.Drops);
|
||||||
|
Assert.Equal(Pack, h.Objects.Get(0x50000A70u)!.ContainerId);
|
||||||
|
Assert.Equal(10, h.Objects.Get(0x50000A70u)!.StackSize);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
|
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ public class InventoryControllerTests
|
||||||
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
|
||||||
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
|
||||||
List<(uint item, uint container, int placement)>? puts = null,
|
List<(uint item, uint container, int placement)>? puts = null,
|
||||||
|
List<(uint item, uint container, uint placement, uint amount)>? splits = null,
|
||||||
List<(uint source, uint target, uint amount)>? merges = null,
|
List<(uint source, uint target, uint amount)>? merges = null,
|
||||||
List<(uint source, uint target)>? mergeNotices = null,
|
List<(uint source, uint target)>? mergeNotices = null,
|
||||||
string? ownerName = null,
|
string? ownerName = null,
|
||||||
|
|
@ -69,6 +70,9 @@ public class InventoryControllerTests
|
||||||
sendUse: uses is null ? null : g => uses.Add(g),
|
sendUse: uses is null ? null : g => uses.Add(g),
|
||||||
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
|
||||||
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
|
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
|
||||||
|
sendStackableSplitToContainer: splits is null
|
||||||
|
? null
|
||||||
|
: (i, c, p, a) => splits.Add((i, c, p, a)),
|
||||||
sendStackableMerge: merges is null ? null : (s, t, a) => merges.Add((s, t, a)),
|
sendStackableMerge: merges is null ? null : (s, t, a) => merges.Add((s, t, a)),
|
||||||
notifyMergeAttempt: mergeNotices is null ? null : (s, t) => mergeNotices.Add((s, t)),
|
notifyMergeAttempt: mergeNotices is null ? null : (s, t) => mergeNotices.Add((s, t)),
|
||||||
onClose: onClose,
|
onClose: onClose,
|
||||||
|
|
@ -570,6 +574,62 @@ public class InventoryControllerTests
|
||||||
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
|
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drop_selectedPartialStack_splitsIntoContainer_withoutMovingOriginal()
|
||||||
|
{
|
||||||
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0xAu,
|
||||||
|
StackSize = 10,
|
||||||
|
StackSizeMax = 100,
|
||||||
|
});
|
||||||
|
objects.MoveItem(0xAu, Player, 0);
|
||||||
|
var selection = new SelectionState();
|
||||||
|
selection.Select(0xAu, SelectionChangeSource.Inventory);
|
||||||
|
var splitQuantity = new StackSplitQuantityState();
|
||||||
|
splitQuantity.Reset(10u);
|
||||||
|
splitQuantity.SetValue(1u);
|
||||||
|
var splits = new List<(uint item, uint container, uint placement, uint amount)>();
|
||||||
|
var puts = new List<(uint item, uint container, int placement)>();
|
||||||
|
var ctrl = Bind(layout, objects, puts: puts, splits: splits,
|
||||||
|
selection: selection, stackSplitQuantity: splitQuantity);
|
||||||
|
|
||||||
|
ctrl.HandleDropRelease(grid, grid.GetItem(5)!, Payload(0xAu));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { (0xAu, Player, 1u, 1u) }, splits);
|
||||||
|
Assert.Empty(puts);
|
||||||
|
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
|
||||||
|
Assert.Equal(0, objects.Get(0xAu)!.ContainerSlot);
|
||||||
|
Assert.Equal(10, objects.Get(0xAu)!.StackSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drop_unselectedStack_ignoresOtherSelectionSplitQuantity_andMovesWholeStack()
|
||||||
|
{
|
||||||
|
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||||
|
var objects = new ClientObjectTable();
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 0xAu, StackSize = 10 });
|
||||||
|
objects.MoveItem(0xAu, Player, 0);
|
||||||
|
objects.AddOrUpdate(new ClientObject { ObjectId = 0xBu, StackSize = 20 });
|
||||||
|
objects.MoveItem(0xBu, Player, 1);
|
||||||
|
var selection = new SelectionState();
|
||||||
|
selection.Select(0xBu, SelectionChangeSource.Inventory);
|
||||||
|
var splitQuantity = new StackSplitQuantityState();
|
||||||
|
splitQuantity.Reset(20u);
|
||||||
|
splitQuantity.SetValue(1u);
|
||||||
|
var splits = new List<(uint item, uint container, uint placement, uint amount)>();
|
||||||
|
var puts = new List<(uint item, uint container, int placement)>();
|
||||||
|
var ctrl = Bind(layout, objects, puts: puts, splits: splits,
|
||||||
|
selection: selection, stackSplitQuantity: splitQuantity);
|
||||||
|
|
||||||
|
ctrl.HandleDropRelease(grid, grid.GetItem(4)!, Payload(0xAu));
|
||||||
|
|
||||||
|
Assert.Empty(splits);
|
||||||
|
Assert.Equal(new[] { (0xAu, Player, 2) }, puts);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Drop_matchingStackOnOccupiedStack_mergesBeforeMoveAndBroadcastsToolbarNotice()
|
public void Drop_matchingStackOnOccupiedStack_mergesBeforeMoveAndBroadcastsToolbarNotice()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,32 @@ public sealed class WorldSessionInventoryActionTests
|
||||||
|
|
||||||
Assert.Equal(InventoryActions.BuildStackableMerge(1, 0xAAu, 0xBBu, 5u), captured);
|
Assert.Equal(InventoryActions.BuildStackableMerge(1, 0xAAu, 0xBBu, 5u), captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendStackableSplitToContainer_UsesNextSequenceAndExactBuilderBytes()
|
||||||
|
{
|
||||||
|
using var session = new WorldSession(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65000));
|
||||||
|
byte[]? captured = null;
|
||||||
|
session.GameActionCapture = body => captured = body;
|
||||||
|
|
||||||
|
session.SendStackableSplitToContainer(0xAAu, 0xBBu, 4u, 2u);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
InventoryActions.BuildStackableSplitToContainer(1, 0xAAu, 0xBBu, 4u, 2u),
|
||||||
|
captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SendStackableSplitTo3D_UsesNextSequenceAndExactBuilderBytes()
|
||||||
|
{
|
||||||
|
using var session = new WorldSession(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65000));
|
||||||
|
byte[]? captured = null;
|
||||||
|
session.GameActionCapture = body => captured = body;
|
||||||
|
|
||||||
|
session.SendStackableSplitTo3D(0xAAu, 2u);
|
||||||
|
|
||||||
|
Assert.Equal(InventoryActions.BuildStackableSplitTo3D(1, 0xAAu, 2u), captured);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,4 +55,17 @@ public sealed class StackSplitQuantityStateTests
|
||||||
|
|
||||||
Assert.Equal(expected, state.Value);
|
Assert.Equal(expected, state.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetObjectSplitSize_usesSliderOnlyForSelectedObject()
|
||||||
|
{
|
||||||
|
var state = new StackSplitQuantityState();
|
||||||
|
state.Reset(10u);
|
||||||
|
state.SetValue(3u);
|
||||||
|
|
||||||
|
Assert.Equal(3u, state.GetObjectSplitSize(0xAu, 0xAu, 10u));
|
||||||
|
Assert.Equal(7u, state.GetObjectSplitSize(0xBu, 0xAu, 7u));
|
||||||
|
Assert.Equal(3u, state.GetObjectSplitSize(0xCu, 0xCu, 0u));
|
||||||
|
Assert.Equal(1u, state.GetObjectSplitSize(0xCu, 0xDu, 0u));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue