diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index ea372377..e92b17cd 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -495,7 +495,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix and exact DAT paperdoll body-map + upper-item-priority route passed live 2026-07-11. - **✓ 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 (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. `CreateObject` retains exact `AmmoType` for the compatibility decision. The same transaction runs in peace and war; ACE owns the latter's old-stance → peace → new-stance motion chain. Player PropertyInt 40 now 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.5 implemented — retail primary-weapon switching (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 final authoritative `WieldObject`, matching retail's waiting state and preventing rapid switches from overlapping on ACE. `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`. - **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/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 28b6cbaa..2a336153 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -459,7 +459,8 @@ include dungeons. primary weapon to the main pack, then any incompatible shield/ammo, waiting for every server move confirmation before wielding the requested bow/sword/caster/two-hander. The shared transaction works - in peace and war; ACE owns stance-specific motions and its PropertyInt 40 update + in peace and war, remains busy through final `WieldObject` confirmation, and + prevents rapid switches from overlapping; ACE owns stance-specific motions and its PropertyInt 40 update now drives the client `CombatState`. - **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** — local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted diff --git a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md index d86d9b83..40db3466 100644 --- a/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md +++ b/docs/research/2026-07-12-retail-weapon-switch-pseudocode.md @@ -89,9 +89,15 @@ on server inventory-request failure(item, error): clear pending send_wield(item, mask): + pending = (requestedItem = item.id, blockingItem = none) update the retained inventory/paperdoll projection optimistically send GetAndWieldItem(item.id, mask) +on server-confirmed WieldObject(item): + if item matches pending.requestedItem + and pending.blockingItem is none: + clear pending + on PrivateUpdatePropertyInt(property = CombatMode, value): if value is exactly NonCombat, Melee, Missile, or Magic: CombatState.SetCombatMode(value) @@ -106,6 +112,12 @@ The inventory controller only sequences the two wire requests and waits for the old weapon's confirmed move; the existing motion and equipped-child render paths consume the server events. +The request is not complete when `GetAndWieldItem` is merely sent. Retail +`UIAttemptWield` records `prevRequest = IR_WIELD` and leaves the item waiting +until `ServerSaysMoveItem` reconciles it. acdream therefore keeps AutoWield +busy through the final authoritative `WieldObject`; allowing another switch +during the optimistic interval makes ACE reject otherwise valid items. + ## Known remaining retail surface This port covers the primary weapon-ready group plus retail's secondary diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index 66136fa0..c8d2f244 100644 --- a/src/AcDream.App/UI/AutoWieldController.cs +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -66,6 +66,8 @@ internal sealed class AutoWieldController : IDisposable private PendingSwitch? _pendingSwitch; private bool _disposed; + public bool IsBusy => _pendingSwitch is not null; + public AutoWieldController( ClientObjectTable objects, Func playerGuid, @@ -82,6 +84,7 @@ internal sealed class AutoWieldController : IDisposable _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectRemoved; _objects.MoveRequestFailed += OnMoveRequestFailed; + _objects.MoveRequestConfirmed += OnMoveRequestConfirmed; _objects.Cleared += OnObjectsCleared; } @@ -160,8 +163,12 @@ internal sealed class AutoWieldController : IDisposable { if (_sendWield is null) return false; + _pendingSwitch = new PendingSwitch(item.ObjectId, BlockingItemId: 0); if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) + { + _pendingSwitch = null; return false; + } // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590. _sendWield(item.ObjectId, (uint)mask); @@ -171,6 +178,7 @@ internal sealed class AutoWieldController : IDisposable private void OnObjectMoved(ClientObject item, uint _, uint newContainerId) { if (_pendingSwitch is not { } pending + || pending.BlockingItemId == 0 || item.ObjectId != pending.BlockingItemId || newContainerId != _playerGuid() || item.CurrentlyEquippedLocation != EquipMask.None) @@ -183,6 +191,13 @@ internal sealed class AutoWieldController : IDisposable TryWield(requested); } + private void OnMoveRequestConfirmed(ClientObject item) + { + if (_pendingSwitch is { BlockingItemId: 0 } pending + && item.ObjectId == pending.RequestedItemId) + _pendingSwitch = null; + } + private void OnMoveRequestFailed(MoveRequestFailure failure) { if (_pendingSwitch is { } pending @@ -304,6 +319,7 @@ internal sealed class AutoWieldController : IDisposable _objects.ObjectMoved -= OnObjectMoved; _objects.ObjectRemoved -= OnObjectRemoved; _objects.MoveRequestFailed -= OnMoveRequestFailed; + _objects.MoveRequestConfirmed -= OnMoveRequestConfirmed; _objects.Cleared -= OnObjectsCleared; } diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 4a67a863..a52eac19 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -112,7 +112,8 @@ public sealed class ItemInteractionController : IDisposable public int BusyCount => _busyCount; - public bool CanMakeInventoryRequest => _readyForInventoryRequest() && _busyCount == 0; + public bool CanMakeInventoryRequest => + _readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy; /// /// Raised for retail confirmation/auxiliary actions whose retained panel is @@ -246,7 +247,7 @@ public sealed class ItemInteractionController : IDisposable Snapshot(item), _playerGuid(), _groundObjectId(), - _readyForInventoryRequest() && _busyCount == 0, + CanMakeInventoryRequest, _activeVendorId(), BypassClassification: false, UseCurrentSelection: false, @@ -277,7 +278,7 @@ public sealed class ItemInteractionController : IDisposable if (!compatible) return false; - if (!_readyForInventoryRequest() || _busyCount != 0) + if (!CanMakeInventoryRequest) return false; _sendUseWithTarget?.Invoke(sourceGuid, targetGuid); _busyCount++; @@ -309,7 +310,7 @@ public sealed class ItemInteractionController : IDisposable Snapshot(item), _playerGuid(), _groundObjectId(), - _readyForInventoryRequest() && _busyCount == 0, + CanMakeInventoryRequest, TargetId: 0, Target: null, AllowGroundFallback: true, diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 52d94507..4d5b66c8 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -85,6 +85,13 @@ public sealed class ClientObjectTable /// public event Action? MoveRolledBack; + /// + /// Fires after the server has confirmed the final outstanding request for + /// an optimistically moved item. This is later than , + /// which also fires for the initial local projection. + /// + public event Action? MoveRequestConfirmed; + /// /// Fires for every InventoryServerSaveFailed response, including requests /// which did not mutate the table optimistically. Transaction coordinators @@ -258,8 +265,16 @@ public sealed class ClientObjectTable public void ConfirmMove(uint itemId) { if (!_pendingMoves.TryGetValue(itemId, out var p)) return; - if (p.outstanding <= 1) _pendingMoves.Remove(itemId); - else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1); + if (p.outstanding <= 1) + { + _pendingMoves.Remove(itemId); + if (_objects.TryGetValue(itemId, out ClientObject? item)) + MoveRequestConfirmed?.Invoke(item); + } + else + { + _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1); + } } /// The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 16876fa9..3cdc55f5 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -485,12 +485,54 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.ActivateItem(bow)); h.Now += 200; - Assert.True(h.Controller.ActivateItem(bow)); + Assert.False(h.Controller.ActivateItem(bow)); Assert.Single(h.Puts); Assert.Empty(h.Wields); } + [Fact] + public void WeaponReplacement_doesNotOverlapWhileAwaitingFinalWieldConfirmation() + { + var h = new Harness(); + const uint sword = 0x50000B15u; + const uint bow = 0x50000B16u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = sword, + Type = ItemType.MeleeWeapon, + CombatUse = 1, + ValidLocations = EquipMask.MeleeWeapon, + }); + h.Objects.MoveItem(sword, Player, -1, EquipMask.MeleeWeapon); + h.AddContained(bow, item => + { + item.Type = ItemType.MissileWeapon; + item.CombatUse = 2; + item.ValidLocations = EquipMask.MissileWeapon; + }); + + Assert.True(h.Controller.ActivateItem(bow)); + h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.Single(h.Wields); + + // Bow is optimistic but ACE has not sent WieldObject yet. Retail's + // waiting state consumes this attempted reverse switch. + h.Now += 200; + Assert.False(h.Controller.ActivateItem(sword)); + Assert.Single(h.Puts); + Assert.Single(h.Wields); + + // Authoritative WieldObject + ConfirmMove completes the transaction. + h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon); + h.Objects.ConfirmMove(bow); + h.Now += 200; + Assert.True(h.Controller.ActivateItem(sword)); + Assert.Equal( + new[] { (sword, Player, 0), (bow, Player, 0) }, + h.Puts); + } + [Fact] public void WeaponReplacement_serverReject_clearsPendingTransactionForRetry() { diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 63214b67..576207cb 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -743,6 +743,25 @@ public sealed class ClientObjectTableTests seen); } + [Fact] + public void ConfirmMove_publishesOnlyAfterFinalOutstandingRequest() + { + var table = new ClientObjectTable(); + const uint item = 0x944u, player = 0x50000001u, pack = 0x40000005u; + table.AddOrUpdate(new ClientObject { ObjectId = item }); + table.MoveItem(item, pack, 2); + table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon); + table.MoveItemOptimistic(item, pack, 0); + var confirmed = new List(); + table.MoveRequestConfirmed += moved => confirmed.Add(moved.ObjectId); + + table.ConfirmMove(item); + Assert.Empty(confirmed); + + table.ConfirmMove(item); + Assert.Equal(new[] { item }, confirmed); + } + [Fact] public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending) {