diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index fb0e1baa..a3aaa37e 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -496,7 +496,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display. - **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired. - **Wave 4.5 implemented — retail primary-weapon switching (corrective live gate pending).** Inventory double-click now routes through a focused `AutoWieldController`: the occupied `0x03500000` weapon-ready group is returned to the player container, followed when necessary by an incompatible shield or mismatched ammo; each blocker waits for authoritative `InventoryPutObjInContainer` before the next step, then `GetAndWieldItem` equips the requested bow/sword/caster/two-hander. The transaction remains inventory-busy through the exact authoritative `WieldObject` acknowledgement, independent of the object table's rollback counter, matching retail's waiting state and preventing rapid switches from overlapping or remaining stuck. `CreateObject` retains exact `AmmoType` for compatibility. The same transaction runs in peace and war; ACE owns the latter's old-stance → peace → new-stance motion chain. Player PropertyInt 40 updates `CombatState`, keeping toolbar/combat UI synchronized with ACE's selected Melee/Missile/Magic stance. Research: `docs/research/2026-07-12-retail-weapon-switch-pseudocode.md`. -- **Wave 4 inventory drag visuals implemented — live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path; shortcut aliases remain unghosted. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`. +- **Wave 4 inventory drag visuals implemented — corrective live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path, with the persistent selection square above the mesh; shortcut aliases remain unghosted. `ItemList_DragOver` destination states are split exactly: contents-grid placement uses the green accept circle `0x060011F9`, while side-bag/main-pack container drop-in uses the green arrow `0x060011F7`. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. - **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. diff --git a/docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md b/docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md index 478849e4..b3474783 100644 --- a/docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md +++ b/docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md @@ -65,6 +65,30 @@ on drag release/cancel: hide m_elem_Icon_Ghosted ``` +## Destination feedback and source overlay order + +`UIElement_ItemList::ItemList_DragOver @ 0x004e3400` distinguishes an ordinary +slot insertion from dropping into a container: + +```text +if target list is a container selector + and target cell is occupied by a container + and that container has an empty item slot: + target.SetDragAcceptState(0x10000046) # ItemSlot_DragOver_DropIn + # 0x060011F7 green arrow +else if target accepts an ordinary item-list placement: + target.SetDragAcceptState(0x10000041) # ItemSlot_DragOver_Accept + # 0x060011F9 green circle +else: + target.SetDragAcceptState(0x10000040) # 0x060011F8 reject +``` + +Therefore the backpack contents grid uses the green circle; the side-bag column +and main-pack container cell use the green drop-in arrow. The selected/open +indicators remain visible while `m_elem_Icon_Ghosted` is active, so the +procedural leaf draws the waiting mesh before those persistent indicators and +keeps drag-accept feedback topmost. + ## Port mapping - `IconComposer` caches the underlay-free composite separately and exposes @@ -85,6 +109,6 @@ on drag release/cancel: - `DragDropSpineTests.InventoryDrag_ghostsSourceUntilRelease` - `DragDropSpineTests.ShortcutDrag_doesNotGhostSource` - `UiItemSlotTests.DefaultWaitingSprite_isRetailGhostMesh` +- `InventoryControllerTests.Empty_sprites_and_drop_feedback_are_applied_per_list_role` - Existing `IconComposerTests.TwoStageWithEffect_copiesTilePixelBeforeUnderlay` locks the two-stage composition order. - diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index bb30d5d6..5db48f7a 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -365,8 +365,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; cell.SetItem(guid, tex, dragIconTexture: dragTex); cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) - cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed) - cell.DragRejectSprite = 0x060011F8u; // red circle + ConfigureDropFeedback(list, cell); cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); if (isContainer) { @@ -388,14 +387,27 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo fallback(); } - private static void AddEmptyCell(UiItemList list) + private void AddEmptyCell(UiItemList list) { var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() }; - cell.DragAcceptSprite = 0x060011F7u; - cell.DragRejectSprite = 0x060011F8u; + ConfigureDropFeedback(list, cell); list.AddItem(cell); } + /// + /// Bind the exact ItemList_DragOver state for this destination. A normal contents-grid + /// insertion uses ItemSlot_DragOver_Accept (0x10000041 → green circle 0x060011F9). + /// An occupied container selector uses ItemSlot_DragOver_DropIn + /// (0x10000046 → green arrow 0x060011F7). Retail: 0x004e3400. + /// + private void ConfigureDropFeedback(UiItemList list, UiItemSlot cell) + { + cell.DragAcceptSprite = ReferenceEquals(list, _contentsGrid) + ? 0x060011F9u + : 0x060011F7u; + cell.DragRejectSprite = 0x060011F8u; + } + /// Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay /// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill = /// GetNumContainedItems / itemsCapacity, clamped [0,1]. -1 hides the bar (non-container / unknown diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 013698b5..c7db762b 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -338,10 +338,22 @@ public sealed class UiItemSlot : UiElement } } + // Pending/drag-source mesh — retail UIElement_UIItem::SetWaitingState (0x004e11b0) + // reveals m_elem_Icon_Ghosted (0x10000349). ItemList_BeginDrag (0x004e32d0) + // sets it for physical inventory/equipment cells while leaving their icon in place. + // Draw it before the persistent selected/open indicators: retail keeps selection visible + // while the source is waiting, so the grey mesh must not erase the selected square. + if (_waiting && SpriteResolve is not null && WaitingSprite != 0) + { + var (tex, _, _) = SpriteResolve(WaitingSprite); + if (tex != 0) + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + // Open-container triangle (retail SetOpenContainerState 0x004e1200) + selected-item square // (retail SetSelectedState 0x004e1240). Drawn procedurally like the digit/drag overlays; // guard id != 0 BEFORE resolving (feedback_ui_resolve_zero_magenta). Triangle under the - // square; both under the transient drag overlay below. + // square; both remain above the transient waiting mesh and below drag-accept feedback. if (IsOpenContainer && SpriteResolve is not null && OpenContainerSprite != 0) { var (tex, _, _) = SpriteResolve(OpenContainerSprite); @@ -355,16 +367,6 @@ public sealed class UiItemSlot : UiElement ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); } - // Pending/drag-source mesh — retail UIElement_UIItem::SetWaitingState (0x004e11b0) - // reveals m_elem_Icon_Ghosted (0x10000349). ItemList_BeginDrag (0x004e32d0) - // sets it for physical inventory/equipment cells while leaving their icon in place. - if (_waiting && SpriteResolve is not null && WaitingSprite != 0) - { - var (tex, _, _) = SpriteResolve(WaitingSprite); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - // Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40). // Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder // with a non-zero GL handle (feedback_ui_resolve_zero_magenta). diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index cf0ea3bf..45374536 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -202,13 +202,16 @@ public class DragDropSpineTests { var (root, _, cell) = RootWithBoundSlot(0x5001u); cell.SourceKind = ItemDragSource.Inventory; + cell.Selected = true; root.OnMouseDown(UiMouseButton.Left, 10, 10); root.OnMouseMove(20, 10); Assert.True(cell.WaitingVisual); + Assert.True(cell.Selected); // selected indicator remains active above the waiting mesh root.OnMouseUp(UiMouseButton.Left, 600, 500); Assert.False(cell.WaitingVisual); + Assert.True(cell.Selected); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index a4c1fdcd..f771017f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -340,7 +340,7 @@ public class InventoryControllerTests } [Fact] - public void Empty_sprites_are_applied_to_the_three_lists() + public void Empty_sprites_and_drop_feedback_are_applied_per_list_role() { var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); InventoryController.Bind(layout, new ClientObjectTable(), () => Player, @@ -351,6 +351,9 @@ public class InventoryControllerTests Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell Assert.Equal(0x06005D9Cu, containers.GetItem(0)!.EmptySprite); // a padded empty side-bag cell Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell + Assert.Equal(0x060011F9u, grid.GetItem(0)!.DragAcceptSprite); // normal slot accept = green circle + Assert.Equal(0x060011F7u, containers.GetItem(0)!.DragAcceptSprite); // container drop-in = green arrow + Assert.Equal(0x060011F7u, top.GetItem(0)!.DragAcceptSprite); // main container drop-in = green arrow } [Fact]