fix(inventory): complete switches on wield response

Clear retail AutoWield on the matching authoritative WieldObject instead of waiting for unrelated rollback bookkeeping to drain. This prevents a completed switch from consuming the next weapon activation locally, including crossbows.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 09:00:54 +02:00
parent ea5cf5b842
commit 3e84027885
9 changed files with 47 additions and 21 deletions

View file

@ -495,7 +495,7 @@ behavior. Estimated 1726 days focused work, 35 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. 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`.
- **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`.
- **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.

View file

@ -459,8 +459,9 @@ 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, remains busy through final `WieldObject` confirmation, and
prevents rapid switches from overlapping; ACE owns stance-specific motions and its PropertyInt 40 update
in peace and war, remains busy through the exact final `WieldObject`
acknowledgement independently of rollback bookkeeping, and prevents rapid
switches from overlapping or remaining stuck; 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

View file

@ -94,6 +94,7 @@ send_wield(item, mask):
send GetAndWieldItem(item.id, mask)
on server-confirmed WieldObject(item):
reconcile the item's optimistic rollback snapshot separately
if item matches pending.requestedItem
and pending.blockingItem is none:
clear pending
@ -116,7 +117,10 @@ 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.
during the optimistic interval makes ACE reject otherwise valid items. Retail
`ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0` clears the matching request
directly; that UI completion is intentionally independent of the object's
separate optimistic rollback counter.
## Known remaining retail surface

View file

@ -84,7 +84,7 @@ internal sealed class AutoWieldController : IDisposable
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.MoveRequestFailed += OnMoveRequestFailed;
_objects.MoveRequestConfirmed += OnMoveRequestConfirmed;
_objects.WieldConfirmed += OnWieldConfirmed;
_objects.Cleared += OnObjectsCleared;
}
@ -191,7 +191,7 @@ internal sealed class AutoWieldController : IDisposable
TryWield(requested);
}
private void OnMoveRequestConfirmed(ClientObject item)
private void OnWieldConfirmed(ClientObject item)
{
if (_pendingSwitch is { BlockingItemId: 0 } pending
&& item.ObjectId == pending.RequestedItemId)
@ -319,7 +319,7 @@ internal sealed class AutoWieldController : IDisposable
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.MoveRequestFailed -= OnMoveRequestFailed;
_objects.MoveRequestConfirmed -= OnMoveRequestConfirmed;
_objects.WieldConfirmed -= OnWieldConfirmed;
_objects.Cleared -= OnObjectsCleared;
}

View file

@ -258,7 +258,7 @@ public static class GameEventWiring
p.Value.ItemGuid,
newContainerId: wielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler)
items.ConfirmWield(p.Value.ItemGuid); // Exact IR_WIELD completion + optimistic reconciliation.
});
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
{

View file

@ -86,11 +86,12 @@ public sealed class ClientObjectTable
public event Action<ClientObject>? MoveRolledBack;
/// <summary>
/// Fires after the server has confirmed the final outstanding request for
/// an optimistically moved item. This is later than <see cref="ObjectMoved"/>,
/// which also fires for the initial local projection.
/// Fires for an authoritative WieldObject response after its object state
/// has been applied. This is deliberately independent of optimistic-move
/// rollback bookkeeping: retail clears IR_WIELD on the matching server
/// response even when separate reconciliation state exists for the item.
/// </summary>
public event Action<ClientObject>? MoveRequestConfirmed;
public event Action<ClientObject>? WieldConfirmed;
/// <summary>
/// Fires for every InventoryServerSaveFailed response, including requests
@ -268,8 +269,6 @@ public sealed class ClientObjectTable
if (p.outstanding <= 1)
{
_pendingMoves.Remove(itemId);
if (_objects.TryGetValue(itemId, out ClientObject? item))
MoveRequestConfirmed?.Invoke(item);
}
else
{
@ -277,6 +276,20 @@ public sealed class ClientObjectTable
}
}
/// <summary>
/// Reconcile an authoritative WieldObject response and publish the exact
/// retail inventory-request completion boundary. The notification is not
/// conditional on the optimistic rollback counter reaching zero.
/// Retail: ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 clears the
/// matching prevRequestObjectID directly.
/// </summary>
public void ConfirmWield(uint itemId)
{
ConfirmMove(itemId);
if (_objects.TryGetValue(itemId, out ClientObject? item))
WieldConfirmed?.Invoke(item);
}
/// <summary>The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its
/// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles
/// any still-outstanding moves). False if nothing was pending.</summary>

View file

@ -523,9 +523,15 @@ public sealed class ItemInteractionControllerTests
Assert.Single(h.Puts);
Assert.Single(h.Wields);
// Authoritative WieldObject + ConfirmMove completes the transaction.
// A separate reconciliation record for the same item must not delay
// retail's exact IR_WIELD completion boundary.
Assert.True(h.Objects.WieldItemOptimistic(
bow, Player, EquipMask.MissileWeapon));
// Authoritative WieldObject + ConfirmWield completes AutoWield even
// though the object table still has another rollback record.
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon);
h.Objects.ConfirmMove(bow);
h.Objects.ConfirmWield(bow);
h.Now += 200;
Assert.True(h.Controller.ActivateItem(sword));
Assert.Equal(

View file

@ -128,6 +128,8 @@ public sealed class GameEventWiringTests
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
var wieldConfirmed = new List<uint>();
items.WieldConfirmed += item => wieldConfirmed.Add(item.ObjectId);
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
@ -137,6 +139,7 @@ public sealed class GameEventWiringTests
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
Assert.Equal(player, items.Get(0x1500)!.ContainerId);
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
}
[Fact]

View file

@ -744,7 +744,7 @@ public sealed class ClientObjectTableTests
}
[Fact]
public void ConfirmMove_publishesOnlyAfterFinalOutstandingRequest()
public void ConfirmWield_publishesIndependentlyOfRollbackOutstandingCount()
{
var table = new ClientObjectTable();
const uint item = 0x944u, player = 0x50000001u, pack = 0x40000005u;
@ -753,13 +753,12 @@ public sealed class ClientObjectTableTests
table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon);
table.MoveItemOptimistic(item, pack, 0);
var confirmed = new List<uint>();
table.MoveRequestConfirmed += moved => confirmed.Add(moved.ObjectId);
table.WieldConfirmed += moved => confirmed.Add(moved.ObjectId);
table.ConfirmMove(item);
Assert.Empty(confirmed);
table.ConfirmWield(item);
table.ConfirmMove(item);
Assert.Equal(new[] { item }, confirmed);
Assert.True(table.RollbackMove(item));
}
[Fact]